Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/editor/components/build-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type BuildToolKind =
| 'door'
| 'window'
| 'column'
| 'shelf'
| 'spawn'

type BuildType = {
Expand All @@ -51,6 +52,7 @@ const BUILD_TYPES: BuildType[] = [
{ id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' },
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
]
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,20 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
/**
* Optional cache key over the geometry-relevant inputs of `node`. When
* set, `<GeometrySystem>` skips the rebuild (dispose + re-create the
* group's children) if the key is unchanged since the last build for
* this node — even though the node was marked dirty. Use for kinds whose
* geometry depends *only* on their own fields (not on `children`,
* `position`, neighbours, or `ctx`): a hosted child reparenting onto a
* shelf, say, dirties the shelf but doesn't change its boards, so without
* this the boards needlessly remount and any pointer hover churns
* (enter/leave) as the meshes are swapped. Must NOT be set for kinds with
* neighbour-dependent geometry (e.g. wall/fence miters via `ctx`), whose
* inputs aren't captured by the node alone.
*/
geometryKey?: (node: z.infer<S>) => string
/**
* Level-batch precompute hook. Called by `<GeometrySystem>` once per
* level per frame, **before** the per-node `def.geometry` calls in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box3,
Euler,
type Group,
type LineSegments,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Quaternion,
Ray,
Vector3,
} from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
Expand Down Expand Up @@ -196,8 +200,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// the lerp on this flag keeps 3D placement smooth without hijacking
// 2D drags that share the same draft.
const has3DPointerDrivenMoveRef = useRef(false)
// The draft mesh's raycast is disabled while placing so the cursor ray
// passes through it to the surface beneath (grid / item / shelf). Without
// this, the ray hits the moving draft first and the surface strategy keeps
// re-deriving the host point from the draft's own (just-moved) geometry —
// on a multi-row shelf this oscillates the chosen row, jittering the item.
// Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
// recreated mid-session) and restored on unmount.
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
const restoreRaycastsRef = useRef<Array<() => void>>([])
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)

// Live camera ref — the shelf-stickiness test reconstructs the cursor world
// ray (camera → grid hit) to check it still points at the shelf volume.
const camera = useThree((s) => s.camera)
const cameraRef = useRef(camera)
cameraRef.current = camera

// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
configRef.current = config
Expand Down Expand Up @@ -487,13 +507,68 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea

let previousGridPos: [number, number, number] | null = null

// Scratch objects reused by the stickiness test (runs per grid:move).
const stickyRay = new Ray()
const stickyBox = new Box3()
const stickyMat = new Matrix4()
const stickyCamPos = new Vector3()

// True while the cursor ray still points at the active shelf's volume.
// Used to keep an item hosted on a shelf "sticky": from an angled camera
// the cursor ray slips off the shelf's thin boards / through its gaps and
// lands on the floor *behind* the shelf, which would otherwise thrash the
// placement between the shelf row and the floor on every micro-move. We
// reconstruct the world ray (camera → grid hit point) and test it against
// the shelf's bounding box — so a ray that passes *through* the shelf but
// lands behind it still counts as "on the shelf". Only a ray that misses
// the shelf box entirely means the user genuinely moved off it. A simple
// footprint test on the floor hit point can't distinguish those.
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
const shelfId = placementState.current.shelfId
if (!shelfId) return false
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
| { width?: number; depth?: number; height?: number }
| undefined
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false

cameraRef.current.getWorldPosition(stickyCamPos)
stickyRay.origin.copy(stickyCamPos)
stickyRay.direction
.set(
gridWorldPoint[0] - stickyCamPos.x,
gridWorldPoint[1] - stickyCamPos.y,
gridWorldPoint[2] - stickyCamPos.z,
)
.normalize()

// Into shelf-local space, then test the shelf's local AABB (origin at the
// base: y ∈ [0, height]) with a small margin.
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
const m = 0.08
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
return stickyRay.intersectsBox(stickyBox)
}

const onGridMove = (event: GridEvent) => {
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
if (draftNode.current === null && asset.attachTo === undefined) {
configRef.current.initDraft(gridPosition.current)
}

has3DPointerDrivenMoveRef.current = true

// Shelf stickiness: while hosting on a shelf, ignore floor events while
// the cursor ray still points at the shelf volume (the ray merely slipped
// off a board / through a gap and hit the floor behind). Detach to the
// floor only once the ray misses the shelf entirely — without this the
// item oscillates between the shelf row and the floor on every micro-move.
if (placementState.current.surface === 'shelf-surface') {
if (cursorRayIntersectsActiveShelf(event.position)) return
detachItemSurfaceToFloor(event as unknown as ItemEvent)
}

lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
if (!cursorGroupRef.current) return
const result = floorStrategy.move(getContext(), event)
Expand Down Expand Up @@ -774,7 +849,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const wz = Math.round(buildingLocalPoint.z * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]

Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
Object.assign(placementState.current, {
surface: 'floor',
surfaceItemId: null,
shelfId: null,
})
gridPosition.current.set(wx, 0, wz)
if (cursorGroupRef.current) {
cursorGroupRef.current.position.set(wx, 0, wz)
Expand Down Expand Up @@ -1212,11 +1291,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onShelfLeave = (event: ShelfEvent) => {
if (placementState.current.surface !== 'shelf-surface') return
if (event.node.id !== placementState.current.shelfId) return
// Intentionally do NOT detach to the floor here. `shelf:leave` fires
// constantly while hosting because the cursor ray slips off the shelf's
// thin boards and through its gaps — detaching on each of those would
// thrash the item between the shelf row and the floor. The grid handler
// owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
// in `onGridMove`): it detaches only once the cursor is clearly off the
// shelf footprint, which is the genuine "left the shelf" signal.
event.stopPropagation()
// Drop back to floor — same pattern as item-leave but without the
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
// with since the shelf rotation already composed cleanly).
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
}

const onShelfClick = (event: ShelfEvent) => {
Expand Down Expand Up @@ -1496,17 +1578,58 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset])

// Disable raycasting on the live draft mesh (and restore it when the draft
// changes or goes away) so the cursor ray passes through the item being
// moved and lands on the surface beneath it.
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
if (raycastDisabledMeshRef.current !== mesh) {
// New draft root (or cleared): restore the prior mesh and reset tracking.
for (const restore of restoreRaycastsRef.current) restore()
restoreRaycastsRef.current = []
raycastDisabledChildrenRef.current = new WeakSet()
raycastDisabledMeshRef.current = mesh
}
if (!mesh) return
// Disable any descendant not handled yet. Item drafts are GLB models whose
// child meshes mount asynchronously (Suspense), so a one-shot traverse
// misses them — those late children keep intercepting the ray and corrupt
// the shelf-row hit the moment the item moves onto a row. Re-walking each
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
mesh.traverse((child) => {
if (raycastDisabledChildrenRef.current.has(child)) return
raycastDisabledChildrenRef.current.add(child)
const original = child.raycast
child.raycast = () => {}
restoreRaycastsRef.current.push(() => {
child.raycast = original
})
})
}, [])

// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])

useFrame((_, delta) => {
if (!asset) return
if (!draftNode.current) return
if (!asset) {
reconcileDraftRaycast(null)
return
}
if (!draftNode.current) {
reconcileDraftRaycast(null)
return
}
const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
reconcileDraftRaycast(mesh)
// mitt listeners outlive the cursor group's mount; bail if it's gone
// (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
// draft's raycast is still restored during that window.
if (!cursorGroupRef.current) return
// The mesh-position lerp below only makes sense once this coordinator
// owns the move via a 3D pointer event. Skip until then so that
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
// writing scene.position directly) aren't fought by useFrame pulling
// the mesh back to its pre-move location.
if (!has3DPointerDrivenMoveRef.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return

// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
Expand Down
Loading
Loading