From 026ead2f1e7c9669b42d910cfdc594024acc1e0c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 13 Aug 2026 09:31:32 +0200 Subject: [PATCH 1/2] fix(editor): derive the wall placement box from the wall-local point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placement wireframe and the item it previews were computed from two different quantities: the node position snapped the hit in WALL-LOCAL coords, while the cursor snapped the raw WORLD hit independently per axis. Those are different lattices — wall-local X runs from `wall.start`, and wall-local Y is measured from the supporting slab's elevation, not world zero — so the box drifted off the item by the slab elevation plus up to a grid step, and the commit (which follows the node) landed where the box was not. `calculateCursorRotation` also returns the wall face's yaw + π, which is invisible for the z-symmetric door/window boxes it was written for but put the asymmetric `wall-side` item box on the far side of the wall. The coordinator already compensated for that π when publishing the 2D floorplan preview; that workaround goes away with the cause. Both now come from `resolveWallPlacementPose`, which maps one wall-local point through the hit object's frame — the exact inverse of how `localPosition` was measured. `z` follows the hosting convention rather than the hit depth, so `wall-side` items store the face they mount on (matching ItemSystem's per-frame push, no first-frame pop) and `wall` items stay centred in the thickness instead of being snapped out of a thick wall. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/item/placement-strategies.test.ts | 167 ++++++++++++++++++ .../tools/item/placement-strategies.ts | 79 ++++++--- .../tools/item/use-placement-coordinator.tsx | 27 +-- 3 files changed, 227 insertions(+), 46 deletions(-) create mode 100644 packages/editor/src/components/tools/item/placement-strategies.test.ts diff --git a/packages/editor/src/components/tools/item/placement-strategies.test.ts b/packages/editor/src/components/tools/item/placement-strategies.test.ts new file mode 100644 index 0000000000..808d3a53c4 --- /dev/null +++ b/packages/editor/src/components/tools/item/placement-strategies.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'bun:test' +import type { ItemNode, WallEvent, WallNode } from '@pascal-app/core' +import { Mesh, type Object3D, Vector3 } from 'three' +import { wallStrategy } from './placement-strategies' +import type { PlacementContext, SpatialValidators } from './placement-types' + +/** + * The wall frame the runtime builds in `updateWallGeometry`: origin at + * `wall.start` lifted to the supporting slab's elevation, yawed by the wall + * angle. Wall-local Y is therefore measured from the slab, NOT from world zero + * — which is exactly why snapping the world hit and the wall-local hit + * separately used to put the preview box and the item on different points. + */ +function makeWallFrame(wall: WallNode, slabElevation: number): Mesh { + const wallMesh = new Mesh() + wallMesh.position.set(wall.start[0], slabElevation, wall.start[1]) + wallMesh.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const collisionMesh = new Mesh() + wallMesh.add(collisionMesh) + wallMesh.updateMatrixWorld(true) + return collisionMesh +} + +function makeWall(overrides: Partial = {}): WallNode { + return { + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + children: [], + start: [2.3, 1.7], + end: [8.3, 1.7], + thickness: 0.2, + ...overrides, + } as WallNode +} + +function makeDraft(): ItemNode { + return { + id: 'item_draft', + type: 'item', + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + children: [], + asset: { + id: 'asset_hold', + category: 'sport', + name: 'Climbing hold', + thumbnail: '', + source: 'library', + src: '', + dimensions: [0.65, 0.33, 0.63], + attachTo: 'wall-side', + offset: [0, 0.165, 0.1], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + } as unknown as ItemNode +} + +/** Front face of the wall: wall-local +Z. */ +const FRONT_NORMAL: [number, number, number] = [0, 0, 1] + +function makeWallEvent(wall: WallNode, collisionMesh: Object3D, localHit: Vector3): WallEvent { + const world = collisionMesh.localToWorld(localHit.clone()) + return { + node: wall, + position: [world.x, world.y, world.z], + localPosition: [localHit.x, localHit.y, localHit.z], + normal: FRONT_NORMAL, + object: collisionMesh, + stopPropagation: () => undefined, + } as unknown as WallEvent +} + +const validators: SpatialValidators = { + canPlaceOnFloor: () => ({ valid: true }), + canPlaceOnWall: () => ({ valid: true }), + canPlaceOnCeiling: () => ({ valid: true }), +} + +function makeContext(draft: ItemNode): PlacementContext { + return { + asset: draft.asset, + levelId: 'level_test', + draftItem: draft, + gridPosition: new Vector3(), + state: { + surface: 'wall', + wallId: 'wall_test', + roofSegmentId: null, + ceilingId: null, + surfaceItemId: null, + shelfId: null, + }, + currentCursorRotationY: 0, + } as unknown as PlacementContext +} + +describe('wallStrategy.move', () => { + /** + * The preview wireframe (`cursorPosition`, world) and the committed node + * (`gridPosition`, wall-local) must describe ONE point. Snapping them + * independently drifted the box off the item by the slab elevation plus up to + * a grid step, and the commit then landed where the box was not. + */ + test.each([ + ['axis-aligned wall on an elevated slab', makeWall(), 0.4], + [ + 'diagonal wall off the world grid', + makeWall({ start: [1.15, 0.35], end: [5.15, 4.35] } as Partial), + 0.15, + ], + ])('keeps the preview box on the committed point — %s', (_label, wall, slabElevation) => { + const collisionMesh = makeWallFrame(wall, slabElevation) + const draft = makeDraft() + const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1)) + + const result = wallStrategy.move(makeContext(draft), event, validators) + if (!result) throw new Error('expected a placement result') + + const cursorFromNode = collisionMesh.localToWorld(new Vector3(...result.gridPosition)) + expect(cursorFromNode.x).toBeCloseTo(result.cursorPosition[0], 6) + expect(cursorFromNode.y).toBeCloseTo(result.cursorPosition[1], 6) + expect(cursorFromNode.z).toBeCloseTo(result.cursorPosition[2], 6) + }) + + test('mounts the wall-side preview on the hit face, not through the wall', () => { + const wall = makeWall() + const collisionMesh = makeWallFrame(wall, 0.4) + const draft = makeDraft() + const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1)) + + const result = wallStrategy.move(makeContext(draft), event, validators) + if (!result) throw new Error('expected a placement result') + + // Front face → wall-local +thickness/2, matching ItemSystem's per-frame push. + expect(result.gridPosition[2]).toBeCloseTo(0.1, 6) + // The cursor frame IS the item frame, so the box's +Z (its depth) points out + // of the same face the item body extends from. + const outward = new Vector3(0, 0, 1).applyAxisAngle( + new Vector3(0, 1, 0), + result.cursorRotationY, + ) + const wallNormal = new Vector3(0, 0, 1).applyAxisAngle( + new Vector3(0, 1, 0), + collisionMesh.parent!.rotation.y, + ) + expect(outward.dot(wallNormal)).toBeCloseTo(1, 6) + }) + + test('carries the wall auto-adjusted Y into the preview box', () => { + const wall = makeWall() + const collisionMesh = makeWallFrame(wall, 0.4) + const draft = makeDraft() + const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1)) + + const result = wallStrategy.move(makeContext(draft), event, { + ...validators, + canPlaceOnWall: () => ({ valid: true, adjustedY: 0.05, wasAdjusted: true }), + }) + if (!result) throw new Error('expected a placement result') + + expect(result.gridPosition[1]).toBeCloseTo(0.05, 6) + expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6) + }) +}) diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 2a7518834a..b11ed58173 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -30,7 +30,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import { - calculateCursorRotation, calculateItemRotation, getGridAlignedDimensions, getSideFromNormal, @@ -178,6 +177,51 @@ export const floorStrategy = { // WALL STRATEGY // ============================================================================ +/** + * Resolve the wall-local node position AND the world pose of the placement + * wireframe from ONE wall-local point, so the box can't drift from the item it + * previews. + * + * `event.object` is the wall's collision mesh — the frame `event.localPosition` + * was measured in — so `localToWorld` is the exact inverse of the hit. Snapping + * the raw world hit per axis instead (the old path) put the box on a different + * lattice than the node: wall-local X runs from `wall.start`, and wall-local Y + * is measured from the supporting slab's elevation, not from world zero. + * + * `z` follows the hosting convention rather than the hit depth: `wall` items + * center in the thickness, `wall-side` items mount on the hit face — mirroring + * `ItemSystem`'s per-frame push, so the wireframe's `z = 0` face lands flush + * with the wall instead of extending through it. + */ +function resolveWallPlacementPose( + event: WallEvent, + localX: number, + localY: number, + attachTo: 'wall' | 'wall-side', + side: 'front' | 'back', + itemRotation: number, +): { + position: [number, number, number] + cursorPosition: [number, number, number] + cursorRotationY: number +} { + const localZ = + attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0 + event.object.updateWorldMatrix(true, false) + const world = event.object.localToWorld(new Vector3(localX, localY, localZ)) + const wallYaw = -Math.atan2( + event.node.end[1] - event.node.start[1], + event.node.end[0] - event.node.start[0], + ) + return { + position: [localX, localY, localZ], + cursorPosition: [world.x, world.y, world.z], + // Same composition the 2D floorplan resolves a wall child with + // (`resolveItemTransform`): the cursor frame IS the item frame. + cursorRotationY: wallYaw + itemRotation, + } +} + export const wallStrategy = { /** * Handle wall:enter — transition from floor to wall surface. @@ -201,11 +245,9 @@ export const wallStrategy = { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const x = snapToHalf(event.localPosition[0]) const y = snapToHalf(event.localPosition[1]) - const z = snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const rawDims = ctx.draftItem @@ -223,11 +265,12 @@ export const wallStrategy = { ) const adjustedY = validation.adjustedY ?? y + const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation) return { stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null }, nodeUpdate: { - position: [x, adjustedY, z], + position: pose.position, parentId: event.node.id, // The draft may arrive from a roof-segment wall face. roofSegmentId: undefined, @@ -235,13 +278,9 @@ export const wallStrategy = { side, rotation: [0, itemRotation, 0], }, - cursorRotationY: cursorRotation, - gridPosition: [x, adjustedY, z], - cursorPosition: [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], + cursorRotationY: pose.cursorRotationY, + gridPosition: pose.position, + cursorPosition: pose.cursorPosition, stopPropagation: true, } }, @@ -262,11 +301,10 @@ export const wallStrategy = { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const attachTo = ctx.draftItem.asset.attachTo as 'wall' | 'wall-side' const snappedX = snapToHalf(event.localPosition[0]) const snappedY = snapToHalf(event.localPosition[1]) - const snappedZ = snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator const validation = validators.canPlaceOnWall( @@ -275,23 +313,20 @@ export const wallStrategy = { snappedX, snappedY, getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), - ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', + attachTo, side, [ctx.draftItem.id], ) const adjustedY = validation.adjustedY ?? snappedY + const pose = resolveWallPlacementPose(event, snappedX, adjustedY, attachTo, side, itemRotation) return { - gridPosition: [snappedX, adjustedY, snappedZ], - cursorPosition: [ - snapToHalf(event.position[0]), - snapToHalf(event.position[1]), - snapToHalf(event.position[2]), - ], - cursorRotationY: cursorRotation, + gridPosition: pose.position, + cursorPosition: pose.cursorPosition, + cursorRotationY: pose.cursorRotationY, nodeUpdate: { - position: [snappedX, adjustedY, snappedZ], + position: pose.position, side, rotation: [0, itemRotation, 0], }, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 048be2771d..01532815f1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1130,19 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY) - const wallMesh = sceneRegistry.nodes.get(event.node.id) - // Derive the world cursor from the corrected wall-local point so the - // visual cursor (world) and the stored position (wall-local) agree; if - // the wall mesh is somehow absent, keep the raw world hit unchanged. - const correctedWorld = wallMesh - ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2])) - : null wallMoveEvent = { ...event, localPosition: [correctedX, correctedY, event.localPosition[2]], - position: correctedWorld - ? [correctedWorld.x, correctedWorld.y, correctedWorld.z] - : event.position, } } const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators()) @@ -1204,22 +1194,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Publish live transform for the 2D floorplan. The floorplan resolves a // wall item's footprint (and its wall-side depth offset) from this - // rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor - // yaw, which is π off from the plan rotation on a wall face — feeding it - // raw flips the footprint to the far side of the wall during placement. - // Publish the plan rotation (wall angle + the item's wall-local yaw) so - // the preview matches what the committed node resolves to. - let liveRotation = result.cursorRotationY - const liveWallId = placementState.current.wallId - const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined - if (liveWall?.type === 'wall') { - const w = liveWall as WallNode - const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0]) - liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0) - } + // rotation as a PLAN-space yaw — which is exactly what the wall strategy + // composes `cursorRotationY` as (wall yaw + the item's wall-local yaw). useLiveTransforms.getState().set(draft.id, { position: result.cursorPosition, - rotation: liveRotation, + rotation: result.cursorRotationY, }) } } From c7bca37b9c5a1e120fb1ce50eb1802813c5b44b9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 13 Aug 2026 09:40:03 +0200 Subject: [PATCH 2/2] fix(editor): stop floor-stacking hosted nodes during a drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FloorElevationSystem` documents that it "respects `floorPlaced.applies` so items with `asset.attachTo` (wall / ceiling mounted) are left alone", but it never called the predicate — only `getFloorPlacedElevation` did. For an opted-out node that resolver returns 0, so the system's write degenerated to `mesh.position.y = position[1]`. Harmless while `position` is the node's own host-local store value, but tools publish live transforms in WORLD space: the moment a wall item started moving, the system copied its world Y into the mesh's wall-local Y slot every frame and the ghost floated off the wall by the host frame's elevation. Hence the reported sequence — correct on `wall:enter` (no live transform yet), wrong throughout the move, correct again after commit (the coordinator clears the live transform first). Honour `applies` before the write. Covers the other three opt-outs too: ceiling items, cabinet modules inside a run, and non-floor duct terminals. Co-Authored-By: Claude Opus 5 (1M context) --- .../systems/floor-elevation/floor-elevation-system.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 361862f8b0..0216f76724 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -89,6 +89,15 @@ export const FloorElevationSystem = () => { const position = (effectiveNode as PositionedNode).position if (!position) return + // `applies === false` means the kind opts OUT of floor stacking for this + // node: its Y belongs to a host frame (a wall/ceiling-mounted item, a + // cabinet module inside a run, a wall duct terminal). `getFloorPlacedElevation` + // already returns 0 for them, so the write below would degenerate to + // copying `position[1]` into the mesh — and tools publish live transforms + // in WORLD space, so during a drag that lifts the ghost off its host by + // the host frame's own elevation. + if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return + // This system is the single drag-time authority for floor-stack mesh Y: // tools publish base positions to live stores, renderers may // reconcile that base Y onto the group, then this presentation system