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 ff22b2c7..7be07160 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 @@ -141,6 +141,7 @@ type NodeDeps = { highlighted: boolean hovered: boolean moving: boolean + liveOverride: LiveNodeOverrides | undefined palette: FloorplanPalette | undefined siblingEpoch: number committedNodes: Record | null @@ -218,7 +219,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const ambientLevelId = useMemo(() => { if (selectedLevelId || !ambientBuildingSourceId) return null const building = nodes[ambientBuildingSourceId] - if (!building || building.type !== 'building') return null + if (building?.type !== 'building') return null let zero: AnyNodeId | null = null let lowestId: AnyNodeId | null = null let lowestIdx = Number.POSITIVE_INFINITY @@ -626,6 +627,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const hovered = hoveredId === id const moving = movingNode?.id === id const live = liveTransforms.get(id) + const liveOverride = liveOverrides.get(id) const dependsOnSiblingInputs = !!( def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides ) @@ -636,6 +638,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { highlighted, hovered, moving, + liveOverride, palette: renderCtx?.palette, siblingEpoch: dependsOnSiblingInputs ? (nodeSiblingEpochs.get(id) ?? 0) : 0, // Sibling-dependent kinds (wall miters, opening cuts) read other nodes' @@ -719,7 +722,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides }) : nodes const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node - const effectiveNode = applyLiveTransform(sourceNode) + const overrideNode = liveOverride + ? ({ ...sourceNode, ...liveOverride } as AnyNode) + : sourceNode + const effectiveNode = applyLiveTransform(overrideNode) const viewState = { selected, highlighted, @@ -763,7 +769,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const visit = (id: AnyNodeId) => { const node = nodes[id] if (!node) return - if ((node as { visible?: boolean }).visible === false) return + if (!isFloorplanNodeVisible(node, liveOverrides.get(id))) return buildEntry(id, node) const childIds = (node as unknown as { children?: AnyNodeId[] }).children if (Array.isArray(childIds)) { @@ -790,7 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const buildingScopedKindSet = new Set(buildingScopedKinds) for (const [id, node] of Object.entries(nodes)) { if (!node || !buildingScopedKindSet.has(node.type)) continue - if ((node as { visible?: boolean }).visible === false) continue + if (!isFloorplanNodeVisible(node, liveOverrides.get(id as AnyNodeId))) continue const parentId = (node as { parentId?: AnyNodeId | null }).parentId if (parentId !== activeBuildingId) continue const cid = id as AnyNodeId @@ -2093,6 +2099,12 @@ function applyPositionLiveTransform( } as AnyNode } +function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean { + const overrideVisible = liveOverride?.visible + if (typeof overrideVisible === 'boolean') return overrideVisible + return (node as { visible?: boolean }).visible !== false +} + function buildContext( node: AnyNode, nodes: Record, @@ -2303,6 +2315,8 @@ function computeAffectedSiblingIds( } else if (node.type === 'door' || node.type === 'window') { const hostId = (node as { parentId?: string }).parentId if (hostId) affected.add(hostId as AnyNodeId) + const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId + if (liveHostId) affected.add(liveHostId as AnyNodeId) } else if (node.type === 'gutter') { const roofId = (node as { parentId?: string }).parentId if (roofId) { @@ -2326,6 +2340,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { 'highlighted', 'hovered', 'moving', + 'liveOverride', 'palette', 'siblingEpoch', 'committedNodes', diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d10b3b64..eb2373d3 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -43,26 +43,13 @@ export const useKeyboard = ({ return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') } - // Shift cycles the snapping mode while a snapping-mode-governed draft is - // armed: wall / fence build, item placement (build + item tool), and any - // active node move (`movingNode` — covers item 3D moves plus the generic - // registry move for shelf / spawn / column / stair). For items, free place - // moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps - // its existing meaning — multi-select in plain select mode (no movingNode), - // free-place bypass during opening / zone placement — so this predicate - // must NOT fire for those. Door / window moves still use Shift for free - // place (out of this overhaul's scope), so they're excluded. - // Shift cycles the snapping mode (and clean-tap Ctrl the grid step) whenever - // there's an active snapping context — i.e. exactly when the HUD shows a - // snapping chip. That single source covers wall/fence/item drafting, every - // node move (including wall-hosted items), and endpoint/polygon reshaping, - // so the keys never silently stop working. Door / window keep Shift = free - // place until the modifier model unifies them. - const isSnappingCycleContext = () => { - const moving = getMovingNode() - if (moving?.type === 'door' || moving?.type === 'window') return false - return getActiveSnapContext() != null - } + // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step) + // whenever there's an active snapping context — i.e. exactly when the HUD + // shows a snapping chip. That single source covers wall/fence/item drafting, + // every node move (including wall-hosted items + door/window openings, which + // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys + // never silently stop working. (Force-place lives on Alt for all of them.) + const isSnappingCycleContext = () => getActiveSnapContext() != null // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // between) cycles the grid step — same context as the Shift snapping-mode diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index d531c02e..d2c65e38 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -166,6 +166,7 @@ const doorHandles: HandleDescriptor[] = [ */ export const doorDefinition: NodeDefinition = { kind: 'door', + snapProfile: 'item', schemaVersion: 1, schema: DoorNode, category: 'structure', @@ -251,6 +252,8 @@ export const doorDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place door on wall' }, + { key: 'R', label: 'Flip side' }, + { key: 'Alt', label: 'Force place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index f61bcc76..570ce29e 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -3,12 +3,20 @@ import { type DoorNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, WallNode as WallNodeSchema, } from '@pascal-app/core' -import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { + isGridSnapActive, + isMagneticSnapActive, + snapToHalf, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { @@ -36,6 +44,7 @@ import { clampToWall, hasWallChildOverlap } from './door-math' */ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const nodeId = node.id as AnyNodeId // Snapshot of the door's "valid" state at move-start — used by // canCommit to decide whether the current snapped position is OK. // The level that owns the wall-snap candidates — resolves the wall-hosted, @@ -72,6 +81,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) wallId: string roofSegmentId: undefined roofFace: undefined + visible: true } | null = null // R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives @@ -88,20 +98,30 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // 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 - // Shift force-place (last apply's modifier) — lets `canCommit` allow an - // overlapping placement, matching the 3D move. Read in `canCommit` so a Shift- + // Alt force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. Read in `canCommit` so an Alt- // held commit over a collision lands instead of reverting. let forcePlace = false + let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId) + let liveOverrideKey: string | null = null + let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId + + const setLiveOverride = (key: string, values: Record) => { + if (liveOverrideKey === key) return + liveOverrideKey = key + useLiveNodeOverrides.getState().set(nodeId, values) + } // Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click - // per grid step, identical free-following or sliding on a wall, keyed on the - // RAW cursor (not the snapped along-wall value). No separate floor→wall cue — - // that distinct sound was the "double" the user heard. 2D `apply` runs once per - // pointermove, so the step-key dedup is sufficient (no per-frame guard needed). - const STEP_M = 0.1 + // each time the door's PLACED position crosses a step. Keyed on the SNAPPED + // position, quantized by the live grid step in grid mode else a gentle fixed + // cadence — so grid mode ticks once per cell (not on every micro mouse-move + // while the door sits in a cell) while lines/off still tick as the door moves. + const FREE_STEP_M = 0.1 let lastStepKey: string | null = null const tickGridStep = (...coords: number[]) => { - const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M + const key = coords.map((c) => Math.round(c / step)).join(',') if (key !== lastStepKey) { lastStepKey = key triggerSFX('sfx:grid-snap') @@ -115,9 +135,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) 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 }) + if (liveTransformActive) { + useLiveTransforms.getState().clear(nodeId) + liveTransformActive = false } + setLiveOverride('free-follow', { visible: false }) const half = node.width / 2 + 0.5 const wall = WallNodeSchema.parse({ start: [planPoint[0] - half, planPoint[1]], @@ -144,28 +166,18 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) visible: true, } as DoorNode usePlacementPreview.getState().set(ghost, wall) + placementPreviewActive = true } const session: FloorplanMoveTargetSession = { - affectedIds: [node.id as AnyNodeId], + affectedIds: [nodeId], flipSide() { flipped = !flipped if (lastApply) this.apply(lastApply) }, apply({ planPoint, modifiers }) { lastApply = { planPoint, modifiers } - forcePlace = modifiers.shiftKey === true - // 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) - } + forcePlace = modifiers.altKey === true const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) @@ -178,32 +190,30 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) } // 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 }) + if (placementPreviewActive) { + usePlacementPreview.getState().clear() + placementPreviewActive = false } // Figma-style along-wall alignment first (edge-to-edge with other - // openings / wall ends); it competes with — and wins over — the 0.5m - // grid snap. Falls back to the grid snap when nothing aligns. Alt - // bypasses alignment; Shift bypasses all snap. - const neighborX = - modifiers.altKey || modifiers.shiftKey - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width: node.width, - selfId: node.id as AnyNodeId, - nodes, - }) - const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) + // openings / wall ends); it competes with — and wins over — the grid + // snap. Follows the magnetic ("lines") mode; the grid component lives in + // `snapToHalf` (mode-aware → raw when grid is off). + const neighborX = !isMagneticSnapActive() + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: nodeId, + nodes, + }) + const snappedLocalX = neighborX ?? snapToHalf(hit.localX) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) - // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`, - // not the snapped value) so the wall slide ticks at the same cadence as the - // off-wall ghost — the same SFX, no separate snap cue. - tickGridStep(hit.localX) + // One click per real position step, keyed on the SNAPPED along-wall value + // so it ticks only when the door actually moves to a new cell. + tickGridStep(clampedX) // Apply the R-flip on top of the wall-derived side. const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side @@ -219,35 +229,38 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, roofFace: undefined, + visible: true, } - // Build the updates atomically — position + rotation + side + - // parentId + wallId in a single scene write. The current door's - // parent might be a different wall; re-anchoring requires moving - // the node in the parent's children list (the registry's - // updateNode does this when parentId changes). - useScene.getState().updateNodes([ - { - id: node.id as AnyNodeId, - data: lastValid, - }, - ]) + setLiveOverride(`wall:${hit.wall.id}:${side}`, { + parentId: hit.wall.id, + wallId: hit.wall.id, + side, + roofSegmentId: undefined, + roofFace: undefined, + visible: true, + }) + useLiveTransforms.getState().set(nodeId, { + position: lastValid.position, + rotation: itemRotation, + }) + liveTransformActive = true }, 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 another wall child — UNLESS Shift + if (!onWall || !lastValid) return false + const live = useScene.getState().nodes[nodeId] as DoorNode | undefined + if (live?.type !== 'door') return false + // Block commit if the door overlaps another wall child — UNLESS Alt // force-places (same `placeable` rule as the 3D move + the shared // `resolveOpeningPlacement`). const collides = hasWallChildOverlap( - live.parentId as string, - live.position[0], - live.position[1], + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], live.width, live.height, live.id, @@ -266,7 +279,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) if (!lastValid) return useScene.getState().updateNodes([ { - id: node.id as AnyNodeId, + id: nodeId, data: lastValid, }, ]) diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 69152bf4..55fba20d 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,6 +1,5 @@ import { type AnyNodeId, - collectAlignmentAnchors, DoorNode, emitter, type GridEvent, @@ -18,6 +17,8 @@ import { consumePlacementDragRelease, EDITOR_LAYER, getSideFromNormal, + isGridSnapActive, + isMagneticSnapActive, isValidWallSideFace, stripPlacementMetadataFlags, triggerSFX, @@ -38,7 +39,10 @@ import { resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveOpeningPlacement } from '../shared/wall-attach-target' -import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import { + collectWallOpeningAlignmentCandidates, + resolveWallSlideAlignment, +} from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import DoorPreview from './preview' @@ -129,35 +133,48 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // 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. + // commit no-ops in open space (a door needs a wall). let freeFollowing = false - let lastMeshEventTime = -1 - // Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change + // Last open-floor cursor point (level-local X/Z), so an R-flip or Alt change // while free-following can re-run the ghost at the same spot with the new // facing/tint — no pointer move required. let lastFloorPoint: [number, number] | null = null - // Live Shift state (force-place). Tracked here so the preview tint can be - // re-evaluated when Shift is pressed/released with the pointer stationary — - // the stored WallEvent carries a STALE shiftKey from the last move. - let shiftHeld = false - // Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a - // grid step — identical whether free-following over open floor or sliding - // along a wall, so the two feel the same (the user's ask). Always keyed on - // the RAW cursor position (continuous ~0.1m cadence), never the snapped - // along-wall value, so the wall slide ticks at the same rate as the ghost. - // Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when - // the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM - // pointermove — a wall mesh can emit `wall:move` more than once per move, and - // the grid + wall paths can both run). No separate snap cue: a distinct - // floor→wall sound was the "double" the user heard. - const STEP_M = 0.1 + // The floor free-follow (`grid:move`, a DOM event) and the wall/roof snap + // (`wall:move`/`roof:move`, R3F mesh events) are INDEPENDENT event streams + // with different clocks, so the old `event.timeStamp` de-dup never matched — + // the free-follow ran during on-wall slides too, and both wrote the scene + // node every frame (a per-frame `nodes` churn that tanked 2D + 3D framerate). + // Instead, stamp one monotonic clock whenever a wall/roof hit owns the + // pointer; the floor handler stands down while that stamp is fresh. `wall:move` + // fires every frame on-wall, so the stamp stays fresh across the pointermove + // interval and the free-follow only re-engages once the cursor is off any wall. + let wallOwnedPointerAt = Number.NEGATIVE_INFINITY + // ~4 frames: comfortably longer than the pointermove interval (so a fast + // on-wall slide never lets the floor follow slip through) yet short enough + // that leaving a wall re-engages the free-follow without a perceptible stick. + const WALL_OWNS_POINTER_MS = 64 + const markWallOwnedPointer = () => { + wallOwnedPointerAt = performance.now() + } + const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS + // Live Alt state (force-place). Tracked here so the preview tint can be + // re-evaluated when Alt is pressed/released with the pointer stationary — + // the stored WallEvent carries a STALE altKey from the last move. + let altHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click each time the door's PLACED + // position crosses a step. Keyed on the SNAPPED value (passed by the caller), + // quantized by the live grid step in grid mode, else a gentle fixed cadence — + // so grid mode ticks once per cell (not on every micro mouse-move while the + // door sits in a cell) while lines/off still tick as the door moves. Two + // guards prevent a doubled cue: `lastStepKey` (cell change) + `lastTickFrame` + // (one per pointermove — wall + grid paths can both run on the same move). + const FREE_STEP_M = 0.1 let lastStepKey: string | null = null let lastTickFrame = -1 const tickGridStep = (frame: number, ...coords: number[]) => { if (frame === lastTickFrame) return - const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M + const key = coords.map((c) => Math.round(c / step)).join(',') if (key === lastStepKey) return lastStepKey = key lastTickFrame = frame @@ -188,7 +205,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const now = globalThis.performance?.now?.() ?? Date.now() const last = lastHostDirtyAt.get(hostId) ?? 0 // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. - if (now - last > 120) { + if (now - last > 60) { lastHostDirtyAt.set(hostId, now) markHostDirty(hostId) } @@ -213,9 +230,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => setGhostPose(null) } - // Alignment candidates — anchors of every OTHER alignable object (the - // moving door is excluded so it never aligns to itself). - const alignmentCandidates = collectAlignmentAnchors( + // Alignment candidates — only OTHER things on a wall (sibling openings + + // wall-mounted items), never ground objects, so the along-wall guides don't + // line up with furniture on the floor. The moving door is excluded. + const alignmentCandidates = collectWallOpeningAlignmentCandidates( useScene.getState().nodes, movingDoorNode.id, ) @@ -267,10 +285,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, - // Alt still hard-disables alignment (no guides). Shift = free-place: - // land at the raw cursor but keep showing the alignment guides. - bypass: event.nativeEvent?.altKey === true, - freePlace: event.nativeEvent?.shiftKey === true, + // Along-wall alignment follows the magnetic ("lines") mode; the grid + // component lives in `snapToHalf` (itself mode-aware). + bypass: !isMagneticSnapActive(), }) const { clampedX, clampedY } = clampToWall( event.node, @@ -301,11 +318,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const applyPreview = (target: NonNullable) => { - // Same click as the off-wall ghost: one grid-snap tick per grid step, - // keyed on the RAW cursor along-wall position (not the snapped clampedX, - // whose ~0.5m jumps would tick at a different cadence). Per-frame guard - // collapses any duplicate wall events on the same pointermove. - tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + // One grid-snap tick per real position step, keyed on the SNAPPED + // along-wall position so it ticks only when the door actually moves to a + // new cell (not on every micro mouse-move). Per-frame guard collapses any + // duplicate wall events on the same pointermove. + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX) // Keep the REAL node hidden and show a tinted ghost in the wall opening — // green when placeable, red when it collides — the same translucent ghost // the free-follow uses, so validity reads at a glance. The node position is @@ -341,12 +358,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // Position the tinted ghost at the wall opening (world frame), facing the // wall normal + the live side (so an R-flip shows correctly). The // wireframe cursor is no longer used on a wall. Tint comes from the SHARED - // placement decision — green when placeable (incl. Shift force-place over a + // placement decision — green when placeable (incl. Alt force-place over a // collision), red otherwise — the SAME `placeable` the commit gate uses. if (cursorGroupRef.current) cursorGroupRef.current.visible = false const placement = resolveOpeningPlacement({ collides: !target.valid, - forcePlace: shiftHeld, + forcePlace: altHeld, }) // The committed door is a CHILD of the wall mesh (group yaw = -wallAngle) // with wall-local `itemRotation` (0 front / π back). The ghost is a @@ -385,12 +402,14 @@ 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 } + // Valid wall hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the wall. + markWallOwnedPointer() freeFollowing = false lastTarget = target lastRoofEvent = null @@ -399,7 +418,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onWallMove = (event: WallEvent) => { - lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -418,6 +436,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => onWallLeave() return } + // Valid wall hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the wall. + markWallOwnedPointer() freeFollowing = false lastTarget = target lastRoofEvent = null @@ -503,11 +524,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (event.node.parentId !== getLevelId()) return const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - // Shift force-places: commit even when the door overlaps another opening. - // The preview keeps its red invalid tint as a warning; Shift just lifts the - // commit block. Read shift from THIS event so it's never stale at commit. + // Alt force-places: commit even when the door overlaps another opening. + // The preview keeps its red invalid tint as a warning; Alt just lifts the + // commit block. Read alt from THIS event so it's never stale at commit. if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return commitToWall(target) event.stopPropagation() } @@ -543,13 +564,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // `DoorTool` build path uses. The node still re-parents to the level so a // later wall-snap / commit has a clean base, but stays `visible:false` until // a wall is hovered. - const freeFollowAt = (localX: number, localZ: number, frame: number) => { + const freeFollowAt = (localX: number, localZ: number) => { freeFollowing = true lastTarget = null lastRoofEvent = null - // Click per grid cell as the ghost slides over open floor (X+Z) — the - // same `tickGridStep` the on-wall slide uses, so both feel identical. - tickGridStep(frame, localX, localZ) + // No snap SFX here: the free-follow fires off-wall (an invalid red ghost, + // not a placeable position) AND interleaves with the on-wall slide on the + // same pointer move (R3F `wall:move` and DOM `grid:move` carry different + // timestamps, so the de-dupe guard can't merge them). Emitting here was the + // source of the constant click while sliding a door along a wall — the + // on-wall `applyPreview` already ticks once per along-wall cell. hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) const levelId = getLevelId() @@ -593,17 +617,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => 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. + // (`onWallMove`). A wall/roof handler owning the pointer right now means the + // cursor is on a wall/roof that snaps — skip the floor follow (see + // `wallOwnsPointer`). Over open floor the door just follows the cursor. + if (wallOwnsPointer()) return const [x, , z] = event.localPosition lastFloorPoint = [x, z] - freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) + freeFollowAt(x, z) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -626,12 +648,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 } + // Valid roof hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the roof. + markWallOwnedPointer() // Wall-frame drag anchor / live transform don't apply on a roof face. freeFollowing = false dragAnchor = null @@ -670,9 +694,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - // Shift force-places over a colliding roof-face target too (see onWallClick). + // Alt force-places over a colliding roof-face target too (see onWallClick). if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return committed = true const segmentId = target.segment.id @@ -778,10 +802,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return // Free-following over open floor can't commit (no wall). A wall hover - // target commits via commitToWall; a roof face via onRoofClick. Shift + // target commits via commitToWall; a roof face via onRoofClick. Alt // force-places over a colliding wall target (the tint stays red as a - // warning); read shift from this pointerup so it's current at commit. - if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { + // warning); read alt from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) { commitToWall(lastTarget) return } @@ -821,7 +845,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } else if (lastFloorPoint) { // Free-following: re-run at the same spot so the floating ghost rebuilds // with the flipped side (its swing/hinge geometry depends on `side`). - freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1]) } else { // No preview yet (R pressed before the first pointermove at initial // placement): flip the hidden node so the FIRST preview/commit already @@ -833,15 +857,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } } - // Shift toggles force-place. Track it live and re-run the on-wall preview so - // the tint flips green↔red the instant Shift is pressed/released, even with + // Alt toggles force-place. Track it live and re-run the on-wall preview so + // the tint flips green↔red the instant Alt is pressed/released, even with // the pointer stationary — the ghost and the commit gate read the same - // `placeable`. (Commit gates still read shift fresh from their own event.) - const onShiftToggle = (e: KeyboardEvent) => { - if (e.key !== 'Shift') return + // `placeable`. (Commit gates still read alt fresh from their own event.) + const onAltToggle = (e: KeyboardEvent) => { + if (e.key !== 'Alt') return const held = e.type === 'keydown' - if (held === shiftHeld) return - shiftHeld = held + if (held === altHeld) return + altHeld = held if (!committed && lastTarget) applyPreview(lastTarget) } @@ -857,8 +881,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('keydown', onKeyDown) - window.addEventListener('keydown', onShiftToggle) - window.addEventListener('keyup', onShiftToggle) + window.addEventListener('keydown', onAltToggle) + window.addEventListener('keyup', onAltToggle) return () => { const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as @@ -907,8 +931,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keydown', onShiftToggle) - window.removeEventListener('keyup', onShiftToggle) + window.removeEventListener('keydown', onAltToggle) + window.removeEventListener('keyup', onAltToggle) } }, [movingDoorNode, exitMoveMode]) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 031328b9..48feda13 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,6 +1,6 @@ 'use client' -import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' +import { type DoorNode, useLiveNodeOverrides, useRegistry, useScene } from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' @@ -16,6 +16,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { useScene.getState().markDirty(node.id) }, [node.id]) const handlers = useNodeEvents(node, 'door') + const liveVisible = useLiveNodeOverrides((s) => { + const visible = s.get(node.id)?.visible + return typeof visible === 'boolean' ? visible : undefined + }) const isTransient = !!(node.metadata as Record | null)?.isTransient const mesh = ( @@ -26,7 +30,7 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { receiveShadow ref={ref} rotation={node.rotation} - visible={node.visible} + visible={liveVisible ?? node.visible} {...(isTransient ? {} : handlers)} > diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 3e5fc2c7..6e6b6e17 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,6 +1,5 @@ import { type AnyNodeId, - collectAlignmentAnchors, DoorNode, emitter, type GridEvent, @@ -18,6 +17,7 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + isMagneticSnapActive, isValidWallSideFace, triggerSFX, useAlignmentGuides, @@ -36,7 +36,10 @@ import { resolveRoofWallOpeningTarget, worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' -import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import { + collectWallOpeningAlignmentCandidates, + resolveWallSlideAlignment, +} from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import DoorPreview from './preview' @@ -143,7 +146,7 @@ const DoorTool: React.FC = () => { // 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, '') + let alignmentCandidates = collectWallOpeningAlignmentCandidates(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 @@ -194,19 +197,17 @@ const DoorTool: React.FC = () => { width: number, height: number, bypass: boolean, - bypassSnap: boolean, ignoreId?: string, ) => { - // bypassSnap is set by Shift (see callers). Shift = free-place: land at the - // raw cursor but keep the along-wall guides visible. bypass (Alt) still - // hard-disables alignment. + // `bypass` disables along-wall alignment — set when magnetic ("lines") + // snap is off. The grid component lives in `snapToHalf`, which is itself + // mode-aware (raw cursor when grid is off). const localX = resolveWallSlideAlignment({ wallNode: wall, rawLocalX, width, candidates: alignmentCandidates, - bypass: bypass && !bypassSnap, - freePlace: bypassSnap, + bypass, }) const { clampedX, clampedY } = clampToWall(wall, localX, width, height) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) @@ -224,9 +225,8 @@ const DoorTool: React.FC = () => { itemRotation: number cursorRotationY: number bypass: boolean - bypassSnap: boolean }) => { - const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args + const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass } = args const width = draftRef.current?.width ?? 0.9 const height = draftRef.current?.height ?? 2.1 @@ -249,7 +249,6 @@ const DoorTool: React.FC = () => { width, height, bypass, - bypassSnap, draftRef.current.id, ) @@ -361,7 +360,7 @@ const DoorTool: React.FC = () => { useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() } @@ -387,17 +386,13 @@ const DoorTool: React.FC = () => { 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, + bypass: !isMagneticSnapActive(), }) event.stopPropagation() } @@ -415,20 +410,16 @@ const DoorTool: React.FC = () => { 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, + !isMagneticSnapActive(), draftRef.current.id, ) - // Shift force-places over a collision (the draft stays red as a warning). - if (!valid && !bypassSnap) return + // Alt force-places over a collision (the draft stays red as a warning). + if (!valid && event.nativeEvent?.altKey !== true) return commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() @@ -448,9 +439,9 @@ const DoorTool: React.FC = () => { // 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. + // A wall/roof mesh handler processed this pointermove (shared DOM + // 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 @@ -523,9 +514,9 @@ const DoorTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - // Shift force-places over a colliding roof-face target (see onWallClick). + // Alt force-places over a colliding roof-face target (see onWallClick). if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return const { segment, face, position } = target const draft = draftRef.current diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index 0a184f2f..34841ef6 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -1,8 +1,30 @@ -import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core' +import { + type AlignmentAnchor, + type AnyNode, + collectAlignmentAnchors, + resolveAlignment, + type WallNode, +} from '@pascal-app/core' import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor' /** Figma-style alignment-snap threshold (meters), matching the move tools. */ export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08 + +/** + * Alignment candidates for a wall opening (door / window): only OTHER things + * hosted ON a wall — sibling openings and wall-mounted items. Floor/ground + * objects are excluded so an opening's along-wall guides line up with what's on + * the walls, never with furniture sitting on the floor below. + */ +export function collectWallOpeningAlignmentCandidates( + nodes: Readonly>, + excludeId: string, +): AlignmentAnchor[] { + return collectAlignmentAnchors(nodes, excludeId).filter((anchor) => { + const parentId = (nodes[anchor.nodeId] as { parentId?: string } | undefined)?.parentId + return !!parentId && nodes[parentId]?.type === 'wall' + }) +} /** * A wall opening (door / window) can only slide ALONG its host wall, so it can * only satisfy an x- or z-guide when the wall runs along that axis. Below this @@ -16,19 +38,14 @@ const MIN_AXIS_COMPONENT = 0.5 * Resolve a wall opening's along-wall position with Figma-style alignment to * other objects, publishing the matching guide as a side effect. * - * The probe is the RAW cursor position on the wall (not the 0.5m snap) so + * The probe is the RAW cursor position on the wall (not the grid snap) so * off-grid anchors are caught; we then keep only the guide on an axis the wall * runs along and map it to the along-wall coordinate that lands the opening on - * it. Falls back to the half-metre snap when nothing aligns, and clears the - * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall - * given `width`). `bypass` disables alignment; `bypassSnap` also skips the - * half-metre fallback. - * - * `freePlace` (Shift) is the "place anywhere, but still show me where I'd - * align" mode: the opening lands at the EXACT raw cursor (no grid snap, no - * jump-to-guide), yet the alignment guides are still computed and shown so the - * user keeps the visual reference while overriding the magnetic pull. It - * supersedes `bypass`/`bypassSnap` when set. + * it. Falls back to the grid snap when nothing aligns, and clears the guide on + * bypass / no-match. Returns the localX to use (X-clamped to the wall given + * `width`). `bypass` disables alignment — set by the caller when magnetic + * ("lines") snap is off; the grid component lives in `snapToHalf`, which is + * itself mode-aware (raw cursor when grid snap is off). */ export function resolveWallSlideAlignment(args: { wallNode: WallNode @@ -36,55 +53,9 @@ export function resolveWallSlideAlignment(args: { width: number candidates: readonly AlignmentAnchor[] bypass: boolean - bypassSnap?: boolean - freePlace?: boolean }): number { - const { - wallNode, - rawLocalX, - width, - candidates, - bypass, - bypassSnap = false, - freePlace = false, - } = args - const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX) - - const dxAxis = wallNode.end[0] - wallNode.start[0] - const dzAxis = wallNode.end[1] - wallNode.start[1] - const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis) - - // Shift / free-place: land at the raw cursor but still publish the guides so - // the user sees alignment relationships without being snapped to them. The - // guides are re-resolved at the freely-placed point so they connect to the - // opening, not the snap target. - if (freePlace) { - if (candidates.length === 0 || axisLength < 1e-6) { - useAlignmentGuides.getState().clear() - return base - } - const c = dxAxis / axisLength - const s = dzAxis / axisLength - const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base)) - const shown = resolveAlignment({ - moving: [ - { - nodeId: '__wall-opening-draft__', - kind: 'corner', - x: wallNode.start[0] + placedX * c, - z: wallNode.start[1] + placedX * s, - }, - ], - candidates, - threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M, - }) - const axisGuides = shown.guides.filter( - (g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT, - ) - if (axisGuides.length === 0) useAlignmentGuides.getState().clear() - else useAlignmentGuides.getState().set(axisGuides) - return placedX - } + const { wallNode, rawLocalX, width, candidates, bypass } = args + const base = snapToHalf(rawLocalX) if (bypass || candidates.length === 0) { useAlignmentGuides.getState().clear() diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 01616452..da5a4bd7 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -160,6 +160,7 @@ const windowHandles: HandleDescriptor[] = [ */ export const windowDefinition: NodeDefinition = { kind: 'window', + snapProfile: 'item', schemaVersion: 1, schema: WindowNode, category: 'structure', @@ -229,6 +230,8 @@ export const windowDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place window on wall' }, + { key: 'R', label: 'Flip side' }, + { key: 'Alt', label: 'Force place' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 48d6e1df..7e591f3f 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -2,13 +2,21 @@ import { type AnyNodeId, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, WallNode as WallNodeSchema, type WindowNode, } from '@pascal-app/core' -import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { + isGridSnapActive, + isMagneticSnapActive, + snapToHalf, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { @@ -32,6 +40,7 @@ import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './windo */ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const nodeId = node.id as AnyNodeId // 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) @@ -70,6 +79,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod wallId: string roofSegmentId: undefined roofFace: undefined + visible: true } | null = null // R flips the window's facing (front ↔ back) mid-placement — see @@ -83,18 +93,29 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // 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 - // Shift force-place (last apply's modifier) — lets `canCommit` allow an + // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. let forcePlace = false + let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId) + let liveOverrideKey: string | null = null + let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId + + const setLiveOverride = (key: string, values: Record) => { + if (liveOverrideKey === key) return + liveOverrideKey = key + useLiveNodeOverrides.getState().set(nodeId, values) + } // Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`): - // ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a - // wall, keyed on the RAW cursor. No separate floor→wall cue (that was the - // "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices. - const STEP_M = 0.1 + // ONE soft `sfx:grid-snap` click each time the window's PLACED position crosses + // a step. Keyed on the SNAPPED value, quantized by the live grid step in grid + // mode else a gentle fixed cadence — grid mode ticks once per cell, lines/off + // tick as the window moves. + const FREE_STEP_M = 0.1 let lastStepKey: string | null = null const tickGridStep = (...coords: number[]) => { - const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M + const key = coords.map((c) => Math.round(c / step)).join(',') if (key !== lastStepKey) { lastStepKey = key triggerSFX('sfx:grid-snap') @@ -104,9 +125,11 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod 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 }) + if (liveTransformActive) { + useLiveTransforms.getState().clear(nodeId) + liveTransformActive = false } + setLiveOverride('free-follow', { visible: false }) const half = node.width / 2 + 0.5 const wall = WallNodeSchema.parse({ start: [planPoint[0] - half, planPoint[1]], @@ -132,26 +155,18 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod visible: true, } as WindowNode usePlacementPreview.getState().set(ghost, wall) + placementPreviewActive = true } const session: FloorplanMoveTargetSession = { - affectedIds: [node.id as AnyNodeId], + affectedIds: [nodeId], flipSide() { flipped = !flipped if (lastApply) this.apply(lastApply) }, apply({ planPoint, modifiers }) { lastApply = { planPoint, modifiers } - forcePlace = modifiers.shiftKey === true - // 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) - } + forcePlace = modifiers.altKey === true const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) @@ -162,25 +177,25 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod 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 }) + if (placementPreviewActive) { + usePlacementPreview.getState().clear() + placementPreviewActive = false } // Figma-style along-wall alignment first (edge-to-edge with other - // openings / wall ends), winning over the 0.5m grid snap; falls back - // to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap. - const neighborX = - modifiers.altKey || modifiers.shiftKey - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width: node.width, - selfId: node.id as AnyNodeId, - nodes, - }) - const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) + // openings / wall ends), winning over the grid snap; falls back to grid + // when nothing aligns. Follows the magnetic ("lines") mode; the grid + // component lives in `snapToHalf` (mode-aware → raw when grid is off). + const neighborX = !isMagneticSnapActive() + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: nodeId, + nodes, + }) + const snappedLocalX = neighborX ?? snapToHalf(hit.localX) const { clampedX, clampedY } = clampToWall( hit.wall, snappedLocalX, @@ -189,10 +204,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod node.height, ) - // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`) - // so the wall slide ticks at the same cadence as the off-wall ghost — same - // SFX, no separate snap cue. - tickGridStep(hit.localX) + // One click per real position step, keyed on the SNAPPED along-wall value + // so it ticks only when the window actually moves to a new cell. + tickGridStep(clampedX) const side: WindowNode['side'] = flipped ? hit.side === 'front' @@ -211,27 +225,35 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, roofFace: undefined, + visible: true, } - useScene.getState().updateNodes([ - { - id: node.id as AnyNodeId, - data: lastValid, - }, - ]) + setLiveOverride(`wall:${hit.wall.id}:${side}`, { + parentId: hit.wall.id, + wallId: hit.wall.id, + side, + roofSegmentId: undefined, + roofFace: undefined, + visible: true, + }) + useLiveTransforms.getState().set(nodeId, { + position: lastValid.position, + rotation: itemRotation, + }) + liveTransformActive = true }, 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 - // Block on overlap UNLESS Shift force-places — same `placeable` rule as + if (!onWall || !lastValid) return false + const live = useScene.getState().nodes[nodeId] as WindowNode | undefined + if (live?.type !== 'window') return false + // Block on overlap UNLESS Alt force-places — same `placeable` rule as // the 3D move + the shared `resolveOpeningPlacement`. const collides = hasWallChildOverlap( - live.parentId as string, - live.position[0], - live.position[1], + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], live.width, live.height, live.id, @@ -249,7 +271,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod if (!lastValid) return useScene.getState().updateNodes([ { - id: node.id as AnyNodeId, + id: nodeId, data: lastValid, }, ]) diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 37ac6567..bab067a8 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,6 +1,5 @@ import { type AnyNodeId, - collectAlignmentAnchors, emitter, type GridEvent, isCurvedWall, @@ -18,6 +17,8 @@ import { consumePlacementDragRelease, EDITOR_LAYER, getSideFromNormal, + isGridSnapActive, + isMagneticSnapActive, isValidWallSideFace, snapToHalf, stripPlacementMetadataFlags, @@ -40,7 +41,10 @@ import { resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' import { resolveOpeningPlacement } from '../shared/wall-attach-target' -import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import { + collectWallOpeningAlignmentCandidates, + resolveWallSlideAlignment, +} from '../shared/wall-opening-alignment' import { WindowFloorProjection } from './floor-projection' import WindowPreview from './preview' import { @@ -149,29 +153,45 @@ 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. + // level and tracks the cursor like an item. `freeFollowing` marks that state. let freeFollowing = false - let lastMeshEventTime = -1 // Last open-floor cursor point (level-local X/Z), so an R-flip while free- // following can re-run the ghost at the same spot with the new facing. let lastFloorPoint: [number, number] | null = null - // Live Shift state (force-place) — lets the preview tint re-evaluate when - // Shift is pressed/released with the pointer stationary (see `MoveDoorTool`). - let shiftHeld = false - // Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical - // whether free-following over floor or sliding along a wall (the user's - // ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the - // snapped along-wall value. Guards: `lastStepKey` (cell change) + - // `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a - // distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`. - const STEP_M = 0.1 + // The floor free-follow (`grid:move`, a DOM event) and the wall/roof snap + // (`wall:move`/`roof:move`, R3F mesh events) are INDEPENDENT event streams + // with different clocks, so the old `event.timeStamp` de-dup never matched — + // the free-follow ran during on-wall slides too, and both wrote the scene + // node every frame (a per-frame `nodes` churn that tanked 2D + 3D framerate). + // Instead, stamp one monotonic clock whenever a wall/roof hit owns the + // pointer; the floor handler stands down while that stamp is fresh. `wall:move` + // fires every frame on-wall, so the stamp stays fresh across the pointermove + // interval and the free-follow only re-engages once the cursor is off any wall. + let wallOwnedPointerAt = Number.NEGATIVE_INFINITY + // ~4 frames: comfortably longer than the pointermove interval (so a fast + // on-wall slide never lets the floor follow slip through) yet short enough + // that leaving a wall re-engages the free-follow without a perceptible stick. + const WALL_OWNS_POINTER_MS = 64 + const markWallOwnedPointer = () => { + wallOwnedPointerAt = performance.now() + } + const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS + // Live Alt state (force-place) — lets the preview tint re-evaluate when + // Alt is pressed/released with the pointer stationary (see `MoveDoorTool`). + let altHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click each time the window's PLACED + // position crosses a step. Keyed on the SNAPPED value (passed by the caller), + // quantized by the live grid step in grid mode, else a gentle fixed cadence — + // so grid mode ticks once per cell (not on every micro mouse-move while the + // window sits in a cell) while lines/off still tick as the window moves. + // Guards: `lastStepKey` (cell change) + `lastTickFrame` (one per pointermove). + const FREE_STEP_M = 0.1 let lastStepKey: string | null = null let lastTickFrame = -1 const tickGridStep = (frame: number, ...coords: number[]) => { if (frame === lastTickFrame) return - const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M + const key = coords.map((c) => Math.round(c / step)).join(',') if (key === lastStepKey) return lastStepKey = key lastTickFrame = frame @@ -208,7 +228,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const now = globalThis.performance?.now?.() ?? Date.now() const last = lastHostDirtyAt.get(hostId) ?? 0 // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. - if (now - last > 120) { + if (now - last > 60) { lastHostDirtyAt.set(hostId, now) markHostDirty(hostId) } @@ -243,10 +263,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode setGhostPose(null) } - // Alignment candidates — anchors of every OTHER alignable object (the - // moving window is excluded so it never aligns to itself). Along-wall only; - // the floor-plane guides don't cover sill height. - const alignmentCandidates = collectAlignmentAnchors( + // Alignment candidates — only OTHER things on a wall (sibling openings + + // wall-mounted items), never ground objects, so the along-wall guides don't + // line up with furniture on the floor. The moving window is excluded. + const alignmentCandidates = collectWallOpeningAlignmentCandidates( useScene.getState().nodes, movingWindowNode.id, ) @@ -281,28 +301,23 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] if (!dragAnchor || dragAnchor.wallId !== event.node.id) { - const bypassSnap = event.nativeEvent?.shiftKey === true dragAnchor = { wallId: event.node.id, rawX: rawLocalX, rawY: rawLocalY, startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, startY: - event.node.id === original.parentId - ? original.position[1] - : bypassSnap - ? rawLocalY - : snapToHalf(rawLocalY), + event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY), } } const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY) - // Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins - // over the 0.5m grid when within threshold; Shift bypasses both. - const bypassY = event.nativeEvent?.shiftKey === true - const sillSnapped = bypassY - ? null - : resolveSillSnap({ + // Vertical sill alignment (snap + guide) is the magnetic ("lines") + // component for Y: a sibling's sill/centre/top wins over the grid when + // within threshold, so it runs only when magnetic snap is on; otherwise + // the mode-aware `snapToHalf` decides Y. + const sillSnapped = isMagneticSnapActive() + ? resolveSillSnap({ wall: event.node, movingId: movingWindowNode.id, localX: targetLocalX, @@ -311,16 +326,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode height: movingWindowNode.height, nodes: useScene.getState().nodes, }) - const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY)) + : null + const targetLocalY = sillSnapped ?? snapToHalf(targetRawLocalY) const localX = resolveWallSlideAlignment({ wallNode: event.node, rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, - // Alt still hard-disables alignment (no guides). Shift = free-place: - // land at the raw cursor but keep showing the along-wall guides. - bypass: event.nativeEvent?.altKey === true, - freePlace: event.nativeEvent?.shiftKey === true, + // Along-wall alignment follows the magnetic ("lines") mode; the grid + // component lives in `snapToHalf` (itself mode-aware). + bypass: !isMagneticSnapActive(), }) const { clampedX, clampedY } = clampToWall( event.node, @@ -352,10 +367,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const applyPreview = (target: NonNullable) => { - // Same click as the off-wall ghost: one grid-snap tick per grid step, - // keyed on the RAW cursor along-wall position (not the snapped clampedX). + // One grid-snap tick per real ALONG-WALL step, keyed on the snapped + // `clampedX` only — NOT the sill `clampedY`, which tracks the cursor's + // vertical position on the wall face and so re-keys on every micro + // mouse-move even when the window stays in the same along-wall cell. // Per-frame guard collapses duplicate wall events on the same pointermove. - tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX) // Keep the REAL node hidden and show a tinted ghost in the wall opening — // green when placeable, red when it collides — matching the free-follow // ghost so validity reads at a glance (see MoveDoorTool). The node position @@ -388,7 +405,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode markHostDirtyThrottled(target.wallId) if (cursorGroupRef.current) cursorGroupRef.current.visible = false - const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld }) + const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: altHeld }) // Ghost world yaw must equal the committed wall-CHILD's world yaw // (-wallAngle + itemRotation); `cursorRotation` is π off here. See // `MoveDoorTool.applyPreview`. @@ -424,12 +441,14 @@ 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 } + // Valid wall hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the wall. + markWallOwnedPointer() freeFollowing = false lastTarget = target lastRoofEvent = null @@ -438,7 +457,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onWallMove = (event: WallEvent) => { - lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -458,6 +476,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode onWallLeave() return } + // Valid wall hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the wall. + markWallOwnedPointer() freeFollowing = false lastTarget = target lastRoofEvent = null @@ -548,11 +569,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (event.node.parentId !== getLevelId()) return const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - // Shift force-places: commit even when the window overlaps another opening. - // The preview keeps its red invalid tint as a warning; Shift just lifts the - // commit block. Read shift from THIS event so it's never stale at commit. + // Alt force-places: commit even when the window overlaps another opening. + // The preview keeps its red invalid tint as a warning; Alt just lifts the + // commit block. Read alt from THIS event so it's never stale at commit. if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return commitToWall(target) event.stopPropagation() } @@ -585,13 +606,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Free-follow: over open floor there's no wall to host the window, so hide // the real (pale, near-invisible-on-grid) node and float a red translucent // ghost at the cursor — same treatment the raw `WindowTool` build path uses. - const freeFollowAt = (localX: number, localZ: number, frame: number) => { + const freeFollowAt = (localX: number, localZ: number) => { freeFollowing = true lastTarget = null lastRoofEvent = null - // Click per grid cell as the ghost slides over open floor (X+Z) — the - // same `tickGridStep` the on-wall slide uses, so both feel identical. - tickGridStep(frame, localX, localZ) + // No snap SFX here: the free-follow fires off-wall (an invalid red ghost, + // not a placeable position) AND interleaves with the on-wall slide on the + // same pointer move (R3F `wall:move` and DOM `grid:move` carry different + // timestamps, so the de-dupe guard can't merge them). Emitting here was the + // source of the constant click while sliding a window along a wall — the + // on-wall `applyPreview` already ticks once per along-wall cell. hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) const levelId = getLevelId() @@ -633,14 +657,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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 + // A wall/roof handler owns the pointer right now — the cursor ray is on a + // wall/roof that snaps, so skip the floor follow (see `wallOwnsPointer`). + if (wallOwnsPointer()) return const [x, , z] = event.localPosition lastFloorPoint = [x, z] - freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) + freeFollowAt(x, z) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -657,7 +679,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode ignoreId: movingWindowNode.id, vertical: { kind: 'free', - snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf, + // `snapToHalf` is mode-aware (raw cursor when grid snap is off). + snap: snapToHalf, }, }) @@ -667,12 +690,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 } + // Valid roof hit owns the pointer for the next few frames; the floor + // free-follow stands down until the cursor genuinely leaves the roof. + markWallOwnedPointer() // Wall-frame drag anchor / live transform don't apply on a roof face. freeFollowing = false dragAnchor = null @@ -710,9 +735,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - // Shift force-places over a colliding roof-face target too (see onWallClick). + // Alt force-places over a colliding roof-face target too (see onWallClick). if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return committed = true const segmentId = target.segment.id @@ -819,10 +844,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return // Free-following over open floor can't commit (no wall). A wall hover - // target commits via commitToWall; a roof face via onRoofClick. Shift + // target commits via commitToWall; a roof face via onRoofClick. Alt // force-places over a colliding wall target (tint stays red as a warning); - // read shift from this pointerup so it's current at commit. - if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { + // read alt from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) { commitToWall(lastTarget) return } @@ -859,7 +884,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } else if (lastFloorPoint) { // Free-following: re-run at the same spot so the floating ghost rebuilds // with the flipped side. - freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1]) } else { // No preview yet (R before the first pointermove): flip the hidden node // so the first preview/commit already reflects the chosen side. @@ -870,13 +895,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } } - // Shift toggles force-place — re-run the on-wall preview so the tint flips - // green↔red live (pointer stationary). Commit gates still read shift fresh. - const onShiftToggle = (e: KeyboardEvent) => { - if (e.key !== 'Shift') return + // Alt toggles force-place — re-run the on-wall preview so the tint flips + // green↔red live (pointer stationary). Commit gates still read alt fresh. + const onAltToggle = (e: KeyboardEvent) => { + if (e.key !== 'Alt') return const held = e.type === 'keydown' - if (held === shiftHeld) return - shiftHeld = held + if (held === altHeld) return + altHeld = held if (!committed && lastTarget) applyPreview(lastTarget) } @@ -892,8 +917,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) window.addEventListener('keydown', onKeyDown) - window.addEventListener('keydown', onShiftToggle) - window.addEventListener('keyup', onShiftToggle) + window.addEventListener('keydown', onAltToggle) + window.addEventListener('keyup', onAltToggle) return () => { // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) @@ -941,8 +966,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keydown', onShiftToggle) - window.removeEventListener('keyup', onShiftToggle) + window.removeEventListener('keydown', onAltToggle) + window.removeEventListener('keyup', onAltToggle) } }, [movingWindowNode, exitMoveMode]) diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 0c670b28..e10cd5bf 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRegistry, useScene, type WindowNode } from '@pascal-app/core' +import { useLiveNodeOverrides, useRegistry, useScene, type WindowNode } from '@pascal-app/core' import { createMaterial, DEFAULT_WINDOW_MATERIAL, @@ -20,6 +20,10 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'window') const shading = useViewer((s) => s.shading) + const liveVisible = useLiveNodeOverrides((s) => { + const visible = s.get(node.id)?.visible + return typeof visible === 'boolean' ? visible : undefined + }) const isTransient = !!(node.metadata as Record | null)?.isTransient const material = useMemo(() => { @@ -40,7 +44,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { position={node.position} ref={ref} rotation={node.rotation} - visible={node.visible} + visible={liveVisible ?? node.visible} {...(isTransient ? {} : handlers)} > diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c0b48113..e37aab09 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,6 +1,5 @@ import { type AnyNodeId, - collectAlignmentAnchors, emitter, type GridEvent, isCurvedWall, @@ -18,6 +17,7 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + isMagneticSnapActive, isValidWallSideFace, snapToHalf, triggerSFX, @@ -38,7 +38,10 @@ import { resolveRoofWallOpeningTarget, worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' -import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' +import { + collectWallOpeningAlignmentCandidates, + resolveWallSlideAlignment, +} from '../shared/wall-opening-alignment' import { WindowFloorProjection } from './floor-projection' import WindowPreview from './preview' import { @@ -157,7 +160,7 @@ const WindowTool: React.FC = () => { // Alignment candidates — anchors of every alignable object; refreshed // after each placement. A window aligns by the plan position of its centre // (along-wall only; the floor-plane guides don't cover sill height). - let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + let alignmentCandidates = collectWallOpeningAlignmentCandidates(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 @@ -209,9 +212,11 @@ const WindowTool: React.FC = () => { } // Sill alignment (snap + guide): a sibling sill/centre/top wins over the - // 0.5m grid when within threshold; Shift bypasses both. `movingId` is the - // draft's id once it exists (so it's excluded from the sibling scan), or '' - // before the draft is created (nothing to exclude yet). + // grid when within threshold — it's the magnetic ("lines") component for the + // vertical axis, so it runs only when magnetic snap is on; otherwise the + // grid `snapToHalf` (itself mode-aware) decides Y. `movingId` is the 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: { wall: WallNode movingId: string @@ -219,18 +224,18 @@ const WindowTool: React.FC = () => { rawLocalY: number width: number height: number - bypassSnap: boolean }): number => { - if (args.bypassSnap) return args.rawLocalY - const sillY = resolveSillSnap({ - wall: args.wall, - movingId: args.movingId, - localX: args.localX, - localY: args.rawLocalY, - width: args.width, - height: args.height, - nodes: useScene.getState().nodes, - }) + const sillY = isMagneticSnapActive() + ? resolveSillSnap({ + wall: args.wall, + movingId: args.movingId, + localX: args.localX, + localY: args.rawLocalY, + width: args.width, + height: args.height, + nodes: useScene.getState().nodes, + }) + : null return sillY ?? snapToHalf(args.rawLocalY) } @@ -242,19 +247,16 @@ const WindowTool: React.FC = () => { width: number, height: number, bypass: boolean, - bypassSnap: boolean, ignoreId?: string, ) => { - // bypassSnap is set by Shift (see callers). Shift = free-place: land at the - // raw cursor but keep the along-wall guides visible. bypass (Alt) still - // hard-disables alignment. + // `bypass` disables along-wall alignment — set when magnetic ("lines") + // snap is off. The grid component lives in `snapToHalf` (mode-aware). const localX = resolveWallSlideAlignment({ wallNode: wall, rawLocalX, width, candidates: alignmentCandidates, - bypass: bypass && !bypassSnap, - freePlace: bypassSnap, + bypass, }) const localY = resolvePlacementY({ wall, @@ -263,7 +265,6 @@ const WindowTool: React.FC = () => { rawLocalY, width, height, - bypassSnap, }) const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) @@ -282,18 +283,8 @@ const WindowTool: React.FC = () => { itemRotation: number cursorRotationY: number bypass: boolean - bypassSnap: boolean }) => { - const { - wall, - rawLocalX, - rawLocalY, - side, - itemRotation, - cursorRotationY, - bypass, - bypassSnap, - } = args + const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, bypass } = args const width = draftRef.current?.width ?? 1.5 const height = draftRef.current?.height ?? 1.5 @@ -317,7 +308,6 @@ const WindowTool: React.FC = () => { width, height, bypass, - bypassSnap, draftRef.current.id, ) @@ -423,7 +413,7 @@ const WindowTool: React.FC = () => { useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() } @@ -449,8 +439,6 @@ const WindowTool: React.FC = () => { 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, @@ -459,8 +447,7 @@ const WindowTool: React.FC = () => { side, itemRotation, cursorRotationY: cursorRotation, - bypass, - bypassSnap, + bypass: !isMagneticSnapActive(), }) event.stopPropagation() } @@ -478,8 +465,6 @@ const WindowTool: React.FC = () => { 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, @@ -487,12 +472,11 @@ const WindowTool: React.FC = () => { event.localPosition[1], draftRef.current.width, draftRef.current.height, - bypass, - bypassSnap, + !isMagneticSnapActive(), draftRef.current.id, ) - // Shift force-places over a collision (the draft stays red as a warning). - if (!valid && !bypassSnap) return + // Alt force-places over a collision (the draft stays red as a warning). + if (!valid && event.nativeEvent?.altKey !== true) return commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() @@ -512,9 +496,9 @@ const WindowTool: React.FC = () => { // 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. + // A wall/roof mesh handler processed this pointermove (shared DOM + // 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 @@ -540,7 +524,8 @@ const WindowTool: React.FC = () => { ignoreId: draftRef.current?.id, vertical: { kind: 'free', - snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf, + // `snapToHalf` is mode-aware (raw cursor when grid snap is off). + snap: snapToHalf, }, }) @@ -591,9 +576,9 @@ const WindowTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - // Shift force-places over a colliding roof-face target (see onWallClick). + // Alt force-places over a colliding roof-face target (see onWallClick). if (!target) return - if (!target.valid && event.nativeEvent?.shiftKey !== true) return + if (!target.valid && event.nativeEvent?.altKey !== true) return const { segment, face, position } = target const draft = draftRef.current