Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions packages/core/src/systems/wall/wall-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import {
calculateLevelMiters,
getAdjacentWallIds,
getWallMiterBoundaryPoints,
type Point2D,
type WallMiterData,
pointToKey,
type WallMiterData,
} from './wall-mitering'

// Reusable CSG evaluator for better performance
Expand Down Expand Up @@ -181,14 +181,32 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {

const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)

// Defensive: if the wall collapsed to zero length (bad data, or a
// cluster pass that over-merged short walls), `generateExtrudedWall`
// returns an empty BufferGeometry with no position attribute. The
// WebGPU renderer crashes reading `.count` on undefined, so hide the
// mesh instead of assigning the empty geometry. The wall stays in
// the scene graph (so Ctrl+Z can still recover it) but draws
// nothing until the start/end become valid again.
if (!newGeo.attributes.position) {
newGeo.dispose()
mesh.visible = false
return
}
mesh.visible = node.visible ?? true

mesh.geometry.dispose()
mesh.geometry = newGeo
// Update collision mesh
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation)
collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo
if (collisionGeo.attributes.position) {
collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo
} else {
collisionGeo.dispose()
}
}

mesh.position.set(node.start[0], slabElevation, node.start[1])
Expand Down
12 changes: 12 additions & 0 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const walkthroughMode = useViewer((s) => s.walkthroughMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection)
Expand Down Expand Up @@ -365,6 +366,17 @@ export const CustomCameraControls = () => {
useViewer.getState().setCameraDragging(false)
}, [])

// The editor's first-person mode is driven by <FirstPersonControls />
// (mounted as a sibling in editor/index.tsx via isFirstPersonMode).
// It takes over the camera with pointer lock + WASD, so we must
// return null here — otherwise drei's CameraControls runs in parallel
// and fights FirstPersonControls for the camera, which is exactly why
// the "walkthrough" button on desktop appeared to do nothing (the
// user saw orbit behaviour because CameraControls was still winning).
if (isFirstPersonMode) {
return null
}

if (walkthroughMode) {
return <WalkthroughControls />
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import {
type CeilingNode,
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
Expand Down Expand Up @@ -38,12 +44,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling')

// Mark dirty on mount so CeilingSystem regenerates the polygon
// geometry after a <Viewer> remount. Without this the placeholder
// zero-size box persists and the ceiling disappears. See
// WallRenderer for the same pattern.
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])

const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999'
return createCeilingMaterials(color)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])

return (
<mesh material={materials.bottomMaterial} ref={ref}>
Expand Down
14 changes: 12 additions & 2 deletions packages/viewer/src/components/renderers/door/door-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type DoorNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'
Expand All @@ -11,6 +11,16 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient

// Mark this node dirty on mount so DoorSystem regenerates its
// geometry on the next frame. Without this, the DoorRenderer keeps
// its zero-size placeholder box forever whenever the <Viewer>
// remounts (e.g. entering preview mode, switching view modes), and
// the door visually disappears — DoorSystem only processes nodes in
// the dirtyNodes set. See WallRenderer for the same pattern.
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])

const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_DOOR_MATERIAL
Expand Down
61 changes: 54 additions & 7 deletions packages/viewer/src/components/renderers/item/item-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,40 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {

useRegistry(node.id, node.type, ref)

// Pick a render path based on whether the item has a loadable model.
//
// - Items with a resolvable `asset.src` → load the GLTF via useGLTF,
// show the animated `PreviewModel` as the Suspense fallback while
// the model downloads.
//
// - Items without a resolvable src (e.g. scanned furniture from
// RoomPlan that ships with a `placeholder` asset and no URL) →
// render `PlaceholderBox` instead. This is a SOLID opaque box, NOT
// the animated preview material. Using PreviewModel as a
// permanent render looks broken: it has `depthTest: false` and an
// animated time-based opacity, so it renders on top of walls and
// pulses, which is what the "flashing / see-through furniture"
// bug report turned out to be.
//
// Also guards `useGLTF('')` — when `resolveCdnUrl` returned null
// (e.g. for `asset://` URLs) the empty-string fallback resolved to
// the current page URL, got HTML back, and crashed GLTFLoader's JSON
// parser with an unrecoverable error that Suspense refused to
// retry (re-throwing the cached promise every render).
const src = node.asset.src
const resolvedUrl = src ? resolveCdnUrl(src) : null

return (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer node={node} />
</Suspense>
</ErrorBoundary>
{resolvedUrl ? (
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer modelUrl={resolvedUrl} node={node} />
</Suspense>
</ErrorBoundary>
) : (
<PlaceholderBox node={node} />
)}
{node.children?.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
Expand Down Expand Up @@ -84,13 +111,33 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
)
}

// Opaque stand-in for items that have no GLTF model to load. Unlike
// `previewMaterial`, this one has normal depth testing and no animated
// transparency, so scanned furniture renders as plain grey boxes that
// sit behind walls correctly instead of pulsing through them.
const placeholderMaterial = new MeshStandardNodeMaterial({
color: '#a8adb3',
roughness: 0.75,
metalness: 0.05,
})

const PlaceholderBox = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const [w, h, d] = node.asset.dimensions
return (
<mesh castShadow material={placeholderMaterial} position-y={h / 2} receiveShadow {...handlers}>
<boxGeometry args={[w, h, d]} />
</mesh>
)
}

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 }: { node: ItemNode }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ModelRenderer = ({ modelUrl, node }: { modelUrl: string; node: ItemNode }) => {
const { scene, nodes, animations } = useGLTF(modelUrl)
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
// Freeze the interactive definition at mount — asset schemas don't change at runtime
Expand Down
16 changes: 12 additions & 4 deletions packages/viewer/src/components/renderers/slab/slab-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
createMaterial,
Expand All @@ -16,9 +16,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {

const handlers = useNodeEvents(node, 'slab')

// Mark dirty on mount so SlabSystem regenerates the polygon geometry
// after a <Viewer> remount (preview mode, view mode switches).
// Otherwise the zero-size placeholder persists. See WallRenderer.
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])

const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const sourceMaterial =
presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const slabMaterial = sourceMaterial.clone()

// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRegistry, type WindowNode } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials'
Expand All @@ -11,6 +11,15 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const handlers = useNodeEvents(node, 'window')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient

// Mark dirty on mount so WindowSystem regenerates the geometry when
// the <Viewer> component remounts (entering preview mode, view mode
// switches, etc.). Without this, the placeholder zero-size box
// persists forever because WindowSystem only walks dirtyNodes. Same
// pattern as WallRenderer.
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])

const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_WINDOW_MATERIAL
Expand Down