From fdf90b30338be7cb9936c416a0647ac2013ca74f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 17 Oct 2026 14:05:52 -0400 Subject: [PATCH 01/45] fix(viewer): preserve imported lighting and matched scene captures --- .../editor/thumbnail-generator.test.ts | 29 +++++++++++++ .../components/editor/thumbnail-generator.tsx | 35 +++++++++------ packages/nodes/src/item/model-loader.test.ts | 43 ++++++++++++++++++- packages/nodes/src/item/model-loader.ts | 2 + .../viewer/src/components/viewer/lights.tsx | 14 +++++- .../src/components/viewer/post-processing.tsx | 11 ++++- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 2 + packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/gltf-unlit.ts | 24 +++++++++++ packages/viewer/src/lib/ink-edges.ts | 3 +- .../viewer/src/lib/scene-pass-mrt.test.ts | 13 ++++-- packages/viewer/src/lib/scene-pass-mrt.ts | 12 +++++- packages/viewer/src/lib/snapshot-pipeline.ts | 16 ++++--- 13 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 packages/viewer/src/lib/gltf-unlit.ts diff --git a/packages/editor/src/components/editor/thumbnail-generator.test.ts b/packages/editor/src/components/editor/thumbnail-generator.test.ts index d7c069647b..d3be8b3ed7 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.test.ts +++ b/packages/editor/src/components/editor/thumbnail-generator.test.ts @@ -6,6 +6,7 @@ import { Group, Layers, Mesh, OrthographicCamera, PerspectiveCamera, Vector2 } f import { CanvasTarget, ClippingGroup, type WebGPURenderer } from 'three/webgpu' import { atCaptureSize, + captureDimensions, capturesOrthographically, interiorCutaway, presentFinished, @@ -48,6 +49,34 @@ function fakeRenderer(canvas: FakeCanvas, pixelRatio: number) { } describe('supersampled capture', () => { + test('an explicit camera renders at the requested resolution and aspect without resizing the pane', () => { + const live: FakeCanvas = { width: 800, height: 1000, style: {} } + const renderer = fakeRenderer(live, 1) + const position: [number, number, number] = [2, 1, 3] + const target: [number, number, number] = [0, 0, 0] + for (const pose of [ + { perspective: { position, target, fov: 35 } }, + { ortho: { position, target, viewWidth: 4 } }, + ]) { + const size = captureDimensions( + [800, 1000], + { ...pose, standardSize: { w: 1920, h: 1280 } }, + 24_000_000, + ) + expect(size).toEqual([1920, 1280]) + const buffer = atCaptureSize(renderer, ...size, () => + renderer.getDrawingBufferSize(new Vector2()).toArray(), + ) + expect(buffer).toEqual([1920, 1280]) + expect([live.width, live.height]).toEqual([800, 1000]) + } + expect( + captureDimensions([800, 1000], { standardSize: { w: 1920, h: 1280 } }, 24_000_000), + ).toEqual([800, 1000]) + expect(captureDimensions([800, 1000], { supersample: 2 }, 24_000_000)).toEqual([1600, 2000]) + expect(captureDimensions([6000, 4000], { supersample: 2 }, 6_000_000)).toEqual([3000, 2000]) + }) + test('renders at its own size while the live canvas keeps its size', () => { // a 1.5 dpr screen: the old capture resized the live canvas with drawing // buffer pixels where CSS pixels were due — 3× during the capture, 1.5× diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index c37a750de8..231af25bdb 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -76,6 +76,25 @@ const TILE_OVERLAP = 96 type CaptureLimits = { passEdge: number; maxPixels: number; software: boolean } +export function captureDimensions( + canvasSize: readonly [number, number], + event: Pick, + maxPixels: number, +): [number, number] { + const supersample = event.supersample ?? 1 + // A requested camera must not inherit the editor pane's aspect or be upscaled from its crop. + const size = + (event.ortho || event.perspective) && event.standardSize && supersample === 1 + ? [event.standardSize.w, event.standardSize.h] + : canvasSize + const scale = Math.min( + Math.max(1, supersample), + MAX_CAPTURE_EDGE / Math.max(size[0], size[1]), + Math.sqrt(maxPixels / (size[0] * size[1])), + ) + return [Math.round(size[0] * scale), Math.round(size[1] * scale)] +} + /** * What one capture may ask of this GPU: a pass no larger than its biggest * texture (and MAX_PASS_EDGE), and — on a software renderer, where a big @@ -700,21 +719,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // a supersampled frame renders at a multiple of the canvas' size into // the capture's own targets; the live canvas is never resized const canvasSize: [number, number] = [gl.domElement.width, gl.domElement.height] + const pose = ortho ?? perspective const limits = captureLimits(gl) - const scale = Math.max( - 1, - Math.min( - supersample, - MAX_CAPTURE_EDGE / Math.max(canvasSize[0], canvasSize[1]), - Math.sqrt(limits.maxPixels / (canvasSize[0] * canvasSize[1])), - ), - ) - const width = Math.round(canvasSize[0] * scale) - const height = Math.round(canvasSize[1] * scale) + const [width, height] = captureDimensions(canvasSize, event, limits.maxPixels) // a supersampled picture is encoded at the size it could render (the // caller asked for canvas × supersample, maybe past this GPU's limits) const encodeSize = supersample > 1 ? { w: width, h: height } : standardSize - const pose = ortho ?? perspective // a background tab draws no frames and (on the WebGL backend) never // finishes a readback: a sheet's capture says so at once if (pose && isTabHidden()) throw new Error(HIDDEN_TAB) @@ -1038,7 +1048,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro } // a sheet's picture (or a supersampled one) is never drawn into // the live canvas the viewport shows - if (pose || scale > 1) throw new Error('Snapshot renderer is not ready. Try again.') + if (pose || width !== canvasSize[0] || height !== canvasSize[1]) + throw new Error('Snapshot renderer is not ready. Try again.') // Fallback: plain render directly to the canvas if (transparent) { const clearColor = ( diff --git a/packages/nodes/src/item/model-loader.test.ts b/packages/nodes/src/item/model-loader.test.ts index 309d4ae35a..ca9904bac5 100644 --- a/packages/nodes/src/item/model-loader.test.ts +++ b/packages/nodes/src/item/model-loader.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test' -import { DefaultLoadingManager, LoadingManager } from 'three' +import { DefaultLoadingManager, LoadingManager, type Mesh, MeshBasicMaterial } from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { cancelItemModelLoad, @@ -63,6 +63,47 @@ describe('classifyItemModelLoadFailure', () => { }) describe('ItemGLTFLoader', () => { + test('preserves imported unlit lighting without changing native basic materials', async () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const gltf = await new ItemGLTFLoader().parseAsync( + JSON.stringify({ + asset: { version: '2.0' }, + extensionsUsed: ['KHR_materials_unlit'], + buffers: [ + { + byteLength: positions.byteLength, + uri: `data:application/octet-stream;base64,${Buffer.from(positions.buffer).toString('base64')}`, + }, + ], + bufferViews: [{ buffer: 0, byteLength: positions.byteLength }], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: 'VEC3', + min: [0, 0, 0], + max: [1, 1, 0], + }, + ], + materials: [{ extensions: { KHR_materials_unlit: {} } }, {}], + meshes: [0, 1].map((material) => ({ + primitives: [{ attributes: { POSITION: 0 }, material }], + })), + nodes: [{ mesh: 0 }, { mesh: 1 }], + scene: 0, + scenes: [{ nodes: [0, 1] }], + }), + '', + ) + const imported = (gltf.scene.children[0] as Mesh).material as MeshBasicMaterial + const lit = (gltf.scene.children[1] as Mesh).material as MeshBasicMaterial + expect(imported.userData.pascalImportedUnlit).toBe(true) + expect(imported.clone().userData.pascalImportedUnlit).toBe(true) + expect(lit.userData.pascalImportedUnlit).toBeUndefined() + expect(new MeshBasicMaterial().userData.pascalImportedUnlit).toBeUndefined() + }) + test('resolves missing responses as an unavailable item instead of rejecting', async () => { const consoleError = spyOn(console, 'error').mockImplementation(() => {}) try { diff --git a/packages/nodes/src/item/model-loader.ts b/packages/nodes/src/item/model-loader.ts index 33b146193a..a4aafae055 100644 --- a/packages/nodes/src/item/model-loader.ts +++ b/packages/nodes/src/item/model-loader.ts @@ -1,3 +1,4 @@ +import { preserveGltfUnlit } from '@pascal-app/viewer' import { DefaultLoadingManager, Group, LoadingManager } from 'three' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -92,6 +93,7 @@ export class ItemGLTFLoader extends GLTFLoader { constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) { super(new LoadingManager()) + this.register(preserveGltfUnlit) this.manager.onError = (url) => { this.resourceFailures.set(url, ++this.resourceFailureVersion) } diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 3b98aa2c3f..584d28cb14 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -168,10 +168,13 @@ export function Lights() { const castsShadow = atmosphere ? true : Boolean(themeKey?.castShadow) if (light && castsShadow) { const focus = shadowFocus.current - const size = shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN + const size = + shadowRadius.current * SHADOW_MARGIN_SCALE + + Math.min(SHADOW_MARGIN, shadowRadius.current * 0.25) const distance = size + SHADOW_BACKOFF const near = SHADOW_BACKOFF - const far = distance + size + // Receivers can lie beyond the caster's bounds, especially under a low sun. + const far = distance + shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN const directionOverride = getKeyLightDirectionOverride() if (directionOverride) { shadowDir.current.set(...directionOverride) @@ -190,6 +193,13 @@ export function Lights() { const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined if (cam) { + // The calibrated building bias detaches furniture-scale contact shadows. + // Small scenes need finer shadow edges too; retain the building map budget. + const smallScene = shadowRadius.current < 1.5 + light.shadow.mapSize.setScalar(smallScene ? 2048 : 1024) + light.shadow.normalBias = smallScene + ? Math.min(SHADOW_NORMAL_BIAS, (4 * size) / light.shadow.mapSize.x) + : SHADOW_NORMAL_BIAS cam.layers.enable(SHADOW_ONLY_LAYER) cam.left = -size cam.right = size diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index eec5710c08..37bdb9fb4a 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -36,7 +36,7 @@ import { isLiveFrameHeld } from '../../lib/live-frame-hold' import { mergedOutline } from '../../lib/merged-outline-node' import { recordPerfSample, timeSpan } from '../../lib/perf-tracks' import { PostProcessingResources } from '../../lib/post-processing-resources' -import { scenePassMrt } from '../../lib/scene-pass-mrt' +import { sceneLightingMask, scenePassMrt } from '../../lib/scene-pass-mrt' import { getSceneTheme } from '../../lib/scene-themes' import { packNormalToRGB, unpackRGBToNormal } from '../../lib/tsl-compat' import useViewer from '../../store/use-viewer' @@ -474,6 +474,7 @@ const PostProcessingPasses = ({ output, diffuseColor, normal: packNormalToRGB(normalView), + lighting: sceneLightingMask(), }), ) scenePassNormal = scenePass.getTextureNode('normal') @@ -481,6 +482,7 @@ const PostProcessingPasses = ({ if (scenePassNormal) { const normalTexture = scenePass.getTexture('normal') normalTexture.type = UnsignedByteType + scenePass.getTexture('lighting').type = UnsignedByteType } // Extract normal from color-encoded texture (SSGI consumes the node form). const normalNode = scenePassNormal @@ -532,10 +534,15 @@ const PostProcessingPasses = ({ // band read as a visible line along the horizon. const aoFarFade = smoothstep(tslFloat(0.9994), tslFloat(0.9998), sampleEdgeDepth(screenUV)) ao = mix(ao, tslFloat(1), aoFarFade) + const lightingWeight = scenePass.getTextureNode('lighting').r + ao = mix(tslFloat(1), ao, lightingWeight) // Composite: scene * AO + diffuse * GI sceneColor = vec4( - add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))), + add( + scenePassColor.rgb.mul(ao), + add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi).mul(lightingWeight)), + ), contentAlpha, ) } diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index ff8b3eed64..05fe443f82 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,12 +1,14 @@ import { useGLTF } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' +import { preserveGltfUnlit } from '../lib/gltf-unlit' import { configureKtx2Support } from '../lib/ktx2-loader' const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { + loader.register(preserveGltfUnlit) configureKtx2Support(loader, gl) loader.setMeshoptDecoder(MeshoptDecoder) }) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 0229e1d432..fa8a383cef 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -105,6 +105,7 @@ export { export type { DisplayState } from './lib/display-state' export { disposeObject3DResources } from './lib/dispose-object3d' export type { EdgeMode } from './lib/edge-style' +export { preserveGltfUnlit } from './lib/gltf-unlit' export { PERF_OVERLAY_ENABLED } from './lib/gpu-perf' export { computeHeroFraming, diff --git a/packages/viewer/src/lib/gltf-unlit.ts b/packages/viewer/src/lib/gltf-unlit.ts new file mode 100644 index 0000000000..8e32a6cc6d --- /dev/null +++ b/packages/viewer/src/lib/gltf-unlit.ts @@ -0,0 +1,24 @@ +import type { Material, Mesh, Object3D } from 'three' + +export function importedUnlitMaterial(material: Material | null): boolean { + return material?.userData.pascalImportedUnlit === true +} + +/** Native basic materials still use AO; imported unlit assets already own their lighting. */ +export function preserveGltfUnlit() { + return { + name: 'PASCAL_imported_unlit', + afterRoot({ scenes }: { scenes: Object3D[] }) { + for (const scene of scenes) { + scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + for (const material of [mesh.material].flat()) { + if ('isMeshBasicMaterial' in material) material.userData.pascalImportedUnlit = true + } + }) + } + return null + }, + } +} diff --git a/packages/viewer/src/lib/ink-edges.ts b/packages/viewer/src/lib/ink-edges.ts index 574fd7163e..04db5f9928 100644 --- a/packages/viewer/src/lib/ink-edges.ts +++ b/packages/viewer/src/lib/ink-edges.ts @@ -14,6 +14,7 @@ import { viewZToPerspectiveDepth, } from 'three/tsl' +import type { Node } from 'three/webgpu' import { unpackRGBToNormal } from './tsl-compat' export function createEdgeDepthSampler(depthTex: any, camera: Camera) { @@ -62,7 +63,7 @@ export function inkedEdges({ // darkness — these are what distinguish soft (thin/faint) from strong // (thick/solid); the edge masks themselves saturate, so a gain wouldn't. // Opacity may be a TSL node (theme-driven scaling) or a plain number. - radius: number + radius: number | Node<'float'> opacity: any sceneRgb: any }) { diff --git a/packages/viewer/src/lib/scene-pass-mrt.test.ts b/packages/viewer/src/lib/scene-pass-mrt.test.ts index ab1db49e7b..08c19d794a 100644 --- a/packages/viewer/src/lib/scene-pass-mrt.test.ts +++ b/packages/viewer/src/lib/scene-pass-mrt.test.ts @@ -1,14 +1,19 @@ import { expect, test } from 'bun:test' import { MaterialBlending, NoBlending } from 'three' import { diffuseColor, normalView, output } from 'three/tsl' -import { scenePassMrt } from './scene-pass-mrt' +import { sceneLightingMask, scenePassMrt } from './scene-pass-mrt' import { packNormalToRGB } from './tsl-compat' -test('the scene pass blends its normal and diffuse attachments as it does its colour', () => { - const target = scenePassMrt({ output, diffuseColor, normal: packNormalToRGB(normalView) }) +test('the scene pass blends its material attachments as it does its colour', () => { + const target = scenePassMrt({ + output, + diffuseColor, + normal: packNormalToRGB(normalView), + lighting: sceneLightingMask(), + }) // A bare mrt() overwrites every attachment but `output` whatever a draw's alpha: a transparent // sprite then stamps its whole quad into the normals the ink reads. - for (const name of ['output', 'diffuseColor', 'normal']) { + for (const name of ['output', 'diffuseColor', 'normal', 'lighting']) { expect(target.getBlendMode(name).blending).toBe(MaterialBlending) } expect(target.getBlendMode('depth').blending).toBe(NoBlending) diff --git a/packages/viewer/src/lib/scene-pass-mrt.ts b/packages/viewer/src/lib/scene-pass-mrt.ts index 39d1224be8..f7381553bf 100644 --- a/packages/viewer/src/lib/scene-pass-mrt.ts +++ b/packages/viewer/src/lib/scene-pass-mrt.ts @@ -1,9 +1,18 @@ import { MaterialBlending } from 'three' -import { mrt } from 'three/tsl' +import { diffuseColor, mrt, uniform, vec4 } from 'three/tsl' import { BlendMode } from 'three/webgpu' +import { importedUnlitMaterial } from './gltf-unlit' const materialBlend = new BlendMode(MaterialBlending) +/** Unlit assets already contain their lighting; screen-space AO/GI must not light them again. */ +export function sceneLightingMask() { + const weight = uniform(1).onObjectUpdate(({ material }) => + importedUnlitMaterial(material) ? 0 : 1, + ) + return vec4(weight, 0, 0, diffuseColor.a) +} + /** * The scene pass's attachments. three blends `output` by the material and leaves every other * attachment unblended, so a transparent draw overwrites the whole of what it covers in the normals @@ -15,4 +24,5 @@ export function scenePassMrt(outputs: Parameters[0]) { return mrt(outputs) .setBlendMode('diffuseColor', materialBlend) .setBlendMode('normal', materialBlend) + .setBlendMode('lighting', materialBlend) } diff --git a/packages/viewer/src/lib/snapshot-pipeline.ts b/packages/viewer/src/lib/snapshot-pipeline.ts index 1b333896e4..78b4a94c61 100644 --- a/packages/viewer/src/lib/snapshot-pipeline.ts +++ b/packages/viewer/src/lib/snapshot-pipeline.ts @@ -8,7 +8,6 @@ import { diffuseColor, float, mix, - mrt, normalView, output, renderOutput, @@ -33,6 +32,7 @@ import type { SceneAtmosphereSource } from '../components/viewer/scene-atmospher import { backdropGradient, deepSkyColor, horizonHazeColor } from './backdrop' import { type EdgeMode, edgeColorFor, edgeOpacityScaleFor } from './edge-style' import { createEdgeDepthSampler, inkedEdges } from './ink-edges' +import { sceneLightingMask, scenePassMrt } from './scene-pass-mrt' import { getSceneTheme } from './scene-themes' import { packNormalToRGB, unpackRGBToNormal } from './tsl-compat' @@ -180,10 +180,11 @@ export async function createSnapshotPipeline({ // inside FXAA's intermediate render target. const scenePass = new SnapshotScenePass('color', scene, camera) scenePass.setMRT( - mrt({ + scenePassMrt({ output, diffuseColor, normal: packNormalToRGB(normalView), + lighting: sceneLightingMask(), }), ) @@ -194,12 +195,14 @@ export async function createSnapshotPipeline({ scenePass.getTexture('diffuseColor').type = UnsignedByteType scenePass.getTexture('normal').type = UnsignedByteType + scenePass.getTexture('lighting').type = UnsignedByteType const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv))) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) - giPass.sliceCount.value = SSGI_PARAMS.sliceCount - giPass.stepCount.value = SSGI_PARAMS.stepCount + // A saved still has no later frames to hide the viewport's sampling noise. + giPass.sliceCount.value = 3 + giPass.stepCount.value = 8 giPass.radius.value = SSGI_PARAMS.radius giPass.expFactor.value = SSGI_PARAMS.expFactor giPass.thickness.value = SSGI_PARAMS.thickness @@ -222,14 +225,14 @@ export async function createSnapshotPipeline({ // horizon picks up a visible AO line in captures. const aoFarFade = smoothstep(float(0.9994), float(0.9998), sampleEdgeDepth(screenUV)) const ao = mix((denoisePass as any).r, float(1), aoFarFade) - const aoRgb = scenePassColor.rgb.mul(ao) + const aoRgb = scenePassColor.rgb.mul(mix(float(1), ao, scenePass.getTextureNode('lighting').r)) // Ink edges, mirroring the viewport pipeline (AO → ink → grade) so // captures carry the same soft/strong edge look the canvas shows. // Uniform-gated like grade/backdrop: one cached pipeline serves all modes. // Radius scales with render height: a supersampled capture (4K → 1080p) // would otherwise halve the apparent line weight vs the viewport's 1px. - const inkRadius = Math.max(1, Math.round(renderer.domElement.height / 1080)) + const inkRadius = uniform(1) const inkedRgb = inkedEdges({ sceneRgb: aoRgb, sampleDepth: sampleEdgeDepth, @@ -336,6 +339,7 @@ export async function createSnapshotPipeline({ const standardW = standardSize?.w ?? THUMBNAIL_WIDTH const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT const { width: captureWidth, height: captureHeight } = renderer.domElement + inkRadius.value = Math.max(1, Math.round(captureHeight / 1080)) // Resize RT if the canvas dimensions changed if (renderTarget.width !== captureWidth || renderTarget.height !== captureHeight) { From 3ddc4b967d1b2ec8e6650075b910e0d947e46704 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 18 Oct 2026 06:29:39 -0400 Subject: [PATCH 02/45] fix(nodes): preserve authored glass materials on imported items --- packages/nodes/src/item/renderer.test.tsx | 72 ++++++++++++++++++++++- packages/nodes/src/item/renderer.tsx | 2 +- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/nodes/src/item/renderer.test.tsx b/packages/nodes/src/item/renderer.test.tsx index 77d2227323..4b61934a24 100644 --- a/packages/nodes/src/item/renderer.test.tsx +++ b/packages/nodes/src/item/renderer.test.tsx @@ -11,7 +11,7 @@ import { } from '@pascal-app/core' import { resolveCdnUrl, useViewer } from '@pascal-app/viewer' import { useLoader } from '@react-three/fiber' -import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from 'three' +import { BoxGeometry, DataTexture, Group, Mesh, MeshStandardMaterial } from 'three' import { installMountedScene, mount } from '../__tests__/harness' import { ScriptedOpeningModel } from '../shared/scripted-opening' import { itemDefinition } from './definition' @@ -20,6 +20,76 @@ import { ItemRenderer } from './renderer' installMountedScene({ nodes: [itemDefinition] }) +test.each([false, true])( + 'authored Glass appearance survives item rendering (material array: %s)', + async (array) => { + useViewer.setState({ textures: true, shading: 'rendered' }) + const texture = new DataTexture(new Uint8Array([33, 80, 120, 90]), 1, 1) + const authored = new MeshStandardMaterial({ + color: '#227799', + map: texture, + transparent: true, + opacity: 0.42, + roughness: 0.17, + metalness: 0.23, + }) + authored.name = 'Glass' + const node = ItemNode.parse({ + id: 'item_authored-glass', + asset: { + id: 'authored-glass', + name: 'Authored glazing', + category: 'object', + src: `/authored-glass-${array}.glb`, + thumbnail: '', + dimensions: [1, 2, 0.02], + }, + }) + useScene.setState({ nodes: { [node.id]: node } }) + const loader = spyOn(ItemGLTFLoader.prototype, 'load').mockImplementation((_url, onLoad) => { + const scene = new Group() + const pane = new Mesh( + new BoxGeometry(1, 2, 0.02), + array ? [authored, new MeshStandardMaterial()] : authored, + ) + pane.name = 'authored-pane' + scene.add(pane) + onLoad({ + scene, + scenes: [scene], + animations: [], + cameras: [], + asset: { version: '2.0' }, + parser: {}, + } as never) + }) + const url = resolveCdnUrl(node.asset.src)! + try { + useLoader.preload(ItemGLTFLoader, url) + await Promise.resolve() + const renderer = await mount() + const pane = renderer.scene.instance.getObjectByName('authored-pane') as Mesh + const material = ( + Array.isArray(pane.material) ? pane.material[0] : pane.material + ) as MeshStandardMaterial + expect(material.color.getHexString()).toBe('227799') + expect(material).toMatchObject({ + transparent: true, + opacity: 0.42, + roughness: 0.17, + metalness: 0.23, + }) + expect(material.map?.image.data).toEqual(texture.image.data) + await renderer.unmount() + } finally { + useLoader.clear(ItemGLTFLoader, url) + loader.mockRestore() + texture.dispose() + authored.dispose() + } + }, +) + const source = { kind: 'script' as const, script: 'b'.repeat(64), diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 746e9d4aa3..dcab151674 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -190,7 +190,7 @@ const resolveItemMaterial = ( ): Material => { // Monochrome (textures off): collapse to the themed furnishing clay colour. if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset) - if (authoredMaterial.name.toLowerCase() === 'glass') return glassMaterial + // Authored material names do not opt an asset into a Pascal preset. if (slotId != null) { const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading) if (override) return override From 97ed5918b8868e1db9b34a2ca788cce15ffa3a62 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 18 Oct 2026 09:30:33 -0400 Subject: [PATCH 03/45] feat: let authored landscapes replace site ground presentation --- packages/core/src/schema/nodes/site.test.ts | 11 ++++++++++ packages/core/src/schema/nodes/site.ts | 3 +++ packages/mcp/src/resources/agent-guide.ts | 1 + packages/mcp/src/tools/apply-patch.test.ts | 20 +++++++++++++++++++ packages/nodes/src/site/panel.tsx | 8 ++++++++ packages/nodes/src/site/renderer.tsx | 10 ++++++---- skills/pascal-3d/references/tool-workflows.md | 2 ++ 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/core/src/schema/nodes/site.test.ts b/packages/core/src/schema/nodes/site.test.ts index 7826d73073..cbd6aa5082 100644 --- a/packages/core/src/schema/nodes/site.test.ts +++ b/packages/core/src/schema/nodes/site.test.ts @@ -5,6 +5,17 @@ import { SiteNode } from './site' import { migrateSiteMetadata } from './site-facts' describe('SiteNode.terrain', () => { + test('ground presentation is optional and survives a scene round-trip without changing children', () => { + const original = SiteNode.parse({ id: 'site_authored-ground', children: ['building_house'] }) + expect(original.showGround).toBeUndefined() + const restored = SiteNode.parse(JSON.parse(JSON.stringify({ ...original, showGround: false }))) + expect(restored.showGround).toBe(false) + expect(restored.children).toEqual(original.children) + expect(restored.polygon).toEqual(original.polygon) + expect(restored.visible).toBe(original.visible) + expect(SiteNode.safeParse({ ...original, showGround: 'false' }).success).toBe(false) + }) + test('a scene saved before terrain existed still parses', () => { const parsed = SiteNode.parse({ id: 'site_1', type: 'site' }) expect(parsed.terrain).toBeUndefined() diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index 6a7c427ab1..3f6d6b6457 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -87,6 +87,8 @@ export const SiteNode = BaseNode.extend({ * so ~11 KB of base64 zeroes does not land in every saved scene. */ terrain: TerrainData.optional(), + /** Hide site ground, including sculpted terrain, and the horizon when other geometry supplies them. */ + showGround: z.boolean().optional(), /** * Address + parcel provenance. All optional so every scene that predates * the parcel service keeps loading unchanged. @@ -145,6 +147,7 @@ export const SiteNode = BaseNode.extend({ Site node - used to represent a site - polygon: polygon data (metres, x east, z south, origin = geocoded point) - terrain: optional sculpted heightfield; absent means flat ground + - showGround: false hides site ground (including sculpted terrain) and horizon in 3D; buildings and parcel lines remain - address / parcel: civic address + GIS parcel provenance (optional) - setbacks: required yards in metres; setbacksSource / zone cite them - frontEdge: index of the street-facing polygon edge diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index ba565f2943..3a0744b917 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -38,6 +38,7 @@ export const AGENT_GUIDE = [ '## Scene Creation Rules', '', '- The site comes first, before the first wall: `get_site` gives the lot, the buildable area inside its setbacks with its centre and its frontage and depth on the street, the footprint a house usually covers, the ground and whether the lot is only the new-project placeholder, in the level coordinates you draw in. Then `place_building` with the outline of the plan you are about to draw, in the coordinates you will draw it (and the yards a site plan gives), so the house rises inside the lot, centred. `create_room` and `add_wall` take your coordinates as given and say when they cross the lot line; `verify_scene` reports a house past it.', + '- When authored landscape geometry supplies the ground, `apply_patch` the site with `showGround: false` to hide its site ground (including sculpted terrain) and horizon in both editor and viewer without shifting the building or hiding parcel lines. The Site panel offers the same Show ground control; omitted or true retains existing presentation.', '- Prefer semantic tools over raw graph patches.', '- Do not hand-write node graphs unless no semantic tool exists.', "- A call whose arguments a tool's schema rejects is refused with the code `invalid_input` before anything runs: read the message, fix the arguments and call again. A level you write without a `height` is given the height the editor would derive from what it holds (an empty level 2.5 m), and `apply_patch` says so in `notes`.", diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts index 746d3ef65b..ae246acc28 100644 --- a/packages/mcp/src/tools/apply-patch.test.ts +++ b/packages/mcp/src/tools/apply-patch.test.ts @@ -55,6 +55,26 @@ describe('apply_patch', () => { expect((stored as { thickness?: number }).thickness).toBe(0.2) }) + test('can replace site ground presentation without changing the site or its buildings', async () => { + const before = structuredClone(bridge.getNodes()) + const site = Object.values(before).find((node) => node.type === 'site')! + const result = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: site.id, data: { showGround: false } }] }, + }) + expect(result.isError).toBeFalsy() + expect(bridge.getNodes()).toEqual({ + ...before, + [site.id]: { ...site, showGround: false }, + }) + const restored = await client.callTool({ + name: 'apply_patch', + arguments: { patches: [{ op: 'update', id: site.id, data: { showGround: null } }] }, + }) + expect(restored.isError).toBeFalsy() + expect(bridge.getNodes()).toEqual(before) + }) + // A pier's `material: {color}` reported applied and stored {}, and // `materialPreset: null` was refused, so the agent could not clear the preset hiding it. test('an update the node would drop is refused, naming the path; null clears a field', async () => { diff --git a/packages/nodes/src/site/panel.tsx b/packages/nodes/src/site/panel.tsx index 80ffea92a7..5e57546a17 100644 --- a/packages/nodes/src/site/panel.tsx +++ b/packages/nodes/src/site/panel.tsx @@ -276,6 +276,14 @@ export function SiteNodePanel() { +