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')
}