Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
STAND_CAPSULE,
STAND_CLEARANCE,
STAND_FLOAT_HEIGHT,
setSurfaceRaycastLayers,
useViewer,
WALKTHROUGH_FOV,
} from '@pascal-app/viewer'
Expand Down Expand Up @@ -156,6 +157,7 @@ const standClearanceRaycaster = new Raycaster()
const standClearanceUp = new Vector3(0, 1, 0)
const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster()
setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
const doorLeafBox = new Box3()
const doorLeafInverseMatrix = new Matrix4()
const doorLeafLocalHit = new Vector3()
Expand All @@ -178,6 +180,7 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const windowInteractionRaycaster = new Raycaster()
setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
const hudBuildingLocalEyePosition = new Vector3()
const hudWorldEyePosition = new Vector3()
const hudLevelBounds = new Box3()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
registerNode,
ShelfNode,
SiteNode,
SlabNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { hideFromScene, STAND_CLEARANCE, showInScene } from '@pascal-app/viewer'
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'

Expand Down Expand Up @@ -91,6 +93,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
world?.dispose()
})

test('standing clearance and floor hits survive a slab source joining and leaving a batch', () => {
registerColliderDefinition('slab', SlabNode, 'structure', 'floor')
const slab = SlabNode.parse({ id: 'slab_clearance_batch', polygon: [] })
setSceneNodes([slab])
mountNode(slab, [4, 0.2, 4], [0, 2, 0])
const source = sceneRegistry.nodes.get(slab.id)!.children[0] as Mesh
const raycaster = new Raycaster()
for (const batched of [false, true, false]) {
if (batched) hideFromScene(source, 'batched')
else showInScene(source, 'batched')
const world = buildFirstPersonColliderWorldFromRegistry()!
expect(world).not.toBeNull()
try {
raycaster.set(new Vector3(0, 3, 0), new Vector3(0, -1, 0))
raycaster.far = STAND_CLEARANCE
expect(raycaster.intersectObject(world.mesh, false)[0]!.point.y).toBeCloseTo(2.1)
raycaster.set(new Vector3(0, 1, 0), new Vector3(0, 1, 0))
expect(raycaster.intersectObjects([world.mesh], false)[0]!.point.y).toBeCloseTo(1.9)
raycaster.set(new Vector3(0, 2.5, 0), new Vector3(0, 1, 0))
expect(raycaster.intersectObjects([world.mesh], false)).toHaveLength(0)
} finally {
world.dispose()
}
}
source.geometry.dispose()
;(source.material as MeshBasicMaterial).dispose()
})

