fix: harden item and baked scene rendering (#522)
This commit is contained in:
@@ -3,8 +3,16 @@
|
|||||||
import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
import {
|
||||||
|
BoxGeometry,
|
||||||
|
type BufferGeometry,
|
||||||
|
EdgesGeometry,
|
||||||
|
type Group,
|
||||||
|
type Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
|
|
||||||
@@ -41,6 +49,13 @@ const fillMaterial = new MeshBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
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 {
|
function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry {
|
||||||
const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
|
const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
|
||||||
const edges = new EdgesGeometry(box)
|
const edges = new EdgesGeometry(box)
|
||||||
@@ -60,26 +75,60 @@ function makeOutlineGeometry(width: number, height: number, depth: number): Buff
|
|||||||
*/
|
*/
|
||||||
export function WallOpeningHighlights() {
|
export function WallOpeningHighlights() {
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
const hoveredId = useViewer((state) => state.hoveredId)
|
||||||
const { scene } = useThree()
|
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) => (
|
||||||
<SelectionOpeningHighlights key={id} selectedId={id} />
|
<OpeningOutlineProxy key={id} openingId={id} />
|
||||||
))}
|
))}
|
||||||
</>,
|
{selectedIds.length > 0 &&
|
||||||
scene,
|
createPortal(
|
||||||
|
<>
|
||||||
|
{selectedIds.map((id) => (
|
||||||
|
<SelectionOpeningHighlights key={id} selectedId={id} />
|
||||||
|
))}
|
||||||
|
</>,
|
||||||
|
scene,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves a selected node into the opening highlight(s) to draw:
|
function OpeningOutlineProxy({ openingId }: { openingId: string }) {
|
||||||
// - a selected wall → a hint over each door / window it hosts ("editable
|
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
|
||||||
// child here").
|
const geometryRevision = useViewer((state) => state.geometryRevision)
|
||||||
// - a directly-selected frameless opening (a `door` whose `openingKind` is
|
const proxyRef = useRef<Mesh | null>(null)
|
||||||
// `'opening'`) → a fill over its own cutout, so the selection reads as
|
const isFramelessOpening = node?.type === 'door' && node.openingKind === 'opening'
|
||||||
// occupied even though the opening renders no geometry of its own.
|
|
||||||
|
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 }) {
|
function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
|
||||||
const node = useScene((state) => state.nodes[selectedId as AnyNodeId])
|
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 <SelectedOpeningHighlight openingId={selectedId} parentId={node.parentId} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
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 <OpeningHighlight depth={depth} openingId={openingId} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
|
function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
|
||||||
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
|
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
|
||||||
// Resize arrows publish width/height to the live-override store during the
|
// Resize arrows publish width/height to the live-override store during the
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ describe('prepareSceneForExport', () => {
|
|||||||
expect(meshes).toHaveLength(1)
|
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', () => {
|
test('neutralises an invisible hitbox root but keeps its visible children', () => {
|
||||||
// Door/window roots are selection hitboxes: a box geometry with an invisible
|
// Door/window roots are selection hitboxes: a box geometry with an invisible
|
||||||
// material (object stays visible). Left intact it would plug the wall opening.
|
// material (object stays visible). Left intact it would plug the wall opening.
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip everything that must not bake into the model:
|
* 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
|
* - Editor overlays on non-scene layers (gizmos, selection handles, ground
|
||||||
* grid, zone fills). The editor camera shows them via extra layers; a
|
* 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
|
* 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<THREE.Object3D>) {
|
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
||||||
const toRemove: THREE.Object3D[] = []
|
const toRemove: THREE.Object3D[] = []
|
||||||
root.traverse((object) => {
|
root.traverse((object) => {
|
||||||
|
if (object.userData.pascalExport === 'strip') {
|
||||||
|
toRemove.push(object)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
||||||
// live off the scene layer; the editor camera shows them via extra layers
|
// 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 —
|
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type ColorPreset,
|
type ColorPreset,
|
||||||
|
configureKtx2Support,
|
||||||
createDefaultMaterial,
|
createDefaultMaterial,
|
||||||
createSurfaceRoleMaterial,
|
createSurfaceRoleMaterial,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { useAnimations } from '@react-three/drei'
|
import { useAnimations } from '@react-three/drei'
|
||||||
import { Clone } from '@react-three/drei/core/Clone'
|
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 { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
||||||
import { MathUtils } from 'three'
|
import { MathUtils } from 'three'
|
||||||
@@ -209,7 +210,8 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
|||||||
|
|
||||||
let itemDracoLoader: DRACOLoader | null = null
|
let itemDracoLoader: DRACOLoader | null = null
|
||||||
|
|
||||||
const configureItemModelLoader = (loader: ItemGLTFLoader) => {
|
const configureItemModelLoader = (loader: ItemGLTFLoader, renderer: unknown) => {
|
||||||
|
configureKtx2Support(loader, renderer)
|
||||||
if (!itemDracoLoader) {
|
if (!itemDracoLoader) {
|
||||||
itemDracoLoader = new DRACOLoader(loader.manager)
|
itemDracoLoader = new DRACOLoader(loader.manager)
|
||||||
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
|
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
|
||||||
@@ -223,8 +225,12 @@ type LoadedItemGltf = GLTF & {
|
|||||||
nodes: Record<string, Object3D>
|
nodes: Record<string, Object3D>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useItemGltf = (url: string): LoadedItemGltf =>
|
const useItemGltf = (url: string): LoadedItemGltf => {
|
||||||
useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf
|
const renderer = useThree((state) => state.gl)
|
||||||
|
return useLoader(ItemGLTFLoader, url, (loader) =>
|
||||||
|
configureItemModelLoader(loader, renderer),
|
||||||
|
) as LoadedItemGltf
|
||||||
|
}
|
||||||
|
|
||||||
type DeferredUnavailableCleanup = {
|
type DeferredUnavailableCleanup = {
|
||||||
consumers: number
|
consumers: number
|
||||||
@@ -413,13 +419,12 @@ function getPreviewMaterial(shading: RenderShading): Material {
|
|||||||
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||||
const shading = useViewer((s) => s.shading)
|
const shading = useViewer((s) => s.shading)
|
||||||
const isExporting = useViewer((s) => s.isExporting)
|
const isExporting = useViewer((s) => s.isExporting)
|
||||||
|
const [w, h, d] = getScaledDimensions(node)
|
||||||
// Loading placeholder — must never land in an exported GLB.
|
// Loading placeholder — must never land in an exported GLB.
|
||||||
if (isExporting) return null
|
if (isExporting) return null
|
||||||
return (
|
return (
|
||||||
<mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
|
<mesh material={getPreviewMaterial(shading)} position-y={h / 2}>
|
||||||
<boxGeometry
|
<boxGeometry args={[w, h, d]} />
|
||||||
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}
|
|
||||||
/>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => {
|
||||||
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
||||||
const unavailable = getUnavailableItemAsset(gltf)
|
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.
|
// Undo can unmount one item while another clone of the same asset still needs them.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Clone
|
<group scale={node.scale}>
|
||||||
dispose={null}
|
<Clone
|
||||||
object={scene}
|
dispose={null}
|
||||||
position={node.asset.offset}
|
object={scene}
|
||||||
ref={ref}
|
position={node.asset.offset}
|
||||||
rotation={node.asset.rotation}
|
ref={ref}
|
||||||
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
|
rotation={node.asset.rotation}
|
||||||
{...handlers}
|
scale={node.asset.scale || [1, 1, 1]}
|
||||||
/>
|
{...handlers}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
{animations.length > 0 && (
|
{animations.length > 0 && (
|
||||||
<ItemAnimation
|
<ItemAnimation
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
raycast={noopRaycast}
|
raycast={noopRaycast}
|
||||||
receiveShadow
|
receiveShadow
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
userData={{ pascalExport: 'strip' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ export function GlbScene({
|
|||||||
_camBox.setFromObject(object)
|
_camBox.setFromObject(object)
|
||||||
} else {
|
} else {
|
||||||
bookmarkNode = rootNode
|
bookmarkNode = rootNode
|
||||||
_camBox.setFromObject(gltf.scene)
|
_camBox.setFromObject(rootNode ?? gltf.scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera
|
const bookmark = (bookmarkNode?.userData as PascalExtras | undefined)?.camera
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { useGLTF } from '@react-three/drei'
|
import { useGLTF } from '@react-three/drei'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
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<typeof useGLTF> => {
|
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
||||||
const gl = useThree((state) => state.gl)
|
const gl = useThree((state) => state.gl)
|
||||||
|
|
||||||
return useGLTF(path, true, true, (loader) => {
|
return useGLTF(path, true, true, (loader) => {
|
||||||
if (ensureKtx2Support(gl)) {
|
configureKtx2Support(loader, gl)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
loader.setKTX2Loader(ktx2Loader as any)
|
|
||||||
}
|
|
||||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export {
|
|||||||
collectIsolationSubtree,
|
collectIsolationSubtree,
|
||||||
isIsolationActive,
|
isIsolationActive,
|
||||||
} from './lib/isolation'
|
} 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 { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ export function ensureKtx2Support(renderer: unknown): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function configureKtx2Support<T>(
|
||||||
|
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 {
|
export function isKtx2Url(url: string): boolean {
|
||||||
return url.toLowerCase().endsWith('.ktx2')
|
return url.toLowerCase().endsWith('.ktx2')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user