From edf84cd988abf3c8d44bbb683147eec7df552ec0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 18 Aug 2026 16:36:54 -0400 Subject: [PATCH 1/2] fix(editor): stop node tops lifting floor placements off the ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #638 made the pointer support election consider node tops by default — `options?.includeNodeTopSurfaces === false` opted out, so every caller that passed nothing (item placement, registry move/presets, slab drafting) started electing them. It also gave `item` a `surfaces.top`, widening the candidate set from wall/item/column to wall, slab, ceiling, cabinet, column, item, shelf, block. A ray aimed at a floor crosses every upward-facing face above that floor first. In a finished room that is the ceiling: placing an item at the room centre elects the ceiling's top face (nearest hit, normal.y ≈ 1) and freezes it into the draft's authored Y via `resolveFrozenFloorPlacementPatch`, so the item sits at ceiling height instead of on the floor. Walls the ray passes over do the same in a narrower band. Restore the opt-in. Keep #638's registry-driven discovery — the kind list is still derived from `capabilities.surfaces.top` rather than hardcoded — but only the tools that build ON a surface ask for it: wall (already did), column, fence, stair, block. Item placement, registry move and slab drafting go back to placing against the floor the pointer indicates. Also exclude the node the active interaction is placing or moving. Its mesh rides the cursor, so electing its own top would raise it by its own height every pointer move. The tools neuter the dragged mesh's `raycast` for their own pointer routing, which happens to cover this today — but that is each tool's private convention, and async-mounted item children are only neutered on the next frame. The election owns the invariant now. Co-Authored-By: Claude Opus 5 --- .../tools/shared/pointer-support-cap.test.ts | 38 +++++++++++++++++-- .../tools/shared/pointer-support-cap.ts | 30 +++++++++++---- .../src/components/tools/stair/stair-tool.tsx | 4 +- packages/nodes/src/block/tool.tsx | 4 +- packages/nodes/src/column/tool.tsx | 4 +- packages/nodes/src/fence/tool.tsx | 2 +- 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index a9cf56598..c8c508509 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -14,6 +14,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three' import { z } from 'zod' +import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel } from '../wall/wall-drafting' import { resolvePointerSupportSurface } from './pointer-support-cap' @@ -38,6 +39,7 @@ describe('resolvePointerSupportSurface node tops', () => { beforeEach(() => { spatialGridManager.clear() sceneRegistry.clear() + useInteractionScope.getState().end() useViewer.setState({ selection: { buildingId: null, @@ -108,24 +110,54 @@ describe('resolvePointerSupportSurface node tops', () => { camera.position.set(0, 5, 0) camera.updateMatrixWorld(true) - const support = resolvePointerSupportSurface(camera, [0, 0, 0]) + const support = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + }) expect(support?.sourceNodeId).toBe(PLATFORM_ID) expect(support?.elevation).toBeCloseTo(2) expect(support?.worldPoint).toEqual([0, 2, 0]) }) - test('keeps the ground result when node-top surfaces are explicitly disabled', () => { + test('keeps the ground result unless node-top surfaces are asked for', () => { addPluginPlatform() const camera = new PerspectiveCamera() camera.position.set(0, 5, 0) camera.updateMatrixWorld(true) + // The default. A floor placement aims THROUGH whatever upward-facing + // geometry sits between the camera and the floor — a room's ceiling, the + // top of the wall the ray passes over — so only the tools that build on a + // surface opt in. + for (const options of [undefined, { includeNodeTopSurfaces: false }]) { + const support = resolvePointerSupportSurface(camera, [0, 0, 0], options) + expect(support?.sourceNodeId).toBeNull() + expect(support?.elevation).toBe(0) + } + }) + + test('never elects the node the active interaction is placing or moving', () => { + addPluginPlatform() + useInteractionScope.getState().begin({ + kind: 'placing', + node: useScene.getState().nodes[PLATFORM_ID]!, + nodeId: PLATFORM_ID, + nodeType: PLATFORM_KIND, + view: '3d', + pressDrag: false, + driver: 'registry-tool', + }) + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + const support = resolvePointerSupportSurface(camera, [0, 0, 0], { - includeNodeTopSurfaces: false, + includeNodeTopSurfaces: true, }) + // Its own top would raise it by its own height on every pointer move. expect(support?.sourceNodeId).toBeNull() expect(support?.elevation).toBe(0) }) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index ef2621db5..3af19b7b2 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -12,6 +12,8 @@ import { import { useViewer } from '@pascal-app/viewer' import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three' import { resolveTerrainGroundHit } from '../../../lib/ground-surface' +import { scopeNodeId } from '../../../lib/interaction/scope' +import useInteractionScope from '../../../store/use-interaction-scope' const originScratch = new Vector3() const hitScratch = new Vector3() @@ -150,22 +152,34 @@ export function resolvePointerSupportSurface( localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] } - const nodeTopSurfaceKinds = - options?.includeNodeTopSurfaces === false - ? [] - : Array.from(nodeRegistry.entries()) - .filter(([, definition]) => definition.capabilities.surfaces?.top !== undefined) - .map(([kind]) => kind) + // Node tops are opt-in. A ray aimed at a floor crosses every upward-facing + // face above that floor first — a room's ceiling, the top of the wall it + // passes over — so electing "the nearest node top along the ray" silently + // lifts anything placed inside a finished room. Only the tools that build ON + // a surface (wall / column / fence / stair / block) mean that, and they say + // so. Everything else places against the floor the pointer indicates. + const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces + ? Array.from(nodeRegistry.entries()) + .filter(([, definition]) => definition.capabilities.surfaces?.top !== undefined) + .map(([kind]) => kind) + : [] if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) { nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize()) const nodes = useScene.getState().nodes const registeredOwners = new Map( [...sceneRegistry.nodes.entries()].map(([nodeId, object]) => [object, nodeId as AnyNodeId]), ) - const belongsToActiveLevel = (nodeId: AnyNodeId) => { + // The node the active interaction is placing/moving cannot be a surface for + // itself: its mesh rides the cursor, so electing its own top would raise it + // by its own height on every pointer move. Tools neuter the dragged mesh's + // `raycast` for their own pointer routing, but that is each tool's private + // convention — the election owns the invariant. + const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope) + const isEligibleCandidate = (nodeId: AnyNodeId) => { let current = nodes[nodeId] const visited = new Set() while (current && !visited.has(current.id)) { + if (current.id === interactingNodeId) return false if (current.id === levelId) return true visited.add(current.id) current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined @@ -189,7 +203,7 @@ export function resolvePointerSupportSurface( const nodeId = rawId as AnyNodeId const node = nodes[nodeId] const object = sceneRegistry.nodes.get(nodeId) - if (!(node?.visible && object?.visible && belongsToActiveLevel(nodeId))) continue + if (!(node?.visible && object?.visible && isEligibleCandidate(nodeId))) continue if ( node.type === 'item' && (!canHostOnTop(node) || isLowProfileItemSurface(node as ItemNode)) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index b5b841c9e..a07de846c 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -438,7 +438,9 @@ export const StairTool: React.FC = () => { } const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => { - const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, { + includeNodeTopSurfaces: true, + }) supportSurfaceRef.current = pointed const fallbackPosition = 'node' in event ? lastCanonicalPositionRef.current : event.localPosition diff --git a/packages/nodes/src/block/tool.tsx b/packages/nodes/src/block/tool.tsx index 54e2e3230..ea1bc895e 100644 --- a/packages/nodes/src/block/tool.tsx +++ b/packages/nodes/src/block/tool.tsx @@ -81,7 +81,9 @@ const BlockTool = () => { const pointedSurfaceFor = (event: GridEvent | FloorPlacementClickTriggerEvent) => typeof HTMLCanvasElement !== 'undefined' && event.nativeEvent?.target instanceof HTMLCanvasElement - ? resolvePointerSupportSurface(cameraRef.current, event.position) + ? resolvePointerSupportSurface(cameraRef.current, event.position, { + includeNodeTopSurfaces: true, + }) : null const resolvePlacement = ( diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 5b6858878..c77cbd2fd 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -96,7 +96,9 @@ const ColumnTool = () => { const pointedSurfaceFor = (event: FloorPlacementClickTriggerEvent) => typeof HTMLCanvasElement !== 'undefined' && event.nativeEvent?.target instanceof HTMLCanvasElement - ? resolvePointerSupportSurface(cameraRef.current, event.position) + ? resolvePointerSupportSurface(cameraRef.current, event.position, { + includeNodeTopSurfaces: true, + }) : null const resolveColumnPlacement = ( diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 77238ea1d..4867ca1be 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -74,7 +74,7 @@ const surfacePointScratch = new Vector3() // them; those keep the uncapped max election and leave the grid plane alone. function pointedSurfaceFor(camera: Camera, event: GridEvent) { return event.nativeEvent?.target instanceof HTMLCanvasElement - ? resolvePointerSupportSurface(camera, event.position) + ? resolvePointerSupportSurface(camera, event.position, { includeNodeTopSurfaces: true }) : null } /** Figma-style alignment-snap threshold (meters), matching the move tools. */ From f2eb6b676feccd6db6f9afd62bb6173a108a7e49 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 18 Aug 2026 16:43:09 -0400 Subject: [PATCH 2/2] fix(viewer): keep batched walls answering the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #608 sews a level's walls into one mesh once they settle (8+ walls, 180ms quiet). Each sewn wall is moved off SCENE_LAYER onto BATCHED_LAYER by `hideBatchedWall` so it costs no draw call while staying in the graph — with its R3F pointer handlers still attached. R3F picks with one shared raycaster whose default mask is SCENE_LAYER alone, so a batched wall stops being hit: no `wall:enter` (no hover outline, no paint preview), no `wall:move`, no `wall:click`. Selection is the circular case — a selected wall leaves the batch, but the click that would select it never lands. #608 saw this for measurement and added `setSurfaceRaycastLayers` for the raycasters that module builds; the shared event raycaster was never opted in. Enable BATCHED_LAYER on it. Additive rather than `setSurfaceRaycastLayers`, which resets the mask — right for the private per-query raycasters it was written for, wrong for the one every pointer event goes through. Co-Authored-By: Claude Opus 5 --- .../viewer/src/components/viewer/index.tsx | 2 ++ .../viewer/pointer-raycast-layers.tsx | 32 +++++++++++++++++++ .../viewer/src/lib/scene-visibility.test.ts | 31 +++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/viewer/src/components/viewer/pointer-raycast-layers.tsx diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 575083c0a..f6a6cb7e0 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -33,6 +33,7 @@ import { SceneRenderer } from '../renderers/scene-renderer' import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' +import { PointerRaycastLayers } from './pointer-raycast-layers' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import { RegisteredSystems } from './registered-systems' import { SceneBvh } from './scene-bvh' @@ -574,6 +575,7 @@ const Viewer = forwardRef(function Viewer( > + { + const raycaster = useThree((state) => state.raycaster) + + useLayoutEffect(() => { + const mask = raycaster.layers.mask + raycaster.layers.enable(BATCHED_LAYER) + return () => { + raycaster.layers.mask = mask + } + }, [raycaster]) + + return null +} diff --git a/packages/viewer/src/lib/scene-visibility.test.ts b/packages/viewer/src/lib/scene-visibility.test.ts index 420d899ee..d203b6dab 100644 --- a/packages/viewer/src/lib/scene-visibility.test.ts +++ b/packages/viewer/src/lib/scene-visibility.test.ts @@ -2,7 +2,13 @@ // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import * as THREE from 'three' -import { BATCHED_LAYER, OVERLAY_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { + BATCHED_LAYER, + OVERLAY_LAYER, + SCENE_LAYER, + SHADOW_ONLY_LAYER, + setSurfaceRaycastLayers, +} from './layers' import { hideFromScene, showInScene } from './scene-visibility' function sceneObject(): THREE.Object3D { @@ -115,4 +121,27 @@ describe('scene visibility', () => { expect(obj.layers.mask).toBe(original) expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) }) + + // A batched wall keeps its pointer handlers, so whatever raycaster drives + // hover / paint / click has to reach it or the wall goes dead the moment its + // level is sewn. `PointerRaycastLayers` enables the bit on R3F's shared + // raycaster; `setSurfaceRaycastLayers` does it for private ones. + test('a batched object answers only a raycaster that opted into the layer', () => { + const obj = sceneObject() + hideFromScene(obj, 'batched') + + const defaultLayers = new THREE.Layers() + expect(obj.layers.test(defaultLayers)).toBe(false) + + const surfaceLayers = new THREE.Layers() + setSurfaceRaycastLayers(surfaceLayers) + expect(obj.layers.test(surfaceLayers)).toBe(true) + + const sharedLayers = new THREE.Layers() + sharedLayers.enable(BATCHED_LAYER) + expect(obj.layers.test(sharedLayers)).toBe(true) + + showInScene(obj, 'batched') + expect(obj.layers.test(defaultLayers)).toBe(true) + }) })