test('excludes ceiling surfaces so the walkthrough player passes through them', () => {
registerColliderDefinition('column', ColumnNode, 'structure')
registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling')
Expand Down
61 changes: 38 additions & 23 deletions packages/editor/src/components/editor/selection-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
createMaterial,
createMaterialFromPresetRef,
getRoofMaterialArray,
registerMaterialCacheCleanup,
useViewer,
} from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
Expand All @@ -54,6 +55,11 @@ import {
hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import {
combinePaintPreviews,
createPaintPreviewOwner,
type PaintPreviewCleanup,
} from '../../lib/paint-preview-owner'
import {
availablePaintScopes,
commitPaintScopeFanout,
Expand Down Expand Up @@ -116,8 +122,6 @@ type SelectableNodeType =
| 'window'
| 'door'

type PaintPreviewCleanup = () => void

type PaintInteraction = {
key: string
apply: (() => void) | null
Expand Down Expand Up @@ -809,6 +813,7 @@ export const SelectionManager = () => {
if (mode !== 'material-paint') return
if (movingNode || isCurveReshape) return

const previewOwner = createPaintPreviewOwner()
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
// The last hover event, replayed when the application scope cycles so the
// preview + chip update under a stationary cursor (Shift fires no pointer move).
Expand All @@ -830,7 +835,7 @@ export const SelectionManager = () => {
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
})

const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const resolvePaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const eraser = useEditor.getState().paintEraser
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
Expand Down Expand Up @@ -953,27 +958,29 @@ export const SelectionManager = () => {
// paint capability builds the preview; restores combine.
const restores: PaintPreviewCleanup[] = []
const sceneNodes = useScene.getState().nodes
for (const target of scopeTargets) {
const targetNode = sceneNodes[target.nodeId]
const targetRoot = getRegisteredNodeObject(target.nodeId)
const targetCap = targetNode
? nodeRegistry.get(targetNode.type)?.capabilities?.paint
: null
if (!(targetNode && targetRoot && targetCap)) continue
const restore = targetCap.applyPreview({
node: targetNode,
role: target.role,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
root: targetRoot,
})
if (restore) restores.push(restore)
try {
for (const target of scopeTargets) {
const targetNode = sceneNodes[target.nodeId]
const targetRoot = getRegisteredNodeObject(target.nodeId)
const targetCap = targetNode
? nodeRegistry.get(targetNode.type)?.capabilities?.paint
: null
if (!(targetNode && targetRoot && targetCap)) continue
const restore = targetCap.applyPreview({
node: targetNode,
role: target.role,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
root: targetRoot,
})
if (restore) restores.push(restore)
}
} catch (error) {
combinePaintPreviews(restores)()
throw error
}
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1)
restores[index]?.()
}
return combinePaintPreviews(restores)
}
: () => previewCursor('not-allowed'),
}
Expand Down Expand Up @@ -1073,6 +1080,9 @@ export const SelectionManager = () => {
return null
}

const getPaintInteraction = (event: NodeEvent) =>
previewOwner.wrap(resolvePaintInteraction(event))

const onEnter = (event: NodeEvent) => {
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
// useNodeEvents now emits hover events during such a drag so surface
Expand Down Expand Up @@ -2276,7 +2286,7 @@ const SelectionMaterialSync = () => {
}, [])

useEffect(() => {
return () => {
const clearHighlights = () => {
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
if (mesh.material === entry.highlightedMaterial) {
mesh.material = entry.originalMaterial
Expand All @@ -2286,6 +2296,11 @@ const SelectionMaterialSync = () => {

highlightedMaterialsRef.current.clear()
}
const unsubscribe = registerMaterialCacheCleanup(clearHighlights)
return () => {
unsubscribe()
clearHighlights()
}
}, [])

return null
Expand Down
120 changes: 120 additions & 0 deletions packages/editor/src/components/systems/roof/roof-edit-system.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { expect, test } from 'bun:test'
import { RoofSegmentNode, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
import { _roots, act, createRoot, extend, type Instance, type ThreeEvent } from '@react-three/fiber'
import { createElement } from 'react'
import * as THREE from 'three'
import useInteractionScope from '../../../store/use-interaction-scope'
import { RoofEditSystem } from './roof-edit-system'

extend({ Group: THREE.Group, Mesh: THREE.Mesh, LineSegments: THREE.LineSegments })

test('roof trim drag keeps its plane hit over a surface joining and leaving a batch', async () => {
const previousViewer = useViewer.getState()
const previousScene = useScene.getState()
const previousScope = useInteractionScope.getState().scope
const previousOverrides = useLiveNodeOverrides.getState()
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
const events = Object.assign(new EventTarget(), { setTimeout, clearTimeout })
Object.defineProperty(globalThis, 'window', { configurable: true, value: events })
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: { body: { style: { cursor: '' } } },
})
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
actGlobal.IS_REACT_ACT_ENVIRONMENT = true
const canvas = Object.assign(new EventTarget(), {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
}) as unknown as HTMLCanvasElement
const root = createRoot(canvas)
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100)
camera.position.set(0, 15, 10)
camera.lookAt(0, 3, 0)
camera.updateMatrixWorld()
const scene = new THREE.Scene()
const segment = RoofSegmentNode.parse({
id: 'rseg_batch_drag',
width: 10,
depth: 10,
metadata: { showTrimPlanes: true },
})
const source = new THREE.Group()
const surface = new THREE.Mesh(new THREE.BoxGeometry(30, 0.25, 30), new THREE.MeshBasicMaterial())
surface.position.y = 2
scene.add(surface)
scene.updateMatrixWorld(true)
try {
sceneRegistry.nodes.set(segment.id, source)
useScene.setState({ nodes: { [segment.id]: segment }, readOnly: false })
useViewer.setState({
hoveredId: null,
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [segment.id] },
})
await root.configure({
gl: {
domElement: canvas,
render() {},
setSize() {},
setPixelRatio() {},
} as unknown as THREE.WebGLRenderer,
camera,
scene,
frameloop: 'never',
dpr: 1,
size: { width: 100, height: 100, top: 0, left: 0 },
})
await act(async () => {
root.render(createElement(RoofEditSystem))
})
let pointerDown: ((event: ThreeEvent<PointerEvent>) => void) | undefined
scene.traverse((object) => {
const instance = (object as THREE.Object3D & { __r3f?: Instance }).__r3f
pointerDown ??= instance?.handlers.onPointerDown
})
expect(pointerDown).toBeDefined()
await act(async () => {
pointerDown!({
button: 0,
clientX: 50,
clientY: 50,
stopPropagation() {},
} as ThreeEvent<PointerEvent>)
})
const move = async () => {
await act(async () => {
events.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 60, clientY: 60 }))
})
return useLiveNodeOverrides.getState().overrides.get(segment.id)?.trim
}
const before = await move()
expect(before).toBeDefined()
expect(Object.values(before!).some((value) => (value as number) > 0)).toBe(true)
hideFromScene(surface, 'batched')
expect(await move()).toEqual(before)
showInScene(surface, 'batched')
expect(await move()).toEqual(before)
await act(async () => {
events.dispatchEvent(new Event('pointercancel'))
})
expect(useLiveNodeOverrides.getState().overrides.has(segment.id)).toBe(false)
} finally {
await act(async () => {
root.render(null)
})
_roots.delete(canvas)
sceneRegistry.nodes.delete(segment.id)
surface.geometry.dispose()
surface.material.dispose()
useScene.setState(previousScene)
useViewer.setState(previousViewer)
useInteractionScope.setState({ scope: previousScope })
useLiveNodeOverrides.setState(previousOverrides)
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument)
else Reflect.deleteProperty(globalThis, 'document')
}
})
Loading
Loading