editor: alignment guides + floor-plan move/placement parity (#372)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(editor): 3D alignment guides for item/wall/fence move + placement Bring Figma-style alignment guides into the 3D editor, reusing the shared pure resolver (`resolveAlignment`) and ephemeral guide store (`useAlignmentGuides`) that previously only drove the 2D floor plan. Core: - `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's line and distance read to the candidate's nearest edge, not the far side. - `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to item edges, never centrelines. - `resolvePointSnap` (point-coincidence variant; kept for future use). Editor: - `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill, in the project's indigo accent, mounted inside ToolManager's building-local group so guides render in the cursor's frame. - Producers wired in the item move tool, item placement coordinator, and the wall + fence endpoint tools; walls and fences cross-align. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): align guides snap to nearest real anchor, drop bbox re-span The 3D alignment guide could place its end dot in empty space: a diagonal wall (or any rotated / non-rectangular object) has bounding-box corners that don't lie on the object, and `refineGuidesToGap` re-spanned the guide to exactly those AABB edges — so the dot floated "along the coordinate" rather than on the item. - `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the perpendicular axis (after the tightest axis match). Anchors are real points (corners / endpoints / midpoints), so the guide always connects to the closest actual point — which also yields the facing-edge gap distance. - All four producers (item move, item placement, wall + fence endpoints) now publish the raw resolver guides; the AABB nearest-edge re-span is gone. - Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers (and their tests / exports). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): group move handle + shared group-transform core, move-tool polish Add a group-move gizmo alongside the existing group-rotate handle, both driven by a new shared `group-transform-shared` module (participant classification, group-box + corner math, connected wall/fence component expansion so attached structure transforms rigidly as one piece). - core: refactor alignment-anchors collection + tests, extend handle registry - editor: group-move-handle, group-transform-shared; rotate handle reuses them; node-arrow-handles gains click-swallow guard; box-select + placement tweaks - nodes: move-tool updates across ceiling/column/slab/roof/registry; door math and panel adjustments; item definition cleanup - nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the wall endpoint tool (fixes missing audio feedback on fence side drag) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): keep autosave alive across page unload The autosave debounces writes by 1s and relies on a `beforeunload` flush for anything still pending. That flush fired a plain `fetch` PUT, which the browser cancels the instant the page unloads — so refreshing right after an edit (e.g. painting a roof material) silently dropped the change and the reload showed the last persisted scene. Thread a `{ keepalive }` option through the save callback and set it on the unload flush so the request survives the unload. Also listen for `pagehide` (fires where `beforeunload` does not, e.g. mobile Safari / bfcache) and clear the dirty flag up front so the two listeners don't double-send. Normal debounced saves omit `keepalive` (its 64KB body cap only constrains the best-effort unload flush, not regular saves). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): paint eraser + reset-all, drop roof cross-role bleed Material paint gains an eraser (clear a surface back to its default) and a "Reset all" action that defaults every painted surface on a node — for a roof that includes each child segment — via a generic `buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific material fields without per-kind knowledge. Also stop a single painted roof surface from bleeding onto the others: `getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment renderer no longer cross-fall-back between top/edge/wall. An unset role resolves only to the legacy catch-all (back-compat) or the theme default, so painting just the shingle, trim, or soffit stays on that surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): alignment guides + drag bounding box across tools Extend the Figma-style 3D alignment guides to the placement and move tools for columns, elevators, roofs, stairs, ceilings, slabs, fences, walls, doors, and windows: each collects alignment anchors from the scene, resolves a snap within the shared threshold, and drives the `useAlignmentGuides` overlay. Wall openings (doors/windows) only snap along their host wall via the new `wall-opening-alignment` helper. Add a shared `DragBoundingBox` overlay (exported from the editor barrel) that renders the dragged object's bounds during a move, wired into the column move tool alongside the alignment snap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles Build the linear resize-arrow's drag plane so it always contains the handle's axis (view direction minus its along-axis component) instead of a plane that merely faces the camera. The old camera-facing normal collapsed when the axis pointed toward the viewer — screen motion barely changed the axis component, so the resize crawled or stopped tracking the cursor. Also slim the extruded arrow/handle geometry (shared by the node arrows, wall side handles, and polygon editor) for a lighter gizmo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): fill-block wall opening highlight + selected frameless openings Draw the selected-wall opening highlight as a translucent block filling the cutout volume (front-side culled) instead of a single vertical pane, so it reads as an occupied slot from any angle — including a top-down floorplan view where an edge-on pane was invisible. Also highlight a directly-selected frameless opening (a `door` with openingKind `'opening'`), which otherwise renders no geometry of its own, and reflect live drag overrides via `useLiveNodeOverrides`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viewer): double-side slab hole side-walls Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall quad is emitted twice with opposite winding. The slab material is forced to FrontSide (DoubleSide poisons the MRT scene pass), under which ExtrudeGeometry's single-sided hole walls get back-face culled and you see straight through the cut. The doubled quads keep the cut's inner thickness visible from any angle without z-fighting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): box-select building-scoped nodes like elevators Building-scoped selectable nodes (e.g. elevators) are children of the building, not the active level, so the level walk never reached them. Also walk the level's building children and box-test any registry- selectable kind by its rendered bounds, matching the column/stair/shelf path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): shelf placement alignment + column placement ghost Shelf placement now snaps to Figma-style alignment guides by its footprint edges (layered on grid snap, Alt bypasses), matching the existing 3D move tool. Guides refresh after each drop and clear on teardown. Column placement migrates to the registry `def.tool` path so it can render a translucent column ghost at the cursor (like the shelf build tool) instead of a bare cursor sphere — the editor package can't import the column geometry, so the tool now lives in packages/nodes: - extract `ColumnBody` from the renderer and add a `ColumnPreview` (cloned translucent material, raycast disabled, origin-positioned) - new `column/tool.tsx` registry placement tool with the same footprint-edge alignment as shelf / column move - wire `def.tool` + tool hints; drop the now-unreachable legacy editor-side `ColumnTool` and its dead file Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): floor-plan alignment, pivot moves & placement ghosts Bring the 3D editor's Figma-style alignment experience to the 2D floor plan across every node kind, fix pivot semantics on move, and add 2D placement ghosts. Alignment - Wall anchors now include ±thickness/2 face corners so columns/items/etc. snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D. - Shared apply-alignment helper (applyFloorplanAlignment / alignFloorplanDraftPoint, with excludeIds) used by move sessions, structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence endpoint drags. - Door/window/wall-item moves get along-wall edge-to-edge snapping. - Generic free-translate move path aligns by edges (corner anchors). Pivot moves (2D) - Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared polygon-centroid mover; stair moves by origin→cursor; matching 3D. - Shelf/column move targets write position directly (single source of truth) so the 3D group no longer sticks on commit. Placement ghosts (2D) - usePlacementPreview store + FloorplanPlacementPreviewLayer render a kind's def.floorplan footprint following the cursor; wired for column and elevator. Fixes - Elevator placement no longer deselects the active floor plan (preserve levelId through setSelection's hierarchy guard). - Guides clear on every commit/cancel/unmount path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(editor): address architecture review for floor-plan work - Move usePlacementPreview store from core to editor: placement ghosts are an editor/tool concern the read-only viewer never needs. Rewire the column tool (via the @pascal-app/editor public surface) and the editor-internal elevator tool + preview layer (relative imports). - FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead of bulk-reading the nodes map during render. - wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nodes): recreate window draft on wall:move when null after placement After a successful click-to-place, the click handler deletes the transient draft and relies on the wall-rebuild → R3F pointer-enter cascade to create a fresh draft for the next placement. If that cascade doesn't fire synchronously (e.g. async geometry rebuild) the next wall:move receives a null draftRef and bails — requiring leave/re-enter to place again. Fix: in onWallMove, when draftRef.current is null but we're hovering a valid wall, recreate the draft immediately (same WindowNode.parse + createNode path as onWallEnter). This is idempotent: if wall:enter does fire first, destroyDraft() in onWallEnter cleans up cleanly. Preserves parity with door multi-place behaviour, matching #367's intent. * fix(nodes): recreate door draft on wall:move when null after placement Mirror of the window fix one commit back: after click-to-place the DoorTool deletes its transient draft and relies on the wall-rebuild \u2192 R3F pointer-enter cascade to spawn a fresh draft for the next placement. When that cascade doesn't fire synchronously, the next wall:move sees a null draftRef and bails \u2014 forcing a leave/re-enter to place again. Recreate the draft in onWallMove when null and over a valid wall on the current level. Idempotent with onWallEnter (destroyDraft cleans up if both fire). --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
Pascal
parent
86db5decb8
commit
46f94b97b3
@@ -44,6 +44,12 @@ export type EditorApi = {
|
||||
* or curving state so the move starts from a clean slate.
|
||||
*/
|
||||
engageMove: (node: AnyNode) => void
|
||||
/**
|
||||
* Like {@link engageMove}, but for a press-drag gizmo: the move commits on
|
||||
* pointer-release instead of waiting for a click, so the on-canvas move cross
|
||||
* behaves as press-drag-release while still showing the placement preview.
|
||||
*/
|
||||
engageMoveDrag: (node: AnyNode) => void
|
||||
/**
|
||||
* Engage endpoint drag for kinds that own start / end anchors (walls,
|
||||
* fences). No-ops for kinds without endpoints.
|
||||
@@ -281,14 +287,25 @@ export type TapActionHandle<N = any> = {
|
||||
* trigger the desired action.
|
||||
*/
|
||||
onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void
|
||||
/** Visual override; defaults to the standard chevron arrow. */
|
||||
shape?: 'arrow' | 'corner-picker'
|
||||
/**
|
||||
* Visual override; defaults to the standard chevron arrow. `'move-cross'`
|
||||
* reuses the 4-way move cross — a tap-to-engage grip that hands the node to
|
||||
* its move tool (via `onActivate`) instead of running the generic translate
|
||||
* drag, so the move tool's own preview / ticker feedback shows up.
|
||||
*/
|
||||
shape?: 'arrow' | 'corner-picker' | 'move-cross'
|
||||
/**
|
||||
* Required when `shape: 'corner-picker'` — controls the dashed leader's
|
||||
* vertical extent. Pure callback so the descriptor doesn't need to
|
||||
* import 3D libs.
|
||||
*/
|
||||
nodeHeight?: (node: N) => number
|
||||
/**
|
||||
* `shape: 'move-cross'` only — tilts the flat cross to lie in the right
|
||||
* plane. `'horizontal'` (default) leaves it flat on the floor; `'node-normal'`
|
||||
* stands it up against the node's facing plane (a wall face).
|
||||
*/
|
||||
plane?: 'horizontal' | 'node-normal'
|
||||
portal?: HandlePortal
|
||||
cursor?: Cursor
|
||||
}
|
||||
|
||||
@@ -81,25 +81,9 @@ export function getEffectiveRoofSurfaceMaterial(
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'edge') {
|
||||
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.wallMaterial,
|
||||
materialPreset:
|
||||
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'wall') {
|
||||
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.edgeMaterial,
|
||||
materialPreset:
|
||||
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No cross-role fallback: an unset role resolves only to the legacy
|
||||
// catch-all (which covers all three roles for back-compat) and otherwise
|
||||
// to the caller's theme default. Painting one surface must never bleed
|
||||
// onto the others.
|
||||
return getLegacyRoofSurfaceMaterial(node)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { nodeRegistry, registerNode } from '../registry'
|
||||
import type { AnyNodeDefinition } from '../registry/types'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import {
|
||||
collectAlignmentAnchors,
|
||||
footprintAABB,
|
||||
footprintAABBFrom,
|
||||
movingFootprintAnchors,
|
||||
polygonAnchors,
|
||||
wallSegmentAnchors,
|
||||
} from './alignment-anchors'
|
||||
|
||||
// Minimal floor-placed def whose footprint reads `dimensions` / `rotation`
|
||||
// straight off the node, so tests can drive the AABB math directly.
|
||||
function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as any,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {
|
||||
floorPlaced: {
|
||||
footprint: (n: AnyNode) => ({
|
||||
dimensions: (n as { dimensions?: [number, number, number] }).dimensions ?? [1, 1, 1],
|
||||
rotation: (n as { rotation?: [number, number, number] }).rotation ?? [0, 0, 0],
|
||||
}),
|
||||
...(applies ? { applies } : {}),
|
||||
},
|
||||
},
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
} as AnyNodeDefinition
|
||||
}
|
||||
|
||||
function plainDef(kind: string): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as any,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {},
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
} as AnyNodeDefinition
|
||||
}
|
||||
|
||||
const node = (over: Record<string, unknown>): AnyNode => over as unknown as AnyNode
|
||||
|
||||
describe('footprintAABBFrom', () => {
|
||||
test('unrotated box is centred at position', () => {
|
||||
const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0)
|
||||
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
|
||||
})
|
||||
|
||||
test('90° rotation swaps width and depth extents', () => {
|
||||
const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2)
|
||||
expect(aabb.minX).toBeCloseTo(-2, 10)
|
||||
expect(aabb.maxX).toBeCloseTo(2, 10)
|
||||
expect(aabb.minZ).toBeCloseTo(-1, 10)
|
||||
expect(aabb.maxZ).toBeCloseTo(1, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('footprintAABB', () => {
|
||||
beforeEach(() => nodeRegistry._reset())
|
||||
|
||||
test('reads dimensions + rotation from a floor-placed kind', () => {
|
||||
registerNode(floorPlacedDef('box'))
|
||||
const aabb = footprintAABB(
|
||||
node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }),
|
||||
)
|
||||
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
|
||||
})
|
||||
|
||||
test('returns null for a kind without a footprint', () => {
|
||||
registerNode(plainDef('wall'))
|
||||
expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull()
|
||||
})
|
||||
|
||||
test('derives an elevator footprint from its width / depth (no floorPlaced needed)', () => {
|
||||
const aabb = footprintAABB(
|
||||
node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }),
|
||||
)
|
||||
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
|
||||
})
|
||||
|
||||
test('returns null when the kind predicate excludes the node', () => {
|
||||
registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached))
|
||||
expect(
|
||||
footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true })),
|
||||
).toBeNull()
|
||||
expect(
|
||||
footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false })),
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('movingFootprintAnchors', () => {
|
||||
beforeEach(() => nodeRegistry._reset())
|
||||
|
||||
test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => {
|
||||
registerNode(floorPlacedDef('box'))
|
||||
const anchors = movingFootprintAnchors(
|
||||
node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }),
|
||||
10,
|
||||
20,
|
||||
)
|
||||
// 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}.
|
||||
expect(anchors).toHaveLength(4)
|
||||
expect(anchors.every((a) => a.kind === 'corner')).toBe(true)
|
||||
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11]))
|
||||
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22]))
|
||||
})
|
||||
|
||||
test('rotationY override drives the AABB regardless of node rotation', () => {
|
||||
registerNode(floorPlacedDef('box'))
|
||||
const anchors = movingFootprintAnchors(
|
||||
node({
|
||||
id: 'm',
|
||||
type: 'box',
|
||||
position: [0, 0, 0],
|
||||
dimensions: [2, 1, 4],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
0,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
)
|
||||
const xs = anchors.map((a) => a.x)
|
||||
// Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1.
|
||||
expect(Math.max(...xs)).toBeCloseTo(2, 10)
|
||||
expect(Math.min(...xs)).toBeCloseTo(-2, 10)
|
||||
})
|
||||
|
||||
test('returns empty for a footprintless kind', () => {
|
||||
registerNode(plainDef('wall'))
|
||||
expect(
|
||||
movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallSegmentAnchors', () => {
|
||||
test('returns both endpoints as corners and the chord midpoint as center', () => {
|
||||
const anchors = wallSegmentAnchors('w', [0, 0], [4, 2])
|
||||
expect(anchors).toEqual([
|
||||
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 4, z: 2 },
|
||||
{ nodeId: 'w', kind: 'center', x: 2, z: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => {
|
||||
// Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1.
|
||||
const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2)
|
||||
expect(anchors).toEqual([
|
||||
{ nodeId: 'w', kind: 'corner', x: 0, z: 0 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 4, z: 0 },
|
||||
{ nodeId: 'w', kind: 'center', x: 2, z: 0 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 0, z: 0.1 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 0, z: -0.1 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 4, z: 0.1 },
|
||||
{ nodeId: 'w', kind: 'corner', x: 4, z: -0.1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('skips face corners for zero/degenerate input', () => {
|
||||
expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3)
|
||||
expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('polygonAnchors', () => {
|
||||
test('returns each vertex as a corner anchor', () => {
|
||||
expect(
|
||||
polygonAnchors('s', [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 3],
|
||||
]),
|
||||
).toEqual([
|
||||
{ nodeId: 's', kind: 'corner', x: 0, z: 0 },
|
||||
{ nodeId: 's', kind: 'corner', x: 2, z: 0 },
|
||||
{ nodeId: 's', kind: 'corner', x: 2, z: 3 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectAlignmentAnchors', () => {
|
||||
beforeEach(() => nodeRegistry._reset())
|
||||
|
||||
test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => {
|
||||
registerNode(floorPlacedDef('box'))
|
||||
const nodes = {
|
||||
moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }),
|
||||
box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }),
|
||||
wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }),
|
||||
slab: node({
|
||||
id: 'slab',
|
||||
type: 'slab',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
],
|
||||
}),
|
||||
}
|
||||
const anchors = collectAlignmentAnchors(nodes, 'moving')
|
||||
const ids = anchors.map((a) => a.nodeId)
|
||||
expect(ids).not.toContain('moving')
|
||||
expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors
|
||||
expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners
|
||||
expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Node → alignment-anchor adapters.
|
||||
*
|
||||
* `alignment.ts` is pure geometry and knows nothing about nodes. This
|
||||
* module bridges the scene graph to it: it reads a floor-placed kind's
|
||||
* footprint from the registry and turns it into the bbox anchors the
|
||||
* resolver matches against. Kept out of `alignment.ts` so that file stays
|
||||
* registry-free.
|
||||
*
|
||||
* All coordinates are XZ meters in the same frame as `node.position`
|
||||
* (building-local for nodes inside a building). The 3D move producer works
|
||||
* entirely in that frame, so the resulting guides line up with the cursor.
|
||||
*/
|
||||
|
||||
import { nodeRegistry } from '../registry'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
|
||||
import { type AlignmentAnchor, bboxCornerAnchors } from './alignment'
|
||||
|
||||
export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
|
||||
|
||||
/**
|
||||
* Axis-aligned XZ bounding box of a rotated rectangle centred at
|
||||
* `position`. Mirrors the rotated-corner math the spatial-grid manager
|
||||
* uses (`getItemFootprint`) so alignment anchors coincide with the
|
||||
* footprint used for collision / slab elevation.
|
||||
*/
|
||||
export function footprintAABBFrom(
|
||||
position: readonly [number, number, number],
|
||||
dimensions: readonly [number, number, number],
|
||||
rotationY: number,
|
||||
): FootprintAABB {
|
||||
const [x, , z] = position
|
||||
const [w, , d] = dimensions
|
||||
const halfW = w / 2
|
||||
const halfD = d / 2
|
||||
const cos = Math.cos(rotationY)
|
||||
const sin = Math.sin(rotationY)
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const [lx, lz] of [
|
||||
[-halfW, -halfD],
|
||||
[halfW, -halfD],
|
||||
[halfW, halfD],
|
||||
[-halfW, halfD],
|
||||
] as const) {
|
||||
const wx = x + (lx * cos - lz * sin)
|
||||
const wz = z + (lx * sin + lz * cos)
|
||||
if (wx < minX) minX = wx
|
||||
if (wx > maxX) maxX = wx
|
||||
if (wz < minZ) minZ = wz
|
||||
if (wz > maxZ) maxZ = wz
|
||||
}
|
||||
|
||||
return { minX, minZ, maxX, maxZ }
|
||||
}
|
||||
|
||||
/** The floor-placed footprint config for a node, or null when it has none
|
||||
* (walls / slabs / polygon kinds) or the kind's predicate excludes it
|
||||
* (e.g. a wall-attached item that doesn't rest on the floor). */
|
||||
function floorFootprint(
|
||||
node: AnyNode,
|
||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (floorPlaced) {
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||
return floorPlaced.footprint(node)
|
||||
}
|
||||
// Elevator isn't a `floorPlaced` kind (no slab-elevation coupling) but it
|
||||
// does rest on the floor with a `width × depth` cab — give it a footprint
|
||||
// so it aligns like other boxes (the registry move tool reads this).
|
||||
if (node.type === 'elevator') {
|
||||
const e = node as { width?: number; depth?: number; rotation?: number }
|
||||
return { dimensions: [e.width ?? 1.6, 1, e.depth ?? 1.6], rotation: [0, e.rotation ?? 0, 0] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** XZ footprint AABB of a floor-placed node at its current position, or
|
||||
* null for kinds without a usable footprint. */
|
||||
export function footprintAABB(node: AnyNode): FootprintAABB | null {
|
||||
const fp = floorFootprint(node)
|
||||
if (!fp) return null
|
||||
const position = (node as { position?: [number, number, number] }).position ?? [0, 0, 0]
|
||||
return footprintAABBFrom(position, fp.dimensions, fp.rotation[1] ?? 0)
|
||||
}
|
||||
|
||||
/** XZ footprint AABB of a floor-placed node relocated so its centre sits at
|
||||
* the proposed (x, z). `rotationY` overrides the node's footprint rotation
|
||||
* (R/T bumps it before the scene commit lands). Null when no footprint. */
|
||||
export function footprintAABBAt(
|
||||
node: AnyNode,
|
||||
x: number,
|
||||
z: number,
|
||||
rotationY?: number,
|
||||
): FootprintAABB | null {
|
||||
const fp = floorFootprint(node)
|
||||
if (!fp) return null
|
||||
return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner anchors for the moving node's footprint relocated so its centre
|
||||
* sits at the proposed (x, z). Corners only — the moving item aligns by its
|
||||
* edges, never its centreline. Returns [] when the kind has no footprint.
|
||||
*/
|
||||
export function movingFootprintAnchors(
|
||||
node: AnyNode,
|
||||
x: number,
|
||||
z: number,
|
||||
rotationY?: number,
|
||||
): AlignmentAnchor[] {
|
||||
const aabb = footprintAABBAt(node, x, z, rotationY)
|
||||
if (!aabb) return []
|
||||
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment anchors for a wall segment: the two centerline endpoints + chord
|
||||
* midpoint, plus — when `thickness` is known — four **face** corner anchors,
|
||||
* each endpoint offset by ±thickness/2 perpendicular to the wall axis.
|
||||
*
|
||||
* The face anchors are what let a footprint align to a wall's *face* rather
|
||||
* than its centerline: for an axis-aligned wall the two same-side face
|
||||
* anchors share a constant X (vertical wall) or Z (horizontal wall) running
|
||||
* the wall's full length, so the point-to-point resolver snaps a moving
|
||||
* corner flush to the face anywhere along the wall (the perpendicular
|
||||
* tie-break connects the guide to the nearer face endpoint). A diagonal wall
|
||||
* gets only its face/centerline endpoints — point-to-point can't represent a
|
||||
* sloped face line; that's an accepted v1 limitation.
|
||||
*
|
||||
* Curve offset is ignored — endpoints are exact and the chord midpoint is
|
||||
* good enough for v1. Coordinates are the wall's `start` / `end`
|
||||
* (building-local XZ meters).
|
||||
*/
|
||||
export function wallSegmentAnchors(
|
||||
id: string,
|
||||
start: readonly [number, number],
|
||||
end: readonly [number, number],
|
||||
thickness?: number,
|
||||
): AlignmentAnchor[] {
|
||||
const anchors: AlignmentAnchor[] = [
|
||||
{ nodeId: id, kind: 'corner', x: start[0], z: start[1] },
|
||||
{ nodeId: id, kind: 'corner', x: end[0], z: end[1] },
|
||||
{ nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 },
|
||||
]
|
||||
|
||||
if (thickness && thickness > 0) {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const len = Math.hypot(dx, dz)
|
||||
if (len > 1e-6) {
|
||||
// Perpendicular to the wall axis, scaled to half-thickness.
|
||||
const half = thickness / 2
|
||||
const px = (-dz / len) * half
|
||||
const pz = (dx / len) * half
|
||||
for (const [bx, bz] of [start, end] as const) {
|
||||
anchors.push({ nodeId: id, kind: 'corner', x: bx + px, z: bz + pz })
|
||||
anchors.push({ nodeId: id, kind: 'corner', x: bx - px, z: bz - pz })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return anchors
|
||||
}
|
||||
|
||||
/** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */
|
||||
export function polygonAnchors(
|
||||
id: string,
|
||||
points: readonly (readonly [number, number])[],
|
||||
): AlignmentAnchor[] {
|
||||
return points.map(([x, z]) => ({ nodeId: id, kind: 'corner' as const, x, z }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment anchors a node contributes to the candidate pool, dispatched by
|
||||
* kind: floor-placed footprints → corner anchors; walls / fences → segment
|
||||
* endpoints + midpoint; slabs / ceilings → polygon vertices. Kinds without a
|
||||
* usable footprint contribute nothing.
|
||||
*/
|
||||
export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] {
|
||||
if (node.type === 'wall' || node.type === 'fence') {
|
||||
const seg = node as {
|
||||
id: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
thickness?: number
|
||||
}
|
||||
// Wall thickness is schema-optional (falls back to the geometry default);
|
||||
// fence always carries one. Either way, pass it through so faces align.
|
||||
return wallSegmentAnchors(seg.id, seg.start, seg.end, seg.thickness ?? DEFAULT_WALL_THICKNESS)
|
||||
}
|
||||
if (node.type === 'slab' || node.type === 'ceiling') {
|
||||
const poly = (node as { polygon?: [number, number][] }).polygon
|
||||
return poly ? polygonAnchors(node.id, poly) : []
|
||||
}
|
||||
const aabb = footprintAABB(node)
|
||||
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors from every alignable node except `excludeId` — the unified
|
||||
* candidate pool every move / placement tool resolves against, so any
|
||||
* draggable object can align to any other (items, walls, fences, slabs,
|
||||
* ceilings, columns).
|
||||
*/
|
||||
export function collectAlignmentAnchors(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
excludeId: string,
|
||||
): AlignmentAnchor[] {
|
||||
const anchors: AlignmentAnchor[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.id === excludeId) continue
|
||||
anchors.push(...nodeAlignmentAnchors(node))
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
@@ -5,6 +5,10 @@ function center(nodeId: string, x: number, z: number): AlignmentAnchor {
|
||||
return { nodeId, kind: 'center', x, z }
|
||||
}
|
||||
|
||||
function corner(nodeId: string, x: number, z: number): AlignmentAnchor {
|
||||
return { nodeId, kind: 'corner', x, z }
|
||||
}
|
||||
|
||||
describe('resolveAlignment', () => {
|
||||
test('returns empty when no candidates within threshold', () => {
|
||||
const result = resolveAlignment({
|
||||
@@ -51,6 +55,17 @@ describe('resolveAlignment', () => {
|
||||
expect(result.guides[0]!.candidateNodeId).toBe('b')
|
||||
})
|
||||
|
||||
test('ties on the matched axis break toward the nearest perpendicular anchor', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [corner('m', 0.02, 4)],
|
||||
candidates: [corner('far', 0, 0), corner('near', 0, 5)],
|
||||
threshold: 0.1,
|
||||
})
|
||||
// Both share X (Δx = 0.02); 'near' (z=5) is closer to the moving z=4 than
|
||||
// 'far' (z=0), so the guide connects to the nearest real anchor.
|
||||
expect(result.guides[0]!.candidateNodeId).toBe('near')
|
||||
})
|
||||
|
||||
test('threshold = 0 disables alignment', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0, 0)],
|
||||
|
||||
@@ -79,11 +79,20 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
|
||||
const { moving, candidates, threshold } = input
|
||||
if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY
|
||||
|
||||
// Best match per axis: smallest |Δ| across all (moving, candidate) pairs.
|
||||
// Tie-break by candidate anchor kind priority (center > edge-mid > corner)
|
||||
// so visually meaningful matches win when |Δ| is equal.
|
||||
let bestX: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null
|
||||
let bestZ: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null
|
||||
// Best match per axis: smallest |Δ| on the matched axis (tightest
|
||||
// alignment), then — crucially — tie-break to the candidate anchor NEAREST
|
||||
// on the perpendicular axis. Anchors are real points (corners / endpoints /
|
||||
// midpoints), so the guide always connects to the closest actual point of
|
||||
// the candidate, never a far one that merely shares the same coordinate.
|
||||
type Best = {
|
||||
delta: number
|
||||
primary: number
|
||||
perp: number
|
||||
m: AlignmentAnchor
|
||||
c: AlignmentAnchor
|
||||
}
|
||||
let bestX: Best | null = null
|
||||
let bestZ: Best | null = null
|
||||
|
||||
for (const m of moving) {
|
||||
for (const c of candidates) {
|
||||
@@ -91,11 +100,17 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
|
||||
const dz = c.z - m.z
|
||||
const adx = Math.abs(dx)
|
||||
const adz = Math.abs(dz)
|
||||
if (adx <= threshold && (bestX === null || adx < Math.abs(bestX.delta))) {
|
||||
bestX = { delta: dx, m, c }
|
||||
if (
|
||||
adx <= threshold &&
|
||||
(bestX === null || adx < bestX.primary || (adx === bestX.primary && adz < bestX.perp))
|
||||
) {
|
||||
bestX = { delta: dx, primary: adx, perp: adz, m, c }
|
||||
}
|
||||
if (adz <= threshold && (bestZ === null || adz < Math.abs(bestZ.delta))) {
|
||||
bestZ = { delta: dz, m, c }
|
||||
if (
|
||||
adz <= threshold &&
|
||||
(bestZ === null || adz < bestZ.primary || (adz === bestZ.primary && adx < bestZ.perp))
|
||||
) {
|
||||
bestZ = { delta: dz, primary: adz, perp: adx, m, c }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,3 +189,23 @@ export function bboxAnchors(
|
||||
{ nodeId, kind: 'center', x: cx, z: cz },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The 4 corner anchors of a bbox — edges only, no edge-midpoints or center.
|
||||
* Used where alignment should lock to an object's edges (left/right/front/
|
||||
* back), never its centreline.
|
||||
*/
|
||||
export function bboxCornerAnchors(
|
||||
nodeId: string,
|
||||
minX: number,
|
||||
minZ: number,
|
||||
maxX: number,
|
||||
maxZ: number,
|
||||
): AlignmentAnchor[] {
|
||||
return [
|
||||
{ nodeId, kind: 'corner', x: minX, z: minZ },
|
||||
{ nodeId, kind: 'corner', x: maxX, z: minZ },
|
||||
{ nodeId, kind: 'corner', x: maxX, z: maxZ },
|
||||
{ nodeId, kind: 'corner', x: minX, z: maxZ },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,22 @@ export {
|
||||
type AlignmentGuideAxis,
|
||||
type AnchorKind,
|
||||
bboxAnchors,
|
||||
bboxCornerAnchors,
|
||||
type ResolveAlignmentInput,
|
||||
type ResolveAlignmentResult,
|
||||
resolveAlignment,
|
||||
} from './alignment'
|
||||
export {
|
||||
collectAlignmentAnchors,
|
||||
type FootprintAABB,
|
||||
footprintAABB,
|
||||
footprintAABBAt,
|
||||
footprintAABBFrom,
|
||||
movingFootprintAnchors,
|
||||
nodeAlignmentAnchors,
|
||||
polygonAnchors,
|
||||
wallSegmentAnchors,
|
||||
} from './alignment-anchors'
|
||||
export {
|
||||
createDragSession,
|
||||
type DragSession,
|
||||
|
||||
Reference in New Issue
Block a user