diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index a3706740e..0a602be11 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -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) { diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 9db7cabd2..f16f69a16 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -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 = { * 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 } diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 0dd2f39a5..89d806b28 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -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) } diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts new file mode 100644 index 000000000..d44b15f25 --- /dev/null +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -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): 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 + }) +}) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts new file mode 100644 index 000000000..3fac4eed3 --- /dev/null +++ b/packages/core/src/services/alignment-anchors.ts @@ -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>, + 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 +} diff --git a/packages/core/src/services/alignment.test.ts b/packages/core/src/services/alignment.test.ts index 3fa8aaaa6..7eb86e128 100644 --- a/packages/core/src/services/alignment.test.ts +++ b/packages/core/src/services/alignment.test.ts @@ -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)], diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts index ce4f8ba66..408e64afa 100644 --- a/packages/core/src/services/alignment.ts +++ b/packages/core/src/services/alignment.ts @@ -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 }, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 92f92e498..56953c4a2 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -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, diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 2fe972fb4..7d01dcb46 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx new file mode 100644 index 000000000..775b27fb5 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -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 `` 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 ( + + + + ) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 04d53d3d8..9a3be98fc 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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() } }, []) diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx new file mode 100644 index 000000000..13ec6cffd --- /dev/null +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -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 (``). + * + * 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) => ( + + ))} + + ) +}) + +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) => ( + + ))} + + + {guide.distance > 1e-4 && ( + +
+ {distLabel} +
+ + )} + + ) +} + +/** Flat circular marker lying on the floor plane at a guide endpoint. */ +function Dot({ position }: { position: Vec3 }) { + return ( + + ) +} + +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` +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a020c6a81..48b0a80b7 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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 measure→fit→measure loop. */} + {/* 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`. */} + {/* 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 diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx new file mode 100644 index 000000000..1ecf71a9d --- /dev/null +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -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 +} + +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([0, 0]) + const dragCleanupRef = useRef<(() => void) | null>(null) + const frozenCorner = useRef(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) => { + 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 }[] = [] + for (const id of affectedIds) { + const patch = overrides.get(id) + if (patch) updates.push({ id, data: patch as Partial }) + } + 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( + + { + 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} + /> + , + scene, + ) +} + +export default GroupMoveHandle diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index bdb1f9dc1..8a0736b59 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -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 + // Remount when the moving set changes so the rest pivot re-seeds cleanly. + return } function GroupRotateHandleInner({ ids }: { ids: string[] }) { @@ -81,7 +79,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const [isDragging, setIsDragging] = useState(false) const [guide, setGuide] = useState(null) const dragCleanupRef = useRef<(() => void) | null>(null) - const frozenPivot = useRef(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) => { event.stopPropagation() - const center = restPivot.clone() - frozenPivot.current = center - - // 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 => s !== null) + frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } + const center = rest.pivot.clone() + + // 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 }[] = [] - for (const s of starts) { - const patch = overrides.get(s.id) - if (patch) updates.push({ id: s.id, data: patch as Partial }) + for (const id of affectedIds) { + const patch = overrides.get(id) + if (patch) updates.push({ id, data: patch as Partial }) } 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) && ( - + )} - + + 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, + 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, + 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 +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index fbe40b8d2..9fb83be78 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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 - onSave?: (scene: SceneGraph) => Promise + onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void @@ -607,6 +608,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } + {!noEditing && } {!noEditing && } {!noEditing && } {!noEditing && } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index e19dbe304..8d2a9bc36 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -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) => { 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) => { event.stopPropagation() @@ -1803,6 +1814,20 @@ function TapActionArrow({ ) } + if (shape === 'move-cross') { + return ( + + ) + } + // Default 'arrow' shape — the standard chevron. return ( ) => void + onEnter: (event: ThreeEvent) => void + onLeave: (event: ThreeEvent) => 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 ( + + + + ) +} + function ArrowShape({ position, rotationY, diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 752e3b058..bf8af2d35 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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, ) } @@ -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'), } diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index be09c7483..0f34e70e0 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -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) diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 3274dde33..0cff52286 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -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() diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx index e93aca5cf..6718acb6d 100644 --- a/packages/editor/src/components/editor/wall-opening-highlights.tsx +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -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) => ( - + ))} , scene, ) } -function WallOpenings({ wallId }: { wallId: string }) { - const wall = useScene((state) => state.nodes[wallId as AnyNodeId]) - - if (!wall || wall.type !== 'wall') return null +// 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 (node?.type === 'wall') { + const depth = node.thickness ?? 0.1 + return ( + <> + {(node.children ?? []).map((childId) => ( + + ))} + + ) + } + + if (node?.type === 'door' && node.openingKind === 'opening') { + return + } + + return null +} - const depth = wall.thickness ?? 0.1 - return ( - <> - {(wall.children ?? []).map((childId) => ( - - ))} - - ) +// 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 } 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(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]) diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx deleted file mode 100644 index 837b1729b..000000000 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ /dev/null @@ -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 - Column -) - -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 = ({ currentLevelId, onPlaced }) => { - const [, setCursorPosition] = useState<[number, number, number] | null>(null) - const cursorRef = useRef(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 ( - - ) -} diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 4b81bd917..450118016 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 8df958c78..4fae6ae1a 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 1b897cf87..027756803 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -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) diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index d5c4fd234..277f56e2a 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -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 } diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 1aa09ee71..43a647b58 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -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) diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx new file mode 100644 index 000000000..16e4480f1 --- /dev/null +++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx @@ -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 ( + + + + + ) +} diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 4f14283ea..676514799 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -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() diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index c82711b9a..12b7b404b 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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]) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 69633b5f0..2a0cdee9f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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 = () => { )} - {!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && ( - - )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( { onPlaced={handlePlacedElevatorSelected} /> )} - {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? ( - - ) : null} + {!movingNode && BuildToolComponent && tool !== 'elevator' ? : 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. */} + ) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 1e461661e..d6fec9c88 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -995,6 +995,7 @@ export function SecondaryToggles() { return (
+
) } diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index 1507cc9c8..eead7524f 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -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 ( -
+
+
+ + +
{ setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 43af28f7b..fd4acf3ac 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000 export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error' interface UseAutoSaveOptions { - onSave?: (scene: SceneGraph) => Promise + onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise 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() diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 19049e8af..046f9b5f4 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -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' diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index 3a2229078..4333003dc 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -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[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()) }, diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts new file mode 100644 index 000000000..a180f68f0 --- /dev/null +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -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 +} diff --git a/packages/editor/src/lib/floorplan/index.ts b/packages/editor/src/lib/floorplan/index.ts index 846b28e3f..4aa913a53 100644 --- a/packages/editor/src/lib/floorplan/index.ts +++ b/packages/editor/src/lib/floorplan/index.ts @@ -1,3 +1,10 @@ +export { + alignFloorplanDraftPoint, + applyFloorplanAlignment, + FLOORPLAN_ALIGNMENT_THRESHOLD_M, + FLOORPLAN_DRAFT_ALIGN_ID, + type FloorplanAlignmentResult, +} from './apply-alignment' export { clampPlanValue, doesPolygonIntersectSelectionBounds, diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index a2015966f..fbd592718 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -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, - node: RoofNode, - targetRole: RoofSurfaceMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, + node: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const clearPatch = (target: AnyNode): Partial => { + const patch: Record = {} + 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 + } + const updates: { id: AnyNodeId; data: Partial }[] = [ - { - id: node.id as AnyNodeId, - data: buildRoofSurfaceMaterialPatch( - node, - targetRole, - material, - materialPreset, - ) as Partial, - }, + { 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 as Partial, - }) + 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 diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 69dfebfbc..0c61bc82d 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( | StairSegmentNode | BuildingNode | null, + placementDragMode: false, + setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), setMovingNode: (node) => set( node === null @@ -695,7 +708,8 @@ const useEditor = create()( // 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()( 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 diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts new file mode 100644 index 000000000..c012b7da5 --- /dev/null +++ b/packages/editor/src/store/use-placement-preview.ts @@ -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((set) => ({ + node: null, + set: (node) => set({ node }), + clear: () => set({ node: null }), +})) + +export default usePlacementPreview diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts index 2bf473fe9..a4dfd8b60 100644 --- a/packages/nodes/src/ceiling/floorplan-move.ts +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -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, - dx: number, - dz: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) -} - -export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ 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 = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 }) diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 5dc303462..4a10c87e4 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -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 { diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 57f693490..7768d632c 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -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) => { diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 09a4ad10d..2aa8f3121 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -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 = { 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 ``. + 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, diff --git a/packages/nodes/src/column/floorplan-move.ts b/packages/nodes/src/column/floorplan-move.ts new file mode 100644 index 000000000..fbf845fe4 --- /dev/null +++ b/packages/nodes/src/column/floorplan-move.ts @@ -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 = ({ 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 +} diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index d16c446ce..37cc905c3 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -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(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 + return ( + <> + + + + ) } export default MoveColumnTool diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index 788c907fc..05c9e5ab4 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -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(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' ? ( + + ) : node.supportStyle === 'y-frame' ? ( + + ) : node.supportStyle === 'v-frame' ? ( + + ) : node.supportStyle === 'x-brace' ? ( + + ) : node.supportStyle === 'k-brace' ? ( + + ) : node.supportStyle === 'single-strut' ? ( + + ) : node.supportStyle === 'tripod' ? ( + + ) : node.supportStyle === 'trestle' ? ( + + ) : node.supportStyle === 'portal-frame' ? ( + + ) : node.supportStyle === 'box-frame' ? ( + + ) : ( + <> + + + + + + + + + + + + + ) +} + +/** + * 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 `` 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(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 ( + + + + + + + + ) +} + export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { const ref = useRef(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 ( @@ -2133,73 +2249,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { visible={node.visible} {...handlers} > - {node.supportStyle === 'a-frame' ? ( - - ) : node.supportStyle === 'y-frame' ? ( - - ) : node.supportStyle === 'v-frame' ? ( - - ) : node.supportStyle === 'x-brace' ? ( - - ) : node.supportStyle === 'k-brace' ? ( - - ) : node.supportStyle === 'single-strut' ? ( - - ) : node.supportStyle === 'tripod' ? ( - - ) : node.supportStyle === 'trestle' ? ( - - ) : node.supportStyle === 'portal-frame' ? ( - - ) : node.supportStyle === 'box-frame' ? ( - - ) : ( - <> - - - - - - - - - - - - - )} + diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx new file mode 100644 index 000000000..73f5eaa2e --- /dev/null +++ b/packages/nodes/src/column/tool.tsx @@ -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 `` branch no longer fires. + */ +const ColumnTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const cursorRef = useRef(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 ( + + + + ) +} + +export default ColumnTool diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9791a7661..d1978c9ac 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -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 { 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: { diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index 9a2dc9df6..15a1b2ddb 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -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. */ diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 93b3102f7..ac7bc4226 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -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 = ({ 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 = { diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 7953139d9..fd9e8d577 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -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) diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 4bba801f2..e778743b0 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -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} diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 85cb6431f..d50eff37a 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -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) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index 6ba3e0bad..78722bfa6 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -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 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 { + 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 { - // 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. }, } diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index 66d46ae96..7ca4e1473 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -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 = { 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 = { ]) }, 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 && diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index e13101f06..c56f4c2ab 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -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(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(() => { diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 956a57b53..6d0716641 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -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]) diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index eb5d18d51..08b75bf3f 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -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 { 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 { } } -// 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 { 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 { } } -// 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 { 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] - }, } } diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index 2a8aad5c1..039509b02 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -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 = ({ node }) => { +export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ 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 = ({ 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, ): 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]] diff --git a/packages/nodes/src/roof-segment/renderer.tsx b/packages/nodes/src/roof-segment/renderer.tsx index f4d1fee8e..69d58acd6 100644 --- a/packages/nodes/src/roof-segment/renderer.tsx +++ b/packages/nodes/src/roof-segment/renderer.tsx @@ -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, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 5b4c2ccc2..11f7356ac 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -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 diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts new file mode 100644 index 000000000..1155a1ce5 --- /dev/null +++ b/packages/nodes/src/shared/polygon-centroid-move.ts @@ -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 `` 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, + 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): [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> + } + nodes: Record + /** 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> } = { + 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 + }, + } +} diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 717961194..1dcab28cf 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -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 + 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 +} diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts new file mode 100644 index 000000000..bda92e227 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -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 +} diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index ee43750d7..21715cc49 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -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 = ({ node }) => { +export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ 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 = ({ 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]) - }, - 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. + // 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: lastPosition }, + data: { position: next }, }, ]) - // 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 + }, + canCommit() { + const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined + if (!live || live.type !== 'shelf') return false + return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) }, } return session diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index af0ea78a7..dd19c3dc7 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -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 diff --git a/packages/nodes/src/slab/floorplan-move.ts b/packages/nodes/src/slab/floorplan-move.ts index 99fe877d6..f0725a049 100644 --- a/packages/nodes/src/slab/floorplan-move.ts +++ b/packages/nodes/src/slab/floorplan-move.ts @@ -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 `` 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, - dx: number, - dz: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) -} - -export const slabFloorplanMoveTarget: FloorplanMoveTarget = ({ 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 ``, - // 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 `` 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 = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 }) diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 7dac9c3b8..4bbb9bec4 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -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 { diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 07f298f16..e80cc372e 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -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) => { diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index a2f30edec..ef33a860a 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -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 = ({ 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 = ({ 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 = ({ 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 }]) }, diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 5c3c51987..8928b3763 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -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 = { 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 = { } }, 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(). diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index eb36b1d20..e37ce12ec 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -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) } diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 530472ec1..083ce0a29 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -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]) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index cdd91eb89..0d294bf41 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -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 = ({ 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, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index fa9918ba3..52c59d930 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -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) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c0b7b5285..0850ff074 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -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) diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index a1e44d2e8..7dfdc08d6 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -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 = { 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`. diff --git a/packages/nodes/src/zone/floorplan-move.ts b/packages/nodes/src/zone/floorplan-move.ts new file mode 100644 index 000000000..a5ccf313c --- /dev/null +++ b/packages/nodes/src/zone/floorplan-move.ts @@ -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 = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 }) diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 8d0312313..e4040616c 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -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) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 403f0438e..0e7fc0ab9 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -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() - - 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 positions: number[] = [] + const uvs: number[] = [] + const indices: number[] = [] + + 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 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.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false }) - geometry.rotateX(-Math.PI / 2) + 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 } diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 038e0dd60..0d7a648e6 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -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