diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b6ff26f8..31e09798 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,14 @@ export { type Space, wallTouchesOthers, } from './lib/space-detection' +export { + closestOnSegment, + collectLevelWallSegments, + nearestWallSegment, + WALL_SNAP_DISTANCE_M, + type WallSegment, + type WallSegmentClosest, +} from './lib/wall-distance' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, diff --git a/packages/core/src/lib/wall-distance.ts b/packages/core/src/lib/wall-distance.ts new file mode 100644 index 00000000..cc80fcd5 --- /dev/null +++ b/packages/core/src/lib/wall-distance.ts @@ -0,0 +1,121 @@ +import type { WallNode } from '../schema/nodes/wall' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { isCurvedWall } from '../systems/wall/wall-curve' + +/** + * Pure plan-space wall-distance math shared by the 2D opening snap + * (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D + * Voronoi debug overlay. One source of truth means the overlay is a + * faithful picture of what the snap actually decides. + * + * A wall segment's Voronoi cell is exactly "the points whose nearest wall + * is this segment", so nearest-segment classification == the segment + * Voronoi diagram. Curved walls are excluded (the opening snap rejects + * them — mitering + arc + opening tears in 3D). + */ + +/** + * Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a + * wall. Tight, because plan walls are thin and often close together — a large + * radius would let a far wall's region reach across a nearer one. Shared so the + * snap and the Voronoi debug overlay clip to the exact same range. + */ +export const WALL_SNAP_DISTANCE_M = 0.4 + +export type WallSegment = { + wall: WallNode + /** [x, z] plan start. */ + start: readonly [number, number] + /** [x, z] plan end. */ + end: readonly [number, number] + /** Unit direction (start → end) in plan. */ + dirX: number + dirY: number + /** Segment length in metres. */ + length: number +} + +export type WallSegmentClosest = { + segment: WallSegment + /** Distance from the query point to the closest point on the segment. */ + distance: number + /** Distance along the wall from `start`, clamped to [0, length]. */ + along: number + /** Signed perpendicular offset from the wall axis (+ on the front side). */ + perp: number +} + +/** + * Collect the straight (non-curved) wall segments that are direct children + * of a level — the candidates an opening can snap onto. + */ +export function collectLevelWallSegments( + nodes: Record, + levelId: AnyNodeId | null, +): WallSegment[] { + if (!levelId) return [] + const level = nodes[levelId] + const childIds = (level as unknown as { children?: AnyNodeId[] })?.children + if (!Array.isArray(childIds)) return [] + + const segments: WallSegment[] = [] + for (const childId of childIds) { + const node = nodes[childId] + if (node?.type !== 'wall') continue + const wall = node as WallNode + if (isCurvedWall(wall)) continue + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) continue + segments.push({ + wall, + start: wall.start, + end: wall.end, + dirX: dx / length, + dirY: dy / length, + length, + }) + } + return segments +} + +/** Closest point + signed offset of one query point against one segment. */ +export function closestOnSegment( + segment: WallSegment, + pointX: number, + pointY: number, +): { distance: number; along: number; perp: number } { + const px = pointX - segment.start[0] + const py = pointY - segment.start[1] + const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY)) + const perp = px * -segment.dirY + py * segment.dirX + const closestX = segment.start[0] + segment.dirX * along + const closestY = segment.start[1] + segment.dirY * along + const distance = Math.hypot(pointX - closestX, pointY - closestY) + return { distance, along, perp } +} + +/** + * The single nearest wall segment to a plan point — its Voronoi cell. Returns + * null when `segments` is empty or (when `maxDistance` is given) nothing is + * within range. Ties resolve to the first segment scanned; callers pass an + * already-curved-filtered list from `collectLevelWallSegments`. + */ +export function nearestWallSegment( + segments: readonly WallSegment[], + pointX: number, + pointY: number, + maxDistance = Number.POSITIVE_INFINITY, + excludeWallId?: AnyNodeId, +): WallSegmentClosest | null { + let best: WallSegmentClosest | null = null + for (const segment of segments) { + if (excludeWallId && segment.wall.id === excludeWallId) continue + const { distance, along, perp } = closestOnSegment(segment, pointX, pointY) + if (distance > maxDistance) continue + if (best && distance >= best.distance) continue + best = { segment, distance, along, perp } + } + return best +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index cc073c50..b5139328 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -630,6 +630,14 @@ export type FloorplanMoveTargetSession = { * returns. */ commit?(): void + /** + * Optional R-key flip toggle. Kinds with a directional facing + * (door / window: front ↔ back) implement this so the overlay can flip + * the orientation mid-placement before commit. Toggling just records the + * intent; the visible change lands when the overlay re-runs `apply()` with + * the last pointer position. Kinds with no facing leave it unset. + */ + flipSide?(): void } export type FloorplanMoveTarget = (args: { diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index b3ac8c9b..a3e7a009 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -21,6 +21,7 @@ import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import type { AnyNode, AnyNodeId } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { resetSceneHistoryPauseDepth } from './history-control' @@ -414,7 +415,10 @@ function migrateRoofSurfaceMaterials(node: Record) { } function migrateNodes(nodes: Record): Record { - const patchedNodes = { ...nodes } + // Repair pre-existing corruption (null children, zero-length walls) before + // any per-type migration runs, so already-saved scenes load cleanly. + const { nodes: healed } = healSceneNodes(nodes) + const patchedNodes = { ...healed } as Record for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts new file mode 100644 index 00000000..349850ed --- /dev/null +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '../../schema' +import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering' + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_test', + children: [], + start, + end, + thickness: 0.1, + height: 2.5, + frontSide: 'interior', + backSide: 'exterior', + metadata: {}, + } as WallNode +} + +function maxBoundaryCoord(walls: WallNode[]): number { + const miter = calculateLevelMiters(walls) + let max = 0 + for (const w of walls) { + const bp = getWallMiterBoundaryPoints(w, miter) + expect(bp).not.toBeNull() + if (!bp) continue + for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + max = Math.max(max, Math.abs(p.x), Math.abs(p.y)) + } + } + return max +} + +describe('wall mitering miter limit', () => { + // Two 3 m walls sharing the origin, meeting at decreasing angles. Without a + // miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ), + // which is the "infinite wall" seen when a room-preset preview lands on top of + // an existing wall. The boundary must stay bounded near the wall length. + test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => { + const rad = (deg * Math.PI) / 180 + const walls = [ + wall('A', [0, 0], [3, 0]), + wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]), + ] + // 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike. + expect(maxBoundaryCoord(walls)).toBeLessThan(4) + }) + + test('still miters a normal 90° corner', () => { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])] + const miter = calculateLevelMiters(walls) + const bpA = getWallMiterBoundaryPoints(walls[0]!, miter) + expect(bpA).not.toBeNull() + if (!bpA) throw new Error('expected miter boundary points') + // The shared corner is pulled to the mitred intersection, offset from the + // raw butt position (halfThickness 0.05) by the diagonal of the joint. + const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x) + expect(startSideX).toBeLessThan(-0.001) + expect(startSideX).toBeGreaterThan(-0.5) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index e6a2f295..193f11be 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -35,6 +35,17 @@ type JunctionData = Map const TOLERANCE = 0.001 +// Miter joints are line-line intersections, so the joint point sits a distance +// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the +// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview +// dragged on top of an existing wall, or a freshly-drawn wall almost parallel +// to its neighbour) that distance runs away to infinity and the wall renders as +// an infinite spike. Cap the joint at this multiple of the wall half-thickness; +// beyond it we fall back to a square (butt) joint, exactly like the existing +// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps +// mitering down to ~11°) while bounding the pathological near-collinear case. +const MITER_LIMIT = 10 + function pointToKey(p: Point2D, tolerance = TOLERANCE): string { const snap = 1 / tolerance return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` @@ -198,6 +209,7 @@ interface ProcessedWall { edgeA: LineEquation // Left edge edgeB: LineEquation // Right edge isPassthrough: boolean // True if wall passes through junction (T-junction) + halfThickness: number // Used to bound the miter joint against runaway spikes } function calculateJunctionIntersections( @@ -228,7 +240,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: true, + halfThickness: halfT, + }) } } else { // Normal wall endpoint (start or end) @@ -245,7 +264,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: false, + halfThickness: halfT, + }) } } @@ -275,6 +301,21 @@ function calculateJunctionIntersections( y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det, } + // Miter limit: `det` only catches walls that are *exactly* parallel. Two + // walls meeting at a shallow angle have a small-but-nonzero `det`, so `p` + // lands far from the junction (∝ 1/sin θ) and the wall renders as an + // infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses + // from the meeting point — those walls fall back to a square joint. + const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness) + const dx = p.x - meetingPoint.x + const dy = p.y - meetingPoint.y + if ( + !(Number.isFinite(p.x) && Number.isFinite(p.y)) || + dx * dx + dy * dy > maxMiter * maxMiter + ) { + continue + } + // Only assign intersection to non-passthrough walls // Passthrough walls don't receive junction data (their geometry doesn't change) if (!wall1.isPassthrough) { diff --git a/packages/core/src/utils/heal-scene-graph.test.ts b/packages/core/src/utils/heal-scene-graph.test.ts new file mode 100644 index 00000000..6b64658e --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { healSceneNodes } from './heal-scene-graph' + +describe('healSceneNodes', () => { + test('strips non-string (null) children entries', () => { + const { nodes, strippedChildRefs } = healSceneNodes({ + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [1, 0], + children: [null, 'item_x'], + }, + item_x: { id: 'item_x', type: 'item' }, + }) + expect(strippedChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x']) + }) + + test('drops childless zero-length walls and removes their parent reference', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] }, + wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] }, + wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] }, + }) + expect(droppedWallIds).toEqual(['wall_zero']) + expect('wall_zero' in nodes).toBe(false) + expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real']) + }) + + test('keeps a zero-length wall that still hosts a door/window', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + }) + expect(droppedWallIds).toEqual([]) + expect('wall_z' in nodes).toBe(true) + }) + + test('passes a clean scene through untouched', () => { + const input = { + wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + } + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input) + expect(droppedWallIds).toEqual([]) + expect(strippedChildRefs).toBe(0) + expect(nodes.wall_a).toBe(input.wall_a) + }) +}) diff --git a/packages/core/src/utils/heal-scene-graph.ts b/packages/core/src/utils/heal-scene-graph.ts new file mode 100644 index 00000000..9ada1400 --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.ts @@ -0,0 +1,83 @@ +// Repairs scene-graph corruption that pre-dates the source fixes, so existing +// saved scenes still load. Two known kinds of damage, both produced by the +// capture wall-merge before it was fixed: +// +// 1. A `children` array containing a non-string entry. The merge re-attached a +// wall-hosted item without minting an id, so `undefined` was pushed into the +// wall's children — which serializes to `[null]`. The wall schema rejects +// `null` children, so the whole scene fails to load. +// 2. A zero-length wall (start === end). It renders nothing, but lingers as a +// junk node and is a foot-gun for snapping/mitering. +// +// Both are also prevented at the source now (see merge-walls.ts and the wall +// miter limit); this is the load-time safety net for already-saved scenes. + +const ZERO_LENGTH_EPS = 1e-6 + +export interface HealSceneResult { + nodes: Record + /** Ids of zero-length walls that were dropped. */ + droppedWallIds: string[] + /** Count of non-string (e.g. null) entries removed from `children` arrays. */ + strippedChildRefs: number +} + +function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } { + if (!node || typeof node !== 'object') return false + const n = node as Record + return ( + n.type === 'wall' && + Array.isArray(n.start) && + Array.isArray(n.end) && + typeof n.start[0] === 'number' && + typeof n.start[1] === 'number' && + typeof n.end[0] === 'number' && + typeof n.end[1] === 'number' + ) +} + +/** + * Returns a healed copy of a `nodes` map. Pure — does not mutate `input`. + * Nodes that need no repair are passed through by reference. + */ +export function healSceneNodes(input: Record): HealSceneResult { + const droppedWallIds: string[] = [] + + // Pass 1: drop childless zero-length walls. (Only childless ones — a wall + // carrying a door/window must keep its hosts, degenerate or not.) + const kept: Record = {} + for (const [id, node] of Object.entries(input)) { + if (isWallLike(node)) { + const children = (node as { children?: unknown }).children + const childless = !Array.isArray(children) || children.length === 0 + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) { + droppedWallIds.push(id) + continue + } + } + kept[id] = node + } + + const dropped = new Set(droppedWallIds) + let strippedChildRefs = 0 + + // Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug) + // and references to walls we just removed. + const nodes: Record = {} + for (const [id, node] of Object.entries(kept)) { + const children = (node as { children?: unknown })?.children + if (Array.isArray(children)) { + const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c)) + if (cleaned.length !== children.length) { + strippedChildRefs += children.length - cleaned.length + nodes[id] = { ...(node as Record), children: cleaned } + continue + } + } + nodes[id] = node + } + + return { nodes, droppedWallIds, strippedChildRefs } +} diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index 23873c3e..128bfc34 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { AnyNode, type AnyNodeType } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' export type ValidationSeverity = 'error' | 'warning' @@ -127,9 +128,22 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { } } - const nodes = nodesRaw as Record + // Heal known pre-existing corruption (null children, zero-length walls) up + // front, so a scene saved before the source fixes still imports instead of + // hard-failing schema validation. `parsed` below carries the repaired nodes. + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes( + nodesRaw as Record, + ) const rootNodeIds = rootNodeIdsRaw as string[] + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { + warnings.push({ + severity: 'warning', + code: 'auto_repaired', + message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`, + }) + } + if (rootNodeIds.length === 0) { errors.push({ severity: 'error', 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 c87211c0..3bdcb5ef 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 @@ -283,6 +283,27 @@ export function FloorplanRegistryMoveOverlay() { } const onKey = (event: KeyboardEvent) => { + // R flips a directional kind's facing mid-placement (door / window: + // front ↔ back). The session records the flip and re-runs its last + // apply so the 2D symbol updates immediately; kinds without a facing + // leave `flipSide` unset and R falls through to the global handler. + // + // Only when THIS overlay is the active mover — i.e. the user has moved + // the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool + // also listens for R while a door/window `movingNode` is placed (split + // / 3D-only view); gating on `hasMovedSinceStart` keeps the two from + // both flipping (or double-playing the cue) on a single R press. + if (event.key === 'r' || event.key === 'R') { + if (!(session.flipSide && hasMovedSinceStart)) return + const t = event.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { + return + } + event.preventDefault() + session.flipSide() + sfxEmitter.emit('sfx:item-rotate') + return + } if (event.key !== 'Escape') return // Claim teardown ownership so the 3D move tool's cleanup skips // its own restore — without this, both sides would race to 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 index 775b27fb..197f9363 100644 --- 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 @@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' */ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { const node = usePlacementPreview((s) => s.node) + const parentNode = usePlacementPreview((s) => s.parentNode) if (!node) return null const builder = nodeRegistry.get(node.type)?.floorplan @@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr // `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. + // `parentNode` is the synthetic wall for an off-wall door/window ghost so + // its builder draws the real swing-arc / pane symbol (see use-placement-preview). const ctx = { resolve: (id: AnyNodeId) => useScene.getState().nodes[id], children: [], siblings: [], - parent: null, + parent: parentNode ?? null, viewState: undefined, } as unknown as GeometryContext 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 9c83ecfa..817bc42b 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 @@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (live) { const floorPlaced = def?.capabilities?.floorPlaced const hasPosition = Array.isArray((node as { position?: unknown }).position) - if (floorPlaced && hasPosition) { + if (node.type === 'door' || node.type === 'window') { + // Door / window movers publish WALL-LOCAL live transforms + // ([along-wall x, sill y, 0], wall-local Y rotation) — see + // wiki/architecture/tools.md. The mover only writes + // `useScene.updateNode` on a wall CHANGE, so a same-wall slide + // updates the 3D mesh imperatively but never the scene node — + // without applying the live transform here the 2D symbol stays + // frozen while the cursor slides. Merge the wall-local position + + // rotation onto the node but KEEP `parentId` (the wall) so + // `buildDoorFloorplan` still resolves `ctx.parent` and draws the + // real swing-arc / pane symbol at the live spot. + const r = (node as { rotation?: unknown }).rotation + effectiveNode = { + ...node, + position: live.position, + rotation: Array.isArray(r) + ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0] + : r, + } as AnyNode + } else if (floorPlaced && hasPosition) { effectiveNode = applyPositionLiveTransform(node, live) } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { const dx = live.position[0] diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx new file mode 100644 index 00000000..c4d81830 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx @@ -0,0 +1,128 @@ +'use client' + +import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { memo, useMemo } from 'react' +import useEditor from '../../../store/use-editor' + +/** + * Dev-only 2D debug overlay for the opening (door / window) wall snap. + * + * The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is + * within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the + * nearest such wall. The set of points within that radius of a segment is a + * **capsule** (stadium): a band of half-width = the snap radius along the + * wall, with semicircular caps at each end. That capsule IS the wall's + * (normally invisible) hit target — so this layer draws it directly, one + * analytic `` per wall, instead of sampling a grid (which produced the + * stair-stepped boundary the previous version showed). No per-point + * classification, so it's cheap regardless of plan size. + * + * Where two walls sit closer than 2× the radius their capsules overlap; the + * snap resolves the overlap to the nearer wall (the translucent fills just + * blend there — a darker patch reads as "either wall is in reach, nearest + * wins"). Drawing the true bisector-clipped cells would need the expensive + * per-point pass this rewrite removes, and the hit-area view is what the + * user asked for. + * + * Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the + * floor-plan scene ``, so it shares the plan→SVG transform and the scene + * rotation with every other floor-plan layer. + */ + +/** Stable hue per wall id so a wall keeps its colour across re-renders. */ +function wallHue(id: string): number { + let hash = 0 + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) >>> 0 + } + // Spread hues around the wheel with an offset that avoids a muddy + // red-orange clump for short, similar ids. + return (hash * 47) % 360 +} + +export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() { + const show2dVoronoi = useEditor((s) => s.show2dVoronoi) + const selectedLevelId = useViewer((s) => s.selection.levelId) + // Recompute only when the wall geometry on this level changes — not on + // every scene write. Dragging a door updates the door node every frame; + // keying the build on this string means the capsule paths don't rebuild + // for openings, only for wall edits. + const nodes = useScene((s) => s.nodes) + const wallsKey = useMemo(() => { + if (!show2dVoronoi || !selectedLevelId) return '' + const segments = collectLevelWallSegments(nodes, selectedLevelId) + return segments + .map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`) + .join('|') + }, [show2dVoronoi, selectedLevelId, nodes]) + + const walls = useMemo(() => { + if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null + // Read nodes imperatively: `wallsKey` already encodes every wall change, + // so this memo is keyed on it rather than on the per-frame `nodes` ref. + const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId) + if (segments.length === 0) return null + + const R = WALL_SNAP_DISTANCE_M + const r = R.toFixed(3) + return segments.map((s) => { + // Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the + // segment endpoints ±R along the normal for the long sides, then a + // semicircular cap (radius R, sweep-flag 0 bulges outward past each end) + // joins them. Verified winding holds for every orientation because the + // whole construction is a rigid transform of the axis-aligned case. + const nx = -s.dirY + const ny = s.dirX + const ax = (s.start[0] + nx * R).toFixed(3) + const ay = (s.start[1] + ny * R).toFixed(3) + const bx = (s.end[0] + nx * R).toFixed(3) + const by = (s.end[1] + ny * R).toFixed(3) + const cx = (s.end[0] - nx * R).toFixed(3) + const cy = (s.end[1] - ny * R).toFixed(3) + const dx = (s.start[0] - nx * R).toFixed(3) + const dy = (s.start[1] - ny * R).toFixed(3) + const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z` + return { + wallId: s.wall.id, + d, + hue: wallHue(s.wall.id), + x1: s.start[0], + y1: s.start[1], + x2: s.end[0], + y2: s.end[1], + } + }) + }, [show2dVoronoi, selectedLevelId, wallsKey]) + + if (!walls) return null + + return ( + + {walls.map(({ wallId, d, hue }) => ( + + ))} + {walls.map(({ wallId, hue, x1, y1, x2, y2 }) => ( + + ))} + + ) +}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6a423670..1bb7229c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -10,6 +10,7 @@ import { calculateLevelMiters, DEFAULT_ANGLE_STEP, type DoorNode, + DoorNode as DoorNodeSchema, type ElevatorNode, emitter, type FenceNode, @@ -45,7 +46,9 @@ import { useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, type WindowNode, + WindowNode as WindowNodeSchema, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -85,6 +88,7 @@ import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor' +import usePlacementPreview from '../../store/use-placement-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' @@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee- 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 { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapToHalf } from '../tools/item/placement-math' @@ -5501,6 +5506,70 @@ export function FloorplanPanel({ return 0 }, [isWindowBuildActive, movingNode, shiftPressed]) + // Float the faithful door/window symbol at the cursor while it isn't over a + // wall (the off-wall placement ghost), by publishing a transient opening on a + // synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer` + // renders it through the real `def.floorplan` builder (swing arc / panes), so + // it reads as a real door/window, not a bare rectangle. Off any wall there's + // no orientation to inherit, so the synthetic wall runs along plan-X. + const showOpeningGhost = useCallback( + (planPoint: WallPlanPoint) => { + const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType) + // Synthetic wall centred at the cursor; the opening sits at its midpoint. + const half = + (isDoor + ? movingNode?.type === 'door' + ? movingNode.width + : 0.9 + : movingNode?.type === 'window' + ? movingNode.width + : 1.5) / + 2 + + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + // Clone the moving opening (carries width / type / hinge / swing) onto the + // synthetic wall, or parse a default for build mode. position[0] = the + // along-wall midpoint so the symbol centres on the cursor. + const base = + movingNode?.type === 'door' || movingNode?.type === 'window' + ? { ...movingNode } + : isDoor + ? DoorNodeSchema.parse({}) + : WindowNodeSchema.parse({}) + const ghost = { + ...base, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, floorplanOpeningLocalY, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } as AnyNode + usePlacementPreview.getState().set(ghost, wall) + }, + [ + DoorNodeSchema, + WallNodeSchema, + WindowNodeSchema, + floorplanOpeningLocalY, + isDoorBuildActive, + movingNode, + movingOpeningType, + ], + ) + // Drop the floating opening ghost whenever opening placement ends (commit, + // tool change, mode switch, cancel) or the active level changes, so a stale + // ghost never lingers on the wrong level. + useEffect(() => { + if (!isOpeningPlacementActive) usePlacementPreview.getState().clear() + }, [isOpeningPlacementActive]) + useEffect(() => { + usePlacementPreview.getState().clear() + }, [levelId]) const isMarqueeSelectionToolActive = mode === 'select' && floorplanSelectionTool === 'marquee' && @@ -8568,7 +8637,16 @@ export function FloorplanPanel({ // `wall:move` events the door / window placement tools listen for. // Same reason `handleBackgroundPlacementClick` runs its opening // branch before its grid catch-all. - if (isOpeningPlacementActive) { + // + // Only the pure BUILD case (a door/window tool armed with no + // `movingNode`) drives placement through these synthesized `wall:*` + // events. When a door/window `movingNode` is set — the community + // preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D + // placement end-to-end via `def.floorplanMoveTarget` (faithful symbol, + // plan-space snap, single-undo commit, R-flip). Running both at once + // made them fight (R-flip overwritten on the next move, click-commit + // dropped), so the move case is excluded here. + if (isOpeningBuildActive && !isOpeningMoveActive) { const closest = findClosestWallPoint(planPoint, walls, { canUseWall: (wall) => !isCurvedWall(wall), }) @@ -8595,9 +8673,23 @@ export function FloorplanPanel({ } else { emitter.emit('wall:move', wallEvent as any) } - } else if (hoveredWallIdRef.current) { - emitFloorplanWallLeave(hoveredWallIdRef.current) - hoveredWallIdRef.current = null + // Snapped to a wall — the real on-wall draft is the preview; drop + // the loose free-follow ghost. + usePlacementPreview.getState().clear() + } else { + if (hoveredWallIdRef.current) { + emitFloorplanWallLeave(hoveredWallIdRef.current) + hoveredWallIdRef.current = null + } + // Off any wall — float the FAITHFUL door/window symbol (swing arc / + // panes) following the cursor, not a bare rectangle. The glyph + // builder needs a wall for `ctx.parent`, so we publish the opening on + // a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to + // `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it + // through the real `def.floorplan` builder. Shift bypasses grid snap. + const snappedPoint = + shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + showOpeningGhost(snappedPoint) } return } @@ -8622,7 +8714,13 @@ export function FloorplanPanel({ // window are also registered kinds, but need wall events — see // comment there). Wall build skips this so its own branch below // updates local `draftEnd` state alongside the registry tool. - if (!isWallBuildActive && isFloorplanGridInteractionActive) { + // + // A door/window MOVE (community preset) is owned by + // `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true + // for it (build mode + a registered `door`/`window` tool), so without + // this exclusion the catch-all would emit `grid:move` and re-drive the + // 3D MoveDoorTool's free-follow, fighting the overlay again. + if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) { const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8969,8 +9067,15 @@ export function FloorplanPanel({ isCeilingBuildActive, isCeilingItemPlacementActive, isFenceBuildActive, - isFloorplanGridInteractionActive, - isOpeningPlacementActive, + // Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the + // grid catch-all true for it, but the overlay owns its commit (its own + // pointerup). Letting the catch-all emit `grid:click` here would consume + // the commit click and fight the overlay. + isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive, + // Only the pure-build opening case (tool armed, no movingNode) commits via + // the synthesized `wall:click`; the move case (community preset) is owned + // by FloorplanRegistryMoveOverlay, which commits on its own pointerup. + isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive, isPolygonBuildActive, isRoofBuildActive, isSlabBuildActive, @@ -10412,6 +10517,13 @@ export function FloorplanPanel({ showGrid={showGrid} /> + {/* Dev-only: draw each wall's opening-snap hit area (the + capsule of points within the snap radius of its centerline). + Gated on the developer-menu toggle. Painted right after the + grid so the translucent capsules sit under the wall / opening + glyphs. */} + + {}, } as any) } + // Drop the off-wall ghost on commit so it doesn't linger at the + // just-placed spot before the next pointer move re-evaluates. + usePlacementPreview.getState().clear() return true } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 6e4d4695..b5f170ab 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -30,6 +30,16 @@ export const useKeyboard = ({ return } + // True while a door/window is being placed: either a fresh clone is moving + // (preset / duplicate path) or a door/window build tool is armed. The + // placement tool owns R/T then (flip the draft before commit), so the + // global selection-based R/T handler must stand down to avoid double-firing. + const isPlacingOpening = () => { + const ed = useEditor.getState() + if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true + return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') + } + const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -171,11 +181,16 @@ export const useKeyboard = ({ } } } - } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { + } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // Doors use R to flip side (front ↔ back, rotation += π); their // open/close toggle lives on E. Windows still use R to toggle // their open/closed state. + // + // Skipped entirely while a door/window placement is active + // (`isPlacingOpening`): the placement tool owns R then (flip the draft + // before commit), and the user can have a node selected at the same + // time — without this guard both would fire (double flip + sfx). const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] @@ -225,7 +240,7 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } } - } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) { + } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node counter-clockwise const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d4097598..80109273 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -369,6 +369,11 @@ type EditorState = { // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean setAllowUndergroundCamera: (enabled: boolean) => void + // Development-only debug overlay: draw each wall's opening-snap hit area + // (the capsule of points within the snap radius of its centerline). Lets us + // see why a door/window snaps where it does. + show2dVoronoi: boolean + setShow2dVoronoi: (enabled: boolean) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null @@ -955,6 +960,8 @@ const useEditor = create()( set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }), allowUndergroundCamera: false, setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), + show2dVoronoi: false, + setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, setFirstPersonMode: (enabled) => { diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts index c012b7da..cce9d27c 100644 --- a/packages/editor/src/store/use-placement-preview.ts +++ b/packages/editor/src/store/use-placement-preview.ts @@ -18,14 +18,22 @@ 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 + /** Optional synthetic parent for the preview's `def.floorplan` context. + * Door / window glyph builders need `ctx.parent` to be a wall to draw their + * real symbol (swing arc / panes); off any real wall we hand them a + * synthetic wall segment centred at the cursor so the floating ghost shows + * the faithful blueprint symbol instead of a bare rectangle. `null` for + * self-contained kinds (column / elevator). */ + parentNode: AnyNode | null + set(node: AnyNode | null, parentNode?: AnyNode | null): void clear(): void } const usePlacementPreview = create((set) => ({ node: null, - set: (node) => set({ node }), - clear: () => set({ node: null }), + parentNode: null, + set: (node, parentNode = null) => set({ node, parentNode }), + clear: () => set({ node: null, parentNode: null }), })) export default usePlacementPreview diff --git a/packages/nodes/src/box-vent/preview.tsx b/packages/nodes/src/box-vent/preview.tsx index 7c93933a..55b4b763 100644 --- a/packages/nodes/src/box-vent/preview.tsx +++ b/packages/nodes/src/box-vent/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildBoxVentGeometry } from './geometry' import type { BoxVentNode } from './schema' @@ -15,7 +16,7 @@ import type { BoxVentNode } from './schema' * leaving raycast active would cause the preview itself to intercept * the cursor ray and starve the placement tool of `roof:move` events. */ -const BoxVentPreview = ({ node }: { node: BoxVentNode }) => { +const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => { const geometry = useMemo( () => buildBoxVentGeometry(node), [node.width, node.depth, node.height, node.hoodOverhang, node.style], @@ -24,17 +25,17 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.85, metalness: 0.05, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index ee424c8e..23c8af63 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -127,11 +127,11 @@ const BoxVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.6, 0.4, 0.6]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 8ac04216..d6aaab1d 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -9,6 +9,7 @@ import { polygonAnchors, resolveAlignment, sceneRegistry, + snapScalar, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -36,10 +37,10 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from * mesh's X/Z position on rebuild (`mesh.position.x = 0`, * `mesh.position.z = 0`) so the visual transitions smoothly. * - * 0.5m grid snap (matches legacy). + * Snaps to the editor's configured grid step (Shift bypasses). */ function snap(value: number) { - return Math.round(value * 2) / 2 + return snapScalar(value, useEditor.getState().gridSnapStep) } /** Figma-style alignment-snap threshold (meters), matching the other tools. */ diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index f20bb607..b2369b75 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -6,6 +6,7 @@ import { type GridEvent, type LevelNode, snapPointAlongAngleRay, + snapPointToGrid, useScene, } from '@pascal-app/core' import { @@ -93,9 +94,9 @@ export const CeilingTool: React.FC = () => { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridX = Math.round(rawPoint[0] * 2) / 2 - const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap + ? rawPoint + : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const ceilingY = event.localPosition[1] + CEILING_HEIGHT diff --git a/packages/nodes/src/chimney/preview.tsx b/packages/nodes/src/chimney/preview.tsx index 74e0b7e2..472ca506 100644 --- a/packages/nodes/src/chimney/preview.tsx +++ b/packages/nodes/src/chimney/preview.tsx @@ -1,8 +1,13 @@ 'use client' -import type { ChimneyNode, RoofSegmentNode } from '@pascal-app/core' +import { + type ChimneyNode, + type RoofSegmentNode, + RoofSegmentNode as RoofSegmentSchema, +} from '@pascal-app/core' import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildChimneyGeometry } from './geometry' const ghostMaterial = new THREE.MeshStandardMaterial({ @@ -15,21 +20,42 @@ const ghostMaterial = new THREE.MeshStandardMaterial({ depthWrite: false, }) +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.85, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + /** * The preview needs a segment fixture to build the body height. The - * placement tool passes the segment under the cursor; before any - * segment is hit, the preview isn't shown at all (the tool guards on - * `previewPos`). + * placement tool passes the segment under the cursor; when floating + * (off-roof fallback), segment is absent — build against RoofSegmentNode + * defaults so the ghost renders flat at the grid position with yaw 0. */ -const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => { +const ChimneyPreview = ({ + node, + segment, + invalid, +}: { + node: ChimneyNode + segment?: RoofSegmentNode + invalid?: boolean +}) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const effectiveSegment = segment ?? RoofSegmentSchema.parse({}) + const geo = useMemo( - () => buildChimneyGeometry(node, segment), + () => buildChimneyGeometry(node, effectiveSegment), [ - segment.wallHeight, - segment.pitch, - segment.roofType, - segment.width, - segment.depth, + effectiveSegment.wallHeight, + effectiveSegment.pitch, + effectiveSegment.roofType, + effectiveSegment.width, + effectiveSegment.depth, node.width, node.depth, node.heightAboveRidge, @@ -68,16 +94,10 @@ const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSeg return ( - { - /* preview should not intercept the cursor */ - }} - /> - {geo.cap && {}} />} - {geo.flues && {}} />} - {geo.cricket && {}} />} + {}} /> + {geo.cap && {}} />} + {geo.flues && {}} />} + {geo.cricket && {}} />} ) } diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index f69424ac..19a7ada0 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -144,12 +144,12 @@ const ChimneyTool = () => { <> } onInvalidTarget={() => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) }} - size={[1, 2.5, 1]} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( // Outer group mirrors the real renderer's `position={segment.position} diff --git a/packages/nodes/src/cupola/preview.tsx b/packages/nodes/src/cupola/preview.tsx index 356d83c0..5fd1fd7f 100644 --- a/packages/nodes/src/cupola/preview.tsx +++ b/packages/nodes/src/cupola/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildCupolaGeometry } from './geometry' import type { CupolaNode } from './schema' @@ -11,7 +12,7 @@ import type { CupolaNode } from './schema' * the ghost stays in lockstep with the committed cupola. Raycast disabled * so the preview doesn't intercept the cursor ray feeding the tool. */ -const CupolaPreview = ({ node }: { node: CupolaNode }) => { +const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => { const geometry = useMemo( () => buildCupolaGeometry(node), [node.width, node.depth, node.height, node.roofStyle, node.finial], @@ -20,17 +21,17 @@ const CupolaPreview = ({ node }: { node: CupolaNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.7, metalness: 0.1, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 8fb8e928..2e137394 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -119,11 +119,11 @@ const CupolaTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.8, 1.2, 0.8]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index d488a791..06cc1dc7 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -3,15 +3,14 @@ import { type DoorNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, } from '@pascal-app/core' -import { snapToHalf } from '@pascal-app/editor' +import { snapToHalf, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' -import { - getRoofHostedOpeningLevelId, - getRoofHostedOpeningPlanPoint, -} from '../shared/roof-opening-host' +import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -38,16 +37,10 @@ import { clampToWall, hasWallChildOverlap } from './door-math' export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { // Snapshot of the door's "valid" state at move-start — used by // canCommit to decide whether the current snapped position is OK. - const startLevelId = (() => { - // Wall-hosted: the wall's parent is the level. Roof-hosted: walk - // segment → roof → level. Cached at start because the parent chain - // doesn't change during a move. - const nodes = useScene.getState().nodes - const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) - if (roofLevelId) return roofLevelId - const wall = nodes[node.parentId as AnyNodeId] - return wall ? (wall.parentId as AnyNodeId | null) : null - })() + // The level that owns the wall-snap candidates — resolves the wall-hosted, + // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). + // Cached at start because the parent chain doesn't change during a move. + const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) const originalWall = node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined @@ -57,6 +50,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) ? projectWallLocalPointToPlan(originalWall, node.position[0]) : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, + // Absolute: query the wall snap with the TRUE cursor, not the door's + // original wall position plus a grab delta. A wall-hosted opening always + // belongs to the wall nearest the cursor (the user's rule), and the 3D + // move snaps on the wall literally under the ray — relative mode would + // anchor the search to the old wall and resist hopping to a closer one + // across a thin gap, picking the "far" wall the user reported. It also + // makes the 2D Voronoi overlay (classified by cursor) predict the snap. + mode: 'absolute', }) // Track the last successful placement so `commit()` can write it @@ -72,13 +73,83 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) roofFace: undefined } | null = null + // R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives + // the wall-facing side every move, so the flip is a persistent XOR applied on + // top of the wall hit, plus a π rotation offset (matching the committed R). + let flipped = false + // Remember the last apply args so the overlay's R keydown can re-run `apply` + // (which has no event of its own) to show the flip immediately. + let lastApply: { + planPoint: readonly [number, number] + modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } + } | null = null + // Whether the cursor is currently over a wall. Off-wall the door free-follows + // the cursor as a ghost (like the 3D move) and is NOT committable — a door + // needs a wall. Starts true so a click before any move keeps the door put. + let onWall = true + + // Off-wall: float the faithful door symbol at the cursor (via a synthetic + // wall fed to the placement-preview layer) and hide the real node, so the + // ghost follows the cursor in 2D instead of the door staying frozen on its + // old wall. Mirrors the fresh-placement free-follow. + const freeFollow = (planPoint: readonly [number, number]) => { + onWall = false + lastValid = null + if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: false }) + } + const half = node.width / 2 + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + const ghost = { + ...node, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, node.position[1], 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + visible: true, + } as DoorNode + usePlacementPreview.getState().set(ghost, wall) + } + const session: FloorplanMoveTargetSession = { affectedIds: [node.id as AnyNodeId], + flipSide() { + flipped = !flipped + if (lastApply) this.apply(lastApply) + }, apply({ planPoint, modifiers }) { + lastApply = { planPoint, modifiers } + // Drop any stale live transform left by the 3D `MoveDoorTool` (it + // publishes one on every wall hover). The 2D registry layer renders + // door/window from `useLiveTransforms` IN PREFERENCE to the scene node, + // but this 2D move writes the scene node — so a leftover 3D entry would + // freeze the symbol on its wall and the slide wouldn't show. Only the + // 2D path runs during an opening move (the panel gates the 3D tool's + // events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded + // on existence: `clear` always allocates a new Map + re-renders. + if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) { + useLiveTransforms.getState().clear(node.id as AnyNodeId) + } const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) - if (!hit) return // pointer off any wall — keep door at last valid position + if (!hit) { + // Off any wall — free-follow the cursor (not committable). + freeFollow(resolvedPlanPoint) + return + } + // Back on a wall — drop the free-follow ghost + reveal the real node. + onWall = true + usePlacementPreview.getState().clear() + if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: true }) + } // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends); it competes with — and wins over — the 0.5m @@ -97,10 +168,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + // Apply the R-flip on top of the wall-derived side. + const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side + const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) + lastValid = { position: [clampedX, clampedY, 0], - rotation: [0, hit.itemRotation, 0], - side: hit.side, + rotation: [0, itemRotation, 0], + side, parentId: hit.wall.id, wallId: hit.wall.id, // Re-anchoring to a wall ends any roof-segment hosting; the @@ -122,6 +197,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) ]) }, canCommit() { + // Off-wall the door is free-following in mid-air — not placeable. The + // overlay then reverts to the pre-move snapshot (door returns to its + // original wall), matching the 3D move where an open-floor click commits + // nothing. + if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined if (!live || live.type !== 'door') return false // Block commit if the door overlaps any other wall child at its diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index ec6e37de..9aa33288 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -3,6 +3,7 @@ import { collectAlignmentAnchors, DoorNode, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -86,6 +87,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => let currentHostId: string | null = movingDoorNode.parentId let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null let committed = false + // Off-wall free-follow: when the cursor is over empty floor (no wall under + // the ray) the door is parented to the level and tracks the cursor like an + // item node. `freeFollowing` distinguishes that state so the placement + // commit no-ops in open space (a door needs a wall). `lastMeshEventTime` + // defers the floor handler whenever a wall/roof mesh event owns the same + // pointermove (shared DOM timeStamp) — that's the only thing that snaps. + let freeFollowing = false + let lastMeshEventTime = -1 + // The door's chosen facing side. R flips it mid-placement (front ↔ back, + // same as the committed-selected R flip) so the user can reorient before + // committing. Initialised from the moving node's side. + let sideOverride: DoorNode['side'] = movingDoorNode.side let lastTarget: { wallNode: WallEvent['node'] wallId: string @@ -154,7 +167,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const getPlacementOrientation = (event: WallEvent) => { const faceSide = getSideFromNormal(event.normal) - const side = movingDoorNode.side ?? faceSide + const side = sideOverride ?? faceSide const rotationOffset = side !== faceSide ? Math.PI : 0 return { side, @@ -274,11 +287,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onWallEnter = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveMoveTarget(event) if (!target) { onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) @@ -286,6 +301,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onWallMove = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -304,20 +320,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) event.stopPropagation() } - const onWallClick = (event: WallEvent) => { + // Promote the moving door into its committed wall placement. Shared by the + // direct wall-mesh click and the floor proximity click. + const commitToWall = (target: NonNullable) => { if (committed) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - if (event.node.parentId !== getLevelId()) return - - const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return committed = true let placedId: string @@ -378,30 +391,84 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() + } + + const onWallClick = (event: WallEvent) => { + if (committed) return + if (!isValidWallSideFace(event.normal)) return + if (isCurvedWall(event.node)) return + if (event.node.parentId !== getLevelId()) return + + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return + commitToWall(target) event.stopPropagation() } const onWallLeave = () => { + // The cursor left the wall mesh. Don't snap back to the origin/original + // here — the floor proximity handler (onGridMove) takes over on the same + // pointermove: it either snaps to a nearby wall or free-follows the + // cursor. The wireframe outline + live transform are cleared so the + // free-follow path can re-establish them. Reverting the node is left to + // onGridMove's free-follow / cancel / commit, so the door never blinks + // back to the building origin between a wall and open floor. hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) + } + + // Free-follow: the door rides the cursor over empty floor, parented to the + // level like an item node (lifted so it stands on the floor). No wall to + // attach to, so it is not committable here. + const freeFollowAt = (localX: number, localZ: number) => { + freeFollowing = true + lastTarget = null + lastRoofEvent = null + hideCursor() + useLiveTransforms.getState().clear(movingDoorNode.id) + const levelId = getLevelId() + const y = movingDoorNode.height / 2 + // Keep the R-flip visible while free-following: face the chosen side + // (back = rotated π) instead of forcing 0, so an R press isn't undone on + // the next mousemove. + const yaw = sideOverride === 'back' ? Math.PI : 0 + if (currentHostId !== levelId) { + if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) + useScene.getState().updateNode(movingDoorNode.id, { + position: [localX, y, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + }) + currentHostId = levelId + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: [localX, y, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + }) } - currentHostId = original.parentId - useScene.getState().updateNode(movingDoorNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) + } + + const onGridMove = (event: GridEvent) => { + if (committed) return + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler owns this exact pointermove (shared DOM + // timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise + // the cursor is over open floor — free-follow it. + if (event.nativeEvent?.timeStamp === lastMeshEventTime) return + + // No proximity magnet: in 3D the wall side faces are big raycast targets, + // so snapping engages only when the cursor ray actually hovers a wall + // (`onWallMove`). Over open floor the door just follows the cursor. + const [x, , z] = event.localPosition + freeFollowAt(x, z) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -424,12 +491,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onRoofHover = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofMoveTarget(event) if (!target) { onRoofLeave() return } // Wall-frame drag anchor / live transform don't apply on a roof face. + freeFollowing = false dragAnchor = null lastTarget = null lastRoofEvent = event @@ -529,26 +598,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onRoofLeave = () => { + // Mirror onWallLeave: don't revert to origin here — onGridMove takes + // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) - } - currentHostId = original.parentId - useScene.getState().updateNode(movingDoorNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { @@ -576,13 +632,53 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return - if (lastTarget) { - onWallClick(lastTarget.event) + // Free-following over open floor can't commit (no wall). A wall hover + // target commits via commitToWall; a roof face via onRoofClick. + if (lastTarget?.valid && !freeFollowing) { + commitToWall(lastTarget) return } if (lastRoofEvent) onRoofClick(lastRoofEvent) } + // R flips the door's facing side mid-placement (front ↔ back), like the + // committed-selected R flip — usable before commit, whether snapped to a + // wall or free-following. Re-applies the preview so the flip shows live. + // No-op on a roof-segment face (those host front-only; nothing to flip). + const onKeyDown = (e: KeyboardEvent) => { + if (committed) return + if (e.key !== 'r' && e.key !== 'R') return + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return + } + // Only act where a flip is meaningful — on a wall hover or while + // free-following. On a roof face leave it to the default R (no flip, no + // sfx) so the cue never fires without a visible effect. + const onWall = lastTarget !== null + if (!(onWall || freeFollowing)) return + e.preventDefault() + sideOverride = sideOverride === 'front' ? 'back' : 'front' + triggerSFX('sfx:item-rotate') + if (onWall) { + // On a wall: re-resolve with the flipped side and re-preview. + const next = resolveMoveTarget(lastTarget!.event) + if (next) { + lastTarget = next + applyPreview(next) + } + } else { + // Free-following on the level: flip the draft's facing in place. + useScene.getState().updateNode(movingDoorNode.id, { + side: sideOverride, + rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], + }) + } + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -591,8 +687,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) + window.addEventListener('keydown', onKeyDown) return () => { const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as @@ -629,8 +727,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + window.removeEventListener('keydown', onKeyDown) } }, [movingDoorNode, exitMoveMode]) diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx new file mode 100644 index 00000000..0bc0892d --- /dev/null +++ b/packages/nodes/src/door/preview.tsx @@ -0,0 +1,45 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { buildDoorPreviewMesh } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import { applyGhost } from '../shared/ghost-materials' +import type { DoorNode } from './schema' + +/** + * Translucent preview of a door — used by the placement tool's floating ghost. + * + * Builds the door mesh via buildDoorPreviewMesh (so the preview shape stays in + * lockstep with committed doors), then applies ghost treatment (translucent, + * raycast-off, tinted red if invalid). + * + * The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox + * material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha). + */ +const DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => { + const mesh = useMemo(() => { + const m = buildDoorPreviewMesh(node) + m.layers.set(EDITOR_LAYER) + return m + }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + + // Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` + // flips; its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) + + // Geometry is freshly built per `mesh` and owned here — dispose it only + // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. + useEffect( + () => () => { + mesh.traverse((obj) => { + const m = obj as { geometry?: { dispose: () => void } } + m.geometry?.dispose() + }) + }, + [mesh], + ) + + return +} + +export default DoorPreview diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index d93971cd..72ffc49a 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -11,6 +11,7 @@ import { spatialGridManager, useScene, type WallEvent, + type WallNode, } from '@pascal-app/core' import { calculateCursorRotation, @@ -22,7 +23,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -37,6 +38,7 @@ import { } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' +import DoorPreview from './preview' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -49,31 +51,61 @@ const FALLBACK_WIDTH = 0.9 const FALLBACK_HEIGHT = 2.1 const roofFallbackPoint = new Vector3() +// What currently owns the cursor frame: a wall/roof mesh hover, or null when +// the cursor is over open floor (the grid handler then free-follows). +type HostKind = 'wall' | 'roof' | null + /** * Door tool — places DoorNodes on walls and on roof-segment wall faces * (the generated base walls under a roof, including coplanar gable ends). * Doors always sit at floor level (clampedY = height/2 — segment base for * roof-hosted doors). + * + * The ghost follows the cursor everywhere (like moving an item): over open + * floor it floats as an invalid (unplaceable) ghost; the moment the cursor + * ray hovers a wall (or roof-segment face) the real draft snaps onto it. + * Snapping engages only on an actual mesh hover — no proximity magnet — since + * the wall side faces are big raycast targets. */ const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) + // Off-host floating ghost: the real door geometry follows the cursor over + // the grid (tinted invalid). Mutually exclusive with the on-host draft — + // when a draft + wireframe is shown this is null and vice-versa. + const [fallbackPose, setFallbackPose] = useState<{ + position: [number, number, number] + rotationY: number + } | null>(null) + + const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), []) + useEffect(() => { useScene.temporal.getState().pause() + let hostKind: HostKind = null + // timeStamp of the most recent wall/roof mesh event. A wall/roof hover and + // the grid raycast from the SAME pointermove share the source DOM event's + // timeStamp, so the grid handler can detect "a mesh handler already owns + // this frame" without depending on event order or on a leave firing (node + // events are suppressed during a camera drag, so a sticky boolean would + // strand the draft after an orbit; a per-frame timestamp self-heals). + let lastMeshEventTime = -1 + // R flips the door's facing side mid-placement (front ↔ back). On a wall + // the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`; + // re-applied to the last wall hover so the flip shows live before commit. + let sideFlip = false + let lastWallEvent: WallEvent | null = null + const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } - const getSlabElevation = (wallEvent: WallEvent) => - spatialGridManager.getSlabElevationForWall( - wallEvent.node.parentId ?? '', - wallEvent.node.start, - wallEvent.node.end, - ) + const getSlabElevationForWall = (wall: WallNode) => + spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -91,17 +123,22 @@ const DoorTool: React.FC = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() clearOpeningGuides3D() + setFallbackPose(null) } // 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, '') + // On-host cursor: the green/red wireframe outline tracks a live draft. + // Showing it always clears the off-host floating ghost (they never + // coexist — a draft means the cursor is on a valid host). const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, ) => { + setFallbackPose(null) const group = cursorGroupRef.current if (!group) return group.visible = true @@ -110,218 +147,123 @@ const DoorTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const showFallbackCursor = (event: GridEvent) => { - if (draftRef.current) return - const [x, y, z] = event.localPosition - updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false) + // Off-host fallback: hide the wireframe outline and float the real door + // geometry (tinted invalid) at the cursor so the armed tool is visible. + const showGhostAt = (position: [number, number, number]) => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + setFallbackPose({ position, rotationY: 0 }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) - useAlignmentGuides.getState().clear() - clearOpeningGuides3D() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z]) } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) - useAlignmentGuides.getState().clear() - clearOpeningGuides3D() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z]) } - const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - const levelId = getLevelId() - if (!levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (event.node.parentId !== levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - destroyDraft() - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const width = 0.9 - const height = 2.1 + // Settle a wall target: alignment snap → clamp → overlap check. Pure read. + const resolveWallPlacement = ( + wall: WallNode, + rawLocalX: number, + width: number, + height: number, + bypass: boolean, + bypassSnap: boolean, + ignoreId?: string, + ) => { const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], + wallNode: wall, + rawLocalX, width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + bypass, + bypassSnap, }) - - const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) - - 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 - - const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) - - publishOpeningGuidesForWallEvent({ - wall: event.node, - movingId: node.id, - centerS: clampedX, - centerY: clampedY, - width, - height, - includeVertical: false, - levelYOffset: getLevelYOffset(), - slabElevation: getSlabElevation(event), - }) - event.stopPropagation() + const { clampedX, clampedY } = clampToWall(wall, localX, width, height) + const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + return { clampedX, clampedY, valid } } - const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (event.node.parentId !== getLevelId()) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - + // Shared create/update path for the wall draft — used by the direct + // wall-mesh hover and the floor proximity snap. Reuses the existing draft + // (reparenting only on an actual wall change to avoid churning the host's + // children array, which flashes 0-vertex wall geometry in WebGPU). + const applyWallTarget = (args: { + wall: WallNode + rawLocalX: number + side: 'front' | 'back' + itemRotation: number + cursorRotationY: number + bypass: boolean + bypassSnap: boolean + }) => { + const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args 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 || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === 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 - } + const node = DoorNode.parse({ + position: [0, height / 2, 0], + rotation: [0, itemRotation, 0], + side, + wallId: wall.id, + parentId: wall.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, wall.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 - // forward `parentId` / `wallId` when the wall actually changed - // — otherwise the reparent path churns the host wall's - // `children` array every tick, which re-renders the wall and - // briefly draws its 0-vertex placeholder geometry (WebGPU then - // flags "Vertex buffer slot 0 ... was not set"). - const isSameWall = event.node.id === draftRef.current.parentId - if (isSameWall) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - }) - markHostDirty(event.node.id) - } else { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - // The draft may arrive from a roof-segment face hover. - roofSegmentId: undefined, - roofFace: undefined, - }) - } - } - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, + const { clampedX, clampedY, valid } = resolveWallPlacement( + wall, + rawLocalX, width, height, - draftRef.current?.id, + bypass, + bypassSnap, + draftRef.current.id, ) + if (wall.id === draftRef.current.parentId) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + }) + markHostDirty(wall.id) + } else { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: wall.id, + wallId: wall.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, + roofFace: undefined, + }) + } + updateCursor( wallLocalToWorld( - event.node, + wall, clampedX, clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevationForWall(wall), ), - cursorRotation, + cursorRotationY, valid, ) if (draftRef.current) { publishOpeningGuidesForWallEvent({ - wall: event.node, + wall, movingId: draftRef.current.id, centerS: clampedX, centerY: clampedY, @@ -329,47 +271,25 @@ const DoorTool: React.FC = () => { height, includeVertical: false, levelYOffset: getLevelYOffset(), - slabElevation: getSlabElevation(event), + slabElevation: getSlabElevationForWall(wall), }) } - event.stopPropagation() + return { clampedX, clampedY, valid } } - const onWallClick = (event: WallEvent) => { - if (!draftRef.current) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - if (event.node.parentId !== getLevelId()) return - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: draftRef.current.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - draftRef.current.width, - draftRef.current.height, - ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - draftRef.current.width, - draftRef.current.height, - draftRef.current.id, - ) - if (!valid) return - + // Promote the draft into a permanent door. Shared by the wall-mesh click + // and the floor proximity click. + const commitDoorAtWall = ( + wall: WallNode, + clampedX: number, + clampedY: number, + side: 'front' | 'back', + itemRotation: number, + ) => { const draft = draftRef.current + if (!draft) return draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -378,18 +298,17 @@ const DoorTool: React.FC = () => { const state = useScene.getState() const doorCount = Object.values(state.nodes).filter((n) => { if (n.type !== 'door') return false - const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined - return wall?.parentId === levelId + const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined + return w?.parentId === levelId }).length - const name = `Door ${doorCount + 1}` const node = DoorNode.parse({ - name, + name: `Door ${doorCount + 1}`, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, - wallId: event.node.id, - parentId: event.node.id, + wallId: wall.id, + parentId: wall.id, width: draft.width, height: draft.height, doorCategory: draft.doorCategory, @@ -414,20 +333,108 @@ const DoorTool: React.FC = () => { panicBarHeight: draft.panicBarHeight, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() + } + // ── Direct wall-mesh hover ────────────────────────────────────── + const onWallHover = (event: WallEvent) => { + hostKind = 'wall' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + destroyDraft() + showWallFallbackCursor(event) + return + } + lastWallEvent = event + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const flipOffset = sideFlip ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + flipOffset + const cursorRotation = + calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + applyWallTarget({ + wall: event.node, + rawLocalX: event.localPosition[0], + side, + itemRotation, + cursorRotationY: cursorRotation, + bypass, + bypassSnap, + }) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + return + } + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0) + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + const { clampedX, clampedY, valid } = resolveWallPlacement( + event.node, + event.localPosition[0], + draftRef.current.width, + draftRef.current.height, + bypass, + bypassSnap, + draftRef.current.id, + ) + if (!valid) return + + commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() } const onWallLeave = () => { + if (hostKind !== 'wall') return + lastWallEvent = null destroyDraft() hideCursor() + hostKind = null + } + + // ── Floor free-follow ─────────────────────────────────────────── + // Over open floor the ghost follows the cursor like a moving item. It does + // NOT snap from proximity — snapping engages only when the cursor ray + // actually hovers a wall (onWallHover) or roof face (onRoofHover). + const onGridFreeFollow = (event: GridEvent) => { + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler processed this exact pointermove (R3F + the + // grid raycast share the source DOM event's timeStamp) — it owns the + // frame and has snapped the draft, so skip the floor follow this tick. + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastMeshEventTime) return + // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft + // and free-follow the cursor with the invalid (unplaceable) ghost. + hostKind = null + lastWallEvent = null + const [x, y, z] = event.localPosition + destroyDraft() + showGhostAt([x, y + FALLBACK_HEIGHT / 2, z]) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -449,6 +456,8 @@ const DoorTool: React.FC = () => { } const onRoofHover = (event: RoofEvent) => { + hostKind = 'roof' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofTarget(event) if (!target) { // On the roof but not over a placeable wall face (slope, soffit, @@ -493,6 +502,7 @@ const DoorTool: React.FC = () => { const draft = draftRef.current draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -545,26 +555,44 @@ const DoorTool: React.FC = () => { } const onRoofLeave = () => { - if (!draftRef.current?.roofSegmentId) return + if (hostKind !== 'roof') return destroyDraft() hideCursor() + hostKind = null } const onCancel = () => { destroyDraft() hideCursor() + hostKind = null } - emitter.on('wall:enter', onWallEnter) - emitter.on('wall:move', onWallMove) + // R flips the door's facing side mid-placement (front ↔ back), like the + // committed-selected R flip. Only meaningful while snapped to a wall (the + // off-wall ghost has no orientation), so it acts only then — re-applying + // the last wall hover so the snapped preview flips live. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'r' && e.key !== 'R') return + if (!lastWallEvent) return + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return + e.preventDefault() + sideFlip = !sideFlip + triggerSFX('sfx:item-rotate') + onWallHover(lastWallEvent) + } + + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) - emitter.on('grid:move', showFallbackCursor) + emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { destroyDraft() @@ -572,16 +600,17 @@ const DoorTool: React.FC = () => { useAlignmentGuides.getState().clear() clearOpeningGuides3D() useScene.temporal.getState().resume() - emitter.off('wall:enter', onWallEnter) - emitter.off('wall:move', onWallMove) + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) - emitter.off('grid:move', showFallbackCursor) + emitter.off('grid:move', onGridFreeFollow) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) } }, []) @@ -597,14 +626,21 @@ const DoorTool: React.FC = () => { useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {fallbackPose && ( + + + + )} + ) } diff --git a/packages/nodes/src/dormer/preview.tsx b/packages/nodes/src/dormer/preview.tsx index e5b419e4..2aa2554c 100644 --- a/packages/nodes/src/dormer/preview.tsx +++ b/packages/nodes/src/dormer/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildDormerGhostGeometry } from './geometry' import type { DormerNode } from './schema' @@ -12,7 +13,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({ depthWrite: false, }) -const DormerPreview = ({ node }: { node: DormerNode }) => { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const geo = useMemo( () => buildDormerGhostGeometry(node), [node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight], @@ -20,7 +33,7 @@ const DormerPreview = ({ node }: { node: DormerNode }) => { useEffect(() => () => geo.dispose(), [geo]) - return {}} /> + return {}} /> } export default DormerPreview diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index fdbdd98a..d677c6a5 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -73,8 +73,8 @@ const DormerTool = () => { <> } onInvalidTarget={clearPreview} - size={[1.8, 1.8, 1.4]} /> {activeBuildingId && segmentXform && hitLocal && ( diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx index 596ebecb..4282f9e9 100644 --- a/packages/nodes/src/downspout/preview.tsx +++ b/packages/nodes/src/downspout/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildDownspoutGeometry } from './geometry' import type { DownspoutRouting } from './routing' import type { DownspoutNode } from './schema' @@ -19,9 +20,11 @@ import type { DownspoutNode } from './schema' const DownspoutPreview = ({ node, routing, + invalid, }: { node: DownspoutNode routing?: DownspoutRouting | null + invalid?: boolean }) => { const geometry = useMemo( () => buildDownspoutGeometry(node, routing), @@ -40,17 +43,17 @@ const DownspoutPreview = ({ const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.7, metalness: 0.2, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.FrontSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx index 7356f1d0..e9ede870 100644 --- a/packages/nodes/src/downspout/tool.tsx +++ b/packages/nodes/src/downspout/tool.tsx @@ -167,8 +167,8 @@ const DownspoutTool = () => { <> } onInvalidTarget={() => setTarget(null)} - size={[0.2, 2.5, 0.2]} validTarget="gutter" /> {activeBuildingId && target && ( diff --git a/packages/nodes/src/eyebrow-vent/preview.tsx b/packages/nodes/src/eyebrow-vent/preview.tsx index 391c448a..3c6589cf 100644 --- a/packages/nodes/src/eyebrow-vent/preview.tsx +++ b/packages/nodes/src/eyebrow-vent/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildEyebrowVentGeometry } from './geometry' import type { EyebrowVentNode } from './schema' @@ -11,7 +12,7 @@ import type { EyebrowVentNode } from './schema' * so the ghost stays in lockstep with the committed vent. Raycast disabled so * the preview doesn't intercept the cursor ray feeding the tool. */ -const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => { +const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => { const geometry = useMemo( () => buildEyebrowVentGeometry(node), [node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio], @@ -20,17 +21,17 @@ const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.7, metalness: 0.1, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 5d5b0d00..93ccb5b9 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -122,11 +122,11 @@ const EyebrowVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.2, 0.4, 0.5]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index 60496d50..40ddfde0 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildGutterGeometry } from './geometry' import type { GutterNode } from './schema' @@ -20,7 +21,7 @@ import type { GutterNode } from './schema' * of the trough walls and visually thicken the ghost relative to the * placed gutter. */ -const GutterPreview = ({ node }: { node: GutterNode }) => { +const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => { const geometry = useMemo( () => buildGutterGeometry(node), [ @@ -39,17 +40,17 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.7, metalness: 0.2, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.FrontSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index 6fd43d2d..948d4feb 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -147,8 +147,8 @@ const GutterTool = () => { <> } onInvalidTarget={() => setTarget(null)} - size={[2, 0.2, 0.25]} /> {activeBuildingId && target && ( diff --git a/packages/nodes/src/ridge-vent/preview.tsx b/packages/nodes/src/ridge-vent/preview.tsx index 931ac348..3935dcfd 100644 --- a/packages/nodes/src/ridge-vent/preview.tsx +++ b/packages/nodes/src/ridge-vent/preview.tsx @@ -2,10 +2,11 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildRidgeVentGeometry } from './geometry' import type { RidgeVentNode } from './schema' -const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => { +const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: boolean }) => { const geometry = useMemo( () => buildRidgeVentGeometry(node), [node.length, node.width, node.height, node.style, node.endCaps], @@ -14,17 +15,17 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.85, metalness: 0.05, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index f0e82fed..f7369355 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -140,6 +140,7 @@ const RidgeVentTool = () => { <> } isValidRoofTarget={(event) => { const hit = resolveRoofSegmentHit( event.node as RoofNode, @@ -150,7 +151,6 @@ const RidgeVentTool = () => { return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) }} onInvalidTarget={() => setPreviewPos(null)} - size={[2, 0.15, 0.35]} /> {activeBuildingId && previewPos && ( diff --git a/packages/nodes/src/shared/ghost-materials.ts b/packages/nodes/src/shared/ghost-materials.ts new file mode 100644 index 00000000..221027b7 --- /dev/null +++ b/packages/nodes/src/shared/ghost-materials.ts @@ -0,0 +1,75 @@ +'use client' + +import type { Material, Mesh, Object3D, Raycaster } from 'three' + +export const INVALID_GHOST_COLOR = 0xef_44_44 + +const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} + +/** + * Apply ghost material treatment to a preview mesh tree. + * + * Traverses the object tree, disables raycasting on all descendants (prevents + * cursor-ray starvation), and clones visible mesh materials to set translucency. + * + * When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4. + * Otherwise sets opacity ~0.5 while preserving the original color. + * + * Skips: meshes whose material.visible === false (door/window root hitbox) and + * children named 'cutout'. + * + * Returns cleanup that disposes only the cloned materials (never originals or geometry). + * + * @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh) + * @param opts - { invalid?: boolean } whether to tint red for invalid placement + * @returns Cleanup function that disposes the cloned materials + */ +export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void { + const invalid = opts?.invalid ?? false + const cloned: Material[] = [] + + root.traverse((obj) => { + // Disable raycast on every descendant to prevent cursor-ray starvation. + obj.raycast = NO_RAYCAST + + const mesh = obj as Mesh + if (!mesh.isMesh) return + if (mesh.name === 'cutout') return + + const original = mesh.material + const wasArray = Array.isArray(original) + + const cloneOne = (mat: Material): Material | null => { + // Skip invisible materials (door/window root hitbox). + if ((mat as { visible?: boolean }).visible === false) return null + const clone = mat.clone() + clone.transparent = true + clone.depthWrite = false + if (invalid) { + ;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR) + ;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex( + INVALID_GHOST_COLOR, + ) + clone.opacity = 0.4 + } else { + clone.opacity = 0.5 + } + cloned.push(clone) + return clone + } + + if (wasArray) { + const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null) + if (clonedMats.length > 0) mesh.material = clonedMats + } else { + const clone = cloneOne(original) + if (clone) mesh.material = clone + } + }) + + return () => { + for (const mat of cloned) { + mat.dispose() + } + } +} diff --git a/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx index 1c7d9576..9064236a 100644 --- a/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx +++ b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, } from '@pascal-app/core' import { DragBoundingBox } from '@pascal-app/editor' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { Vector3 } from 'three' const INVALID_PREVIEW_COLOR = 0xef_44_44 @@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter' export function RoofAttachmentFallbackPreview({ activeBuildingId, + ghost, isValidRoofTarget, lift = 0, onInvalidTarget, @@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({ validTarget = 'roof', }: { activeBuildingId: string | null | undefined + ghost?: ReactNode isValidRoofTarget?: (event: RoofEvent) => boolean lift?: number onInvalidTarget?: () => void - size: [number, number, number] + size?: [number, number, number] validTarget?: ValidTarget }) { const [position, setPosition] = useState<[number, number, number] | null>(null) @@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({ if (!(activeBuildingId && position)) return null + // When ghost is provided, render the ghost instead of DragBoundingBox + if (ghost) { + return {ghost} + } + + // Fallback to DragBoundingBox for callers not yet migrated + if (!size) return null return ( , +): AnyNodeId | null { + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const parent = node.parentId ? nodes[node.parentId] : undefined + if (!parent) return null + if (parent.type === 'level') return parent.id as AnyNodeId + return (parent.parentId as AnyNodeId | null) ?? null +} + /** * Level-plan [x, z] of a roof-hosted node — its face-local center mapped * through the face frame, then composed through the segment's and roof's diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index f2bc6be2..d54c5a89 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -1,9 +1,11 @@ import { type AnyNode, type AnyNodeId, + collectLevelWallSegments, getScaledDimensions, type ItemNode, - isCurvedWall, + nearestWallSegment, + WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -22,8 +24,6 @@ import { * rejects curved walls (mitering + arc + opening would tear in 3D). */ -const WALL_SNAP_DISTANCE_M = 1.5 - export type WallHit = { wall: WallNode /** Distance along the wall from `start` (clamped to [0, length]). */ @@ -61,10 +61,14 @@ export function projectWallLocalPointToPlan( } /** - * Walk every wall under `parentLevelId` and return the closest one to - * `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`. - * `excludeWallId` skips a specific wall (e.g. the current parent during - * a re-parent flow if you want a "must change" guard). + * Return the single closest wall under `parentLevelId` to `planPoint` — the + * wall whose segment-Voronoi cell the point lies in — or `null` if nothing is + * within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall. + * + * The nearest-segment scan + curved-wall filter live in core + * (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D + * Voronoi debug overlay classifies points with the exact same math — the + * overlay is then a faithful picture of where this snaps. */ export function findClosestWallInPlan( planPoint: readonly [number, number], @@ -72,78 +76,36 @@ export function findClosestWallInPlan( parentLevelId: AnyNodeId | null, excludeWallId?: AnyNodeId, ): WallHit | null { - if (!parentLevelId) return null - const level = nodes[parentLevelId] - const childIds = (level as unknown as { children?: AnyNodeId[] })?.children - if (!Array.isArray(childIds)) return null + const segments = collectLevelWallSegments(nodes, parentLevelId) + const closest = nearestWallSegment( + segments, + planPoint[0], + planPoint[1], + WALL_SNAP_DISTANCE_M, + excludeWallId, + ) + if (!closest) return null - let best: WallHit | null = null + const { segment, along, perp } = closest + // Side determination, calibrated to the 3D wall convention. In wall-local + // space the wall extends along +X and its +Z axis is the front-face normal; + // `perp >= 0` is consistently the front side (see `closestOnSegment`). + const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back' + // Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back. + // The node is parented to the wall, so this composes with the wall's own + // rotation at render — never a world-space rotation here. + const itemRotation = side === 'front' ? 0 : Math.PI - for (const childId of childIds) { - const node = nodes[childId] - if (!node || node.type !== 'wall') continue - if (childId === excludeWallId) continue - const wall = node as WallNode - if (isCurvedWall(wall)) continue - - const sx = wall.start[0] - const sy = wall.start[1] - const dx = wall.end[0] - sx - const dy = wall.end[1] - sy - const wallLength = Math.hypot(dx, dy) - if (wallLength < 1e-6) continue - - const dirX = dx / wallLength - const dirY = dy / wallLength - - // Project pointer onto wall axis. - const px = planPoint[0] - sx - const py = planPoint[1] - sy - const along = px * dirX + py * dirY - const perpRaw = px * -dirY + py * dirX // signed perpendicular distance - const clampedAlong = Math.max(0, Math.min(wallLength, along)) - - // Distance from the pointer to the wall segment (not just the line). - const closestPointX = sx + dirX * clampedAlong - const closestPointY = sy + dirY * clampedAlong - const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY) - if (distance > WALL_SNAP_DISTANCE_M) continue - if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue - - // Side determination, calibrated to the 3D wall convention. In - // wall-local space the wall extends along +X and its +Z axis is the - // front-face normal. After `mesh.rotation.y = -wallAngle`: - // - For a wall going `+X` in plan (wallAngle=0): wall-local +Z - // maps to world +Z = plan +Y, so the front face is on plan +Y. - // `perpRaw = py` is positive → front. - // - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z - // maps to world -X = plan -X, so the front face is on plan -X. - // `perpRaw = -px` is positive there → front. - // So `perpRaw >= 0` is consistently the front side. The earlier - // labelling had this flipped, which produced rotations that were - // off by 90° on non-horizontal walls. - const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back' - - // Rotation in wall-local space — matches 3D `calculateItemRotation`: - // 0 when the item faces the front normal (+Z), π for the back. The - // node is parented to the wall, so this composes with the wall's - // own rotation when rendered. Don't return a world-space rotation - // here — the consumer writes this straight into `node.rotation[1]`. - const itemRotation = side === 'front' ? 0 : Math.PI - - best = { - wall, - localX: clampedAlong, - perpDistance: perpRaw, - side, - dirX, - dirY, - wallLength, - itemRotation, - } + return { + wall: segment.wall, + localX: along, + perpDistance: perp, + side, + dirX: segment.dirX, + dirY: segment.dirY, + wallLength: segment.length, + itemRotation, } - - return best } /** Figma-style along-wall alignment threshold (meters) — parity with the diff --git a/packages/nodes/src/skylight/preview.tsx b/packages/nodes/src/skylight/preview.tsx index ee44bc5b..09807c83 100644 --- a/packages/nodes/src/skylight/preview.tsx +++ b/packages/nodes/src/skylight/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildFrameGeometry } from './frame-csg' import type { SkylightNode } from './schema' @@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({ depthWrite: false, }) -const SkylightPreview = ({ node }: { node: SkylightNode }) => { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const frame = useMemo( () => buildFrameGeometry({ @@ -48,8 +61,8 @@ const SkylightPreview = ({ node }: { node: SkylightNode }) => { return ( - {}} /> - {}} /> + {}} /> + {}} /> ) } diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 73d3933a..5d6b005c 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -114,11 +114,11 @@ const SkylightTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.2, 0.2, 1]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 57c95298..bc4252bc 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -6,6 +6,7 @@ import { type GridEvent, type LevelNode, snapPointAlongAngleRay, + snapPointToGrid, useScene, } from '@pascal-app/core' import { @@ -76,9 +77,9 @@ export const SlabTool: React.FC = () => { if (!cursorRef.current) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridX = Math.round(rawPoint[0] * 2) / 2 - const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap + ? rawPoint + : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] diff --git a/packages/nodes/src/solar-panel/preview.tsx b/packages/nodes/src/solar-panel/preview.tsx index 10ec4b72..15b705a8 100644 --- a/packages/nodes/src/solar-panel/preview.tsx +++ b/packages/nodes/src/solar-panel/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildSolarPanelGeometry } from './geometry' import type { SolarPanelNode } from './schema' @@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({ depthWrite: false, }) -const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const geometry = useMemo( () => buildSolarPanelGeometry(node), [ @@ -38,7 +51,7 @@ const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => { return ( { /* preview should not intercept the cursor */ }} diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 7af8aeab..f397b7ed 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -135,11 +135,11 @@ const SolarPanelTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.8, 0.2, 1.2]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 69f4475b..54e6ce75 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -1,6 +1,13 @@ 'use client' -import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + emitter, + type GridEvent, + SpawnNode, + sceneRegistry, + snapScalar, + useScene, +} from '@pascal-app/core' import { CursorSphere, getFloorStackPreviewPosition, @@ -11,7 +18,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { type Group, Vector3 } from 'three' -const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep) const worldVector = new Vector3() function getExistingSpawnIds() { @@ -31,14 +38,14 @@ function getLevelLocalPosition( if (!levelObject) { return bypassSnap ? [event.localPosition[0], 0, event.localPosition[2]] - : [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] + : [snapToGrid(event.localPosition[0]), 0, snapToGrid(event.localPosition[2])] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) return bypassSnap ? [worldVector.x, 0, worldVector.z] - : [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] + : [snapToGrid(worldVector.x), 0, snapToGrid(worldVector.z)] } /** @@ -58,11 +65,11 @@ const SpawnTool = () => { const onGridMove = (event: GridEvent) => { // Cursor lives in the ToolManager's building-local group. Use - // event.localPosition directly (already building-local) with the - // same half-meter snap the legacy tool uses. + // event.localPosition directly (already building-local), snapped to the + // editor's configured grid step (Shift bypasses). const bypassSnap = event.nativeEvent?.shiftKey === true - const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0]) - const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2]) + const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0]) + const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2]) const position: [number, number, number] = [nextX, 0, nextZ] const previewNode = SpawnNode.parse({ name: 'Spawn Point', diff --git a/packages/nodes/src/turbine-vent/preview.tsx b/packages/nodes/src/turbine-vent/preview.tsx index 04a63689..4ebc4bee 100644 --- a/packages/nodes/src/turbine-vent/preview.tsx +++ b/packages/nodes/src/turbine-vent/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildTurbineVentGeometry } from './geometry' import type { TurbineVentNode } from './schema' @@ -12,7 +13,7 @@ import type { TurbineVentNode } from './schema' * lockstep with the committed vent. Raycast is disabled so the preview * doesn't intercept the cursor ray feeding the placement tool. */ -const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => { +const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid?: boolean }) => { const geometry = useMemo( () => buildTurbineVentGeometry(node), [node.style, node.diameter, node.height, node.neckHeight, node.vaneCount, node.baseOverhang], @@ -21,17 +22,17 @@ const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.6, metalness: 0.2, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 8ba0b37d..9634e5e6 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -122,11 +122,11 @@ const TurbineVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.5, 0.8, 0.5]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index cfa3c187..76115406 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -2,16 +2,15 @@ import { type AnyNodeId, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, type WindowNode, } from '@pascal-app/core' -import { snapToHalf } from '@pascal-app/editor' +import { snapToHalf, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' -import { - getRoofHostedOpeningLevelId, - getRoofHostedOpeningPlanPoint, -} from '../shared/roof-opening-host' +import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -32,15 +31,9 @@ import { clampToWall, hasWallChildOverlap } from './window-math' */ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - const startLevelId = (() => { - // Wall-hosted: the wall's parent is the level. Roof-hosted: walk - // segment → roof → level. - const nodes = useScene.getState().nodes - const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) - if (roofLevelId) return roofLevelId - const wall = nodes[node.parentId as AnyNodeId] - return wall ? (wall.parentId as AnyNodeId | null) : null - })() + // The level that owns the wall-snap candidates — resolves the wall-hosted, + // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). + const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) const originalWall = node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined @@ -50,12 +43,21 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod ? projectWallLocalPointToPlan(originalWall, node.position[0]) : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, + // Absolute: query the wall snap with the TRUE cursor (see the matching + // comment in `doorFloorplanMoveTarget`). Relative mode anchored the search + // to the original wall, which let the window snap to a farther wall across + // a thin gap instead of the one under the cursor. + mode: 'absolute', }) // Preserve the source window's local Y — 2D move doesn't have a way // to express vertical motion, so we keep whatever vertical position - // the window had when the move started. - const startLocalY = node.position[1] + // the window had when the move started. A fresh preset/catalog clone is + // created at y=0, which would sit the window's centre on the floor (half + // below ground); default those to a realistic sill so it floats above + // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). + const DEFAULT_SILL = 0.9 + const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2 // Track the last successful placement so `commit()` can write it // atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. @@ -69,13 +71,72 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod roofFace: undefined } | null = null + // R flips the window's facing (front ↔ back) mid-placement — see + // `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the + // flip is a persistent XOR plus a π rotation offset. + let flipped = false + let lastApply: { + planPoint: readonly [number, number] + modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } + } | null = null + // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor + // as a ghost and isn't committable (it needs a wall). Starts true. + let onWall = true + + const freeFollow = (planPoint: readonly [number, number]) => { + onWall = false + lastValid = null + if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: false }) + } + const half = node.width / 2 + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + const ghost = { + ...node, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, startLocalY, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + visible: true, + } as WindowNode + usePlacementPreview.getState().set(ghost, wall) + } + const session: FloorplanMoveTargetSession = { affectedIds: [node.id as AnyNodeId], + flipSide() { + flipped = !flipped + if (lastApply) this.apply(lastApply) + }, apply({ planPoint, modifiers }) { + lastApply = { planPoint, modifiers } + // Drop any stale live transform left by the 3D `MoveWindowTool` — see + // `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer + // keeps rendering the window at the 3D tool's last hover (it prefers + // `useLiveTransforms` over the scene node for door/window), so the 2D + // slide — which writes the scene node — wouldn't show. Guarded on + // existence: `clear` allocates a new Map + re-renders. + if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) { + useLiveTransforms.getState().clear(node.id as AnyNodeId) + } const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) - if (!hit) return + if (!hit) { + freeFollow(resolvedPlanPoint) + return + } + onWall = true + usePlacementPreview.getState().clear() + if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: true }) + } // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends), winning over the 0.5m grid snap; falls back @@ -99,10 +160,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod node.height, ) + const side: WindowNode['side'] = flipped + ? hit.side === 'front' + ? 'back' + : 'front' + : hit.side + const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) + lastValid = { position: [clampedX, clampedY, 0], - rotation: [0, hit.itemRotation, 0], - side: hit.side, + rotation: [0, itemRotation, 0], + side, parentId: hit.wall.id, wallId: hit.wall.id, // Re-anchoring to a wall ends any roof-segment hosting; the @@ -119,6 +187,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod ]) }, canCommit() { + // Off-wall the window is free-following — not placeable; the overlay + // reverts to the pre-move snapshot. Matches the 3D move. + if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined if (!live || live.type !== 'window') return false const overlapping = hasWallChildOverlap( diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 2793e57b..2f31401b 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, collectAlignmentAnchors, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -106,6 +107,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode let currentHostId: string | null = movingWindowNode.parentId let committed = false + // Off-wall free-follow: over empty floor the window is parented to the + // level and tracks the cursor like an item. `freeFollowing` marks that + // state; `lastMeshEventTime` defers the floor handler whenever a wall/roof + // mesh event owns the same pointermove — that's the only thing that snaps. + let freeFollowing = false + let lastMeshEventTime = -1 + // The window's chosen facing side. R flips it mid-placement (front ↔ back), + // matching the committed-selected R flip. Initialised from the moving node. + let sideOverride: WindowNode['side'] = movingWindowNode.side let dragAnchor: { wallId: string rawX: number @@ -146,6 +156,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } + + // Sill-center height used while the window isn't on a wall (free-follow and + // proximity). Fresh preset clones are created at position [0,0,0], which + // would bury half the window below the floor; default such windows to a + // ~0.9m sill so the ghost floats at a realistic height. An existing window + // keeps its own sill. + const DEFAULT_SILL = 0.9 + const getSillCenterY = () => { + const y = movingWindowNode.position[1] + return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2 + } const getSlabElevation = (wallEvent: WallEvent) => spatialGridManager.getSlabElevationForWall( wallEvent.node.parentId ?? '', @@ -189,9 +210,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const faceSide = getSideFromNormal(event.normal) + const side = sideOverride ?? faceSide + const rotationOffset = side !== faceSide ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + rotationOffset + const cursorRotation = + calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] @@ -318,11 +342,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onWallEnter = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveMoveTarget(event) if (!target) { onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) @@ -330,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onWallMove = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -349,21 +376,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) event.stopPropagation() } - const onWallClick = (event: WallEvent) => { + // Promote the moving window into its committed wall placement. Shared by + // the direct wall-mesh click and the floor proximity click. + const commitToWall = (target: NonNullable) => { if (committed) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return - - const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return committed = true let placedId: string @@ -429,31 +452,78 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() + } + + const onWallClick = (event: WallEvent) => { + if (committed) return + if (!isValidWallSideFace(event.normal)) return + if (isCurvedWall(event.node)) return + // Only interact with walls on the current level + if (event.node.parentId !== getLevelId()) return + + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return + commitToWall(target) event.stopPropagation() } const onWallLeave = () => { + // The cursor left the wall mesh. Don't snap back to the origin/original + // here — the floor proximity handler (onGridMove) takes over on the same + // pointermove: it snaps to a nearby wall or free-follows the cursor, so + // the window never blinks back to the building origin between a wall and + // open floor. Revert is left to free-follow / cancel / commit. hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return // No original to restore for duplicates - // Move mode: restore to original position while off-wall - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) + } + + // Free-follow: the window rides the cursor over empty floor, parented to + // the level like an item node, kept at a sensible sill height. No wall to + // attach to, so it is not committable here. + const freeFollowAt = (localX: number, localZ: number) => { + freeFollowing = true + lastTarget = null + lastRoofEvent = null + hideCursor() + useLiveTransforms.getState().clear(movingWindowNode.id) + const levelId = getLevelId() + const sillCenterY = getSillCenterY() + // Keep the R-flip visible while free-following (back = rotated π). + const yaw = sideOverride === 'back' ? Math.PI : 0 + if (currentHostId !== levelId) { + if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + }) + currentHostId = levelId + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + }) } - currentHostId = original.parentId - useScene.getState().updateNode(movingWindowNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) + } + + const onGridMove = (event: GridEvent) => { + if (committed) return + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler owns this exact pointermove (shared DOM + // timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise + // the cursor is over open floor — free-follow it. No proximity magnet: + // snapping engages only when the cursor ray actually hovers a wall. + if (event.nativeEvent?.timeStamp === lastMeshEventTime) return + const [x, , z] = event.localPosition + freeFollowAt(x, z) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -480,12 +550,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onRoofHover = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofMoveTarget(event) if (!target) { onRoofLeave() return } // Wall-frame drag anchor / live transform don't apply on a roof face. + freeFollowing = false dragAnchor = null lastTarget = null lastRoofEvent = event @@ -586,26 +658,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onRoofLeave = () => { + // Mirror onWallLeave: don't revert to origin here — onGridMove takes + // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) - } - currentHostId = original.parentId - useScene.getState().updateNode(movingWindowNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { @@ -633,13 +692,47 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return - if (lastTarget) { - onWallClick(lastTarget.event) + // Free-following over open floor can't commit (no wall). A wall hover + // target commits via commitToWall; a roof face via onRoofClick. + if (lastTarget?.valid && !freeFollowing) { + commitToWall(lastTarget) return } if (lastRoofEvent) onRoofClick(lastRoofEvent) } + // R flips the window's facing side mid-placement (front ↔ back), like the + // committed-selected R flip — usable before commit, whether snapped to a + // wall or free-following. No-op on a roof-segment face (front-only host). + const onKeyDown = (e: KeyboardEvent) => { + if (committed) return + if (e.key !== 'r' && e.key !== 'R') return + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return + } + const onWall = lastTarget !== null + if (!(onWall || freeFollowing)) return + e.preventDefault() + sideOverride = sideOverride === 'front' ? 'back' : 'front' + triggerSFX('sfx:item-rotate') + if (onWall) { + const next = resolveMoveTarget(lastTarget!.event) + if (next) { + lastTarget = next + applyPreview(next) + } + } else { + useScene.getState().updateNode(movingWindowNode.id, { + side: sideOverride, + rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], + }) + } + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -648,8 +741,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) + window.addEventListener('keydown', onKeyDown) return () => { // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) @@ -687,8 +782,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + window.removeEventListener('keydown', onKeyDown) } }, [movingWindowNode, exitMoveMode]) diff --git a/packages/nodes/src/window/preview.tsx b/packages/nodes/src/window/preview.tsx new file mode 100644 index 00000000..e9291d44 --- /dev/null +++ b/packages/nodes/src/window/preview.tsx @@ -0,0 +1,54 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { buildWindowPreviewMesh } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import { applyGhost } from '../shared/ghost-materials' +import type { WindowNode } from './schema' + +/** + * Translucent preview of a window — used by the placement tool's floating ghost. + * + * Builds the window mesh via buildWindowPreviewMesh (so the preview shape stays in + * lockstep with committed windows), then applies ghost treatment (translucent, + * raycast-off, tinted red if invalid). + * + * The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox + * material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha). + */ +const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean }) => { + const mesh = useMemo(() => { + const m = buildWindowPreviewMesh(node) + m.layers.set(EDITOR_LAYER) + return m + }, [ + node.width, + node.height, + node.frameDepth, + node.openingShape, + node.windowType, + node.sill, + node.sillDepth, + node.sillThickness, + ]) + + // Ghost treatment (clone + tint + raycast-off) re-applies if `invalid` + // flips; its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid]) + + // Geometry is freshly built per `mesh` and owned here — dispose it only + // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. + useEffect( + () => () => { + mesh.traverse((obj) => { + const m = obj as { geometry?: { dispose: () => void } } + m.geometry?.dispose() + }) + }, + [mesh], + ) + + return +} + +export default WindowPreview diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index ccbce2fd..6716ad17 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -10,6 +10,7 @@ import { spatialGridManager, useScene, type WallEvent, + type WallNode, WindowNode, } from '@pascal-app/core' import { @@ -23,7 +24,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -38,6 +39,7 @@ import { worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import WindowPreview from './preview' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' // Shared edge material — reuse across renders, just toggle color @@ -51,33 +53,65 @@ const edgeMaterial = new LineBasicNodeMaterial({ const FALLBACK_WIDTH = 1.5 const FALLBACK_HEIGHT = 1.5 const FALLBACK_SILL_LIFT = 0.45 +// Default sill centre for a window snapped from the floor (the floor cursor +// carries no wall-face height). 0.9 m sill + half the 1.5 m default height. +const DEFAULT_SILL_CENTER_Y = 0.9 + FALLBACK_HEIGHT / 2 const roofFallbackPoint = new Vector3() +// What currently owns the cursor frame: a wall/roof mesh hover, or null when +// the cursor is over open floor (the grid handler then free-follows). +type HostKind = 'wall' | 'roof' | null + /** * Window tool — places WindowNodes on walls and on roof-segment wall * faces (the generated base walls under a roof, including coplanar gable * ends — a window can sit in the gable pediment). - * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. + * + * The ghost follows the cursor everywhere (like moving an item): over open + * floor it floats as an invalid (unplaceable) ghost; the moment the cursor ray + * hovers a wall (or roof-segment face) the real draft snaps onto it. Snapping + * engages only on an actual mesh hover — no proximity magnet. */ const WindowTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) + // Off-host floating ghost: the real window geometry follows the cursor + // over the grid (tinted invalid). Mutually exclusive with the on-host draft. + const [fallbackPose, setFallbackPose] = useState<{ + position: [number, number, number] + rotationY: number + } | null>(null) + + const ghostStub = useMemo( + () => WindowNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), + [], + ) + useEffect(() => { useScene.temporal.getState().pause() + let hostKind: HostKind = null + // timeStamp of the most recent wall/roof mesh event. A wall/roof hover and + // the grid raycast from the SAME pointermove share the source DOM event's + // timeStamp, so the grid handler can detect "a mesh handler already owns + // this frame" without depending on event order or on a leave firing (node + // events are suppressed during a camera drag, so a sticky boolean would + // strand the draft after an orbit; a per-frame timestamp self-heals). + let lastMeshEventTime = -1 + // R flips the window's facing side mid-placement (front ↔ back); re-applied + // to the last wall hover so the flip shows live before commit. + let sideFlip = false + let lastWallEvent: WallEvent | null = null + const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } - const getSlabElevation = (wallEvent: WallEvent) => - spatialGridManager.getSlabElevationForWall( - wallEvent.node.parentId ?? '', - wallEvent.node.start, - wallEvent.node.end, - ) + const getSlabElevationForWall = (wall: WallNode) => + spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -96,6 +130,7 @@ const WindowTool: React.FC = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() clearOpeningGuides3D() + setFallbackPose(null) } // Alignment candidates — anchors of every alignable object; refreshed @@ -103,11 +138,15 @@ const WindowTool: React.FC = () => { // (along-wall only; the floor-plane guides don't cover sill height). let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // On-host cursor: the green/red wireframe outline tracks a live draft. + // Showing it always clears the off-host floating ghost (they never + // coexist — a draft means the cursor is on a valid host). const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, ) => { + setFallbackPose(null) const group = cursorGroupRef.current if (!group) return group.visible = true @@ -116,26 +155,23 @@ const WindowTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const showFallbackCursor = (event: GridEvent) => { - if (draftRef.current) return - const [x, y, z] = event.localPosition - updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) + // Off-host fallback: hide the wireframe outline and float the real window + // geometry (tinted invalid) at the cursor so the armed tool is visible. + const showGhostAt = (position: [number, number, number]) => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + setFallbackPose({ position, rotationY: 0 }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) - useAlignmentGuides.getState().clear() - clearOpeningGuides3D() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) - useAlignmentGuides.getState().clear() - clearOpeningGuides3D() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) } // Sill alignment (snap + guide): a sibling sill/centre/top wins over the @@ -143,226 +179,146 @@ const WindowTool: React.FC = () => { // draft's id once it exists (so it's excluded from the sibling scan), or '' // before the draft is created (nothing to exclude yet). const resolvePlacementY = (args: { - event: WallEvent + wall: WallNode movingId: string localX: number + rawLocalY: number width: number height: number + bypassSnap: boolean }): number => { - const rawY = args.event.localPosition[1] - if (args.event.nativeEvent?.shiftKey === true) return rawY + if (args.bypassSnap) return args.rawLocalY const sillY = resolveSillSnap({ - wall: args.event.node, + wall: args.wall, movingId: args.movingId, localX: args.localX, - localY: rawY, + localY: args.rawLocalY, width: args.width, height: args.height, nodes: useScene.getState().nodes, }) - return sillY ?? snapToHalf(rawY) + return sillY ?? snapToHalf(args.rawLocalY) } - const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - const levelId = getLevelId() - if (!levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - // Only interact with walls on the current level - if (event.node.parentId !== levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - destroyDraft() - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const width = 1.5 - const height = 1.5 + // Settle a wall target: alignment snap → sill clamp → overlap check. + const resolveWallPlacement = ( + wall: WallNode, + rawLocalX: number, + rawLocalY: number, + width: number, + height: number, + bypass: boolean, + bypassSnap: boolean, + ignoreId?: string, + ) => { const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], + wallNode: wall, + rawLocalX, width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const localY = resolvePlacementY({ event, movingId: '', localX, width, height }) - - const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) - - 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 - - const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) - - publishOpeningGuidesForWallEvent({ - wall: event.node, - movingId: node.id, - centerS: clampedX, - centerY: clampedY, - width, - height, - includeVertical: true, - levelYOffset: getLevelYOffset(), - slabElevation: getSlabElevation(event), - }) - event.stopPropagation() - } - - const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - 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 || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + bypass, + bypassSnap, }) const localY = resolvePlacementY({ - event, - movingId: draftRef.current?.id ?? '', + wall, + movingId: ignoreId ?? '', localX, + rawLocalY, width, height, + bypassSnap, }) + const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) + const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + return { clampedX, clampedY, valid } + } - const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) + // Shared create/update path for the wall draft — used by the direct + // wall-mesh hover and the floor proximity snap. Reuses the existing draft + // (reparenting only on an actual wall change to avoid churning the host's + // children array, which flashes 0-vertex wall geometry in WebGPU). + const applyWallTarget = (args: { + wall: WallNode + rawLocalX: number + rawLocalY: number + side: 'front' | 'back' + itemRotation: number + cursorRotationY: number + bypass: boolean + bypassSnap: boolean + }) => { + const { + wall, + rawLocalX, + rawLocalY, + side, + itemRotation, + cursorRotationY, + bypass, + bypassSnap, + } = args + const width = draftRef.current?.width ?? 1.5 + const height = draftRef.current?.height ?? 1.5 - // 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 - } + const node = WindowNode.parse({ + position: [0, DEFAULT_SILL_CENTER_Y, 0], + rotation: [0, itemRotation, 0], + side, + wallId: wall.id, + parentId: wall.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, wall.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 - // forward `parentId` / `wallId` when the wall actually changed - // — otherwise the reparent path churns the host wall's - // `children` array every tick, which re-renders the wall and - // briefly draws its 0-vertex placeholder geometry (WebGPU then - // flags "Vertex buffer slot 0 ... was not set"). - const isSameWall = event.node.id === draftRef.current.parentId - if (isSameWall) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - }) - markHostDirty(event.node.id) - } else { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - // The draft may arrive from a roof-segment face hover. - roofSegmentId: undefined, - roofFace: undefined, - }) - } - } - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, + const { clampedX, clampedY, valid } = resolveWallPlacement( + wall, + rawLocalX, + rawLocalY, width, height, - draftRef.current?.id, + bypass, + bypassSnap, + draftRef.current.id, ) + if (wall.id === draftRef.current.parentId) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + }) + markHostDirty(wall.id) + } else { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: wall.id, + wallId: wall.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, + roofFace: undefined, + }) + } + updateCursor( wallLocalToWorld( - event.node, + wall, clampedX, clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevationForWall(wall), ), - cursorRotation, + cursorRotationY, valid, ) if (draftRef.current) { publishOpeningGuidesForWallEvent({ - wall: event.node, + wall, movingId: draftRef.current.id, centerS: clampedX, centerY: clampedY, @@ -370,79 +326,44 @@ const WindowTool: React.FC = () => { height, includeVertical: true, levelYOffset: getLevelYOffset(), - slabElevation: getSlabElevation(event), + slabElevation: getSlabElevationForWall(wall), }) } - event.stopPropagation() + return { clampedX, clampedY, valid } } - const onWallClick = (event: WallEvent) => { - if (!draftRef.current) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: draftRef.current.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const localY = resolvePlacementY({ - event, - movingId: draftRef.current.id, - localX, - width: draftRef.current.width, - height: draftRef.current.height, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - draftRef.current.width, - draftRef.current.height, - ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - draftRef.current.width, - draftRef.current.height, - draftRef.current.id, - ) - if (!valid) return - + // Promote the draft into a permanent window. Shared by the wall-mesh click + // and the floor proximity click. + const commitWindowAtWall = ( + wall: WallNode, + clampedX: number, + clampedY: number, + side: 'front' | 'back', + itemRotation: number, + ) => { const draft = draftRef.current + if (!draft) return draftRef.current = null + hostKind = null - // Delete transient draft (paused, invisible to undo) useScene.getState().deleteNode(draft.id) - - // Resume → create permanent node (single undoable action) useScene.temporal.getState().resume() const levelId = getLevelId() const state = useScene.getState() const windowCount = Object.values(state.nodes).filter((n) => { if (n.type !== 'window') return false - const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined - return wall?.parentId === levelId + const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined + return w?.parentId === levelId }).length - const name = `Window ${windowCount + 1}` const node = WindowNode.parse({ - name, + name: `Window ${windowCount + 1}`, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, - wallId: event.node.id, - parentId: event.node.id, + wallId: wall.id, + parentId: wall.id, width: draft.width, height: draft.height, windowType: draft.windowType, @@ -461,20 +382,110 @@ const WindowTool: React.FC = () => { sillThickness: draft.sillThickness, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() + } + // ── Direct wall-mesh hover ────────────────────────────────────── + const onWallHover = (event: WallEvent) => { + hostKind = 'wall' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + destroyDraft() + showWallFallbackCursor(event) + return + } + lastWallEvent = event + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const flipOffset = sideFlip ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + flipOffset + const cursorRotation = + calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + applyWallTarget({ + wall: event.node, + rawLocalX: event.localPosition[0], + rawLocalY: event.localPosition[1], + side, + itemRotation, + cursorRotationY: cursorRotation, + bypass, + bypassSnap, + }) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + return + } + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0) + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + const { clampedX, clampedY, valid } = resolveWallPlacement( + event.node, + event.localPosition[0], + event.localPosition[1], + draftRef.current.width, + draftRef.current.height, + bypass, + bypassSnap, + draftRef.current.id, + ) + if (!valid) return + + commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() } const onWallLeave = () => { + if (hostKind !== 'wall') return + lastWallEvent = null destroyDraft() hideCursor() + hostKind = null + } + + // ── Floor free-follow ─────────────────────────────────────────── + // Over open floor the ghost follows the cursor like a moving item. It does + // NOT snap from proximity — snapping engages only when the cursor ray + // actually hovers a wall (onWallHover) or roof face (onRoofHover). + const onGridFreeFollow = (event: GridEvent) => { + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler processed this exact pointermove (R3F + the + // grid raycast share the source DOM event's timeStamp) — it owns the + // frame and has snapped the draft, so skip the floor follow this tick. + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastMeshEventTime) return + // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft + // and free-follow the cursor with the invalid (unplaceable) ghost. + hostKind = null + lastWallEvent = null + const [x, y, z] = event.localPosition + destroyDraft() + showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z]) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -501,6 +512,8 @@ const WindowTool: React.FC = () => { } const onRoofHover = (event: RoofEvent) => { + hostKind = 'roof' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofTarget(event) if (!target) { // On the roof but not over a placeable wall face (slope, soffit, @@ -545,6 +558,7 @@ const WindowTool: React.FC = () => { const draft = draftRef.current draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -591,26 +605,44 @@ const WindowTool: React.FC = () => { } const onRoofLeave = () => { - if (!draftRef.current?.roofSegmentId) return + if (hostKind !== 'roof') return destroyDraft() hideCursor() + hostKind = null } const onCancel = () => { destroyDraft() hideCursor() + hostKind = null } - emitter.on('wall:enter', onWallEnter) - emitter.on('wall:move', onWallMove) + // R flips the window's facing side mid-placement (front ↔ back), like the + // committed-selected R flip. Only meaningful while snapped to a wall (the + // off-wall ghost has no orientation), so it acts only then — re-applying + // the last wall hover so the snapped preview flips live. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'r' && e.key !== 'R') return + if (!lastWallEvent) return + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return + e.preventDefault() + sideFlip = !sideFlip + triggerSFX('sfx:item-rotate') + onWallHover(lastWallEvent) + } + + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) - emitter.on('grid:move', showFallbackCursor) + emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { destroyDraft() @@ -618,16 +650,17 @@ const WindowTool: React.FC = () => { useAlignmentGuides.getState().clear() clearOpeningGuides3D() useScene.temporal.getState().resume() - emitter.off('wall:enter', onWallEnter) - emitter.off('wall:move', onWallMove) + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) - emitter.off('grid:move', showFallbackCursor) + emitter.off('grid:move', onGridFreeFollow) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) } }, []) @@ -643,14 +676,21 @@ const WindowTool: React.FC = () => { useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {fallbackPose && ( + + + + )} + ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 158e4036..ef5a314a 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -89,7 +89,7 @@ export { createColumnTorusGeometry, } from './systems/column/column-geometry' export { DoorAnimationSystem } from './systems/door/door-animation-system' -export { DoorSystem } from './systems/door/door-system' +export { buildDoorPreviewMesh, DoorSystem } from './systems/door/door-system' export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system' // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 @@ -151,5 +151,5 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials' // removed in Phase 6 when the legacy mount points are deleted. export { WallSystem } from './systems/wall/wall-system' export { WindowAnimationSystem } from './systems/window/window-animation-system' -export { WindowSystem } from './systems/window/window-system' +export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 2a93b926..f9e243e8 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -2372,3 +2372,13 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { } cutout.visible = false } + +/** + * Build a fresh door mesh for preview/ghost rendering. + * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware). + */ +export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh { + const mesh = new THREE.Mesh() + updateDoorMesh(node, mesh) + return mesh +} diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 6f953877..e499b07d 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -3457,3 +3457,13 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { } cutout.visible = false } + +/** + * Build a fresh window mesh for preview/ghost rendering. + * Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware). + */ +export function buildWindowPreviewMesh(node: WindowNode): THREE.Mesh { + const mesh = new THREE.Mesh() + updateWindowMesh(node, mesh) + return mesh +}