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:
Sudhir Yadav
2026-06-04 14:03:00 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Pascal
parent 86db5decb8
commit 46f94b97b3
84 changed files with 4399 additions and 1021 deletions
+6 -1
View File
@@ -102,7 +102,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const handleLoad = useCallback(async () => initialScene, [initialScene])
const handleSave = useCallback(
async (graph: SceneGraph) => {
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
@@ -120,6 +120,11 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
'If-Match': String(versionRef.current),
},
body: JSON.stringify({ name: meta.name, graph }),
// `keepalive` lets the request outlive a page unload (the autosave
// flush on refresh/close). Browsers cap keepalive bodies at 64KB, so
// only the unload flush opts in — normal debounced saves omit it and
// can carry arbitrarily large scenes.
keepalive: options?.keepalive,
})
if (response.status === 409) {
+19 -2
View File
@@ -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
}
+4 -20
View File
@@ -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)],
+44 -9
View File
@@ -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 },
]
}
+12
View File
@@ -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,
@@ -5,6 +5,7 @@ import {
type AnyNode,
type AnyNodeId,
bboxAnchors,
bboxCornerAnchors,
type FloorplanMoveTargetSession,
nodeRegistry,
pauseSceneHistory,
@@ -365,6 +366,11 @@ export function FloorplanRegistryMoveOverlay() {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
// Sessions that publish Figma-style alignment guides during `apply`
// (item / shelf / column) leave them in the store; this cleanup runs
// after every terminal path (commit + Esc both unmount via
// `setMovingNode(null)`), so clearing here drops any lingering guide.
useAlignmentGuides.getState().clear()
// Same belt-and-suspenders pattern for the wall bridge ghost
// previews — clear unconditionally so Esc / mid-drag unmount /
// 3D-takeover paths all end up with no stale ghosts left over.
@@ -428,7 +434,11 @@ export function FloorplanRegistryMoveOverlay() {
// simple translate suffices.
const dxProposed = gridX - originalPosition[0]
const dzProposed = gridZ - originalPosition[2]
const movingAnchors = bboxAnchors(
// Corner-only for the moving node so it aligns by its edges, never
// its centreline — matching the placement tools and Path 1 move
// sessions. Candidates keep their full 9-point set (we DO want to
// align to a neighbour's centre / edge-midpoints).
const movingAnchors = bboxCornerAnchors(
movingNode.id,
movingLocalBBox.x + dxProposed,
movingLocalBBox.y + dzProposed,
@@ -0,0 +1,59 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { memo } from 'react'
import usePlacementPreview from '../../../store/use-placement-preview'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
/**
* Renders a faint, non-interactive ghost of the node being placed by a
* registry placement tool (e.g. column), following the cursor in the floor
* plan. The 3D view shows a translucent mesh preview; in 2D that mesh is
* hidden (canvas `display:none`), so without this the user only saw the grid
* cursor dot + alignment guides — no sense of the footprint they were about
* to drop. The placement tool publishes a transient, already-positioned +
* aligned node to `usePlacementPreview`; we build its `def.floorplan`
* footprint with a minimal (unselected) context and render it.
*
* Mounted inside the floor-plan scene `<g>` so the geometry's level-local
* meters get the same world→SVG transform every other entry does.
*/
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
const node = usePlacementPreview((s) => s.node)
if (!node) return null
const builder = nodeRegistry.get(node.type)?.floorplan
if (!builder) return null
// Minimal, unselected context — preview never shows selection chrome
// (move handles / resize arrows / hatch live behind `viewState.selected`).
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
// and `parent: null` short-circuits the elevator's level walk) so the layer
// never subscribes to / bulk-reads the nodes map during render.
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: null,
viewState: undefined,
} as unknown as GeometryContext
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node,
ctx,
)
if (!geometry) return null
return (
<g data-floorplan-placement-preview opacity={0.5} pointerEvents="none">
<FloorplanGeometryRenderer geometry={geometry} />
</g>
)
})
@@ -14,6 +14,7 @@ import {
pauseSceneHistory,
resolveBuildingForLevel,
resumeSceneHistory,
useAlignmentGuides,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
@@ -310,7 +311,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
rotation: [0, live.rotation, 0] as [number, number, number],
parentId: null,
} as AnyNode
} else if (node.type === 'slab' || node.type === 'ceiling') {
} else if (node.type === 'column') {
// Same world-plan override as item/shelf, but column stores its
// Y rotation as a scalar (not a tuple).
effectiveNode = {
...node,
position: live.position,
rotation: live.rotation,
parentId: null,
} as AnyNode
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0]
const dz = live.position[2]
if (dx !== 0 || dz !== 0) {
@@ -653,6 +663,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
resumeSceneHistory(useScene)
drag.historyPaused = false
}
// Affordances that publish Figma alignment guides during `apply`
// (fence endpoint) leave them in the store on cancel — `canCommit`
// (the pointer-up clear) never runs on a cancel.
useAlignmentGuides.getState().clear()
// Drop any live overrides the session may have published. No-op
// for affordances whose `apply()` writes straight to scene; the
// override-routed sessions (wall endpoint, wall curve) rely on
@@ -686,6 +700,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
for (const id of drag.session.affectedIds) overrides.clear(id)
dragRef.current = null
}
// Clear any alignment guide a session left behind on mid-drag unmount.
useAlignmentGuides.getState().clear()
}
}, [])
@@ -0,0 +1,143 @@
'use client'
import { type AlignmentGuide, useAlignmentGuides } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { memo, useMemo } from 'react'
import { BoxGeometry, CircleGeometry } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
/**
* Figma-style alignment guides for the 3D editor — the spatial twin of
* `FloorplanAlignmentGuideLayer`. Subscribes to the shared
* `useAlignmentGuides` store (published by the move / placement / wall tools
* during a drag) and draws each guide as a dashed ribbon on the floor with a
* flat circular marker at each endpoint and a distance pill.
*
* The dashes + dots lie flat on the floor plane (XZ) — a ground ribbon, like
* the design reference — so they're real 3D geometry, not screen billboards.
* Only the distance pill is screen-space (`<Html>`).
*
* Guide coordinates are XZ meters in the building-local frame; this layer is
* mounted inside ToolManager's building-local group so they render at the
* right world position (and line up with the cursor).
*/
const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
const PILL_COLOR = '#6366f1' // indigo-500 — same hue, darker for white-text contrast
const GUIDE_Y = 0.03 // small lift so guides read above the floor grid
const DASH_LEN = 0.18 // world-meter dash length
const DASH_GAP = 0.12 // world-meter gap between dashes
const LINE_WIDTH = 0.06 // world-meter ribbon thickness
const DOT_RADIUS = 0.11 // world-meter radius of the endpoint markers
const MAX_DASHES = 80 // cap so a very long guide can't spawn thousands of quads
// Shared resources — one violet material + unit geometries scaled per
// instance, so guide churn during a drag doesn't rebuild GPU buffers.
const guideMaterial = new MeshBasicNodeMaterial({
color: LINE_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
const DOT_GEOMETRY = new CircleGeometry(1, 24)
type Vec3 = [number, number, number]
export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
if (guides.length === 0) return null
return (
<>
{guides.map((guide, i) => (
<GuideLine guide={guide} key={i} />
))}
</>
)
})
function GuideLine({ guide }: { guide: AlignmentGuide }) {
const { x: fx, z: fz } = guide.from
const { x: tx, z: tz } = guide.to
const distLabel = formatMeters(guide.distance)
// Lay out the dash centres along the from→to direction. The ribbon
// stretches the dash period up if the line is long enough to exceed the
// dash cap, so it always reads as a continuous dashed line.
const { dashes, angleY } = useMemo(() => {
const dx = tx - fx
const dz = tz - fz
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
if (length < 1e-4) return { dashes: [] as Vec3[], angleY: angle }
const ux = dx / length
const uz = dz / length
const period = Math.max(DASH_LEN + DASH_GAP, length / MAX_DASHES)
const centres: Vec3[] = []
for (let d = period / 2; d - DASH_LEN / 2 <= length; d += period) {
centres.push([fx + ux * d, GUIDE_Y, fz + uz * d])
}
return { dashes: centres, angleY: angle }
}, [fx, fz, tx, tz])
const mid: Vec3 = [(fx + tx) / 2, GUIDE_Y, (fz + tz) / 2]
return (
<>
{dashes.map((centre, i) => (
<mesh
geometry={DASH_GEOMETRY}
key={i}
layers={EDITOR_LAYER}
material={guideMaterial}
position={centre}
renderOrder={1000}
rotation={[0, angleY, 0]}
scale={[DASH_LEN, 0.002, LINE_WIDTH]}
/>
))}
<Dot position={[fx, GUIDE_Y + 0.001, fz]} />
<Dot position={[tx, GUIDE_Y + 0.001, tz]} />
{guide.distance > 1e-4 && (
<Html
center
position={mid}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
style={{ backgroundColor: PILL_COLOR }}
>
{distLabel}
</div>
</Html>
)}
</>
)
}
/** Flat circular marker lying on the floor plane at a guide endpoint. */
function Dot({ position }: { position: Vec3 }) {
return (
<mesh
geometry={DOT_GEOMETRY}
layers={EDITOR_LAYER}
material={guideMaterial}
position={position}
renderOrder={1001}
rotation={[-Math.PI / 2, 0, 0]}
scale={[DOT_RADIUS, DOT_RADIUS, DOT_RADIUS]}
/>
)
}
function formatMeters(meters: number): string {
// Sub-centimetre = "0"; otherwise up to 2 decimals, trimmed. Matches the
// 2D floor-plan guide layer's pill formatting.
if (meters < 0.005) return '0'
const fixed = meters.toFixed(2)
return `${fixed.replace(/\.?0+$/, '')}m`
}
@@ -36,6 +36,7 @@ import {
StairSegmentNode as StairSegmentNodeSchema,
sampleWallCenterline,
sceneRegistry,
useAlignmentGuides,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
@@ -62,6 +63,7 @@ import { createPortal } from 'react-dom'
import { Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow'
import {
alignFloorplanDraftPoint,
buildFloorplanItemEntry,
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
collectLevelDescendants,
@@ -86,6 +88,7 @@ import {
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
@@ -110,6 +113,7 @@ import {
createWallOnCurrentLevel,
isSegmentLongEnough,
snapWallDraftPoint,
snapPointToGrid as snapWallPointToGrid,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
@@ -6315,6 +6319,9 @@ export function FloorplanPanel() {
clearWallCurveDrag()
clearSiteBoundaryInteraction()
setCursorPoint(null)
// Drop any Figma-style alignment guide a draft branch left behind so it
// doesn't linger after the tool deactivates / Esc / draft reset.
useAlignmentGuides.getState().clear()
}, [
clearFencePlacementDraft,
clearCeilingPlacementDraft,
@@ -7407,14 +7414,20 @@ export function FloorplanPanel() {
}
if (isCeilingBuildActive) {
emitFloorplanGridEvent('move', planPoint, event)
const snappedPoint = snapPolygonDraftPoint({
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
// the previous vertex. Alignment runs only when angle snap is OFF
// (first vertex, or Shift held) — when the angle is being locked,
// pulling the vertex sideways would break it.
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
angleSnap,
})
if (angleSnap) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
@@ -7422,7 +7435,8 @@ export function FloorplanPanel() {
}
if (isRoofBuildActive) {
const snappedPoint = getSnappedFloorplanPoint(planPoint)
let snappedPoint = getSnappedFloorplanPoint(planPoint)
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
@@ -7439,18 +7453,25 @@ export function FloorplanPanel() {
}
if (isFenceBuildActive) {
emitFloorplanGridEvent('move', planPoint, event)
// Fence draft: grid snap only — orthogonal fences fall out of
// a grid-aligned start. Shift switches to the fine grid step
// for precision. Mirrors `wall/tool.tsx`.
const snappedPoint = snapFenceDraftPoint({
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — same endpoint-wins precedence as the wall branch.
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
})
const fenceGridBase = snapWallPointToGrid(
planPoint,
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
)
const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
let snappedPoint = fenceSnapped
if (fenceLocked) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
)
@@ -7469,11 +7490,14 @@ export function FloorplanPanel() {
// the local polygon-draft state actually updates as the cursor
// moves (the catch-all would otherwise swallow the move event).
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
angleSnap,
})
if (angleSnap) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
// Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it).
@@ -7573,14 +7597,26 @@ export function FloorplanPanel() {
return
}
// Wall draft: grid snap only — orthogonal walls follow naturally
// from a grid-aligned start. Shift switches to the fine grid step
// (0.05m) for precision.
const snappedPoint = snapWallDraftPoint({
// Wall draft: grid snap (orthogonal walls follow naturally from a
// grid-aligned start; Shift = fine 0.05m step), then Figma-style
// alignment layered on top. An existing wall endpoint / join snap
// wins outright — never pull the cursor off a corner the user is
// closing onto — so alignment runs ONLY when the wall snap left the
// point on the plain grid. Alt bypasses alignment.
const gridStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
})
const gridBase = snapWallPointToGrid(planPoint, gridStep)
const lockedToWall = wallSnapped[0] !== gridBase[0] || wallSnapped[1] !== gridBase[1]
let snappedPoint = wallSnapped
if (lockedToWall) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
}
// Emit `grid:move` so the registry-driven wall tool's 3D preview
// tracks the cursor. The local draftEnd update below is what
@@ -9115,6 +9151,12 @@ export function FloorplanPanel() {
would create a measurefitmeasure loop. */}
<g ref={floorplanContentRef}>
<FloorplanRegistryLayer />
{/* Faint footprint ghost of the node being placed by a
registry placement tool (e.g. column), following the
cursor. The 3D mesh preview is hidden in 2D, so this is
the only placement visual in the floor plan. See
`floorplan-placement-preview-layer.tsx`. */}
<FloorplanPlacementPreviewLayer />
{/* Bridge-wall ghost previews painted on top of the
registry layer (drag-time only); cleared by the
wall move's `commit()` so real bridges replace
@@ -0,0 +1,271 @@
'use client'
import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import {
classifyParticipant,
collectParticipants,
computeGroupBox,
CORNER_OFFSET,
expandToComponent,
type Vec2,
} from './group-transform-shared'
import {
ARROW_COLOR,
ARROW_HOVER_COLOR,
ARROW_SCALE,
createMoveCrossHandleGeometry,
swallowNextClick,
useArrowMaterial,
} from './node-arrow-handles'
/**
* Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
* transformable nodes are selected, a single move cross appears at the
* selection's front-left bounding-box corner (the rotate gizmo sits on the
* right). Dragging it slides every selected node by the same ground-plane
* delta; connected (unselected) wall/fence endpoints follow so junctions stay
* welded. Commits the whole slide in one batched `updateNodes` (one undo).
*/
export function GroupMoveHandle() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode)
const movingNode = useEditor((s) => s.movingNode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const nodes = useScene((s) => s.nodes)
const participantIds = useMemo(
() => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null),
[selectedIds, levelId, nodes],
)
// Gate on the explicit selection, but move the full connected wall/fence
// component so attached structure slides rigidly as one piece.
const fullIds = useMemo(
() => expandToComponent(participantIds, nodes, levelId),
[participantIds, levelId, nodes],
)
const shouldRender =
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
if (!shouldRender) return null
return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
}
function GroupMoveHandleInner({ ids }: { ids: string[] }) {
const { camera, raycaster, gl, scene } = useThree()
const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
const arrowMaterial = useArrowMaterial()
const [isHovered, setIsHovered] = useState(false)
const [isDragging, setIsDragging] = useState(false)
// Live ground-plane delta so the gizmo rides along with the group it moves.
const [liveDelta, setLiveDelta] = useState<Vec2>([0, 0])
const dragCleanupRef = useRef<(() => void) | null>(null)
const frozenCorner = useRef<Vector3 | null>(null)
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
useEffect(() => () => dragCleanupRef.current?.(), [])
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
// Front-left bbox corner at mid-height (mirrors the rotate gizmo on the
// right), plus the group's base Y for the ground drag plane.
const rest = useMemo(() => {
const box = computeGroupBox(ids)
if (!box) return null
const corner = new Vector3(
box.min.x - CORNER_OFFSET,
(box.min.y + box.max.y) / 2,
box.max.z + CORNER_OFFSET,
)
return { corner, baseY: box.min.y }
}, [ids])
if (!rest) return null
const baseCorner = isDragging && frozenCorner.current ? frozenCorner.current : rest.corner
const corner: [number, number, number] = [
baseCorner.x + liveDelta[0],
baseCorner.y,
baseCorner.z + liveDelta[1],
]
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
frozenCorner.current = rest.corner.clone()
const planeY = rest.baseY
// Snapshot selected participants + connected wall/fence neighbours.
const { starts, links } = collectParticipants(
ids,
useScene.getState().nodes,
useViewer.getState().selection.levelId,
)
if (starts.length === 0) return
// Horizontal drag plane at the group's base; delta measured in world XZ
// (= level-local XZ on an axis-aligned level).
const plane = new Plane(new Vector3(0, 1, 0), -planeY)
const ndc = new Vector2()
const setNDC = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
}
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
raycaster.setFromCamera(ndc, camera)
const hit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hit)) return
const startHit = hit.clone()
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
setIsDragging(true)
// Snap the slide to the active grid step so the group lands on the grid
// (Shift bypasses for free movement). Snapping the delta keeps the
// selection's internal layout intact — grid-aligned items stay aligned.
const step = useEditor.getState().gridSnapStep
let lastSnap: Vec2 | null = null
const onMove = (e: PointerEvent) => {
setNDC(e.clientX, e.clientY)
raycaster.setFromCamera(ndc, camera)
const moveHit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
const snap = !e.shiftKey && step > 0
const dx = snap ? Math.round((moveHit.x - startHit.x) / step) * step : moveHit.x - startHit.x
const dz = snap ? Math.round((moveHit.z - startHit.z) / step) * step : moveHit.z - startHit.z
// Ticker on each grid-cell crossing, like single-item placement.
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
sfxEmitter.emit('sfx:grid-snap')
lastSnap = [dx, dz]
}
const overrides = useLiveNodeOverrides.getState()
for (const s of starts) {
if (s.kind === 'endpoint') {
overrides.set(s.id, {
start: [s.start[0] + dx, s.start[1] + dz],
end: [s.end[0] + dx, s.end[1] + dz],
})
} else {
// Slide on the floor: XZ shift, Y and rotation untouched.
overrides.set(s.id, {
position: [s.position[0] + dx, s.position[1], s.position[2] + dz],
})
}
useScene.getState().markDirty(s.id)
}
// Shared endpoints of connected neighbours follow by the same delta so
// the junction stays welded; the far end stays put.
for (const l of links) {
overrides.set(l.id, {
start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
})
useScene.getState().markDirty(l.id)
}
setLiveDelta([dx, dz])
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
setIsDragging(false)
setLiveDelta([0, 0])
frozenCorner.current = null
dragCleanupRef.current = null
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const id of affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
return updates
}
const onUp = () => {
// Eat the click that follows pointer-up so the selection manager doesn't
// treat it as a canvas click and clear the multi-selection.
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
const updates = commitFromOverrides()
// Resume before the commit so the single batched `updateNodes` is the one
// tracked set — collapsing the whole group move into one undo.
useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup()
}
const onCancel = () => {
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup()
}
dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
}
return createPortal(
<group position={corner} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activate}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'move'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'move') document.body.style.cursor = ''
}}
renderOrder={1010}
/>
</group>,
scene,
)
}
export default GroupMoveHandle
@@ -1,18 +1,21 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Box3, OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import {
CORNER_OFFSET,
classifyParticipant,
collectParticipants,
computeGroupBox,
expandToComponent,
type Vec2,
type Vec3,
} from './group-transform-shared'
import {
ARROW_COLOR,
ARROW_HOVER_COLOR,
@@ -21,25 +24,12 @@ import {
GuideRing,
RotationGuide,
type RotationGuideData,
swallowNextClick,
useArrowMaterial,
} from './node-arrow-handles'
const ROTATE_SNAP = Math.PI / 12 // 15°
type MovableNode = AnyNode & {
position: [number, number, number]
rotation: [number, number, number]
}
function isMovable(node: AnyNode | undefined, levelId: string | null): node is MovableNode {
if (!node || node.parentId !== levelId) return false
const p = (node as { position?: unknown }).position
const r = (node as { rotation?: unknown }).rotation
const isVec3 = (v: unknown): v is [number, number, number] =>
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
return isVec3(p) && isVec3(r)
}
/**
* Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting
* directly on the active level) are selected, a single rotation handle appears
@@ -61,16 +51,24 @@ export function GroupRotateHandle() {
const nodes = useScene((s) => s.nodes)
const participantIds = useMemo(
() => selectedIds.filter((id) => isMovable(nodes[id as AnyNodeId], levelId)),
() => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null),
[selectedIds, levelId, nodes],
)
// Gate on the explicit selection (so a single connected wall still gets the
// per-node handles), but transform the full connected wall/fence component so
// attached structure rotates rigidly as one piece.
const fullIds = useMemo(
() => expandToComponent(participantIds, nodes, levelId),
[participantIds, levelId, nodes],
)
const shouldRender =
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
if (!shouldRender) return null
// Remount when the participant set changes so the rest pivot re-seeds cleanly.
return <GroupRotateHandleInner ids={participantIds} key={participantIds.join(',')} />
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} />
}
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
@@ -81,7 +79,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const [isDragging, setIsDragging] = useState(false)
const [guide, setGuide] = useState<RotationGuideData | null>(null)
const dragCleanupRef = useRef<(() => void) | null>(null)
const frozenPivot = useRef<Vector3 | null>(null)
const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null)
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
@@ -93,59 +91,64 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE * 1.05
// World-space bounding-box center of the selected meshes (XZ), Y at the
// group's base. Levels are axis-aligned in XZ, so world XZ coincides with
// each node's level-local `position` XZ — letting us rotate `position`
// directly against this pivot without per-node frame conversion.
const restPivot = useMemo(() => {
const box = new Box3()
const tmp = new Box3()
let found = false
for (const id of ids) {
const obj = sceneRegistry.nodes.get(id)
if (!obj) continue
obj.updateWorldMatrix(true, true)
tmp.setFromObject(obj)
if (tmp.isEmpty()) continue
box.union(tmp)
found = true
}
if (!found) return null
return new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
// World-space bounding box of the selected meshes. Levels are axis-aligned in
// XZ, so world XZ coincides with each node's level-local placement — letting
// us rotate `position` / `start` / `end` directly against the pivot without
// per-node frame conversion.
// - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits
const rest = useMemo(() => {
const box = computeGroupBox(ids)
if (!box) return null
const pivot = new Vector3(
(box.min.x + box.max.x) / 2,
box.min.y,
(box.min.z + box.max.z) / 2,
)
const corner = new Vector3(
box.max.x + CORNER_OFFSET,
(box.min.y + box.max.y) / 2,
box.max.z + CORNER_OFFSET,
)
return { pivot, corner }
}, [ids])
if (!restPivot) return null
const pivot = isDragging && frozenPivot.current ? frozenPivot.current : restPivot
if (!rest) return null
const active = isDragging && frozenRest.current ? frozenRest.current : rest
const corner = active.corner
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
const center = restPivot.clone()
frozenPivot.current = center
frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() }
const center = rest.pivot.clone()
// Snapshot each participant's pre-drag transform from the store.
const sceneNodes = useScene.getState().nodes
const starts = ids
.map((id) => {
const node = sceneNodes[id as AnyNodeId] as MovableNode | undefined
if (!node) return null
return {
id: id as AnyNodeId,
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
}
})
.filter((s): s is NonNullable<typeof s> => s !== null)
// Snapshot the selected participants + connected wall/fence neighbours whose
// shared endpoints must follow the rotation (so junctions stay welded).
const { starts, links } = collectParticipants(
ids,
useScene.getState().nodes,
useViewer.getState().selection.levelId,
)
if (starts.length === 0) return
// Horizontal drag plane at the pivot; bearing measured around the pivot.
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
// Wedge radius tracks how far the group spreads from the pivot.
// Wedge radius tracks how far the group spreads from the pivot — sample each
// participant's anchor point(s).
let spread = 0
const reach = (x: number, z: number) => {
spread = Math.max(spread, Math.hypot(x - center.x, z - center.z))
}
for (const s of starts) {
spread = Math.max(spread, Math.hypot(s.position[0] - center.x, s.position[2] - center.z))
if (s.kind === 'endpoint') {
reach(s.start[0], s.start[1])
reach(s.end[0], s.end[1])
} else {
reach(s.position[0], s.position[2])
}
}
const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
@@ -180,29 +183,46 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
while (delta < -Math.PI) delta += 2 * Math.PI
if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
// Orbit each node's position CCW by `delta` (atan2 x→z sense) and turn
// its yaw by `-delta` to match three.js Y-rotation handedness (same
// Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
// turn its yaw by `-delta` to match three.js Y-rotation handedness (same
// convention as the single-item rotate handle in item/definition.ts).
// Endpoint nodes (walls/fences) have no yaw — swinging both endpoints
// around the pivot rotates them rigidly; their curveOffset sagitta is
// rotation-invariant, so arcs are preserved.
const cos = Math.cos(delta)
const sin = Math.sin(delta)
const rot = (x: number, z: number): Vec2 => {
const dx = x - center.x
const dz = z - center.z
return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
}
const overrides = useLiveNodeOverrides.getState()
for (const s of starts) {
const dx = s.position[0] - center.x
const dz = s.position[2] - center.z
const position: [number, number, number] = [
center.x + dx * cos - dz * sin,
s.position[1],
center.z + dx * sin + dz * cos,
]
const rotation: [number, number, number] = [
s.rotation[0],
s.rotation[1] - delta,
s.rotation[2],
]
overrides.set(s.id, { position, rotation })
if (s.kind === 'endpoint') {
overrides.set(s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) })
} else {
const [px, pz] = rot(s.position[0], s.position[2])
const position: Vec3 = [px, s.position[1], pz]
const rotation =
s.kind === 'vec3'
? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
: s.rotation - delta
overrides.set(s.id, { position, rotation })
}
useScene.getState().markDirty(s.id)
}
// Drag each linked neighbour's shared endpoint to the same rotated spot
// (rot is deterministic, so it lands exactly on the selected wall's
// rotated endpoint), keeping the junction welded; the far end stays put.
for (const l of links) {
overrides.set(l.id, {
start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
})
useScene.getState().markDirty(l.id)
}
if (Math.abs(delta) < 0.0087) {
setGuide(null)
} else {
@@ -232,39 +252,44 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
useViewer.getState().setInputDragging(false)
setIsDragging(false)
setGuide(null)
frozenPivot.current = null
frozenRest.current = null
dragCleanupRef.current = null
}
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
const commitFromOverrides = () => {
const overrides = useLiveNodeOverrides.getState()
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const s of starts) {
const patch = overrides.get(s.id)
if (patch) updates.push({ id: s.id, data: patch as Partial<AnyNode> })
for (const id of affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
return updates
}
const onUp = () => {
// Eat the click that follows pointer-up so the selection manager doesn't
// treat it as a canvas click and clear the multi-selection.
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
const updates = commitFromOverrides()
// Resume before the commit so the single batched `updateNodes` is the
// one tracked set — collapsing the whole group rotation into one undo.
useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates)
for (const s of starts) {
useLiveNodeOverrides.getState().clear(s.id)
useScene.getState().markDirty(s.id)
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup()
}
const onCancel = () => {
// Revert: drop overrides + mark dirty so renderers rebuild from the store.
for (const s of starts) {
useLiveNodeOverrides.getState().clear(s.id)
useScene.getState().markDirty(s.id)
for (const id of affectedIds) {
useLiveNodeOverrides.getState().clear(id)
useScene.getState().markDirty(id)
}
cleanup()
}
@@ -278,11 +303,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
return createPortal(
<>
{(isHovered || isDragging) && (
<group position={[pivot.x, pivot.y, pivot.z]}>
<group position={[corner.x, corner.y, corner.z]}>
<GuideRing radius={0.2 * scale} y={0} />
</group>
)}
<group position={[pivot.x, pivot.y, pivot.z]} scale={scale}>
<group position={[corner.x, corner.y, corner.z]} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
@@ -0,0 +1,189 @@
import { type AnyNode, type AnyNodeId, sceneRegistry } from '@pascal-app/core'
import { Box3 } from 'three'
// Shared plumbing for the group transform gizmos (rotate + move). Both operate
// on the same multi-selection: classify each participant by how its placement
// transforms, snapshot pre-drag state, and pull connected wall/fence neighbours
// along so junctions stay welded.
// Outward clearance from a bbox corner so a gizmo doesn't sit on the geometry.
export const CORNER_OFFSET = 0.3
// Two endpoints within this distance count as the same junction — a hair looser
// than the store's 1e-6 so near-but-not-exact corners still hold together.
const JUNCTION_EPS = 1e-4
export type Vec2 = [number, number]
export type Vec3 = [number, number, number]
const isVec3 = (v: unknown): v is Vec3 =>
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
const isVec2 = (v: unknown): v is Vec2 =>
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
// How a participant's placement transforms rigidly around / with the group:
// - 'vec3' position + [x,y,z] rotation (items, …)
// - 'scalar' position + numeric rotation (columns)
// - 'endpoint' start/end tuples (walls, fences)
export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
// A selected node qualifies when it sits directly on the active level and its
// placement is one of the transformable shapes. Doors/windows parent to their
// wall (not the level), so they're excluded here and ride their wall.
export function classifyParticipant(
node: AnyNode | undefined,
levelId: string | null,
): ParticipantKind | null {
if (!node || node.parentId !== levelId) return null
const p = (node as { position?: unknown }).position
const r = (node as { rotation?: unknown }).rotation
const start = (node as { start?: unknown }).start
const end = (node as { end?: unknown }).end
if (isVec3(p) && isVec3(r)) return 'vec3'
if (isVec3(p) && typeof r === 'number') return 'scalar'
if (isVec2(start) && isVec2(end)) return 'endpoint'
return null
}
// Pre-drag placement snapshot + how to transform it.
export type ParticipantStart =
| { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
| { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
| { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
// An unselected wall/fence sharing a junction with a transforming endpoint. Only
// the touching endpoint(s) follow, so the neighbour stays attached while its far
// end stays put (it stretches, mirroring single-wall move).
export type LinkedNeighbor = {
id: AnyNodeId
start: Vec2
end: Vec2
startLinked: boolean
endLinked: boolean
}
const nearPoint = (a: Vec2, b: Vec2) =>
Math.abs(a[0] - b[0]) <= JUNCTION_EPS && Math.abs(a[1] - b[1]) <= JUNCTION_EPS
// Snapshot the selected participants and the connected (unselected) wall/fence
// neighbours whose shared endpoints should follow the transform.
export function collectParticipants(
ids: string[],
sceneNodes: Record<string, AnyNode | undefined>,
levelId: string | null,
): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
const starts: ParticipantStart[] = []
for (const id of ids) {
const node = sceneNodes[id]
const kind = classifyParticipant(node, levelId)
if (!node || !kind) continue
if (kind === 'vec3') {
const n = node as AnyNode & { position: Vec3; rotation: Vec3 }
starts.push({
id: id as AnyNodeId,
kind,
position: [n.position[0], n.position[1], n.position[2]],
rotation: [n.rotation[0], n.rotation[1], n.rotation[2]],
})
} else if (kind === 'scalar') {
const n = node as AnyNode & { position: Vec3; rotation: number }
starts.push({
id: id as AnyNodeId,
kind,
position: [n.position[0], n.position[1], n.position[2]],
rotation: n.rotation,
})
} else {
const n = node as AnyNode & { start: Vec2; end: Vec2 }
starts.push({
id: id as AnyNodeId,
kind,
start: [n.start[0], n.start[1]],
end: [n.end[0], n.end[1]],
})
}
}
const endpoints: Vec2[] = []
for (const s of starts) {
if (s.kind === 'endpoint') endpoints.push(s.start, s.end)
}
const links: LinkedNeighbor[] = []
if (endpoints.length > 0) {
const selected = new Set(starts.map((s) => s.id))
for (const [nid, node] of Object.entries(sceneNodes)) {
if (selected.has(nid as AnyNodeId)) continue
if (classifyParticipant(node, levelId) !== 'endpoint') continue
const n = node as AnyNode & { start: Vec2; end: Vec2 }
const start: Vec2 = [n.start[0], n.start[1]]
const end: Vec2 = [n.end[0], n.end[1]]
const startLinked = endpoints.some((p) => nearPoint(start, p))
const endLinked = endpoints.some((p) => nearPoint(end, p))
if (startLinked || endLinked) {
links.push({ id: nid as AnyNodeId, start, end, startLinked, endLinked })
}
}
}
return { starts, links }
}
// Grow a selection to the full connected component of walls/fences: any
// endpoint node transitively reachable through shared junctions from a selected
// endpoint node joins in, so the whole rigid structure transforms as one piece
// (rather than tearing/stretching at the boundary). Non-endpoint selections
// (items, columns) pass through unchanged.
export function expandToComponent(
selectedIds: string[],
sceneNodes: Record<string, AnyNode | undefined>,
levelId: string | null,
): string[] {
const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
for (const [id, node] of Object.entries(sceneNodes)) {
if (classifyParticipant(node, levelId) === 'endpoint') {
const n = node as AnyNode & { start: Vec2; end: Vec2 }
endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
}
}
const included = new Set(selectedIds)
if (!endpoints.some((e) => included.has(e.id))) return selectedIds
let changed = true
while (changed) {
changed = false
for (const e of endpoints) {
if (included.has(e.id)) continue
const touches = endpoints.some(
(o) =>
included.has(o.id) &&
(nearPoint(e.start, o.start) ||
nearPoint(e.start, o.end) ||
nearPoint(e.end, o.start) ||
nearPoint(e.end, o.end)),
)
if (touches) {
included.add(e.id)
changed = true
}
}
}
return Array.from(included)
}
// World-space union bounding box of the selected meshes, or null if none are
// mounted yet. Levels are axis-aligned in XZ, so world XZ coincides with each
// node's level-local placement — letting callers transform placement directly
// against box-derived points without per-node frame conversion.
export function computeGroupBox(ids: string[]): Box3 | null {
const box = new Box3()
const tmp = new Box3()
let found = false
for (const id of ids) {
const obj = sceneRegistry.nodes.get(id)
if (!obj) continue
obj.updateWorldMatrix(true, true)
tmp.setFromObject(obj)
if (tmp.isEmpty()) continue
box.union(tmp)
found = true
}
return found ? box : null
}
@@ -60,6 +60,7 @@ import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid'
import { GroupMoveHandle } from './group-move-handle'
import { GroupRotateHandle } from './group-rotate-handle'
import { NodeArrowHandles } from './node-arrow-handles'
import { SelectionManager } from './selection-manager'
@@ -138,7 +139,7 @@ export interface EditorProps {
// Persistence — defaults to localStorage when omitted
onLoad?: () => Promise<SceneGraph | null>
onSave?: (scene: SceneGraph) => Promise<void>
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
onDirty?: () => void
onSaveStatusChange?: (status: SaveStatus) => void
@@ -607,6 +608,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{!noEditing && <BoxSelectTool />}
{!noEditing && <NodeArrowHandles />}
{!noEditing && <GroupRotateHandle />}
{!noEditing && <GroupMoveHandle />}
{!noEditing && <WallOpeningHighlights />}
{!noEditing && <WallMoveSideHandles />}
{!noEditing && <FloatingActionMenu />}
@@ -110,9 +110,9 @@ export const ARROW_HOVER_COLOR = '#a5b4fc'
// unchanged.
export function createRotateArrowHandleGeometry() {
const R = 0.2
const ribbonHalfWidth = 0.02 // ribbon thickness / 2
const ribbonHalfWidth = 0.028 // ribbon thickness / 2
const halfSweep = Math.PI / 3 // 60° per side → 120° total arc
const headHalfWidth = 0.045 // arrowhead wings extend this far past ribbon
const headHalfWidth = 0.05 // arrowhead wings extend this far past ribbon
const headOvershoot = 0.075 // tangential reach of the arrowhead tip
const rIn = R - ribbonHalfWidth
const rOut = R + ribbonHalfWidth
@@ -170,16 +170,16 @@ export function createRotateArrowHandleGeometry() {
shape.closePath()
const geometry = new ExtrudeGeometry(shape, {
depth: 0.06,
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.018,
bevelSize: 0.012,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 6,
bevelSegments: 8,
curveSegments: 24,
steps: 1,
})
geometry.translate(0, 0, -0.03)
geometry.translate(0, 0, -0.0225)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
@@ -200,16 +200,16 @@ function createArrowHandleGeometry() {
shape.lineTo(-0.04, -0.12)
shape.lineTo(0.22, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelThickness: 0.018,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 10,
bevelSegments: 8,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -0.04)
geometry.translate(0, 0, -0.0225)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
@@ -221,8 +221,8 @@ function createArrowHandleGeometry() {
// merge into the 4-way move cross.
function createDoubleArrowShape(): Shape {
const L = 0.36 // half-length to each tip
const rw = 0.03 // ribbon half-width
const hw = 0.12 // arrowhead half-width
const rw = 0.042 // ribbon half-width
const hw = 0.13 // arrowhead half-width
// Long inner ribbon so opposing arrowheads sit well apart rather than
// meeting in a cramped knot at the centre.
const hx = 0.2 // where each arrowhead meets the ribbon
@@ -244,20 +244,20 @@ function createDoubleArrowShape(): Shape {
// 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the
// XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads
// as a floor-move grip centred on the item.
function createMoveCrossHandleGeometry() {
export function createMoveCrossHandleGeometry() {
const shape = createDoubleArrowShape()
const extrudeOpts = {
depth: 0.06,
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.018,
bevelSize: 0.012,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 6,
bevelSegments: 8,
curveSegments: 8,
steps: 1,
}
const armX = new ExtrudeGeometry(shape, extrudeOpts)
armX.translate(0, 0, -0.03)
armX.translate(0, 0, -0.0225)
armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ
const armZ = armX.clone()
armZ.rotateY(Math.PI / 2) // second arm → points along ±Z
@@ -275,7 +275,7 @@ function createMoveCrossHandleGeometry() {
return merged
}
function swallowNextClick() {
export function swallowNextClick() {
const swallow = (clickEvent: Event) => {
clickEvent.stopPropagation()
clickEvent.preventDefault()
@@ -750,9 +750,6 @@ function LinearArrow({
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
// Raycast plane at the handle's world position, perpendicular to the
// camera's projected horizontal direction. For axis='y' we need the
// plane to be vertical too — projection.y maps directly.
rideObject.updateMatrixWorld()
// Freeze the ride frame at drag-start. Some kinds park their mesh
// position on the field being dragged (ceiling: mesh.position.y =
@@ -762,8 +759,21 @@ function LinearArrow({
// pose for the duration of the drag.
const initialFrameInverse = new Matrix4().copy(rideObject.matrixWorld).invert()
const worldOrigin = new Vector3(...position).applyMatrix4(rideObject.matrixWorld)
const planeNormal = new Vector3().subVectors(camera.position, worldOrigin).setY(0)
if (planeNormal.lengthSq() === 0) return
// Drag plane MUST contain the handle's axis. The resize value is read off
// the hit point's component along that axis, so a plane that merely faces
// the camera (the old `setY(0)` normal) collapses when the axis points
// toward the viewer: the axis lies near the plane normal, screen motion
// barely changes the axis component, and the resize crawls / stops
// following the cursor. Build the world-space axis from the frozen ride
// frame, then take the view direction with its along-axis part removed —
// that plane contains the axis yet faces the camera as squarely as
// possible for a stable intersection. (For axis='y' this reduces to the
// old vertical plane, since the view's vertical component is dropped.)
const axisIndex = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
const worldAxis = new Vector3().setFromMatrixColumn(rideObject.matrixWorld, axisIndex).normalize()
const viewDir = new Vector3().subVectors(worldOrigin, camera.position)
const planeNormal = viewDir.addScaledVector(worldAxis, -viewDir.dot(worldAxis))
if (planeNormal.lengthSq() < 1e-10) return
planeNormal.normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
@@ -1767,7 +1777,8 @@ function TapActionArrow({
const position = descriptor.placement.position(node, placementSceneApi)
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
const shape = descriptor.shape ?? 'arrow'
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
const cursor: Cursor =
descriptor.cursor ?? (shape === 'corner-picker' || shape === 'move-cross' ? 'move' : 'ew-resize')
const onActivate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
@@ -1803,6 +1814,20 @@ function TapActionArrow({
)
}
if (shape === 'move-cross') {
return (
<MoveCrossShape
isHovered={isHovered}
onActivate={onActivate}
onEnter={onEnter}
onLeave={onLeave}
position={position}
tilt={descriptor.plane === 'node-normal'}
zoom={zoom}
/>
)
}
// Default 'arrow' shape — the standard chevron.
return (
<ArrowShape
@@ -1817,6 +1842,51 @@ function TapActionArrow({
)
}
// 4-way move cross for tap-action handles — same visual as the translate
// gizmo's cross, but a click target that hands the node to its move tool. The
// cross is built flat in XZ; `tilt` stands it up into a wall face (XY plane).
function MoveCrossShape({
position,
zoom,
isHovered,
tilt,
onActivate,
onEnter,
onLeave,
}: {
position: readonly [number, number, number]
zoom: number
isHovered: boolean
tilt: boolean
onActivate: (event: ThreeEvent<PointerEvent>) => void
onEnter: (event: ThreeEvent<PointerEvent>) => void
onLeave: (event: ThreeEvent<PointerEvent>) => void
}) {
const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
const arrowMaterial = useArrowMaterial()
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
const iconRotation: [number, number, number] = tilt ? [Math.PI / 2, 0, 0] : [0, 0, 0]
return (
<group position={position} rotation={iconRotation} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={onActivate}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
renderOrder={1010}
/>
</group>
)
}
function ArrowShape({
position,
rotationY,
@@ -45,7 +45,6 @@ import {
type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch,
buildRoofSurfaceMaterialPatch,
buildRoofSurfaceMaterialUpdates,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
hasActivePaintMaterial,
@@ -294,12 +293,9 @@ function applyRoofSegmentPaintPreview(
if (!(edge || wall || top)) return null
const fallback = parent ? getRoofMaterialArray(parent) : null
const fb = (n: number) => fallback?.[n] ?? null
const arr: Material[] = [
edge ?? wall ?? top ?? fb(0)!,
wall ?? edge ?? top ?? fb(1)!,
wall ?? edge ?? top ?? fb(2)!,
top ?? wall ?? edge ?? fb(3)!,
]
// Per-role only, then the parent's themed slot — matches the renderer so the
// preview never bleeds a painted surface onto the segment's other surfaces.
const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
if (arr.some((m) => !m)) return null
return previewMeshMaterial(mesh, arr)
}
@@ -844,11 +840,31 @@ export const SelectionManager = () => {
})
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const eraser = useEditor.getState().paintEraser
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
if (!isNodeInCurrentLevel(node)) return null
// The eraser clears a surface back to its default by painting with an
// empty material — every `build*SurfaceMaterialPatch` interprets
// `undefined` material/preset as "reset this role". So a single spec
// with both fields undefined drives the same apply/preview paths as a
// real material; only the enabled-gate differs (no material required).
const paintEnabled = eraser || hasActivePaintMaterial(activePaintMaterial)
const paintSpec: ActivePaintMaterial = eraser
? {
material: undefined,
materialPreset: undefined,
sourceTarget:
activePaintMaterial?.sourceTarget ?? useEditor.getState().activePaintTarget,
}
: (activePaintMaterial ?? {
material: undefined,
materialPreset: undefined,
sourceTarget: useEditor.getState().activePaintTarget,
})
// Registry-driven paint dispatch — kinds that declare
// `capabilities.paint` route hover / click / preview through
// their definition. Wall, chimney, and dormer use this; legacy
@@ -864,9 +880,9 @@ export const SelectionManager = () => {
localPosition: event.localPosition as readonly [number, number, number] | undefined,
hitObjectName: event.nativeEvent.object?.name,
})
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
const compatible = role !== null && paintEnabled
return {
key: `${node.type}:${node.id}:${role ?? 'unsupported'}`,
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply:
@@ -877,8 +893,8 @@ export const SelectionManager = () => {
paintCap.buildPatch({
node,
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
}) as Partial<AnyNode>,
)
}
@@ -891,8 +907,8 @@ export const SelectionManager = () => {
return paintCap.applyPreview({
node,
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
root,
})
}
@@ -911,7 +927,7 @@ export const SelectionManager = () => {
if (!roofNode || roofNode.type !== 'roof') return null
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
const compatible = role !== null && paintEnabled
// Painting directly on a segment (only possible in segment edit
// mode, where the per-segment mesh is visible) writes to the
// segment's own role-specific fields. Painting the merged shell
@@ -920,14 +936,11 @@ export const SelectionManager = () => {
return {
key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
segmentTarget ? segmentTarget.id : roofNode.id
}:${role ?? 'unsupported'}`,
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
compatible && role
? () => {
const sceneState = useScene.getState()
if (segmentTarget) {
@@ -935,35 +948,35 @@ export const SelectionManager = () => {
segmentTarget.id as AnyNodeId,
buildRoofSegmentSurfaceMaterialPatch(
segmentTarget,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
role,
paintSpec.material,
paintSpec.materialPreset,
),
)
} else {
sceneState.updateNodes(
buildRoofSurfaceMaterialUpdates(
sceneState.nodes,
sceneState.updateNode(
roofNode.id as AnyNodeId,
buildRoofSurfaceMaterialPatch(
roofNode as RoofNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
role,
paintSpec.material,
paintSpec.materialPreset,
),
)
}
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
compatible && role
? () =>
segmentTarget
? applyRoofSegmentPaintPreview(
segmentTarget,
roofNode as RoofNode,
role,
activePaintMaterial,
paintSpec,
)
: applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
: applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
: () => previewCursor('not-allowed'),
}
}
@@ -978,16 +991,13 @@ export const SelectionManager = () => {
if (!stairNode || stairNode.type !== 'stair') return null
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
const compatible = role !== null && paintEnabled
return {
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: stairNode.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
compatible && role
? () => {
useScene
.getState()
@@ -995,16 +1005,16 @@ export const SelectionManager = () => {
stairNode.id as AnyNodeId,
buildStairSurfaceMaterialPatch(
stairNode as StairNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
role,
paintSpec.material,
paintSpec.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
compatible && role
? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec)
: () => previewCursor('not-allowed'),
}
}
@@ -1021,10 +1031,10 @@ export const SelectionManager = () => {
node.type === 'ceiling' ||
node.type === 'shelf'
) {
const compatible = hasActivePaintMaterial(activePaintMaterial)
const compatible = paintEnabled
return {
key: `${node.type}:${node.id}:surface`,
key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`,
hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply: compatible
@@ -1035,7 +1045,7 @@ export const SelectionManager = () => {
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
>(paintSpec.material, paintSpec.materialPreset),
)
}
: null,
@@ -1043,7 +1053,7 @@ export const SelectionManager = () => {
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
activePaintMaterial,
paintSpec,
)
: () => previewCursor('not-allowed'),
}
@@ -2,9 +2,14 @@
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { getPlanPointDistance } from '../../lib/floorplan'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_FINE_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
import {
snapPointToGrid as snapWallPointToGrid,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
} from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[]
@@ -135,20 +140,28 @@ export function useFloorplanBackgroundPlacement({
}
if (isCeilingBuildActive) {
emitFloorplanGridEvent('click', planPoint, event)
const snappedPoint = snapPolygonDraftPoint({
// Align the committed vertex the same way the move-preview did, so
// the placed point matches what the user saw. Skip when angle snap
// owns the vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
angleSnap,
})
if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
}
emitFloorplanGridEvent('click', snappedPoint, event)
handleCeilingPlacementPoint(snappedPoint)
return true
}
if (isRoofBuildActive) {
const snappedPoint = getSnappedFloorplanPoint(planPoint)
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: event.altKey,
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -162,16 +175,25 @@ export function useFloorplanBackgroundPlacement({
}
if (isFenceBuildActive) {
emitFloorplanGridEvent('click', planPoint, event)
// Fence draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
const snappedPoint = snapFenceDraftPoint({
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — endpoint snap wins (same precedence as move).
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
})
const fenceGridBase = snapWallPointToGrid(
planPoint,
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
)
const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
const snappedPoint = fenceLocked
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
if (!fenceDraftStart) {
@@ -193,11 +215,15 @@ export function useFloorplanBackgroundPlacement({
// swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
angleSnap,
})
if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
}
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
@@ -220,12 +246,22 @@ export function useFloorplanBackgroundPlacement({
// / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
// Wall draft: grid snap only; Shift = fine step. See `wall/tool.tsx`.
const snappedPoint = snapWallDraftPoint({
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
// Figma alignment — endpoint/join snap wins (same precedence as the
// move-preview branch), so committing onto a corner still works.
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
})
const wallGridBase = snapWallPointToGrid(
planPoint,
shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
)
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
const snappedPoint = wallLocked
? wallSnapped
: alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
emitFloorplanGridEvent('click', snappedPoint, event)
handleWallPlacementPoint(snappedPoint)
@@ -86,12 +86,12 @@ function createArrowHandleGeometry() {
shape.lineTo(0.22, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelThickness: 0.018,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 10,
bevelSegments: 8,
curveSegments: 16,
steps: 1,
})
@@ -99,7 +99,7 @@ function createArrowHandleGeometry() {
// Centre the extruded plate around y=0 and re-orient it so the depth
// axis points up: the chevron lies flat in the XZ plane, tip along +X,
// wings spread across ±Z.
geometry.translate(0, 0, -0.04)
geometry.translate(0, 0, -0.0225)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
@@ -1,18 +1,10 @@
'use client'
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import {
BoxGeometry,
type BufferGeometry,
DoubleSide,
EdgesGeometry,
type Group,
PlaneGeometry,
Vector3,
} from 'three'
import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
@@ -36,14 +28,15 @@ const outlineMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
// Translucent pane that fills the opening so it reads as a highlighted
// region. Sits in the opening's plane (XY, facing the wall normal) and is
// double-sided so it shows from either side of the wall.
// Translucent block that fills the opening volume so the cutout reads as an
// occupied slot from every angle — the front face shows it head-on, the top
// face shows it from a top-down floorplan view (a single vertical pane would
// be edge-on, hence invisible, when looking straight down). Front-side culling
// keeps the translucency even instead of doubling up on overlapping back faces.
const fillMaterial = new MeshBasicNodeMaterial({
color: ACCENT,
transparent: true,
opacity: 0.22,
side: DoubleSide,
opacity: 0.5,
depthTest: false,
depthWrite: false,
})
@@ -56,7 +49,7 @@ function makeOutlineGeometry(width: number, height: number, depth: number): Buff
}
/**
* When a wall is selected, draws a translucent indigo highlight (filled pane
* When a wall is selected, draws a translucent indigo highlight (filled block
* + outline) over each door / window it hosts. Openings whose `openingKind`
* is `'opening'` have no visible geometry, so without this affordance the
* user can't tell an editable cutout lives there — the fill marks it (and
@@ -74,43 +67,73 @@ export function WallOpeningHighlights() {
return createPortal(
<>
{selectedIds.map((id) => (
<WallOpenings key={id} wallId={id} />
<SelectionOpeningHighlights key={id} selectedId={id} />
))}
</>,
scene,
)
}
function WallOpenings({ wallId }: { wallId: string }) {
const wall = useScene((state) => state.nodes[wallId as AnyNodeId])
// Resolves a selected node into the opening highlight(s) to draw:
// - a selected wall → a hint over each door / window it hosts ("editable
// child here").
// - a directly-selected frameless opening (a `door` whose `openingKind` is
// `'opening'`) → a fill over its own cutout, so the selection reads as
// occupied even though the opening renders no geometry of its own.
function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) {
const node = useScene((state) => state.nodes[selectedId as AnyNodeId])
if (!wall || wall.type !== 'wall') return null
if (node?.type === 'wall') {
const depth = node.thickness ?? 0.1
return (
<>
{(node.children ?? []).map((childId) => (
<OpeningHighlight depth={depth} key={childId} openingId={childId} />
))}
</>
)
}
const depth = wall.thickness ?? 0.1
return (
<>
{(wall.children ?? []).map((childId) => (
<OpeningHighlight depth={depth} key={childId} openingId={childId} />
))}
</>
)
if (node?.type === 'door' && node.openingKind === 'opening') {
return <SelectedOpeningHighlight openingId={selectedId} parentId={node.parentId} />
}
return null
}
// A frameless opening selected on its own. Pulls the cutout depth from its
// host wall's thickness so the fill block matches the wall it sits in.
function SelectedOpeningHighlight({
openingId,
parentId,
}: {
openingId: string
parentId: string | null
}) {
const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined))
const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1
return <OpeningHighlight depth={depth} openingId={openingId} />
}
function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
// Resize arrows publish width/height to the live-override store during the
// drag and only commit to the scene node on pointer-up, so read the
// override-merged dimensions to keep the highlight box tracking the resize.
const override = useLiveNodeOverrides((s) => s.overrides.get(openingId))
const groupRef = useRef<Group>(null)
const isOpening = node?.type === 'door' || node?.type === 'window'
const width = isOpening ? node.width : 0
const height = isOpening ? node.height : 0
const width = isOpening ? ((override?.width as number | undefined) ?? node.width) : 0
const height = isOpening ? ((override?.height as number | undefined) ?? node.height) : 0
const outlineGeometry = useMemo(
() => (isOpening ? makeOutlineGeometry(width, height, depth) : null),
[isOpening, width, height, depth],
)
const fillGeometry = useMemo(
() => (isOpening ? new PlaneGeometry(width, height) : null),
[isOpening, width, height],
() => (isOpening ? new BoxGeometry(width, height, depth) : null),
[isOpening, width, height, depth],
)
useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry])
useEffect(() => () => fillGeometry?.dispose(), [fillGeometry])
@@ -1,94 +0,0 @@
import '../../../three-types'
import {
COLUMN_PRESETS,
ColumnNode,
type ColumnNode as ColumnNodeType,
type ColumnPresetId,
emitter,
type GridEvent,
type LevelNode,
useScene,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="Column"
src="/icons/column.png"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return ColumnNode.parse({
name: label,
position,
rotation: 0,
...preset,
})
}
type ColumnToolProps = {
currentLevelId: LevelNode['id'] | null
onPlaced?: (nodeId: ColumnNodeType['id']) => void
}
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
const cursorRef = useRef<Group>(null)
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
const nextPosition: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
setCursorPosition(nextPosition)
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
}
const onGridClick = (event: GridEvent) => {
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
]
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
useScene.getState().createNode(column, currentLevelId)
onPlaced?.(column.id)
sfxEmitter.emit('sfx:structure-build')
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
}
}, [currentLevelId, onPlaced])
if (!currentLevelId) return null
return (
<CursorSphere
color="#a78bfa"
height={2.5}
ref={cursorRef}
showTooltip
tooltipContent={COLUMN_ICON}
/>
)
}
@@ -1,16 +1,20 @@
import {
type AnyNodeId,
type BuildingNode,
collectAlignmentAnchors,
ElevatorNode,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere'
import {
DEFAULT_ELEVATOR_CAB_HEIGHT,
@@ -24,6 +28,8 @@ import {
} from './elevator-defaults'
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
type ElevatorToolProps = {
buildingId: BuildingNode['id'] | null
@@ -119,6 +125,25 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
const previewEdgeGeometry = useMemo(() => createElevatorPreviewEdgeGeometry(), [])
// Default-shaped elevator for the 2D floor-plan placement ghost. The 3D
// preview meshes below are hidden in 2D (canvas `display:none`), so this
// feeds `usePlacementPreview` → `FloorplanPlacementPreviewLayer`, which
// renders the elevator's footprint following the cursor.
const floorplanPreviewNode = useMemo(
() =>
ElevatorNode.parse({
name: 'Elevator',
position: [0, 0, 0],
rotation: 0,
width: DEFAULT_ELEVATOR_WIDTH,
depth: DEFAULT_ELEVATOR_DEPTH,
cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
}),
[],
)
useEffect(() => {
const currentBuildingId = resolveCurrentBuildingId({
buildingId,
@@ -130,9 +155,53 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The elevator aligns by its ORIGIN point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the elevator origin onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveAlignment({
moving: [{ nodeId: '__elevator-draft__', kind: 'corner', x: rawX, z: rawZ }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId,
preferredLevelId: levelId as LevelNode['id'] | null,
@@ -143,6 +212,13 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ)
previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
// Publish the 2D floor-plan ghost at the snapped/aligned cursor.
usePlacementPreview.getState().set({
...floorplanPreviewNode,
position: [gridX, supportY, gridZ],
rotation: rotationRef.current,
})
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -161,8 +237,13 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
})
if (!latestBuildingId) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
commitElevatorPlacement(
latestBuildingId,
levelId,
@@ -171,6 +252,11 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
rotationRef.current,
onPlaced,
)
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
// The placed elevator's footprint now renders for real; drop the ghost
// (the next grid:move re-publishes it for the following placement).
usePlacementPreview.getState().clear()
}
const onKeyDown = (event: KeyboardEvent) => {
@@ -188,6 +274,16 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta
if (previewRef.current) previewRef.current.rotation.y = rotationRef.current
// Reflect the rotation in the 2D ghost immediately (no pointer move
// needed) by republishing at the last snapped cursor position.
const last = previousGridPosRef.current
if (last) {
usePlacementPreview.getState().set({
...floorplanPreviewNode,
position: [last[0], 0, last[1]],
rotation: rotationRef.current,
})
}
}
}
@@ -199,8 +295,10 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
}
}, [buildingId, levelId, onPlaced])
}, [buildingId, levelId, onPlaced, floorplanPreviewNode])
return (
<group>
@@ -1,15 +1,21 @@
import type { AssetInput } from '@pascal-app/core'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
type CeilingEvent,
collectAlignmentAnchors,
emitter,
type GridEvent,
getScaledDimensions,
type ItemEvent,
movingFootprintAnchors,
resolveAlignment,
resolveLevelId,
type ShelfEvent,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useLiveTransforms,
useScene,
useSpatialQuery,
@@ -58,6 +64,10 @@ import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
/** Figma-style alignment-snap threshold (meters), matching the 2D
* floor-plan overlay and the 3D registry move tool. */
const ALIGNMENT_THRESHOLD_M = 0.08
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
@@ -355,6 +365,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
// Lazily-gathered alignment candidates — the corner anchors of every
// OTHER floor-placed node, excluding the draft. Computed on the first
// floor move (once the draft id exists) and reused for the rest of the
// drag; the scene graph is stable during placement. Coords are
// building-local, matching the draft's grid position and the guide
// layer's frame.
let alignmentCandidates: AlignmentAnchor[] | null = null
// Reset placement state
placementState.current = configRef.current.initialState ?? {
surface: 'floor',
@@ -409,6 +427,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const applyTransition = (result: TransitionResult) => {
// Alignment guides are floor-only; clear them when the cursor moves
// onto a wall / ceiling / item surface (only those paths call this).
useAlignmentGuides.getState().clear()
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
@@ -503,6 +524,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
revalidate()
// ---- Press-drag commit-on-release ----
// When the move was engaged by the press-drag move cross (vs. click-to-
// place), commit on pointer-up instead of waiting for a click. Each surface
// move handler records how to commit at the current cursor; the release
// replays it. Captured once at setup — a fresh coordinator mounts per move.
const dragMode = useEditor.getState().placementDragMode
let releaseCommit: (() => void) | null = null
// Eat the click the browser fires after pointer-up so the surface
// `:click` handlers don't commit a second time.
const swallowNextClick = () => {
const swallow = (e: Event) => {
e.stopPropagation()
e.preventDefault()
}
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300)
}
const onReleaseCommit = () => {
if (!releaseCommit) return
const commit = releaseCommit
releaseCommit = null
swallowNextClick()
commit()
}
// ---- Floor Handlers ----
let previousGridPos: [number, number, number] | null = null
@@ -552,6 +598,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onGridMove = (event: GridEvent) => {
releaseCommit = () => onGridClick(event)
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
if (draftNode.current === null && asset.attachTo === undefined) {
configRef.current.initDraft(gridPosition.current)
@@ -574,35 +621,71 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const result = floorStrategy.move(getContext(), event)
if (!result) return
// Figma-style alignment snap layered on top of the floor strategy's
// grid snap: when the draft's edge lines up (on X or Z) with another
// item's edge, snap and publish a guide. The guide connects to the
// nearest real corner of the candidate (resolver tie-break), so the dot
// always sits on an actual point. The delta is applied to BOTH the grid
// and cursor positions below. Alt bypasses.
const draft = draftNode.current
let alignX = 0
let alignZ = 0
const bypassAlign = event.nativeEvent?.altKey === true
if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors(useScene.getState().nodes, draft.id)
const ar = resolveAlignment({
moving: movingFootprintAnchors(
draft as unknown as AnyNode,
result.gridPosition[0],
result.gridPosition[2],
cursorGroupRef.current.rotation.y,
),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
alignX = ar.snap.dx
alignZ = ar.snap.dz
}
useAlignmentGuides.getState().set(ar.guides)
} else {
useAlignmentGuides.getState().clear()
}
const gridPos: [number, number, number] = [
result.gridPosition[0] + alignX,
result.gridPosition[1],
result.gridPosition[2] + alignZ,
]
const cursorPos: [number, number, number] = [
result.cursorPosition[0] + alignX,
result.cursorPosition[1],
result.cursorPosition[2] + alignZ,
]
// Play snap sound when grid position changes
if (
previousGridPos &&
(result.gridPosition[0] !== previousGridPos[0] ||
result.gridPosition[2] !== previousGridPos[2])
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(
result.cursorPosition[0],
result.cursorPosition[1],
result.cursorPosition[2],
)
previousGridPos = [...gridPos]
gridPosition.current.set(...gridPos)
cursorGroupRef.current.position.set(cursorPos[0], cursorPos[1], cursorPos[2])
// Floor items only rotate on Y; keep the preview box (and the live
// transform the 2D floorplan mirrors) aligned with the draft's
// rotation. Without this the box stays at its seed rotation until a
// manual R/T, so a moved already-rotated item shows an axis-aligned box.
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
if (draft) draft.position = gridPos
// Publish live transform for 2D floorplan
if (draft) {
useLiveTransforms.getState().set(draft.id, {
position: result.gridPosition,
position: gridPos,
rotation: cursorGroupRef.current.rotation.y,
})
}
@@ -611,6 +694,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onGridClick = (event: GridEvent) => {
// Drop alignment guides on click — the move commits (guides done) or
// placement re-arms (the next move republishes them).
useAlignmentGuides.getState().clear()
const result = floorStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
@@ -669,6 +755,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onWallMove = (event: WallEvent) => {
releaseCommit = () => onWallClick(event)
has3DPointerDrivenMoveRef.current = true
if (!cursorGroupRef.current) return
const ctx = getContext()
@@ -890,6 +977,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onItemMove = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
releaseCommit = () => onItemClick(event)
has3DPointerDrivenMoveRef.current = true
if (!cursorGroupRef.current) return
const ctx = getContext()
@@ -1115,6 +1203,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onCeilingMove = (event: CeilingEvent) => {
releaseCommit = () => onCeilingClick(event)
has3DPointerDrivenMoveRef.current = true
if (!cursorGroupRef.current) return
if (!draftNode.current && placementState.current.surface === 'ceiling') {
@@ -1244,6 +1333,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onShelfMove = (event: ShelfEvent) => {
releaseCommit = () => onShelfClick(event)
has3DPointerDrivenMoveRef.current = true
// A shelf event can fire before the cursor group mounts or after
// teardown, leaving the ref null; bail before dereferencing it below.
@@ -1437,6 +1527,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- tool:cancel (Escape / programmatic) ----
const onCancel = () => {
useAlignmentGuides.getState().clear()
if (configRef.current.onCancel) {
configRef.current.onCancel()
}
@@ -1510,10 +1601,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave)
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
return () => {
tearingDown = true
if (dragMode) window.removeEventListener('pointerup', onReleaseCommit)
unsubDraftWatch()
useAlignmentGuides.getState().clear()
// Clear live transform for any remaining draft
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
@@ -5,13 +5,17 @@ import '../../../three-types'
import {
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
type EventSuffix,
emitter,
type GridEvent,
movingFootprintAnchors,
type NodeEvent,
nodeRegistry,
resolveAlignment,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
@@ -33,6 +37,11 @@ const snapToGridStep = (value: number) => {
/** 90° steps, matching the GLB item placement rotation. */
const ROTATION_STEP = Math.PI / 2
/** Figma-style alignment-snap threshold (meters), matching the 2D
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Generic move tool for any registry-backed kind.
*
@@ -228,9 +237,38 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
})
}
// Static alignment candidates — anchors of every OTHER alignable object
// (items, walls, fences, slabs, ceilings, columns), gathered once at drag
// start (the scene graph is stable during an imperative move). Coords are
// building-local, the same frame as `event.localPosition` and the
// rendered cursor, so the guide dots line up with the cursor.
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
const onGridMove = (event: GridEvent) => {
const x = snapToGridStep(event.localPosition[0])
const z = snapToGridStep(event.localPosition[2])
let x = snapToGridStep(event.localPosition[0])
let z = snapToGridStep(event.localPosition[2])
// Figma-style alignment snap layered on top of grid snap: when the
// moving item's edge lines up (on X or Z) with another item's edge,
// snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
x += result.snap.dx
z += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
hasMovedRef.current = true
setCursorPosition([x, 0, z])
lastCursorRef.current = [x, 0, z]
@@ -323,6 +361,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Now safe to clear — node.position is already the new value, so
// `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`.
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
@@ -398,6 +437,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
m.rotation.y = originalRotationY
}
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
@@ -417,6 +457,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Restore the moved meshes' raycast so they're hoverable / selectable
// again after the drag ends.
for (const restore of restoreRaycasts) restore()
// Drop any alignment guides this drag published — covers Esc / mid-drag
// unmount / commit paths uniformly.
useAlignmentGuides.getState().clear()
if (!committed) {
sceneRegistry.nodes
.get(node.id)
@@ -1,12 +1,15 @@
import {
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
RoofNode,
RoofSegmentNode,
resolveAlignment,
sceneRegistry,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -22,6 +25,8 @@ import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5
const DEFAULT_PITCH_DEG = 40
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Creates a roof group with one default gable segment
@@ -166,6 +171,45 @@ export const RoofTool: React.FC = () => {
outlineRef.current.geometry = new BufferGeometry()
// Alignment candidates — anchors of every alignable object; refreshed
// after each roof commits. Both corners of the rectangle align.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted corner onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveAlignment({
moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const updateOutline = (
corner1: [number, number, number],
corner2: [number, number, number],
@@ -188,8 +232,13 @@ export const RoofTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
const y = event.localPosition[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
@@ -221,8 +270,13 @@ export const RoofTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
const y = event.localPosition[1]
if (corner1Ref.current) {
@@ -237,6 +291,8 @@ export const RoofTool: React.FC = () => {
corner1Ref.current = null
outlineRef.current.visible = false
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
} else {
corner1Ref.current = [gridX, y, gridZ]
setPreview((prev) => ({
@@ -253,6 +309,7 @@ export const RoofTool: React.FC = () => {
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
}
useAlignmentGuides.getState().clear()
}
emitter.on('grid:move', onGridMove)
@@ -263,6 +320,7 @@ export const RoofTool: React.FC = () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
corner1Ref.current = null
}
@@ -10,6 +10,7 @@ import {
type ItemNode,
isRegistrySelectable,
type LevelNode,
resolveBuildingForLevel,
type SlabNode,
sceneRegistry,
useScene,
@@ -290,6 +291,25 @@ function collectNodeIdsInBounds(bounds: Bounds | null): string[] {
}
}
}
// Building-scoped selectable nodes (e.g. elevator) are siblings of the
// level — children of the building, not the level — so the loop above
// never reaches them. Walk the active level's building children and
// box-test any registry-selectable kind by its rendered bounds, the same
// path column/stair/shelf use.
const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
const buildingNode = buildingId ? nodes[buildingId] : undefined
const buildingChildren =
buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children)
? (buildingNode.children as AnyNodeId[])
: []
for (const childId of buildingChildren) {
const node = nodes[childId]
if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue
if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id)
}
}
}
return result
@@ -487,6 +507,10 @@ const BoxSelectToolInner: React.FC = () => {
const onCanvasPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return
if (useViewer.getState().cameraDragging) return
// A gizmo/handle drag is underway (group rotate/move, resize arrows, …).
// Those use raw window listeners too, so without this guard box-select
// would run in parallel and clobber the selection on release.
if (useViewer.getState().inputDragging) return
const point = raycastToGround(e)
if (!point) return
@@ -504,6 +528,18 @@ const BoxSelectToolInner: React.FC = () => {
const onCanvasPointerUp = (e: PointerEvent) => {
if (e.button !== 0) return
// If a gizmo/handle drag is in progress, don't let box-select replace the
// selection. Canvas listeners fire before the gizmo's window pointer-up
// (which clears `inputDragging`), so this still reads true here. Reset our
// own state and bail.
if (useViewer.getState().inputDragging) {
pointerDown.current = false
isDragging.current = false
if (rectFillRef.current) rectFillRef.current.visible = false
if (outlineRef.current) outlineRef.current.visible = false
syncPreviewSelectedIds([])
return
}
if (!pointerDown.current) return
if (isDragging.current) {
@@ -585,6 +621,8 @@ const BoxSelectToolInner: React.FC = () => {
}
if (!pointerDown.current) return
// A gizmo/handle drag took over — don't draw a selection box underneath it.
if (useViewer.getState().inputDragging) return
currentPoint.current.set(snappedX, event.position[1], snappedZ)
@@ -0,0 +1,157 @@
'use client'
import { sceneRegistry } from '@pascal-app/core'
import { useEffect, useMemo } from 'react'
import {
Box3,
BoxGeometry,
EdgesGeometry,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Vector3,
} from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
const NO_RAYCAST = () => null
/** green-500 — matches the item placement box's "placeable" state. */
const DEFAULT_COLOR = 0x22_c5_5e
type LocalBounds = { size: [number, number, number]; center: [number, number, number] }
/**
* The node's bounding box in its OWN, unrotated frame — measured from the
* rendered geometry so it captures the full extent (base, cap, overhang),
* not just the declared width/height/depth. World position + rotation are
* cancelled out (invert the root's world matrix), so the result is stable
* regardless of where the live drag has moved the node; the caller re-applies
* the current Y rotation on the group. Returns null when nothing measurable.
*/
function measureLocalBounds(obj: Object3D): LocalBounds | null {
obj.updateWorldMatrix(true, true)
const inverseRoot = new Matrix4().copy(obj.matrixWorld).invert()
const box = new Box3()
const meshBox = new Box3()
const toLocal = new Matrix4()
let measured = false
obj.traverse((child) => {
const mesh = child as Mesh
if (!mesh.isMesh || !mesh.geometry) return
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
const bb = mesh.geometry.boundingBox
if (!bb) return
toLocal.copy(inverseRoot).multiply(mesh.matrixWorld)
meshBox.copy(bb).applyMatrix4(toLocal)
box.union(meshBox)
measured = true
})
if (!measured || box.isEmpty()) return null
const size = box.getSize(new Vector3())
const center = box.getCenter(new Vector3())
return { size: [size.x, size.y, size.z], center: [center.x, center.y, center.z] }
}
interface DragBoundingBoxProps {
/** Node whose rendered geometry is measured for the box extents. */
nodeId: string
/** Footprint origin on the floor, `[x, 0, z]` — the node's live position. */
position: [number, number, number]
/** Y rotation (radians) applied to the box, matching the dragged node. */
rotationY?: number
/** Declared `[width, height, depth]`, used until/if the mesh can't be measured. */
fallbackSize?: [number, number, number]
color?: number
}
/**
* Footprint box drawn around a node while it is being dragged — the same
* affordance items get during placement: a wireframe cube spanning the node's
* full measured extent plus a ground plane with a radial opacity gradient
* (transparent in the centre, opaque toward the edges). Overlay layer +
* `depthTest: false` keep it drawn on top of scene geometry throughout the
* drag, and the box visualises the bounds that drive alignment snapping.
*/
export function DragBoundingBox({
nodeId,
position,
rotationY = 0,
fallbackSize = [0, 0, 0],
color = DEFAULT_COLOR,
}: DragBoundingBoxProps) {
const measured = useMemo(() => {
const obj = sceneRegistry.nodes.get(nodeId)
return obj ? measureLocalBounds(obj) : null
}, [nodeId])
const [w, h, d] = measured?.size ?? fallbackSize
const [cx, cy, cz] = measured?.center ?? [0, fallbackSize[1] / 2, 0]
const minY = cy - h / 2
const edgeGeometry = useMemo(() => {
const box = new BoxGeometry(w, h, d)
const edges = new EdgesGeometry(box)
box.dispose()
return edges
}, [w, h, d])
// Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid
// z-fighting with slabs.
const planeGeometry = useMemo(() => {
const plane = new PlaneGeometry(w, d)
plane.rotateX(-Math.PI / 2)
plane.translate(cx, minY + 0.01, cz)
return plane
}, [w, d, cx, minY, cz])
const edgeMaterial = useMemo(
() => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }),
[color],
)
const planeMaterial = useMemo(() => {
const material = new MeshBasicNodeMaterial({
color,
transparent: true,
depthTest: false,
depthWrite: false,
})
material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6)
return material
}, [color])
useEffect(
() => () => {
edgeGeometry.dispose()
planeGeometry.dispose()
edgeMaterial.dispose()
planeMaterial.dispose()
},
[edgeGeometry, planeGeometry, edgeMaterial, planeMaterial],
)
if (w <= 0 || h <= 0 || d <= 0) return null
return (
<group position={position} rotation={[0, rotationY, 0]}>
<mesh
geometry={planeGeometry}
layers={EDITOR_LAYER}
material={planeMaterial}
raycast={NO_RAYCAST}
renderOrder={999}
/>
<lineSegments
geometry={edgeGeometry}
layers={EDITOR_LAYER}
material={edgeMaterial}
position={[cx, cy, cz]}
raycast={NO_RAYCAST}
renderOrder={999}
/>
</group>
)
}
@@ -49,16 +49,16 @@ function createEdgeArrowGeometry() {
shape.lineTo(-0.04, -0.12)
shape.lineTo(0.22, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
depth: 0.045,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelThickness: 0.018,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 10,
bevelSegments: 8,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -0.04)
geometry.translate(0, 0, -0.0225)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
@@ -1,9 +1,12 @@
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
StairNode,
StairSegmentNode,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -31,6 +34,8 @@ import {
} from './stair-defaults'
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Generates the step-profile geometry for the ghost preview.
@@ -147,9 +152,53 @@ export const StairTool: React.FC = () => {
rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The stair aligns by its ORIGIN point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the stair origin onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
const ar = resolveAlignment({
moving: [{ nodeId: '__stair-draft__', kind: 'corner', x: rawX, z: rawZ }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
const y = event.localPosition[1]
if (cursorRef.current) {
@@ -173,9 +222,16 @@ export const StairTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
)
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
}
const onKeyDown = (event: KeyboardEvent) => {
@@ -206,6 +262,7 @@ export const StairTool: React.FC = () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear()
}
}, [currentLevelId])
@@ -9,7 +9,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { ColumnTool } from './column/column-tool'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
@@ -136,7 +136,16 @@ export const ToolManager: React.FC = () => {
nodeId: AnyNodeId,
elevatorBuildingId: BuildingNode['id'],
) => {
setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] })
// Preserve the active level. `setSelection`'s hierarchy guard nulls
// `levelId` whenever `buildingId` is passed without an explicit
// `levelId` — which deselected the current floor plan the moment an
// elevator was placed. Pass the current level through so the floor
// plan stays selected.
setSelection({
buildingId: elevatorBuildingId,
levelId: activeLevelId ?? null,
selectedIds: [nodeId],
})
}
return (
@@ -252,9 +261,6 @@ export const ToolManager: React.FC = () => {
<RegistryToolComponent />
</Suspense>
)}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && (
<ElevatorTool
buildingId={buildingId as BuildingNode['id'] | null}
@@ -262,9 +268,11 @@ export const ToolManager: React.FC = () => {
onPlaced={handlePlacedElevatorSelected}
/>
)}
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
<BuildToolComponent />
) : null}
{!movingNode && BuildToolComponent && tool !== 'elevator' ? <BuildToolComponent /> : null}
{/* Figma-style alignment guides published by the move / placement
tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */}
<Alignment3DGuideLayer />
</group>
</>
)
@@ -995,6 +995,7 @@ export function SecondaryToggles() {
return (
<div className="flex items-center gap-1">
<ReferencesControl />
<ReferenceFloorControl />
</div>
)
}
@@ -1,10 +1,15 @@
'use client'
import { useScene } from '@pascal-app/core'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Eraser, RotateCcw } from 'lucide-react'
import { useEffect } from 'react'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import {
buildResetSurfaceMaterialUpdates,
resolvePaintTargetFromSelection,
} from './../../../lib/material-paint'
import useEditor from './../../../store/use-editor'
import { Button } from '../primitives/button'
import { MaterialPicker } from './material-picker'
/**
@@ -18,9 +23,14 @@ export function MaterialPaintPanel() {
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
const paintEraser = useEditor((state) => state.paintEraser)
const setPaintEraser = useEditor((state) => state.setPaintEraser)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
const canResetSelection =
selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null
useEffect(() => {
const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
@@ -29,8 +39,35 @@ export function MaterialPaintPanel() {
}
}, [nodes, selectedId, setActivePaintTarget])
const resetSelection = () => {
if (!selectedNode) return
useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
}
return (
<div className="w-full">
<div className="w-full space-y-2">
<div className="flex items-center gap-2">
<Button
aria-pressed={paintEraser}
className="flex-1"
onClick={() => setPaintEraser(!paintEraser)}
size="sm"
variant={paintEraser ? 'default' : 'outline'}
>
<Eraser />
Erase
</Button>
<Button
className="flex-1"
disabled={!canResetSelection}
onClick={resetSelection}
size="sm"
variant="outline"
>
<RotateCcw />
Reset all
</Button>
</div>
<MaterialPicker
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
+10 -3
View File
@@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
interface UseAutoSaveOptions {
onSave?: (scene: SceneGraph) => Promise<void>
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
onDirty?: () => void
onSaveStatusChange?: (status: SaveStatus) => void
isVersionPreviewMode?: boolean
@@ -148,23 +148,30 @@ export function useAutoSave({
}, AUTOSAVE_DEBOUNCE_MS)
})
// Flush any unsaved change while the page is going away. The network
// save MUST set `keepalive` — a normal fetch is cancelled by the browser
// the moment the page unloads, so a quick refresh right after an edit
// would otherwise drop the change entirely. `pagehide` fires in cases
// (mobile Safari, bfcache) where `beforeunload` does not.
function flushOnExit() {
if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
if (onSaveRef.current) {
onSaveRef.current(sceneGraph).catch(() => {})
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
} else {
saveSceneToLocalStorage(sceneGraph)
}
hasDirtyChangesRef.current = false
}
window.addEventListener('beforeunload', flushOnExit)
window.addEventListener('pagehide', flushOnExit)
return () => {
executeSaveRef.current = null
window.removeEventListener('beforeunload', flushOnExit)
window.removeEventListener('pagehide', flushOnExit)
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
flushOnExit()
unsubscribe()
+8
View File
@@ -59,6 +59,7 @@ export {
usePlacementCoordinator,
} from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
PolygonEditor,
@@ -182,13 +183,19 @@ export {
// their own polygon in isolation — the stair (parent) owns the
// computation and emits the whole stack as one registry entry.
export {
alignFloorplanDraftPoint,
applyFloorplanAlignment,
buildFloorplanStairEntry,
FLOORPLAN_ALIGNMENT_THRESHOLD_M,
FLOORPLAN_DRAFT_ALIGN_ID,
type FloorplanAlignmentResult,
type FloorplanStairArrowEntry,
type FloorplanStairEntry,
type FloorplanStairSegmentEntry,
getFloorplanWallThickness,
} from './lib/floorplan'
export {
buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
@@ -223,5 +230,6 @@ export {
type PaletteViewProps,
usePaletteViewRegistry,
} from './store/use-palette-view-registry'
export { default as usePlacementPreview } from './store/use-placement-preview'
export { useUploadStore } from './store/use-upload'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
+12
View File
@@ -33,6 +33,7 @@ export function createEditorApi(): EditorApi {
return {
engageMove(node: AnyNode) {
const editor = useEditor.getState()
editor.setPlacementDragMode(false)
// `setMovingNode` is typed against a narrower union than `AnyNode`
// (every concrete kind enumerated). Descriptors pass any node; the
// cast lets registry-driven move kinds through without forcing a
@@ -43,6 +44,17 @@ export function createEditorApi(): EditorApi {
editor.setCurvingWall(null)
editor.setCurvingFence(null)
},
engageMoveDrag(node: AnyNode) {
const editor = useEditor.getState()
// Flag drag mode BEFORE mounting the move tool so the coordinator reads
// it at setup and wires its commit-on-release listener.
editor.setPlacementDragMode(true)
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
},
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
endpointEngagers[node.type]?.(node, endpoint, useEditor.getState())
},
@@ -0,0 +1,111 @@
import {
type AlignmentAnchor,
type AlignmentGuide,
collectAlignmentAnchors,
resolveAlignment,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
/**
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
* move — parity with the 3D tools' `ALIGNMENT_THRESHOLD_M`. Pure-2D drafting
* can pass a zoom-scaled `threshold` instead so the magnetic pull stays
* constant in screen pixels across zoom levels.
*/
export const FLOORPLAN_ALIGNMENT_THRESHOLD_M = 0.08
export type FloorplanAlignmentResult = {
/** Plan point with the alignment snap delta applied (grid snap should
* already be baked into the input point). */
point: [number, number]
snapped: boolean
guides: AlignmentGuide[]
}
/**
* Layer Figma-style alignment on top of an already grid-snapped plan point,
* shared by the 2D move sessions (`*FloorplanMoveTarget`) and the structural
* drafting branches in `floorplan-panel`.
*
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
* renders them. Returns the adjusted point. When `bypass` is true (Alt held)
* the point is returned unchanged and guides are cleared, matching the
* "No snap" affordance the placement tools advertise.
*
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
* the scene is stable during a single drag, so re-collecting per pointer-move
* is wasted work.
*/
export function applyFloorplanAlignment(
point: readonly [number, number],
movingAnchors: AlignmentAnchor[],
candidates: AlignmentAnchor[],
opts?: { bypass?: boolean; threshold?: number },
): FloorplanAlignmentResult {
if (opts?.bypass) {
useAlignmentGuides.getState().clear()
return { point: [point[0], point[1]], snapped: false, guides: [] }
}
const result = resolveAlignment({
moving: movingAnchors,
candidates,
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(result.guides)
if (!result.snap) {
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
}
return {
point: [point[0] + result.snap.dx, point[1] + result.snap.dz],
snapped: true,
guides: result.guides,
}
}
/** Synthetic node id for the in-progress structural-draft vertex. Never
* collides with a real scene node, so `collectAlignmentAnchors` excludes
* nothing real. */
export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
/**
* Align a single grid-snapped structural-draft vertex (wall / fence / polygon
* / roof endpoint) against every other node's anchors (incl. wall faces from
* the wall-face anchor work). Treats the vertex as one corner anchor, gathers
* candidates from the live scene, publishes guides (cleared on `bypass`), and
* returns the possibly-snapped point.
*
* Used by BOTH the move-preview branch and the click-commit handler so the
* committed vertex lands exactly where the preview showed it. Caller owns the
* per-kind precedence (existing-wall endpoint/join snap wins; angle-snap
* suppresses alignment) and only calls this when alignment should apply.
*
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
* align to its own (and its linked siblings') geometry that moves with it.
*/
export function alignFloorplanDraftPoint(
point: readonly [number, number],
opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] },
): [number, number] {
if (opts?.bypass) {
useAlignmentGuides.getState().clear()
return [point[0], point[1]]
}
let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
if (opts?.excludeIds?.length) {
const excluded = new Set(opts.excludeIds)
candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
}
const { point: snapped } = applyFloorplanAlignment(
point,
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
candidates,
{ threshold: opts?.threshold },
)
return snapped
}
@@ -1,3 +1,10 @@
export {
alignFloorplanDraftPoint,
applyFloorplanAlignment,
FLOORPLAN_ALIGNMENT_THRESHOLD_M,
FLOORPLAN_DRAFT_ALIGN_ID,
type FloorplanAlignmentResult,
} from './apply-alignment'
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
+34 -23
View File
@@ -160,34 +160,45 @@ export function buildRoofSegmentSurfaceMaterialPatch(
}
}
export function buildRoofSurfaceMaterialUpdates(
/**
* Clear every painted material on a node back to its default. Works for any
* kind without per-type knowledge: it nulls the catch-all `material` /
* `materialPreset` plus any role field (`*Material` / `*MaterialPreset`) that
* the node actually carries. For a roof it also resets every child segment, so
* a single call defaults the whole roof system. `updateNode` merges patches
* shallowly without re-validation, so the `undefined` values land as cleared
* fields and the renderer falls back to the theme defaults.
*/
export function buildResetSurfaceMaterialUpdates(
nodes: Record<string, AnyNode>,
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
node: AnyNode,
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const clearPatch = (target: AnyNode): Partial<AnyNode> => {
const patch: Record<string, undefined> = {}
for (const key of Object.keys(target)) {
if (
key === 'material' ||
key === 'materialPreset' ||
key.endsWith('Material') ||
key.endsWith('MaterialPreset')
) {
patch[key] = undefined
}
}
return patch as Partial<AnyNode>
}
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
{
id: node.id as AnyNodeId,
data: buildRoofSurfaceMaterialPatch(
node,
targetRole,
material,
materialPreset,
) as Partial<AnyNode>,
},
{ id: node.id as AnyNodeId, data: clearPatch(node) },
]
if (targetRole !== 'top') return updates
for (const segmentId of node.children ?? []) {
const segment = nodes[segmentId as AnyNodeId]
if (segment?.type !== 'roof-segment') continue
updates.push({
id: segment.id as AnyNodeId,
data: { material, materialPreset } as Partial<RoofSegmentNode> as Partial<AnyNode>,
})
if (node.type === 'roof') {
for (const segmentId of (node as RoofNode).children ?? []) {
const segment = nodes[segmentId as AnyNodeId]
if (segment?.type === 'roof-segment') {
updates.push({ id: segment.id as AnyNodeId, data: clearPatch(segment) })
}
}
}
return updates
+21 -2
View File
@@ -204,6 +204,13 @@ type EditorState = {
| StairSegmentNode
| BuildingNode
| null
/**
* True while a move was engaged by a press-drag gizmo (the on-canvas move
* cross) rather than a click-to-place flow. The placement coordinator reads
* this to commit on pointer-release instead of waiting for a click.
*/
placementDragMode: boolean
setPlacementDragMode: (dragMode: boolean) => void
setMovingNode: (
node:
| ItemNode
@@ -270,6 +277,10 @@ type EditorState = {
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void
// When true, clicking a surface in paint mode clears it back to its
// default material instead of applying `activePaintMaterial`.
paintEraser: boolean
setPaintEraser: (eraser: boolean) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
@@ -688,6 +699,8 @@ const useEditor = create<EditorState>()(
| StairSegmentNode
| BuildingNode
| null,
placementDragMode: false,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
setMovingNode: (node) =>
set(
node === null
@@ -695,7 +708,8 @@ const useEditor = create<EditorState>()(
// non-owning side's effect cleanup — which fires after
// `setMovingNode(null)` propagates — can still read who
// finalised. The next non-null `setMovingNode` resets it.
{ movingNode: null }
// Always clear the press-drag flag when a move ends.
{ movingNode: null, placementDragMode: false }
: { movingNode: node, movingNodeOrigin: null },
),
movingNodeOrigin: null as '2d' | '3d' | null,
@@ -713,12 +727,17 @@ const useEditor = create<EditorState>()(
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
// Picking a material implies paint, not erase — clear the eraser so the
// next click applies the chosen material.
setActivePaintMaterial: (material) =>
set({ activePaintMaterial: material, paintEraser: false }),
activePaintTarget: 'wall',
setActivePaintTarget: (target) =>
set((state) =>
state.activePaintTarget === target ? state : { activePaintTarget: target },
),
paintEraser: false,
setPaintEraser: (eraser) => set({ paintEraser: eraser }),
primeMaterialPaintFromSelection: () => {
const selectedId =
useViewer.getState().selection.selectedIds.length === 1
@@ -0,0 +1,31 @@
// Ephemeral store for a placement tool's 2D floor-plan ghost. A registry
// placement tool (e.g. column / elevator) publishes a fully-positioned,
// transient preview node on each `grid:move`; the floor-plan
// placement-preview layer subscribes and renders the node's `def.floorplan`
// footprint as a faint ghost that follows the cursor. The 3D view already
// shows a translucent mesh preview, so this only feeds the 2D layer.
//
// Editor-only: the read-only viewer route never places nodes. Lives here
// rather than in `core` for that reason; node-kind tools (e.g. column) reach
// it through the `@pascal-app/editor` public surface, the same way they
// already consume `triggerSFX`. Producers clear on commit, cancel, and
// unmount.
import type { AnyNode } from '@pascal-app/core'
import { create } from 'zustand'
type PlacementPreviewState = {
/** Transient preview node, already positioned + rotated at the (snapped,
* aligned) cursor. `null` when no placement is active. */
node: AnyNode | null
set(node: AnyNode | null): void
clear(): void
}
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
node: null,
set: (node) => set({ node }),
clear: () => set({ node: null }),
}))
export default usePlacementPreview
+11 -85
View File
@@ -1,88 +1,14 @@
import {
type AnyNodeId,
type CeilingNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core'
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
/**
* 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool`
* live-drag pattern. See the equivalent module in `slab/floorplan-move.ts`
* for the full rationale; the only ceiling-specific detail is the
* preserved Y offset (`CeilingSystem` positions the mesh at `height 0.01`
* on rebuild, so the direct `mesh.position.y` mirrors that to avoid a
* vertical teleport when the React group position is reconciled).
* 2D floor-plan move handler for ceiling. Delegates to the shared polygon
* centroid-pivot mover (same pivot semantics as slab / items). See
* `shared/polygon-centroid-move.ts` for the rationale.
*
* `meshY = height 0.01`: `CeilingSystem` parks the ceiling group at that Y
* on rebuild, so mirroring it during the drag avoids a vertical teleport in
* split view.
*/
const GRID_STEP = 0.5
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node }) => {
const ceilingId = node.id as AnyNodeId
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
const height = node.height ?? 2.5
let anchor: [number, number] | null = null
let lastDelta: [number, number] = [0, 0]
const session: FloorplanMoveTargetSession = {
affectedIds: [ceilingId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
if (!anchor) {
anchor = [snapped[0], snapped[1]]
return
}
const dx = snapped[0] - anchor[0]
const dz = snapped[1] - anchor[1]
lastDelta = [dx, dz]
useLiveTransforms.getState().set(ceilingId, {
position: [dx, 0, dz],
rotation: 0,
})
const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined
// Preserve ceiling height — `CeilingSystem` sets `mesh.position.y =
// height 0.01` on each rebuild; mirror that during the drag so
// the mesh stays at ceiling height (not collapsed to y=0).
if (mesh) mesh.position.set(dx, height - 0.01, dz)
},
canCommit() {
const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined
if (!live || live.type !== 'ceiling') return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Sync commit sequence — see `slab/floorplan-move.ts` for the
// full ordering rationale (scene write → direct markDirty →
// useLiveTransforms.clear, all sync in this handler so React
// render + CeilingSystem rebuild land in the same paint).
useScene.getState().updateNodes([
{
id: ceilingId,
data: {
polygon: translatePolygon(originalPolygon, dx, dz),
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
},
},
])
useScene.getState().markDirty(ceilingId)
useLiveTransforms.getState().clear(ceilingId)
return true
},
}
return session
}
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 })
+37 -1
View File
@@ -3,9 +3,13 @@
import {
type AnyNodeId,
type CeilingNode,
collectAlignmentAnchors,
emitter,
type GridEvent,
polygonAnchors,
resolveAlignment,
sceneRegistry,
useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
@@ -32,6 +36,9 @@ function snap(value: number) {
return Math.round(value * 2) / 2
}
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
@@ -104,6 +111,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const height = heightRef.current
const ceilingId = node.id
// Alignment candidates — every other alignable object's anchors,
// gathered once (the scene graph is stable during the drag).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, ceilingId)
let wasCommitted = false
const applyPreview = (deltaX: number, deltaZ: number) => {
@@ -146,7 +157,29 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
applyPreview(localX - anchor[0], localZ - anchor[1])
let deltaX = localX - anchor[0]
let deltaZ = localZ - anchor[1]
// Figma-style alignment snap: align the ceiling's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
deltaX += result.snap.dx
deltaZ += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
applyPreview(deltaX, deltaZ)
}
const onGridClick = (event: GridEvent) => {
@@ -167,6 +200,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
useScene.getState().markDirty(ceilingId as AnyNodeId)
}
useLiveTransforms.getState().clear(ceilingId)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
@@ -176,6 +210,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const onCancel = () => {
clearPreview()
useAlignmentGuides.getState().clear()
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
markToolCancelConsumed()
exitMoveMode()
@@ -186,6 +221,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel)
return () => {
useAlignmentGuides.getState().clear()
if (!wasCommitted) {
clearPreview()
} else {
+62 -4
View File
@@ -1,6 +1,14 @@
'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
EDITOR_LAYER,
@@ -25,6 +33,8 @@ import { CeilingNode } from './schema'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint(
lastPoint: [number, number],
@@ -83,6 +93,11 @@ export const CeilingTool: React.FC = () => {
// draw isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
const verticalGeo = useMemo(
() =>
new BufferGeometry().setFromPoints([
@@ -100,20 +115,60 @@ export const CeilingTool: React.FC = () => {
useEffect(() => {
if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// ceiling's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const gridX = Math.round(rawPoint[0] * 2) / 2
const gridZ = Math.round(rawPoint[1] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET
const lastPoint = points[points.length - 1]
const displayPoint =
const orthoPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
setSnappedCursorPosition(displayPoint)
if (
points.length > 0 &&
@@ -144,6 +199,7 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
useAlignmentGuides.getState().clear()
} else {
setPoints([...points, clickPoint])
}
@@ -155,12 +211,14 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
useAlignmentGuides.getState().clear()
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
useAlignmentGuides.getState().clear()
}
const onKeyDown = (e: KeyboardEvent) => {
+18 -5
View File
@@ -6,6 +6,7 @@ import {
} from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnFloorplanMoveTarget } from './floorplan-move'
import { columnParametrics } from './parametrics'
import { ColumnNode } from './schema'
@@ -327,16 +328,28 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
affordanceTools: {
move: () => import('./move-tool'),
},
// Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
// registry-first path mounts this and skips the legacy `<ColumnTool>`.
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place column' },
{ key: 'Alt', label: 'No snap' },
{ key: 'Esc', label: 'Cancel' },
],
floorplan: buildColumnFloorplan,
// 2D body move routes through this kind-specific target so the column
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
// of the overlay's generic free-translate path, which aligned by bbox
// centre and gathered candidates from SVG bounding boxes only. Mirrors the
// shelf move target.
floorplanMoveTarget: columnFloorplanMoveTarget,
// 2D drag affordances — `column-resize` handles every dimension arrow
// the floor-plan builder emits per cross-section / support style (the
// payload's `dim` field discriminates radius / uniform / width / depth
// / brace-width / brace-depth / spreads). `column-rotate` powers the
// corner rotate-arrow. Body move continues to flow through the
// orange move-handle dot via the registry overlay's generic
// free-translate path — columns don't need a kind-specific
// `floorplanMoveTarget` since they have no linked-cascade
// requirements like wall.
// corner rotate-arrow.
floorplanAffordances: {
'column-resize': columnResizeAffordance,
'column-rotate': columnRotateAffordance,
@@ -0,0 +1,92 @@
import {
type AnyNode,
type AnyNodeId,
type ColumnNode,
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
movingFootprintAnchors,
useScene,
} from '@pascal-app/core'
import {
applyFloorplanAlignment,
snapPointToGrid,
triggerSFX,
type WallPlanPoint,
} from '@pascal-app/editor'
/**
* 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`:
* each pointermove writes the absolute world-plan position straight to
* `useScene` (history paused by the overlay). The 2D SVG and the 3D group
* transform both read `node.position` reactively, so they stay in lockstep;
* the overlay's snapshot-diff makes the drag one undoable step. `canCommit`
* only validates.
*
* Columns previously fell through to the overlay's generic free-translate
* path, which aligned a column by its bbox *centre* and gathered candidates
* from SVG bounding boxes only (missing wall faces / diagonal walls). Routing
* through a kind-specific target gives column the same footprint-edge
* alignment as shelf / item — including snapping flush to wall faces (the
* pillar↔wall case this whole feature targets).
*
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern; for a
* `position`-field kind that leaves the 3D group stuck at the old spot on
* commit (nothing reconciles it off the cleared live transform, since the
* geometry doesn't rebuild on a position-only change). See the shelf handler
* for the full rationale.
*
* Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`.
*/
const GRID_STEP = 0.5
export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ node, nodes }) => {
const columnId = node.id as AnyNodeId
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
const rotationY = node.rotation ?? 0
let lastPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null
// Alignment candidates gathered once — scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, columnId)
const session: FloorplanMoveTargetSession = {
affectedIds: [columnId],
apply({ planPoint, modifiers }) {
const gridSnapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
// Figma-style alignment layered on the grid snap (Alt bypasses).
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
node as unknown as AnyNode,
gridSnapped[0],
gridSnapped[1],
rotationY,
),
candidates,
{ bypass: modifiers.altKey },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
const snapKey = `${snapped[0]},${snapped[1]}`
if (snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
// Single source of truth — write the absolute position straight to the
// scene (history paused by the overlay). 2D SVG and 3D group transform
// both follow `node.position` reactively, so they can't diverge.
useScene.getState().updateNodes([{ id: columnId, data: { position: next } }])
},
canCommit() {
const live = useScene.getState().nodes[columnId] as ColumnNode | undefined
if (!live || live.type !== 'column') return false
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
},
}
return session
}
+61 -12
View File
@@ -4,13 +4,23 @@ import {
type AnyNodeId,
type ColumnNode,
ColumnNode as ColumnNodeSchema,
collectAlignmentAnchors,
emitter,
type GridEvent,
movingFootprintAnchors,
resolveAlignment,
sceneRegistry,
useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import {
CursorSphere,
DragBoundingBox,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
/**
@@ -37,8 +47,12 @@ const snapToGridStep = (value: number) => {
/** 90° steps, matching the GLB item / shelf placement rotation. */
const ROTATION_STEP = Math.PI / 2
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
const [previewRotation, setPreviewRotation] = useState<number>(node.rotation)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
@@ -61,9 +75,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
: {}
const isNew = !!meta.isNew
// Alignment candidates — every other alignable object's anchors, gathered
// once (the scene graph is stable during the imperative drag).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
const applyPreview = (position: [number, number, number]) => {
lastPosition = position
setPreviewPosition(position)
setPreviewRotation(rotationY)
useLiveTransforms.getState().set(node.id, {
position,
rotation: rotationY,
@@ -77,11 +96,29 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onGridMove = (event: GridEvent) => {
hasMoved = true
applyPreview([
snapToGridStep(event.localPosition[0]),
0,
snapToGridStep(event.localPosition[2]),
])
let x = snapToGridStep(event.localPosition[0])
let z = snapToGridStep(event.localPosition[2])
// Figma-style alignment snap on top of grid snap; Alt bypasses. The
// guide connects to the candidate's nearest real anchor (resolver
// tie-break), so the dot always sits on an actual point.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationY),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
x += result.snap.dx
z += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
applyPreview([x, 0, z])
}
// R / T rotate the dragged column about Y in 90° steps (matches the move
@@ -99,11 +136,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onGridClick = (event: GridEvent) => {
if (!hasMoved) return
const position: [number, number, number] = [
snapToGridStep(event.localPosition[0]),
0,
snapToGridStep(event.localPosition[2]),
]
useAlignmentGuides.getState().clear()
// Commit at the last previewed position so the alignment snap (which
// may pull off-grid) is preserved, rather than re-snapping the raw
// click to the grid.
const position: [number, number, number] = [...lastPosition]
const nodeId = (node as { id?: ColumnNode['id'] }).id
if (nodeId && useScene.getState().nodes[nodeId]) {
@@ -134,6 +171,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(node.position[0], node.position[1], node.position[2])
@@ -155,6 +193,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear()
if (!committed) {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
@@ -166,7 +205,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
}
}, [exitMoveMode, node])
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
return (
<>
<CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
<DragBoundingBox
fallbackSize={[node.width, node.height, node.depth]}
nodeId={node.id}
position={previewPosition}
rotationY={previewRotation}
/>
</>
)
}
export default MoveColumnTool
+126 -76
View File
@@ -20,7 +20,7 @@ import {
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { createContext, useContext, useMemo, useRef } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
const ColumnMaterialContext = createContext<Material>(baseMaterial())
@@ -2076,6 +2076,130 @@ function Capital({ node, y, height }: { node: ColumnNode; y: number; height: num
)
}
/**
* The column's geometry tree — either a fabricated support frame or the
* classical base / shaft / capital stack. Extracted from `ColumnRenderer`
* so the translucent placement ghost (`ColumnPreview`) renders the exact
* same shape without the registry registration, pointer handlers, or
* live-transform wiring the real renderer layers on. Material and edge
* softness arrive through context, so each caller controls appearance by
* wrapping this in its own providers.
*/
function ColumnBody({ node }: { node: ColumnNode }) {
const shaftLayout = useMemo(() => {
const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4)
const capitalHeight =
node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4)
const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight)
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
return node.supportStyle === 'a-frame' ? (
<AFrameSupport node={node} />
) : node.supportStyle === 'y-frame' ? (
<YFrameSupport node={node} />
) : node.supportStyle === 'v-frame' ? (
<VFrameSupport node={node} />
) : node.supportStyle === 'x-brace' ? (
<XBraceSupport node={node} />
) : node.supportStyle === 'k-brace' ? (
<KBraceSupport node={node} />
) : node.supportStyle === 'single-strut' ? (
<SingleStrutSupport node={node} />
) : node.supportStyle === 'tripod' ? (
<TripodSupport node={node} />
) : node.supportStyle === 'trestle' ? (
<TrestleSupport node={node} />
) : node.supportStyle === 'portal-frame' ? (
<PortalFrameSupport node={node} />
) : node.supportStyle === 'box-frame' ? (
<BoxFrameSupport node={node} />
) : (
<>
<Base height={shaftLayout.baseHeight} node={node} />
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
<Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<LatheBands node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<LowerCarvedBand
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<DravidianShaftPanels
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<SpiralRibs node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
<Capital
height={shaftLayout.capitalHeight}
node={node}
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
/>
<CapitalCarvings
capitalHeight={shaftLayout.capitalHeight}
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
node={node}
/>
</>
)
}
/**
* Translucent, non-interactive ghost of a column — the placement tool's
* cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree
* as the real renderer via `<ColumnBody>` but:
* - clones the material and makes it transparent (cloning is required:
* `createColumnMaterial` can hand back a shared/cached instance, and
* mutating it would turn every committed column see-through);
* - disables raycast on every mesh so the ghost doesn't intercept the
* placement cursor ray (which would stall `grid:move`);
* - renders at the local origin so the caller's cursor group positions it.
*/
export const ColumnPreview = ({ node }: { node: ColumnNode }) => {
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const groupRef = useRef<Group>(null)
const material = useMemo(() => {
const ghost = createColumnMaterial({
material: node.material,
materialPreset: node.materialPreset,
shading,
textures,
colorPreset,
}).clone()
ghost.transparent = true
ghost.opacity = 0.5
ghost.depthWrite = false
return ghost
}, [shading, textures, colorPreset, node.material, node.materialPreset])
useEffect(() => () => material.dispose(), [material])
// Strip pointer events off the freshly-built meshes every render — the
// geometry tree rebuilds when the ghost's dimensions change, so a one-shot
// effect wouldn't cover later meshes.
useEffect(() => {
groupRef.current?.traverse((obj) => {
;(obj as unknown as { raycast: () => void }).raycast = () => {}
})
})
return (
<ColumnMaterialContext.Provider value={material}>
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
<group ref={groupRef}>
<ColumnBody node={node} />
</group>
</ColumnEdgeSoftnessContext.Provider>
</ColumnMaterialContext.Provider>
)
}
export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
const ref = useRef<Group>(null!)
// Merge any live drag override so width / depth / radius / height
@@ -2115,14 +2239,6 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
useRegistry(node.id, node.type, ref)
const shaftLayout = useMemo(() => {
const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4)
const capitalHeight =
node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4)
const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight)
return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight }
}, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height])
return (
<ColumnMaterialContext.Provider value={material}>
<ColumnEdgeSoftnessContext.Provider value={node.edgeSoftness ?? 0.025}>
@@ -2133,73 +2249,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
visible={node.visible}
{...handlers}
>
{node.supportStyle === 'a-frame' ? (
<AFrameSupport node={node} />
) : node.supportStyle === 'y-frame' ? (
<YFrameSupport node={node} />
) : node.supportStyle === 'v-frame' ? (
<VFrameSupport node={node} />
) : node.supportStyle === 'x-brace' ? (
<XBraceSupport node={node} />
) : node.supportStyle === 'k-brace' ? (
<KBraceSupport node={node} />
) : node.supportStyle === 'single-strut' ? (
<SingleStrutSupport node={node} />
) : node.supportStyle === 'tripod' ? (
<TripodSupport node={node} />
) : node.supportStyle === 'trestle' ? (
<TrestleSupport node={node} />
) : node.supportStyle === 'portal-frame' ? (
<PortalFrameSupport node={node} />
) : node.supportStyle === 'box-frame' ? (
<BoxFrameSupport node={node} />
) : (
<>
<Base height={shaftLayout.baseHeight} node={node} />
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
<Rings
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<LatheBands
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<Flutes
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<LowerCarvedBand
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<DravidianShaftPanels
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<SpiralRibs
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<Capital
height={shaftLayout.capitalHeight}
node={node}
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
/>
<CapitalCarvings
capitalHeight={shaftLayout.capitalHeight}
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
node={node}
/>
</>
)}
<ColumnBody node={node} />
</group>
</ColumnEdgeSoftnessContext.Provider>
</ColumnMaterialContext.Provider>
+158
View File
@@ -0,0 +1,158 @@
'use client'
import {
COLUMN_PRESETS,
ColumnNode,
type ColumnPresetId,
collectAlignmentAnchors,
emitter,
type GridEvent,
movingFootprintAnchors,
resolveAlignment,
snapPointToGrid,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import type { Group } from 'three'
import { ColumnPreview } from './renderer'
const GRID_STEP = 0.5
/** Figma-style alignment-snap threshold (meters), matching the move tools and
* the shelf placement tool. */
const ALIGNMENT_THRESHOLD_M = 0.08
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
const { label, ...preset } = COLUMN_PRESETS[presetId]
return ColumnNode.parse({
name: label,
position,
rotation: 0,
...preset,
})
}
/**
* Registry-driven column placement tool. Mirrors the shelf build tool:
* a translucent `ColumnPreview` ghost follows the cursor (the piece the
* legacy editor-side `ColumnTool` lacked — it only showed a sphere), grid
* snap is layered with Figma-style alignment, and a `grid:click` commits.
*
* Lives in `packages/nodes` (not the editor) specifically so it can import
* the column geometry for the ghost — the editor package can't depend on
* `nodes`. Wired via `def.tool`, so `ToolManager`'s registry-first path
* mounts it and the legacy `<ColumnTool>` branch no longer fires.
*/
const ColumnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
// Default-preset column for the placement ghost — matches exactly what the
// commit creates (`basicPillar`), so the preview is faithful.
const previewNode = useMemo(() => createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [0, 0, 0]), [])
useEffect(() => {
if (!activeLevelId) return
previousSnapRef.current = null
// Alignment candidates — anchors of every other alignable object, gathered
// here and refreshed after each placement so a just-placed column becomes a
// target for the next one. `previewNode.id` never collides with a scene
// node, so nothing real is excluded.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
// Figma-style alignment snap layered on top of grid snap: when the
// preview column's footprint edge lines up (on X or Z) with another
// object's edge, snap there and publish a guide. Alt bypasses.
let ax = sx
let az = sz
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
ax += result.snap.dx
az += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
cursorRef.current?.position.set(ax, event.localPosition[1], az)
// Publish a transient, positioned preview node for the 2D floor-plan
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
// placement-preview layer renders this node's footprint at the snapped,
// aligned cursor so users see the pillar before they click.
usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] })
const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [ax, az]
}
}
const onGridClick = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
let ax = sx
let az = sz
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
ax += result.snap.dx
az += result.snap.dz
}
}
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az])
useScene.getState().createNode(column, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [column.id] })
triggerSFX('sfx:structure-build')
// The placed column is now a valid alignment target for the next one;
// refresh the candidate pool and drop the guide from this drop. The
// 2D ghost re-publishes on the next move.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
}
}, [activeLevelId, previewNode])
if (!activeLevelId) return null
return (
<group ref={cursorRef}>
<ColumnPreview node={previewNode} />
</group>
)
}
export default ColumnTool
+4
View File
@@ -5,6 +5,7 @@ import type {
NodeDefinition,
WallNode,
} from '@pascal-app/core'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
import { doorFloorplanMoveTarget } from './floorplan-move'
@@ -85,9 +86,12 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
currentValue: (n) => n.height,
apply: (initial, newHeight) => {
const bottom = initial.position[1] - initial.height / 2
// Scale the handle so it tracks the door instead of staying glued to a
// fixed floor height (shared with the panel's Height slider).
return {
height: newHeight,
position: [initial.position[0], bottom + newHeight / 2, initial.position[2]],
handleHeight: scaleHandleHeight(initial.handleHeight, initial.height, newHeight),
}
},
placement: {
+16
View File
@@ -8,6 +8,22 @@ import {
type WindowNode,
} from '@pascal-app/core'
/**
* Keep the door handle at the same relative height when the door is resized:
* scale it by the height ratio, then clamp to the panel's slider bounds
* [0.5, height - 0.1] so it never lands outside the (possibly shrunk) door.
* Used by both the height-resize arrow and the panel's Height slider so the
* handle tracks the door whichever way it's resized.
*/
export function scaleHandleHeight(
handleHeight: number,
oldHeight: number,
newHeight: number,
): number {
const ratio = oldHeight > 0 ? newHeight / oldHeight : 1
return Math.min(Math.max(handleHeight * ratio, 0.5), Math.max(0.5, newHeight - 0.1))
}
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
*/
+15 -3
View File
@@ -6,7 +6,7 @@ import {
useScene,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
/**
@@ -55,8 +55,20 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position
// Snap the wall-local X to 0.5m grid (Shift bypasses).
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
// Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends); it competes with — and wins over — the 0.5m
// grid snap. Falls back to the grid snap when nothing aligns. Alt
// bypasses; Shift drops the grid snap for fine positioning.
const neighborX = modifiers.altKey
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width: node.width,
selfId: node.id as AnyNodeId,
nodes,
})
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
lastValid = {
+33 -4
View File
@@ -1,10 +1,12 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useLiveTransforms,
useScene,
type WallEvent,
@@ -15,7 +17,6 @@ import {
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
@@ -23,6 +24,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
@@ -94,8 +96,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
}
// Alignment candidates — anchors of every OTHER alignable object (the
// moving door is excluded so it never aligns to itself).
const alignmentCandidates = collectAlignmentAnchors(
useScene.getState().nodes,
movingDoorNode.id,
)
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
@@ -131,7 +141,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingDoorNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
@@ -190,7 +206,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingDoorNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
@@ -255,7 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const { side, itemRotation } = getPlacementOrientation(event)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingDoorNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
@@ -395,6 +423,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
}
useLiveTransforms.getState().clear(movingDoorNode.id)
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
+8 -1
View File
@@ -16,6 +16,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { scaleHandleHeight } from './door-math'
const doorTypeOptions = [
{ label: 'Hinged', value: 'hinged', available: true },
@@ -716,7 +717,13 @@ export default function DoorPanel() {
max={4}
min={1.0}
onChange={(v) =>
handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
handleUpdate({
height: v,
position: [node.position[0], v / 2, node.position[2]],
// Keep the handle at the same relative height as the door resizes,
// matching the height-resize arrow.
handleHeight: scaleHandleHeight(node.handleHeight, node.height, v),
})
}
precision={2}
restoreOnCommit={false}
+52 -4
View File
@@ -1,10 +1,12 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useScene,
type WallEvent,
} from '@pascal-app/core'
@@ -14,13 +16,13 @@ import {
EDITOR_LAYER,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
@@ -68,8 +70,13 @@ const DoorTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A door aligns by the plan position of its centre.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
@@ -100,9 +107,15 @@ const DoorTool: React.FC = () => {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = 0.9
const height = 2.1
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
@@ -147,12 +160,38 @@ const DoorTool: React.FC = () => {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
// Draft may be null after a successful placement (the click handler
// deletes it and relies on the wall rebuild → pointer-enter cascade to
// recreate it). Recreate it here on the first subsequent move so the
// preview is ready for the next click without requiring a leave/enter.
if (!draftRef.current) {
const levelId = getLevelId()
if (levelId && event.node.parentId === levelId) {
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
}
}
if (draftRef.current) {
// Update the scene store on every move so the 2D floor plan
// stays in sync (it re-renders from `node.position`). Only
@@ -212,7 +251,13 @@ const DoorTool: React.FC = () => {
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: draftRef.current.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
@@ -279,6 +324,8 @@ const DoorTool: React.FC = () => {
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
event.stopPropagation()
}
@@ -302,6 +349,7 @@ const DoorTool: React.FC = () => {
return () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -1,8 +1,12 @@
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
type DragAction,
type FenceNode,
resolveAlignment,
useAlignmentGuides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -43,6 +47,10 @@ import {
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
/** Figma-style alignment-snap threshold (meters), matching the wall / item
* tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
@@ -67,6 +75,9 @@ export type MoveFenceEndpointCtx = {
linkedOriginals: LinkedFenceSnapshot[]
levelWalls: WallNode[]
levelFences: FenceNode[]
/** Alignment anchors (endpoints + midpoints) of every OTHER wall / fence on
* the level (building-local), feeding the resolver. */
alignCandidates: AlignmentAnchor[]
}
export type MoveFenceEndpointDraft = {
@@ -132,6 +143,10 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
else if (node.type === 'fence') levelFences.push(node)
}
// Alignment targets — anchors of every other alignable object (walls,
// fences, items, slabs, ceilings, columns).
const alignCandidates = collectAlignmentAnchors(useScene.getState().nodes, fence.id)
return {
fenceId: fence.id as AnyNodeId,
endpoint,
@@ -143,6 +158,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
linkedOriginals: snapshotLinked(fence.id, parentId, originalMovingPoint),
levelWalls,
levelFences,
alignCandidates,
}
},
@@ -158,14 +174,32 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
ignoreFenceIds: [ctx.fenceId as string],
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
})
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint
// Figma-style alignment: nudge the dragged endpoint onto another wall /
// fence endpoint or midpoint axis when within threshold, and publish a
// guide. The resolver connects to the NEAREST real anchor, so the dot
// always sits on an actual point. Alt is reserved for detach.
let aligned = snapped
if (ctx.alignCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
candidates: ctx.alignCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
}
const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint
const nextEnd = ctx.endpoint === 'end' ? aligned : ctx.fixedPoint
const detached = modifiers.alt
const linkedUpdates = detached
? []
: linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, snapped)
: linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, aligned)
return {
movingPoint: snapped,
movingPoint: aligned,
start: nextStart,
end: nextEnd,
linkedUpdates,
@@ -190,6 +224,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
},
commit: (draft, ctx, scene) => {
useAlignmentGuides.getState().clear()
// Min-length rejection still matters — too-short fence is invalid
// and should bounce back via the cancel path (snapshot restore).
// But the "no-change" rejection is removed: see
@@ -220,7 +255,8 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
},
cancel: (_ctx, _scene) => {
// No-op — createDragSession.cancel() calls scene.restoreAll()
useAlignmentGuides.getState().clear()
// No-op otherwise — createDragSession.cancel() calls scene.restoreAll()
// which puts every touched node back via the snapshot.
},
}
@@ -7,11 +7,13 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
useAlignmentGuides,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
alignFloorplanDraftPoint,
type FencePlanPoint,
getSegmentGridStep,
isSegmentLongEnough,
@@ -164,15 +166,23 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
})
const nextStart = endpoint === 'start' ? snapped : fixedPoint
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
// Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching
// the 3D fence endpoint action. The dragged fence and its linked
// siblings (which cascade with the endpoint) are excluded from the
// candidate pool. Alt is reserved for detach here, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint
const nextEnd = endpoint === 'end' ? aligned : fixedPoint
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({
id: l.id,
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
start: pointsNearlyEqual(l.start, originalMovingPoint) ? aligned : l.start,
end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end,
}))
useScene.getState().updateNodes([
@@ -184,6 +194,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
])
},
canCommit() {
// Pointer-up always runs canCommit — drop the alignment guide here
// so it doesn't linger after a commit / reject.
useAlignmentGuides.getState().clear()
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
return (
!!finalFence &&
@@ -1,6 +1,12 @@
'use client'
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
import {
type FenceNode,
getWallCurveLength,
useAlignmentGuides,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
CursorSphere,
type FencePlanPoint,
@@ -15,7 +21,7 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { moveFenceEndpointDragAction } from './actions/move-endpoint'
/**
@@ -119,6 +125,19 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const liveEnd = liveFence?.end ?? target.fence.end
const movingPoint = endpoint === 'start' ? liveStart : liveEnd
// Ticker SFX on each grid-snap step, mirroring the wall endpoint tool.
// The action snaps the point before writing to the scene, so `movingPoint`
// only changes in discrete grid steps — the right cadence for the click.
// First tick just seeds the ref (no sound on mount).
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
useEffect(() => {
const prev = previousGridPosRef.current
if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = movingPoint
}, [movingPoint])
// Neighbour segments at the parent level — computed once at mount.
const parentId = target.fence.parentId ?? null
const neighbourSegments = useMemo(() => {
@@ -153,6 +172,10 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
],
)
// Safety net: drop any alignment guides if the tool unmounts without the
// action's commit / cancel running (e.g. abrupt teardown).
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
// Window-level keystate for the detach badge — independent of grid
// event modifiers so the badge can toggle without a pointer move.
useEffect(() => {
+53 -19
View File
@@ -2,12 +2,15 @@
import {
calculateLevelMiters,
collectAlignmentAnchors,
emitter,
type FenceNode,
type GridEvent,
getWallMiterBoundaryPoints,
type LevelNode,
type Point2D,
resolveAlignment,
useAlignmentGuides,
useScene,
type WallMiterData,
type WallNode,
@@ -35,6 +38,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
// HUD label heights are measured from the top of the preview bar, so they
// track whatever height a seeded preset draws at (`previewHeight`).
const DRAFT_LABEL_Y_OFFSET = 0.22
@@ -463,10 +468,34 @@ export const FenceTool: React.FC = () => {
useEffect(() => {
let previousFenceEnd: FencePlanPoint | null = null
// Alignment candidates — anchors of every alignable object. Refreshed
// after each segment commits (the new fence becomes a candidate too).
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
const refreshAlignmentCandidates = () => {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
}
// Align the drafted point onto another object's nearest real anchor and
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
const ar = resolveAlignment({
moving: [{ nodeId: '__fence-draft__', kind: 'corner', x: point[0], z: point[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
}
const stopDrafting = () => {
buildingState.current = 0
previewRef.current.visible = false
setDraftMeasurement(null)
useAlignmentGuides.getState().clear()
}
const onGridMove = (event: GridEvent) => {
@@ -476,14 +505,13 @@ export const FenceTool: React.FC = () => {
// Default = active grid step; Shift switches to the fine step
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 1) {
const snappedLocal = snapFenceDraftPoint({
point: localPoint,
walls,
fences,
step,
})
const snappedLocal = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
bypassAlign,
)
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
@@ -513,7 +541,10 @@ export const FenceTool: React.FC = () => {
),
)
} else {
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences, step })
const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
bypassAlign,
)
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null)
}
@@ -528,26 +559,23 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 0) {
const snappedStart = snapFenceDraftPoint({
point: localClick,
walls,
fences,
step: clickStep,
})
const snappedStart = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
bypassAlign,
)
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const snappedEnd = snapFenceDraftPoint({
point: localClick,
walls,
fences,
step: clickStep,
})
const snappedEnd = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
bypassAlign,
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
@@ -557,6 +585,11 @@ export const FenceTool: React.FC = () => {
)
if (!createdFence) return
// The new segment is now a real node — make it an alignment target
// for the next segment, and drop the just-shown guide.
refreshAlignmentCandidates()
useAlignmentGuides.getState().clear()
const nextStart = createdFence.end
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current)
@@ -594,6 +627,7 @@ export const FenceTool: React.FC = () => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
useAlignmentGuides.getState().clear()
}
}, [unit])
+31 -34
View File
@@ -9,12 +9,14 @@ import { itemFloorplanMoveTarget } from './floorplan-move'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
// Gizmo sits just past the front-right footprint corner; the guide ring
// The two floor gizmos flank the item at mid-height so they never overlap,
// even on small items: move sits past the left edge, rotate past the right
// edge, both floated the same distance in front of the item. Mirrors the
// wall-item layout below (WALL_SIDE_OFFSET / WALL_GIZMO_LIFT). The guide ring
// traces a circle slightly outside the footprint's bounding circle.
const ROTATE_CORNER_OFFSET = 0.25
const GIZMO_SIDE_OFFSET = 0.3
const GIZMO_FRONT_OFFSET = 0.3
const ROTATE_RING_OFFSET = 0.06
// How far past the item's front edge the move cross floats.
const MOVE_FRONT_OFFSET = 0.35
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
// does the angular drag math (raycasts a horizontal plane at the gizmo's
@@ -35,12 +37,12 @@ function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
return { rotation: [rx, ry - delta, rz] }
},
placement: {
// Front-right corner of the footprint at mid-height. The registered
// item mesh carries position + rotation only (scale lives on an
// inner mesh), so the scaled footprint maps straight to world.
// Past the item's right edge at mid-height, floated in front. The
// registered item mesh carries position + rotation only (scale lives on
// an inner mesh), so the scaled footprint maps straight to world.
position: (n) => {
const [w, h, d] = getScaledDimensions(n)
return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET]
return [w / 2 + GIZMO_SIDE_OFFSET, h / 2, d / 2 + GIZMO_FRONT_OFFSET]
},
// Fixed 45° tilt leans the curve toward the item's front face.
rotationY: () => -Math.PI / 4,
@@ -56,27 +58,24 @@ function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
}
}
// Free ground-plane move gizmo — the 4-way cross just outside the front edge.
// Press-drag-release slides the item across the floor (live preview, commit
// on release). `snapExtents` aligns the item's edges to the grid the same
// way placement does, swapping width / depth at 90° turns.
// The 4-way move cross, just past the item's left edge. Press-drag hands the
// item to its placement coordinator (showing the bounding box, dimension labels
// and grid-snap ticker) and commits on release — press-drag-release motion with
// the full placement feedback.
function itemMoveHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'translate',
kind: 'tap-action',
shape: 'move-cross',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
placement: {
// Sit just outside the item's front edge (centred in X, clear of the
// model), low to the floor so it reads as a floor-move grip.
// Past the item's left edge at mid-height, mirroring the rotate grip on
// the right so the two never overlap on small items.
position: (n) => {
const [, , d] = getScaledDimensions(n)
return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET]
const [w, h, d] = getScaledDimensions(n)
return [-(w / 2 + GIZMO_SIDE_OFFSET), h / 2, d / 2 + GIZMO_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const [dimX, , dimZ] = getScaledDimensions(n)
const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
@@ -113,27 +112,25 @@ function itemWallRotateHandle(): HandleDescriptor<ItemNodeType> {
}
}
// Slide the item across the wall face — constrained to the wall plane (along
// the wall + up/down), depth pinned. Sits just past the item's left edge.
// Move cross past the item's left edge on the wall face. Tap to hand the item
// to its placement coordinator (`engageMove`) — same feedback as the floating
// move button, and the coordinator handles the wall ↔ floor ↔ ceiling
// transitions the generic translate drag couldn't. `plane: 'node-normal'`
// stands the cross up against the wall face.
function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'translate',
kind: 'tap-action',
shape: 'move-cross',
plane: 'node-normal',
portal: 'grandparent',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
placement: {
position: (n) => {
const [w] = getScaledDimensions(n)
return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const [dimX, dimY] = getScaledDimensions(n)
// A 90° roll about the normal swaps the item's along-wall + vertical
// footprint.
const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9
return [swap ? dimY : dimX, swap ? dimX : dimY]
},
}
}
+40 -9
View File
@@ -2,14 +2,16 @@ import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
getScaledDimensions,
type ItemNode,
movingFootprintAnchors,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
/**
* 2D floor-plan move handler for item. Branches on `asset.attachTo`:
@@ -34,7 +36,7 @@ import { findClosestWallInPlan } from '../shared/wall-attach-target'
const GRID_STEP = 0.5
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node }) => {
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node, nodes }) => {
const attachTo = node.asset.attachTo
const startLevelId: AnyNodeId | null = (() => {
// Walk to the owning level depending on the item's current parent:
@@ -64,7 +66,7 @@ export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node })
if (attachTo === 'ceiling') {
return buildSurfaceItemSession(node, startLevelId, 'ceiling')
}
return buildFloorItemSession(node, startLevelId)
return buildFloorItemSession(node, startLevelId, nodes)
}
function buildWallItemSession(
@@ -83,11 +85,24 @@ function buildWallItemSession(
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return
const snappedLocalX = modifiers.shiftKey
? hit.localX
: Math.round(hit.localX / GRID_STEP) * GRID_STEP
const [width] = getScaledDimensions(node)
// Figma-style along-wall alignment (edge-to-edge with other openings /
// wall items / wall ends), winning over the 0.5m grid snap; falls back
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap.
const neighborX = modifiers.altKey
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width,
selfId: node.id as AnyNodeId,
nodes,
})
const snappedLocalX =
neighborX ??
(modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP)
const halfW = width / 2
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
@@ -125,13 +140,29 @@ function buildWallItemSession(
function buildFloorItemSession(
node: ItemNode,
startLevelId: AnyNodeId | null,
nodes: Record<AnyNodeId, AnyNode>,
): FloorplanMoveTargetSession {
const rotationY = node.rotation[1] ?? 0
// Alignment candidates gathered once — scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
const gridSnapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
// Figma-style alignment layered on the grid snap (Alt bypasses).
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
node as unknown as AnyNode,
gridSnapped[0],
gridSnapped[1],
rotationY,
),
candidates,
{ bypass: modifiers.altKey },
)
const sourceY = node.position[1]
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
+3 -6
View File
@@ -88,13 +88,10 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
// Some slots have explicit materials; fill the rest from the themed array so
// an untextured slot still picks up the scene-theme role colour, not blank white.
// Per-role only, then the themed parent slot — no cross-role fallback, so
// painting one segment surface never bleeds onto its other surfaces.
const slot = (i: number) => themedArray?.[i] ?? new THREE.MeshStandardMaterial()
return [
edge ?? wall ?? top ?? slot(0),
wall ?? edge ?? top ?? slot(1),
wall ?? edge ?? top ?? slot(2),
top ?? wall ?? edge ?? slot(3),
] as THREE.Material[]
return [edge ?? slot(0), wall ?? slot(1), wall ?? slot(2), top ?? slot(3)] as THREE.Material[]
}, [
node.material,
node.materialPreset,
+48 -5
View File
@@ -1,14 +1,17 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
resolveAlignment,
type StairNode,
type StairSegmentNode,
sceneRegistry,
useAlignmentGuides,
useLiveTransforms,
useScene,
type WallNode,
@@ -25,6 +28,9 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
@@ -158,6 +164,29 @@ export const MoveRoofTool: React.FC<{
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
// Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide
// layer), so we leave them on the plain grid+corner snap. The moving node
// is aligned by its ORIGIN point (how this tool positions it), snapped to
// any other alignable object's anchors.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id)
: []
const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => {
if (!alignTopLevel || bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [lx, lz]
}
const ar = resolveAlignment({
moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz]
}
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(
@@ -213,7 +242,15 @@ export const MoveRoofTool: React.FC<{
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
// Layer alignment snap on top (top-level stair/roof). Recompute the
// world point from the aligned building-local point so it stays correct
// under building rotation.
const [lx, lz] = alignLocalPoint(
snappedLocal[0],
snappedLocal[1],
event.nativeEvent?.altKey === true,
)
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
if (
previousGridPosRef.current &&
@@ -223,7 +260,6 @@ export const MoveRoofTool: React.FC<{
}
previousGridPosRef.current = [gridX, gridZ]
const [lx, lz] = snappedLocal
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -249,11 +285,16 @@ export const MoveRoofTool: React.FC<{
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
const [lx, lz] = snappedLocal
const [lx, lz] = alignLocalPoint(
snappedLocal[0],
snappedLocal[1],
event.nativeEvent?.altKey === true,
)
const [gridX, , gridZ] = localToWorldPoint([lx, lz], y)
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
useAlignmentGuides.getState().clear()
wasCommitted = true
// The store still holds the original values (we didn't update during drag).
@@ -286,6 +327,7 @@ export const MoveRoofTool: React.FC<{
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
@@ -340,8 +382,9 @@ export const MoveRoofTool: React.FC<{
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
// Clear ephemeral live transform
// Clear ephemeral live transform + any alignment guides
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
// Skip restore when the 2D floor-plan overlay claimed teardown
// ownership — same contract `FloorplanRegistryMoveOverlay` uses to
@@ -0,0 +1,143 @@
import {
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
type FloorplanMoveTargetSession,
polygonAnchors,
resolveAlignment,
sceneRegistry,
useAlignmentGuides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
/**
* Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone).
*
* **Pivot semantics.** The move uses the polygon's **centroid** as the pivot:
* the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the
* same way a regular item's origin snaps to the cursor in both 3D and 2D.
* This replaces the old grab-relative delta ("drag from wherever you first
* touched"), so polygon kinds move consistently with every other item.
*
* **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`):
* polygon kinds carry their position in their vertices, not a `position`
* field. The live preview translates the rendered `<group>` by the delta
* (`ParametricNodeRenderer` consumes `useLiveTransforms.position` as the
* group position) so the SVG follows the EXACT snapped result with no CSG
* rebuild per tick. On commit we write the translated polygon once. Because
* the live delta and the committed polygon are derived from the same
* `lastDelta`, the visual and the commit always agree.
*
* `meshY` mirrors the value the kind's system sets on rebuild (slab: 0;
* ceiling: `height 0.01`) so the 3D mesh doesn't teleport vertically in a
* split view during the drag.
*/
const GRID_STEP = 0.5
/** Figma-style alignment threshold (meters) — parity with the 3D move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
/** Average of the polygon's vertices — matches the 3D `MoveSlabTool` pivot. */
function polygonCentroid(polygon: ReadonlyArray<readonly [number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export function createPolygonCentroidMoveTarget(args: {
node: {
id: string
type: string
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
nodes: Record<AnyNodeId, AnyNode>
/** 3D mesh Y the kind's system parks the group at on rebuild. */
meshY: number
}): FloorplanMoveTargetSession {
const { node, nodes, meshY } = args
const id = node.id as AnyNodeId
const typeGuard = node.type
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const hasHoles = Array.isArray(node.holes)
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
const originalCenter = polygonCentroid(originalPolygon)
// Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, id)
let lastDelta: [number, number] = [0, 0]
return {
affectedIds: [id],
apply({ planPoint, modifiers }) {
// Centroid → snapped cursor. Grid-snap the target centroid (Shift
// drops the grid snap), then layer Figma alignment on the translated
// polygon's vertices and fold its snap into the delta. Alt bypasses.
const target: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
let dx = target[0] - originalCenter[0]
let dz = target[1] - originalCenter[1]
if (!modifiers.altKey && candidates.length > 0) {
const result = resolveAlignment({
moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)),
candidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
dx += result.snap.dx
dz += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
lastDelta = [dx, dz]
// Live-drag exception: write the delta to BOTH `useLiveTransforms`
// (React source of truth) and the mesh (direct Three.js) so they don't
// fight per frame.
useLiveTransforms.getState().set(id, { position: [dx, 0, dz], rotation: 0 })
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
if (mesh) mesh.position.set(dx, meshY, dz)
},
canCommit() {
const live = useScene.getState().nodes[id] as { type?: string } | undefined
if (!live || live.type !== typeGuard) return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Sync commit: scene write → direct markDirty → clear live transform,
// so the React render and the kind's geometry rebuild land in the same
// paint (no original-position blink). Only write `holes` for kinds that
// have them (zone has none).
const data: { polygon: Array<[number, number]>; holes?: Array<Array<[number, number]>> } = {
polygon: translatePolygon(originalPolygon, dx, dz),
}
if (hasHoles) {
data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz))
}
useScene.getState().updateNodes([{ id, data }])
useScene.getState().markDirty(id)
useLiveTransforms.getState().clear(id)
return true
},
}
}
@@ -1,4 +1,11 @@
import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
getScaledDimensions,
type ItemNode,
isCurvedWall,
type WallNode,
} from '@pascal-app/core'
/**
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
@@ -127,3 +134,83 @@ export function findClosestWallInPlan(
return best
}
/** Figma-style along-wall alignment threshold (meters) — parity with the
* XZ placement / move threshold. */
const ALONG_WALL_ALIGN_THRESHOLD_M = 0.08
/** The along-wall span of a wall-hosted node (door / window / wall item):
* its centre `localX` and half-width. `null` for kinds with no along-wall
* footprint. */
function wallAttachmentSpan(node: AnyNode): { center: number; half: number } | null {
if (node.type === 'door' || node.type === 'window') {
const n = node as { position: [number, number, number]; width: number }
return { center: n.position[0], half: n.width / 2 }
}
if (node.type === 'item') {
const item = node as ItemNode
const attachTo = item.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
const [w] = getScaledDimensions(item)
return { center: item.position[0], half: w / 2 }
}
return null
}
/**
* Figma-style alignment for a wall-hosted opening / item, along the wall
* axis. Snaps the moving node's edges (or centre) to other attachments'
* edges/centres on the same wall, plus the wall ends. Edge-to-edge first,
* so two doors line up flush.
*
* Returns the adjusted `localX` when a neighbour stop is within threshold,
* or `null` when nothing aligns — callers treat `null` as "no alignment,
* fall back to the grid snap". This lets along-wall alignment COMPETE with
* the 0.5m grid (openings have arbitrary widths rarely on the grid, so
* layering on top of the grid snap would almost never trigger).
*
* Snap-only for v1 — no guide is published (the floor-plan guide layer
* renders XZ guides; an along-wall guide on a diagonal wall needs extra
* projection work, deferred).
*/
export function snapLocalXToNeighbors(args: {
wall: WallNode
localX: number
width: number
selfId: AnyNodeId
nodes: Record<AnyNodeId, AnyNode>
threshold?: number
}): number | null {
const { wall, localX, width, selfId, nodes, threshold = ALONG_WALL_ALIGN_THRESHOLD_M } = args
const half = width / 2
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
// Candidate stops along the wall: both ends + every other attachment's
// edges and centre.
const candidateStops: number[] = [0, wallLength]
for (const node of Object.values(nodes)) {
if (!node || node.id === selfId) continue
if ((node as { parentId?: string }).parentId !== wall.id) continue
const span = wallAttachmentSpan(node)
if (!span) continue
candidateStops.push(span.center - span.half, span.center, span.center + span.half)
}
// Moving stops: our two edges (edge-to-edge alignment) + centre.
const movingStops = [localX - half, localX, localX + half]
let bestDelta: number | null = null
let bestAbs = threshold
for (const ms of movingStops) {
for (const cs of candidateStops) {
const d = cs - ms
const ad = Math.abs(d)
if (ad <= bestAbs && (bestDelta === null || ad < bestAbs)) {
bestAbs = ad
bestDelta = d
}
}
}
return bestDelta === null ? null : localX + bestDelta
}
@@ -0,0 +1,110 @@
import {
type AlignmentAnchor,
resolveAlignment,
useAlignmentGuides,
type WallNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
/**
* A wall opening (door / window) can only slide ALONG its host wall, so it can
* only satisfy an x- or z-guide when the wall runs along that axis. Below this
* |component| (≈ wall within 60° of the axis) the along-wall move needed to
* reach the guide blows up, so we skip it rather than jump the opening across
* the wall.
*/
const MIN_AXIS_COMPONENT = 0.5
/**
* Resolve a wall opening's along-wall position with Figma-style alignment to
* other objects, publishing the matching guide as a side effect.
*
* The probe is the RAW cursor position on the wall (not the 0.5m snap) so
* off-grid anchors are caught; we then keep only the guide on an axis the wall
* runs along and map it to the along-wall coordinate that lands the opening on
* it. Falls back to the half-metre snap when nothing aligns, and clears the
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` (Alt) disables alignment.
*/
export function resolveWallSlideAlignment(args: {
wallNode: WallNode
rawLocalX: number
width: number
candidates: readonly AlignmentAnchor[]
bypass: boolean
}): number {
const { wallNode, rawLocalX, width, candidates, bypass } = args
const base = snapToHalf(rawLocalX)
if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear()
return base
}
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
const wallLength = Math.sqrt(dx * dx + dz * dz)
if (wallLength < 1e-6) {
useAlignmentGuides.getState().clear()
return base
}
const cos = dx / wallLength
const sin = dz / wallLength
const clampX = (localX: number) => Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const probe = resolveAlignment({
moving: [
{
nodeId: '__wall-opening-draft__',
kind: 'corner',
x: wallNode.start[0] + rawLocalX * cos,
z: wallNode.start[1] + rawLocalX * sin,
},
],
candidates,
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
})
// Keep only a guide on an axis the wall runs along, mapped to the along-wall
// position that satisfies it; pick the nearest such.
let bestLocalX: number | null = null
let bestDelta = Number.POSITIVE_INFINITY
for (const guide of probe.guides) {
const denom = guide.axis === 'x' ? cos : sin
if (Math.abs(denom) < MIN_AXIS_COMPONENT) continue
const origin = guide.axis === 'x' ? wallNode.start[0] : wallNode.start[1]
const targetLocalX = (guide.coord - origin) / denom
const delta = Math.abs(targetLocalX - rawLocalX)
if (delta < bestDelta) {
bestDelta = delta
bestLocalX = targetLocalX
}
}
if (bestLocalX === null) {
useAlignmentGuides.getState().clear()
return base
}
const clampedX = clampX(bestLocalX)
// Re-resolve from where the opening actually lands (post-clamp) so the
// published guide connects to the opening, not the raw cursor.
const published = resolveAlignment({
moving: [
{
nodeId: '__wall-opening-draft__',
kind: 'corner',
x: wallNode.start[0] + clampedX * cos,
z: wallNode.start[1] + clampedX * sin,
},
],
candidates,
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
})
if (published.guides.length === 0) {
useAlignmentGuides.getState().clear()
} else {
useAlignmentGuides.getState().set(published.guides)
}
return clampedX
}
+60 -57
View File
@@ -1,51 +1,76 @@
import {
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
movingFootprintAnchors,
type ShelfNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, triggerSFX, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
import {
applyFloorplanAlignment,
snapPointToGrid,
triggerSFX,
type WallPlanPoint,
} from '@pascal-app/editor'
/**
* 2D floor-plan move handler for shelf — behaves like items in the
* floor-plan move flow:
* 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`,
* because shelf is a `position`-field kind (it carries its location in
* `node.position`, not in polygon vertices):
*
* - Each pointermove writes the absolute world-plan target position
* to `useLiveTransforms` (so the 2D layer's `effectiveNode` override
* re-renders the SVG at the new position) AND mutates the
* registered mesh's `position` directly (so the 3D view mirrors the
* drag in real time).
* - On commit, `canCommit` writes the final position to `scene` as a
* single tracked update — the dispatcher's snapshot-diff captures
* it as one undoable step.
* - On any non-commit unmount (escape, abnormal teardown) the
* dispatcher clears `useLiveTransforms` for affectedIds, so the 3D
* visual snaps back to the reverted scene state.
* - Each pointermove writes the absolute world-plan position straight
* to `useScene` (history is paused by the overlay). This is the single
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
* `ParametricNodeRenderer` group transform both follow it reactively,
* so 2D and 3D can never diverge.
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
* history, and re-applies the final position as one undoable step.
* `canCommit` only validates.
*
* Unlike `slab` / `ceiling`, this writes the **absolute** position (the
* shelf carries its location in `node.position`, not in polygon
* vertices). The 2D layer's override branch for `shelf` mirrors `item`'s
* world-plan handling.
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
* `slab` / `ceiling` use. That works for polygon kinds because their commit
* rebuilds geometry (the vertices change), which forces the 3D group to
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
* scene directly removes that second source of truth entirely.
*/
const GRID_STEP = 0.5
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node }) => {
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
const shelfId = node.id as AnyNodeId
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
const originalRotationY = node.rotation[1] ?? 0
let lastPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null
// Alignment candidates — corner/edge/segment anchors of every OTHER node
// (incl. wall faces). Gathered once: the scene is stable during the drag
// (only the shelf moves), so re-collecting per tick is wasted work.
const candidates = collectAlignmentAnchors(nodes, shelfId)
const session: FloorplanMoveTargetSession = {
affectedIds: [shelfId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
const gridSnapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
// Figma-style alignment layered on the grid snap — the shelf footprint
// edges snap to neighbours / wall faces and a guide is published. Alt
// bypasses (matches placement tools' "No snap").
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
node as unknown as AnyNode,
gridSnapped[0],
gridSnapped[1],
originalRotationY,
),
candidates,
{ bypass: modifiers.altKey },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
@@ -57,44 +82,22 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
// Live preview — same shape items use. `useLiveTransforms.position`
// holds world-plan coords (level-local); the 2D `FloorplanRegistryLayer`
// override for `shelf` reads this and re-renders the SVG entry.
useLiveTransforms.getState().set(shelfId, {
position: next,
rotation: originalRotationY,
})
// Mirror to the 3D mesh so split-view follows the cursor without
// touching scene state per tick (no CSG, no React re-render of
// geometry — same imperative live-drag pattern as the 3D
// `MoveRegistryNodeTool`).
const mesh = sceneRegistry.nodes.get(shelfId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(next[0], next[1], next[2])
// Single source of truth — write the absolute position straight to
// the scene (history is paused by the overlay). Both the 2D SVG and
// the 3D group transform read `node.position` reactively, so they
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
// into one undoable step on commit.
useScene.getState().updateNodes([
{
id: shelfId,
data: { position: next },
},
])
},
canCommit() {
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
if (!live || live.type !== 'shelf') return false
if (lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) {
return false
}
// Side-effect commit — write final position. The dispatcher's
// snapshot-diff right after `canCommit` returns picks this up as
// the single tracked change for undo. `useLiveTransforms` is
// cleared in the dispatcher's commit path (and in our
// abnormal-unmount cleanup) so the 3D view reconciles to the
// committed scene position on the next render.
useScene.getState().updateNodes([
{
id: shelfId,
data: { position: lastPosition },
},
])
// The shelf's geometry doesn't depend on `position` (it's the
// group's transform, not the build inputs), but we mark dirty so
// any sibling-aware system that does watch position re-runs.
useScene.getState().markDirty(shelfId)
useLiveTransforms.getState().clear(shelfId)
return true
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
},
}
return session
+53 -5
View File
@@ -2,13 +2,17 @@
import {
type AnyNode,
collectAlignmentAnchors,
type EventSuffix,
emitter,
type GridEvent,
movingFootprintAnchors,
type NodeEvent,
resolveAlignment,
ShelfNode,
sceneRegistry,
snapPointToGrid,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
@@ -21,6 +25,11 @@ import ShelfPreview from './preview'
const worldVector = new Vector3()
const GRID_STEP = 0.5
/** Figma-style alignment-snap threshold (meters), matching the move tools and
* the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the
* grid snap without fighting it. */
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Click-trigger kinds: when the user clicks ANY of these during shelf
* placement, we commit at the latest cursor position. R3F's pointer
@@ -107,15 +116,47 @@ const ShelfTool = () => {
*/
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
// Alignment candidates — anchors of every OTHER alignable object (items,
// walls, fences, slabs, ceilings, columns, other shelves). Gathered once
// here and refreshed after each placement so a just-placed shelf becomes a
// target for the next one. `previewNode.id` never collides with a scene
// node, so nothing real is excluded.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
const onGridMove = (event: GridEvent) => {
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
lastCursorRef.current = [sx, event.localPosition[1], sz]
// Figma-style alignment snap layered on top of grid snap: when the
// preview shelf's footprint edge lines up (on X or Z) with another
// object's edge, snap there and publish a guide. The probe uses the
// shelf's footprint corners at the proposed grid position so it aligns
// by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt
// bypasses.
let ax = sx
let az = sz
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
ax += result.snap.dx
az += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
cursorRef.current?.position.set(ax, event.localPosition[1], az)
lastCursorRef.current = [ax, event.localPosition[1], az]
const prev = previousSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) {
if (!prev || prev[0] !== ax || prev[1] !== az) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [sx, sz]
previousSnapRef.current = [ax, az]
}
}
@@ -134,6 +175,10 @@ const ShelfTool = () => {
useScene.getState().createNode(shelf, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
triggerSFX('sfx:structure-build')
// The placed shelf is now a valid alignment target for the next one;
// refresh the candidate pool and drop the guide from this drop.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
const native = (event as { nativeEvent?: unknown }).nativeEvent
if (
@@ -162,8 +207,11 @@ const ShelfTool = () => {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
// Drop any alignment guide left over when the tool deactivates (kind
// switch, Esc, unmount) so it doesn't linger over the canvas.
useAlignmentGuides.getState().clear()
}
}, [activeLevelId])
}, [activeLevelId, previewNode])
if (!activeLevelId) return null
+10 -127
View File
@@ -1,131 +1,14 @@
import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type SlabNode,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
import type { FloorplanMoveTarget, SlabNode } from '@pascal-app/core'
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
/**
* 2D floor-plan move handler for slab — mirrors the 3D `MoveSlabTool`
* live-drag pattern so the visual stays smooth in split view.
* 2D floor-plan move handler for slab. Delegates to the shared polygon
* centroid-pivot mover: the slab's centroid snaps to the (grid-snapped,
* Figma-aligned) cursor — the same pivot semantics as a regular item's
* origin — instead of the old grab-relative delta. See
* `shared/polygon-centroid-move.ts` for the live-drag / commit rationale.
*
* **Why not write the polygon every tick?** Per-tick `scene.update` on
* `polygon` triggers a CSG geometry rebuild in `GeometrySystem` every
* frame. Even with a synchronous `markDirty`, the rebuild dispose/add
* pair flickers in the 3D viewer and the slab visibly catches up to
* the cursor one frame late — the same regression `commit f4ea07e` was
* fixed for in the 3D mover. The fix there: don't touch `scene` during
* the drag at all. Translate the rendered `<group>` via the live-drag
* exception (`mesh.position` + `useLiveTransforms.position = delta`).
* On commit, write the polygon once.
*
* **Delta semantics** (see `wiki/architecture/tools.md` — "useLiveTransforms
* contract is per-kind, not generic"): polygon-based kinds carry their
* "position" in the polygon vertices, not a node.position field. The
* `useLiveTransforms.position` must be a translation **delta**
* (`[Δx, 0, Δz]`), which `ParametricNodeRenderer` consumes as the group
* position. Visual = group.position + group.children-in-original-coords
* = (delta) + (original polygon vertices) = translated, with no
* geometry rebuild.
*
* **Commit path**: `canCommit` is the only side-effectful write to
* `scene`. The dispatcher captured snapshots before the first apply,
* so its snapshot-diff after `canCommit` returns will see one update
* (the translated polygon) and run the single-undo dance against it.
* `MoveSlabTool`'s cleanup (fires when `setMovingNode(null)` runs after
* the commit) handles the `useLiveTransforms.clear` + the React-render
* that resets `group.position` to (0,0,0) — by then `GeometrySystem`
* has rebuilt with the new polygon, so the visual lands at the same
* world position with no teleport.
* `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild.
*/
const GRID_STEP = 0.5
function translatePolygon(
polygon: ReadonlyArray<readonly [number, number]>,
dx: number,
dz: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
}
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node }) => {
const slabId = node.id as AnyNodeId
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
const originalHoles = (node.holes ?? []).map((hole) =>
hole.map(([x, z]) => [x, z] as [number, number]),
)
let anchor: [number, number] | null = null
let lastDelta: [number, number] = [0, 0]
const session: FloorplanMoveTargetSession = {
affectedIds: [slabId],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
if (!anchor) {
anchor = [snapped[0], snapped[1]]
return
}
const dx = snapped[0] - anchor[0]
const dz = snapped[1] - anchor[1]
lastDelta = [dx, dz]
// Live-drag exception (wiki/architecture/tools.md): write the
// delta to BOTH `mesh.position` (direct Three.js mutation) and
// `useLiveTransforms.position` (React-bound source of truth).
// They MUST match — `ParametricNodeRenderer` re-renders on every
// useLiveTransforms change and reconciles `<group position={...}>`,
// so a divergence makes the two writes fight every frame.
useLiveTransforms.getState().set(slabId, {
position: [dx, 0, dz],
rotation: 0,
})
const mesh = sceneRegistry.nodes.get(slabId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(dx, 0, dz)
},
canCommit() {
const live = useScene.getState().nodes[slabId] as SlabNode | undefined
if (!live || live.type !== 'slab') return false
const [dx, dz] = lastDelta
if (dx === 0 && dz === 0) return false
// Side-effect commit sequence — mirrors `MoveSlabTool.onGridClick`
// so the React render that clears `group.position` (via the
// useLiveTransforms.clear below) and the `GeometrySystem` rebuild
// (via the sync `markDirty`) land in the same paint cycle. Order
// matters:
// 1. Write the translated polygon to `scene`. The dispatcher's
// snapshot-diff right after `canCommit` returns will pick
// this up as the single tracked change for undo.
// 2. `markDirty` directly — bypasses the rAF-deferred batch in
// `updateNodesAction`, so `GeometrySystem` sees the dirty
// flag synchronously and can rebuild this frame (without
// this the rebuild slides into the next frame and the slab
// visually pops to its original position for one paint).
// 3. Clear `useLiveTransforms` — `ParametricNodeRenderer` then
// re-renders `<group position={[0,0,0]}>` instead of the
// live delta. Without the rebuild from step 2 also landing
// this frame, the group would render at (0,0,0) over the
// *unrebuilt* (still-original) geometry → original-position
// blink. With step 2 in place, the rebuild and the React
// render commit together → smooth.
useScene.getState().updateNodes([
{
id: slabId,
data: {
polygon: translatePolygon(originalPolygon, dx, dz),
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
},
},
])
useScene.getState().markDirty(slabId)
useLiveTransforms.getState().clear(slabId)
return true
},
}
return session
}
export const slabFloorplanMoveTarget: FloorplanMoveTarget<SlabNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
+37 -1
View File
@@ -2,12 +2,16 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
polygonAnchors,
resolveAlignment,
type SlabNode,
sceneRegistry,
useAlignmentGuides,
useLiveTransforms,
useScene,
type WallNode,
@@ -40,6 +44,9 @@ import type * as THREE from 'three'
* nothing for zundo to record. The single `scene.update` on commit
* becomes the single undo step naturally.
*/
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
@@ -125,6 +132,10 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
// Alignment candidates — every other alignable object's anchors,
// gathered once (the scene graph is stable during the drag).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, slabId)
let wasCommitted = false
const applyPreview = (deltaX: number, deltaZ: number) => {
@@ -170,7 +181,29 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
applyPreview(localX - anchor[0], localZ - anchor[1])
let deltaX = localX - anchor[0]
let deltaZ = localZ - anchor[1]
// Figma-style alignment snap: align the slab's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses.
const bypass = event.nativeEvent?.altKey === true
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
deltaX += result.snap.dx
deltaZ += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
applyPreview(deltaX, deltaZ)
}
const onGridClick = (event: GridEvent) => {
@@ -197,6 +230,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// GeometrySystem rebuild zeros it on the next frame, by which
// point the new geometry is in place — visual stays smooth.
useLiveTransforms.getState().clear(slabId)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [slabId] })
@@ -208,6 +242,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
// No scene state to roll back — we never wrote anything. Just
// restore the mesh visual.
clearPreview()
useAlignmentGuides.getState().clear()
useViewer.getState().setSelection({ selectedIds: [slabId] })
markToolCancelConsumed()
exitMoveMode()
@@ -218,6 +253,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel)
return () => {
useAlignmentGuides.getState().clear()
if (!wasCommitted) {
clearPreview()
} else {
+62 -4
View File
@@ -1,6 +1,14 @@
'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
EDITOR_LAYER,
@@ -26,6 +34,8 @@ import { SlabNode } from './schema'
*/
const Y_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint(
lastPoint: [number, number],
@@ -80,21 +90,66 @@ export const SlabTool: React.FC = () => {
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
useEffect(() => {
if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// slab's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const gridX = Math.round(rawPoint[0] * 2) / 2
const gridZ = Math.round(rawPoint[1] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
const lastPoint = points[points.length - 1]
const displayPoint =
const orthoPoint =
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
setSnappedCursorPosition(displayPoint)
if (
points.length > 0 &&
@@ -121,6 +176,7 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
} else {
setPoints([...points, clickPoint])
}
@@ -132,12 +188,14 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
useAlignmentGuides.getState().clear()
}
const onKeyDown = (e: KeyboardEvent) => {
+34 -65
View File
@@ -1,83 +1,56 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type StairNode,
snapScalar,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
/**
* 2D floor-plan move handler for stair — kicks in when the user clicks
* "Move" on the stair action menu and the floor-plan view is active.
* 2D floor-plan move handler for stair.
*
* **Delta-based motion, anchored on first pointermove.** The first
* `apply` only captures `rawAnchor` — the cursor's pointer position the
* instant the move begins — and skips writing to the scene. Subsequent
* applies translate the stair's original position by the cursor's raw
* delta and then snap the *absolute* result to the 0.5 m grid.
* **Pivot semantics.** The stair's ORIGIN (its `position`) follows the
* snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`)
* uses: it positions the stair by its origin at the grid-snapped, aligned
* cursor, NOT by the grab offset under the mouse. This replaces the old
* grab-relative delta so dragging in 2D tracks the same point as 3D.
*
* Anchoring matters because the action-menu Move button portals to
* `document.body`, so the move starts with the cursor wherever the menu
* sits (often nowhere near the stair). The previous "position = snapped
* cursor" implementation made the stair teleport to the menu's screen
* position on the very first pointermove, which is the "drag doesn't
* happen properly" symptom. Mirrors the same anchor pattern wall's
* `floorplan-move.ts` uses.
* Figma alignment is layered on the origin point (single anchor), matching
* `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are
* cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
*
* Snapping the absolute position (rather than the delta) keeps the
* stair on the same 0.5 m grid the 3D StairTool placement and 3D
* MoveRegistryNodeTool use — so dragging in 2D lands at the same
* grid intersections you'd hit dragging in 3D.
*
* Routing through `floorplanMoveTarget` (instead of the overlay's
* generic Path 2 translate) fixes two latent bugs the generic path
* has for stair:
*
* 1. Path 2's `onPointerUp` bails when `event.target.closest(
* '[data-floorplan-scene]')` fails — which happens when the
* pointer-up lands on empty grid background. Path 1 uses the
* overlay's bounding-rect check, which accepts any pointer
* inside the SVG viewport.
* 2. Path 2 commits via a single `updateNode` call that has no
* "self-owned commit" hook, so the overlay's diff path can
* silently revert when the final state matches the snapshot.
* The `commit()` below mirrors door's pattern: take ownership
* of the atomic write so the deterministic
* revert → resume → `session.commit()` path runs.
* The position is written straight to scene each tick (the stair has a real
* `position` field, unlike polygon kinds) and re-applied atomically via
* `commit()` so the overlay's deterministic revert → resume → commit path
* records a single undo step (same pattern door / window use).
*/
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node }) => {
// Capture the stair's original position once — apply() reads these
// every tick instead of re-querying scene state (which would
// double-apply our own writes).
const originalX = node.position[0]
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
const startY = node.position[1]
const originalZ = node.position[2]
let rawAnchor: [number, number] | null = null
// Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
let lastValid: { position: [number, number, number] } | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
if (!rawAnchor) {
rawAnchor = [planPoint[0], planPoint[1]]
return
}
const rawDx = planPoint[0] - rawAnchor[0]
const rawDz = planPoint[1] - rawAnchor[1]
// Snap the absolute new position to the editor's current grid
// step (the same one the cursor / draft snap to — driven by
// `useEditor.gridSnapStep`). Hardcoding 0.5 here caused the stair
// to snap to half-metre cells even when the user had set the grid
// to a finer step like 0.1, so the cursor and the stair SVG
// landed at different grid points. Shift bypasses snap entirely.
// Snap the origin to the editor's current grid step (driven by
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
const step = getSegmentGridStep()
const rawX = originalX + rawDx
const rawZ = originalZ + rawDz
const sx = modifiers.shiftKey ? rawX : snapScalar(rawX, step)
const sz = modifiers.shiftKey ? rawZ : snapScalar(rawZ, step)
const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step)
const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step)
// Figma alignment on the origin point (Alt bypasses), matching the 3D
// move tool. Publishes guides via `useAlignmentGuides`.
const { point: aligned } = applyFloorplanAlignment(
[gx, gz],
[{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates,
{ bypass: modifiers.altKey },
)
const sx = aligned[0]
const sz = aligned[1]
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
lastValid = { position: [sx, startY, sz] }
@@ -91,12 +64,8 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node
},
commit() {
// Own the atomic write so the overlay takes the deterministic
// commit-path (revert → resume → session.commit()). The dispatcher's
// diff path would otherwise re-derive the final state by comparing
// the post-apply scene to the snapshot — which produces an empty
// diff (and silent revert) when the committed move happens to have
// identical key/value pairs to the snapshot. Owning commit removes
// that foot-gun. Same pattern door / window use.
// commit-path (revert → resume → session.commit()). Same pattern
// door / window use.
if (!lastValid) return
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
},
@@ -6,11 +6,13 @@ import {
getMaxWallCurveOffset,
getWallChordFrame,
normalizeWallCurveOffset,
useAlignmentGuides,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isSegmentLongEnough,
snapScalarToGrid,
@@ -190,9 +192,16 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
})
// Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall
// and its linked siblings (which cascade with the corner) are excluded
// from the candidate pool. Alt is reserved for detach, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint
const primaryStart: WallPlanPoint = endpoint === 'start' ? snapped : fixedPoint
const primaryEnd: WallPlanPoint = endpoint === 'end' ? snapped : fixedPoint
const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint
const primaryEnd: WallPlanPoint = endpoint === 'end' ? aligned : fixedPoint
// ALT detaches: the linked walls keep their original endpoints,
// and only the dragged wall moves.
@@ -229,6 +238,9 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
}
},
canCommit() {
// Pointer-up always runs canCommit — drop the alignment guide here
// so it doesn't linger after a commit / reject.
useAlignmentGuides.getState().clear()
// The dragged wall must still be long enough at the preview
// length — checked against `lastPrimary*`, not scene, because
// scene holds baseline values until commit().
+39 -4
View File
@@ -2,13 +2,16 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
DEFAULT_WALL_HEIGHT,
emitter,
type GridEvent,
getWallCurveLength,
getWallThickness,
pauseSceneHistory,
resolveAlignment,
resumeSceneHistory,
useAlignmentGuides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -43,6 +46,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* `wall/definition.ts`. Editor state trigger is
* `useEditor.movingWallEndpoint`.
*/
/** Figma-style alignment-snap threshold (meters), matching the item move /
* placement tools. 8 cm gives a magnetic pull without fighting grid snap. */
const ALIGNMENT_THRESHOLD_M = 0.08
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
@@ -207,6 +214,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
// Alignment candidates — anchors of every OTHER alignable object (walls,
// fences, items, slabs, ceilings, columns), gathered once (the set is
// stable during the drag). Coords are building-local, the same frame as
// the cursor and the 3D guide layer, so the published guide lines up.
const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId)
pauseSceneHistory(useScene)
let wasCommitted = false
@@ -285,20 +298,40 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
})
// Figma-style alignment: nudge the dragged endpoint onto another wall /
// fence endpoint or midpoint axis when within threshold, and publish a
// guide. The resolver connects to the NEAREST real anchor of the
// candidate, so the dot always sits on an actual point (endpoint /
// midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here.
let alignedPoint = snappedPoint
if (wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
}
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
(alignedPoint[0] !== previousGridPosRef.current[0] ||
alignedPoint[1] !== previousGridPosRef.current[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
previousGridPosRef.current = alignedPoint
hasDraggedRef.current = true
applyPreview(snappedPoint, event.nativeEvent.altKey)
applyPreview(alignedPoint, event.nativeEvent.altKey)
}
const onPointerUp = () => {
useAlignmentGuides.getState().clear()
// Press-release without drag: dismiss the tool without committing.
if (!hasDraggedRef.current) {
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -345,6 +378,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
const onCancel = () => {
useAlignmentGuides.getState().clear()
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
@@ -390,6 +424,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
window.addEventListener('blur', onWindowBlur)
return () => {
useAlignmentGuides.getState().clear()
if (!wasCommitted) {
restoreOriginal(false)
}
+47 -12
View File
@@ -1,10 +1,13 @@
import {
calculateLevelMiters,
collectAlignmentAnchors,
emitter,
type GridEvent,
getWallMiterBoundaryPoints,
type LevelNode,
type Point2D,
resolveAlignment,
useAlignmentGuides,
useScene,
type WallMiterData,
type WallNode,
@@ -45,6 +48,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
*/
const WALL_HEIGHT = 2.5
const DRAFT_WALL_THICKNESS = 0.1
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
// HUD label heights are measured from the top of the preview bar, so they
// track whatever height a seeded preset draws at (`previewHeight`).
const DRAFT_LABEL_Y_OFFSET = 0.22
@@ -429,12 +434,36 @@ export const WallTool: React.FC = () => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
// Alignment candidates — anchors of every alignable object. Refreshed
// after each segment commits (the new wall becomes a candidate too).
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
const refreshAlignmentCandidates = () => {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
}
// Align the drafted point onto another object's nearest real anchor and
// publish the guide. Alt bypasses. Returns the (possibly snapped) point.
const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
const ar = resolveAlignment({
moving: [{ nodeId: '__wall-draft__', kind: 'corner', x: point[0], z: point[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
}
const stopDrafting = () => {
buildingState.current = 0
if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false
}
setDraftMeasurement(null)
useAlignmentGuides.getState().clear()
}
const onGridMove = (event: GridEvent) => {
@@ -448,14 +477,11 @@ export const WallTool: React.FC = () => {
// walls fall out of grid snap naturally when the start sits on
// a grid intersection.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
gridPosition = snapWallDraftPoint({ point: localPoint, walls, step })
const bypassAlign = event.nativeEvent?.altKey === true
gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign)
if (buildingState.current === 1) {
const snappedLocal = snapWallDraftPoint({
point: localPoint,
walls,
step,
})
const snappedLocal = gridPosition
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
@@ -501,9 +527,13 @@ export const WallTool: React.FC = () => {
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 0) {
const snappedStart = snapWallDraftPoint({ point: localClick, walls, step: clickStep })
const snappedStart = alignPoint(
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
bypassAlign,
)
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
@@ -517,11 +547,10 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const snappedEnd = snapWallDraftPoint({
point: localClick,
walls,
step: clickStep,
})
const snappedEnd = alignPoint(
snapWallDraftPoint({ point: localClick, walls, step: clickStep }),
bypassAlign,
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
@@ -532,6 +561,11 @@ export const WallTool: React.FC = () => {
)
if (!createdWall) return
// The new segment is now a real node — make it an alignment target
// for the next segment, and drop the just-shown guide.
refreshAlignmentCandidates()
useAlignmentGuides.getState().clear()
const nextStart = createdWall.end
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current)
@@ -574,6 +608,7 @@ export const WallTool: React.FC = () => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
useAlignmentGuides.getState().clear()
}
}, [unit])
+14 -2
View File
@@ -6,7 +6,7 @@ import {
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan } from '../shared/wall-attach-target'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
/**
@@ -49,7 +49,19 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
if (!hit) return
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
// Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends), winning over the 0.5m grid snap; falls back
// to grid when nothing aligns. Alt bypasses; Shift drops the grid snap.
const neighborX = modifiers.altKey
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width: node.width,
selfId: node.id as AnyNodeId,
nodes,
})
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
const { clampedX, clampedY } = clampToWall(
hit.wall,
snappedLocalX,
+34 -3
View File
@@ -1,9 +1,11 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useLiveTransforms,
useScene,
type WallEvent,
@@ -23,6 +25,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
const edgeMaterial = new LineBasicNodeMaterial({
@@ -113,8 +116,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
}
// Alignment candidates — anchors of every OTHER alignable object (the
// moving window is excluded so it never aligns to itself). Along-wall only;
// the floor-plane guides don't cover sill height.
const alignmentCandidates = collectAlignmentAnchors(
useScene.getState().nodes,
movingWindowNode.id,
)
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
@@ -141,7 +153,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingWindowNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -205,7 +223,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingWindowNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -274,7 +298,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: movingWindowNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -427,6 +457,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
}
useLiveTransforms.getState().clear(movingWindowNode.id)
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
+55 -7
View File
@@ -1,9 +1,11 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useAlignmentGuides,
useScene,
type WallEvent,
WindowNode,
@@ -21,6 +23,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
// Shared edge material — reuse across renders, just toggle color
@@ -70,8 +73,14 @@ const WindowTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A window aligns by the plan position of its centre
// (along-wall only; the floor-plane guides don't cover sill height).
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
@@ -103,11 +112,16 @@ const WindowTool: React.FC = () => {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = 1.5
const height = 1.5
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
@@ -153,14 +167,39 @@ const WindowTool: React.FC = () => {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
// Draft may be null after a successful placement (the click handler
// deletes it and relies on the wall rebuild → pointer-enter cascade to
// recreate it). Recreate it here on the first subsequent move so the
// preview is ready for the next click without requiring a leave/enter.
if (!draftRef.current) {
const levelId = getLevelId()
if (levelId && event.node.parentId === levelId) {
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
}
}
if (draftRef.current) {
// Update the scene store on every move so the 2D floor plan
// stays in sync (it re-renders from `node.position`). Only
@@ -221,7 +260,13 @@ const WindowTool: React.FC = () => {
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: draftRef.current.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true,
})
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -287,6 +332,8 @@ const WindowTool: React.FC = () => {
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
event.stopPropagation()
}
@@ -310,6 +357,7 @@ const WindowTool: React.FC = () => {
return () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
+6
View File
@@ -5,6 +5,7 @@ import {
zoneMoveEdgeAffordance,
zoneMoveVertexAffordance,
} from './floorplan-affordances'
import { zoneFloorplanMoveTarget } from './floorplan-move'
import { zoneParametrics } from './parametrics'
import { ZoneNode } from './schema'
@@ -46,6 +47,11 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
priority: 4,
},
floorplan: buildZoneFloorplan,
// 2D body move — centroid-pivot polygon mover (same as slab / ceiling).
// Without this, zone fell through to the overlay's generic free-translate
// path, which committed a `position` field zone has no schema for, so the
// polygon never actually moved on drop.
floorplanMoveTarget: zoneFloorplanMoveTarget,
// Polygon editor when selected — same three operations slabs / ceilings
// expose. The shared factories key off `node.polygon`, optional
// `node.holes` (absent on zones). See `floorplan-affordances.ts`.
+15
View File
@@ -0,0 +1,15 @@
import type { FloorplanMoveTarget, ZoneNode } from '@pascal-app/core'
import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move'
/**
* 2D floor-plan move handler for zone. Delegates to the shared polygon
* centroid-pivot mover — the zone's centroid snaps to the (grid-snapped,
* Figma-aligned) cursor. Zone has no `holes`, which the helper handles.
*
* Previously zone had no move target and fell through to the overlay's
* generic free-translate path, which committed a `position` field zone
* doesn't have (the polygon never moved on commit). Routing through this
* polygon mover translates the actual vertices. `meshY = 0`.
*/
export const zoneFloorplanMoveTarget: FloorplanMoveTarget<ZoneNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
@@ -91,11 +91,16 @@ export function getRoofMaterialArray(
return roleArray
}
// Each slot resolves to its own role only, then the themed default — never
// another role. Cross-role fallback here used to splatter a single painted
// surface (e.g. the edge) across the shingle and soffit slots. The legacy
// catch-all still fills every role because `getEffectiveRoofSurfaceMaterial`
// returns it for top/edge/wall alike.
const materialArray: RoofMaterialArray = [
edgeMaterial ?? wallMaterial ?? topMaterial ?? roofMaterial,
wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial,
wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial,
topMaterial ?? wallMaterial ?? edgeMaterial ?? roofMaterial,
edgeMaterial ?? roofMaterial,
wallMaterial ?? ceilingMaterial,
wallMaterial ?? ceilingMaterial,
topMaterial ?? roofMaterial,
]
roofMaterialArrayCache.set(cacheKey, materialArray)
@@ -83,6 +83,15 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
/**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*
* Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side
* walls can be emitted double-sided. The slab material is forced to FrontSide
* (DoubleSide on the floor-role NodeMaterial poisons the MRT scene pass — see
* nodes/slab/geometry.ts), and ExtrudeGeometry's hole walls are single-sided,
* so their interior faces get back-face culled and you see straight through the
* cut. Emitting each hole-wall quad twice with opposite winding makes the inner
* thickness visible from any angle: the two coincident triangles never z-fight
* because exactly one faces the camera under FrontSide culling.
*/
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode)
@@ -91,23 +100,79 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
if (polygon.length < 3) return new THREE.BufferGeometry()
const shape = new THREE.Shape()
shape.moveTo(polygon[0]![0], -polygon[0]![1])
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
shape.closePath()
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
for (const holePolygon of holePolygons) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
for (let i = 1; i < holePolygon.length; i++)
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
holePath.closePath()
shape.holes.push(holePath)
const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holes2d = holePolygons
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
// --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
geometry.rotateX(-Math.PI / 2)
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
// --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls
// emit a second flipped quad (own verts) so they read as double-sided.
const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => {
const base = positions.length / 3
const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001)
positions.push(a.x, 0, a.y)
uvs.push(0, 0)
positions.push(b.x, 0, b.y)
uvs.push(len, 0)
positions.push(b.x, elevation, b.y)
uvs.push(len, elevation)
positions.push(a.x, elevation, a.y)
uvs.push(0, elevation)
// Standard winding on a CCW polygon gives inward-facing normals (see pool
// path), so the unflipped quad faces outward; flipped is its back face.
if (!flipped) {
indices.push(base, base + 2, base + 1, base, base + 3, base + 2)
} else {
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
const b = hole[(i + 1) % hole.length]!
addWall(a, b, false)
addWall(a, b, true)
}
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
+1 -1
View File
@@ -111,7 +111,7 @@ The store name suggests a uniform contract; the writes in practice are not. Docu
| `slab` / `ceiling` / `fence` / polygon-based movers | position **delta** (`[Δx, 0, Δz]`) | unused / 0 |
| `column` / `roof` / `elevator` / `spawn` / single-position kinds | world plan | world Y |
Anything that subscribes to `useLiveTransforms` to inform 2D rendering needs to handle these frames explicitly. The `FloorplanRegistryLayer` currently narrows its override to `node.type === 'item'` and sets `parentId: null` on the effective node so the resolver treats the live position as world plan coords. Extending the override to other kinds requires either standardising the frame at the writer (preferred long-term) or per-kind handling in the consumer.
Anything that subscribes to `useLiveTransforms` to inform 2D rendering needs to handle these frames explicitly. The `FloorplanRegistryLayer` override currently branches by kind: `item` / `shelf` / `column` are treated as world-plan (it copies `live.position` onto the effective node and forces `parentId: null` so the resolver skips the parent-chain transform), while `slab` / `ceiling` / `zone` are treated as a polygon **delta** (it translates the polygon vertices by `live.position`). Each kind added to the live-drag path grows this consumer-side switch; the preferred long-term fix is to standardise the frame at the writer so the consumer stops branching by `node.type`.
## Wall-attached node rotations must be wall-local