From 10c9c6ad27273b05f44eb55980b3e4e4259cd2c8 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 12:15:08 -0400 Subject: [PATCH] fix: harden item and baked scene rendering (#522) --- .../editor/wall-opening-highlights.tsx | 95 ++++++++++++------- packages/editor/src/lib/glb-export.test.ts | 16 ++++ packages/editor/src/lib/glb-export.ts | 8 ++ packages/nodes/src/item/renderer.tsx | 46 ++++----- packages/nodes/src/site/renderer.tsx | 1 + .../src/components/viewer/glb-scene.tsx | 2 +- packages/viewer/src/hooks/use-gltf-ktx2.tsx | 7 +- packages/viewer/src/index.ts | 2 +- packages/viewer/src/lib/ktx2-loader.ts | 9 ++ 9 files changed, 125 insertions(+), 61 deletions(-) diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx index 6718acb6..a72bfc1b 100644 --- a/packages/editor/src/components/editor/wall-opening-highlights.tsx +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -3,8 +3,16 @@ import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { createPortal, useFrame, useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef } from 'react' -import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + type BufferGeometry, + EdgesGeometry, + type Group, + type Mesh, + MeshBasicMaterial, + Vector3, +} from 'three' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' @@ -41,6 +49,13 @@ const fillMaterial = new MeshBasicNodeMaterial({ depthWrite: false, }) +// Keep the cutout in the render list for the outliner's override material +// without letting its source material alter the normal scene pass. +const outlineProxyMaterial = new MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, +}) + function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry { const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD) const edges = new EdgesGeometry(box) @@ -60,26 +75,60 @@ function makeOutlineGeometry(width: number, height: number, depth: number): Buff */ export function WallOpeningHighlights() { const selectedIds = useViewer((state) => state.selection.selectedIds) + const hoveredId = useViewer((state) => state.hoveredId) const { scene } = useThree() + const outlineProxyIds = Array.from(new Set(hoveredId ? [...selectedIds, hoveredId] : selectedIds)) - if (selectedIds.length === 0) return null + if (selectedIds.length === 0 && !hoveredId) return null - return createPortal( + return ( <> - {selectedIds.map((id) => ( - + {outlineProxyIds.map((id) => ( + ))} - , - scene, + {selectedIds.length > 0 && + createPortal( + <> + {selectedIds.map((id) => ( + + ))} + , + scene, + )} + ) } -// Resolves a selected node into the opening highlight(s) to draw: -// - a selected wall → a hint over each door / window it hosts ("editable -// child here"). -// - a directly-selected frameless opening (a `door` whose `openingKind` is -// `'opening'`) → a fill over its own cutout, so the selection reads as -// occupied even though the opening renders no geometry of its own. +function OpeningOutlineProxy({ openingId }: { openingId: string }) { + const node = useScene((state) => state.nodes[openingId as AnyNodeId]) + const geometryRevision = useViewer((state) => state.geometryRevision) + const proxyRef = useRef(null) + const isFramelessOpening = node?.type === 'door' && node.openingKind === 'opening' + + useLayoutEffect(() => { + void geometryRevision + if (!isFramelessOpening) return + const root = sceneRegistry.nodes.get(openingId as AnyNodeId) + const proxy = root?.getObjectByName('cutout') as Mesh | undefined + if (!proxy) return + + proxy.material = outlineProxyMaterial + proxy.visible = true + proxyRef.current = proxy + + return () => { + proxy.visible = false + proxyRef.current = null + } + }, [geometryRevision, isFramelessOpening, openingId]) + + useFrame(() => { + if (proxyRef.current) proxyRef.current.visible = true + }) + + return null +} + function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) { const node = useScene((state) => state.nodes[selectedId as AnyNodeId]) @@ -94,27 +143,9 @@ function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) { ) } - if (node?.type === 'door' && node.openingKind === 'opening') { - return - } - return null } -// A frameless opening selected on its own. Pulls the cutout depth from its -// host wall's thickness so the fill block matches the wall it sits in. -function SelectedOpeningHighlight({ - openingId, - parentId, -}: { - openingId: string - parentId: string | null -}) { - const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined)) - const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1 - return -} - function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) { const node = useScene((state) => state.nodes[openingId as AnyNodeId]) // Resize arrows publish width/height to the live-override store during the diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 6e07c38b..94c0e7de 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -78,6 +78,22 @@ describe('prepareSceneForExport', () => { expect(meshes).toHaveLength(1) }) + test('strips presentation-only geometry marked by its renderer', () => { + const root = new THREE.Group() + const siteGround = meshWithNodeMaterial(nodeMaterial()) + const horizonDisc = meshWithNodeMaterial(nodeMaterial()) + horizonDisc.userData.pascalExport = 'strip' + root.add(siteGround, horizonDisc) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes: THREE.Mesh[] = [] + scene.traverse((object) => { + if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh) + }) + expect(meshes).toHaveLength(1) + }) + test('neutralises an invisible hitbox root but keeps its visible children', () => { // Door/window roots are selection hitboxes: a box geometry with an invisible // material (object stays visible). Left intact it would plug the wall opening. diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index add2ea99..e6b67b5b 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -185,6 +185,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false }) /** * Strip everything that must not bake into the model: + * - Renderer-owned presentation geometry explicitly marked + * `userData.pascalExport = 'strip'` (for example the site's 800 m horizon + * disc). These meshes make the authoring viewport look grounded but aren't + * part of the portable scene artifact. * - Editor overlays on non-scene layers (gizmos, selection handles, ground * grid, zone fills). The editor camera shows them via extra layers; a * thumbnail/bake is layer 0 only. Scene-layer affordances that can't be @@ -199,6 +203,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false }) function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set) { const toRemove: THREE.Object3D[] = [] root.traverse((object) => { + if (object.userData.pascalExport === 'strip') { + toRemove.push(object) + return + } // Editor-only overlays (gizmos, selection handles, ground grid, zone fills) // live off the scene layer; the editor camera shows them via extra layers // but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree — diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 0dd6242a..6fdac599 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -20,6 +20,7 @@ import { } from '@pascal-app/core' import { type ColorPreset, + configureKtx2Support, createDefaultMaterial, createSurfaceRoleMaterial, ErrorBoundary, @@ -34,7 +35,7 @@ import { } from '@pascal-app/viewer' import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' -import { useFrame, useLoader } from '@react-three/fiber' +import { useFrame, useLoader, useThree } from '@react-three/fiber' import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three' import { MathUtils } from 'three' @@ -209,7 +210,8 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => { let itemDracoLoader: DRACOLoader | null = null -const configureItemModelLoader = (loader: ItemGLTFLoader) => { +const configureItemModelLoader = (loader: ItemGLTFLoader, renderer: unknown) => { + configureKtx2Support(loader, renderer) if (!itemDracoLoader) { itemDracoLoader = new DRACOLoader(loader.manager) itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/') @@ -223,8 +225,12 @@ type LoadedItemGltf = GLTF & { nodes: Record } -const useItemGltf = (url: string): LoadedItemGltf => - useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf +const useItemGltf = (url: string): LoadedItemGltf => { + const renderer = useThree((state) => state.gl) + return useLoader(ItemGLTFLoader, url, (loader) => + configureItemModelLoader(loader, renderer), + ) as LoadedItemGltf +} type DeferredUnavailableCleanup = { consumers: number @@ -413,13 +419,12 @@ function getPreviewMaterial(shading: RenderShading): Material { const PreviewModel = ({ node }: { node: ItemNode }) => { const shading = useViewer((s) => s.shading) const isExporting = useViewer((s) => s.isExporting) + const [w, h, d] = getScaledDimensions(node) // Loading placeholder — must never land in an exported GLB. if (isExporting) return null return ( - - + + ) } @@ -444,11 +449,6 @@ const ClearPreviewModel = ({ node }: { node: ItemNode }) => { ) } -const multiplyScales = ( - a: [number, number, number], - b: [number, number, number], -): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]] - const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => { const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '') const unavailable = getUnavailableItemAsset(gltf) @@ -581,15 +581,17 @@ const LoadedModelRenderer = ({ // Undo can unmount one item while another clone of the same asset still needs them. return ( <> - + + + {animations.length > 0 && ( { raycast={noopRaycast} receiveShadow rotation={[-Math.PI / 2, 0, 0]} + userData={{ pascalExport: 'strip' }} /> )} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 7f64f622..d3b26f9f 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -525,7 +525,7 @@ export function GlbScene({ _camBox.setFromObject(object) } else { bookmarkNode = rootNode - _camBox.setFromObject(gltf.scene) + _camBox.setFromObject(rootNode ?? gltf.scene) } const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 000b8ff6..ff8b3eed 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -1,16 +1,13 @@ import { useGLTF } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' -import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader' +import { configureKtx2Support } from '../lib/ktx2-loader' const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { - if (ensureKtx2Support(gl)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - loader.setKTX2Loader(ktx2Loader as any) - } + configureKtx2Support(loader, gl) loader.setMeshoptDecoder(MeshoptDecoder) }) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index b0752610..9a7e7165 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -69,7 +69,7 @@ export { collectIsolationSubtree, isIsolationActive, } from './lib/isolation' -export { ensureKtx2Support } from './lib/ktx2-loader' +export { configureKtx2Support, ensureKtx2Support } from './lib/ktx2-loader' export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { applyMaterialPresetToMaterials, diff --git a/packages/viewer/src/lib/ktx2-loader.ts b/packages/viewer/src/lib/ktx2-loader.ts index 0ef7d792..24dc0b2c 100644 --- a/packages/viewer/src/lib/ktx2-loader.ts +++ b/packages/viewer/src/lib/ktx2-loader.ts @@ -162,6 +162,15 @@ export function ensureKtx2Support(renderer: unknown): boolean { } } +export function configureKtx2Support( + loader: { setKTX2Loader: (ktx2: T) => unknown }, + renderer: unknown, +): boolean { + if (!ensureKtx2Support(renderer)) return false + loader.setKTX2Loader(ktx2Loader as unknown as T) + return true +} + export function isKtx2Url(url: string): boolean { return url.toLowerCase().endsWith('.ktx2') }