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. */ 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) + }) })