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
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -38,6 +39,7 @@ describe('resolvePointerSupportSurface node tops', () => {
beforeEach(() => {
spatialGridManager.clear()
sceneRegistry.clear()
useInteractionScope.getState().end()
useViewer.setState({
selection: {
buildingId: null,
Expand Down Expand Up @@ -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)
})
Expand Down
30 changes: 22 additions & 8 deletions packages/editor/src/components/tools/shared/pointer-support-cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<AnyNodeId>()
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
Expand All @@ -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))
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/components/tools/stair/stair-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/nodes/src/block/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
4 changes: 3 additions & 1 deletion packages/nodes/src/column/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion packages/nodes/src/fence/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -574,6 +575,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
>
<FrameLimiter fps={maxFps} paused={renderPaused} />
<ViewerCamera />
<PointerRaycastLayers />
<GPUDeviceWatcher />
<ToneMappingExposure />
<SceneReadyTracker
Expand Down
32 changes: 32 additions & 0 deletions packages/viewer/src/components/viewer/pointer-raycast-layers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import { useThree } from '@react-three/fiber'
import { useLayoutEffect } from 'react'
import { BATCHED_LAYER } from '../../lib/layers'

/**
* Lets R3F's pointer raycaster see geometry a collective batch draws.
*
* R3F picks with one shared raycaster whose default mask is `SCENE_LAYER`
* alone. A wall sewn into its level's merged mesh is moved off that layer
* (`hideBatchedWall`) while staying in the graph with its pointer handlers
* intact — so without this the wall answers no hover, paint or click the
* moment it joins a batch, and a floor's walls go dead a fraction of a second
* after the last edit settles.
*
* Additive rather than `setSurfaceRaycastLayers`: that helper resets the mask
* for the private raycasters callers build per query, and this one is shared.
*/
export const PointerRaycastLayers = () => {
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
}
31 changes: 30 additions & 1 deletion packages/viewer/src/lib/scene-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
})
})
Loading