fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration

The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move
(freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move
(DOM) de-dup compared event.timeStamp across two event systems with different
clocks, so it never matched and the floor free-follow ran during on-wall
slides too, ping-ponging the host and churning the nodes ref → framerate
collapse in both 2D and 3D. Replace it with a single-clock wall-ownership
window (performance.now, ~4 frames): the floor follow stands down while a
wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh +
useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that
the per-frame churn is gone.

Also completes the door/window modifier-model migration (#10): Shift=cycle /
Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude
ground-line candidates from along-wall opening alignment; emit the move SFX
once per snapped step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 10:21:37 -04:00
co-authored by Claude Opus 4.8
parent 8a57105eec
commit 76096ffe72
13 changed files with 486 additions and 439 deletions
@@ -141,6 +141,7 @@ type NodeDeps = {
highlighted: boolean
hovered: boolean
moving: boolean
liveOverride: LiveNodeOverrides | undefined
palette: FloorplanPalette | undefined
siblingEpoch: number
committedNodes: Record<string, AnyNode> | null
@@ -218,7 +219,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
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<string, AnyNode>,
@@ -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',
+7 -20
View File
@@ -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
+3
View File
@@ -166,6 +166,7 @@ const doorHandles: HandleDescriptor<DoorNodeType>[] = [
*/
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
snapProfile: 'item',
schemaVersion: 1,
schema: DoorNode,
category: 'structure',
@@ -251,6 +252,8 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
{ key: 'R', label: 'Flip side' },
{ key: 'Alt', label: 'Force place' },
{ key: 'Esc', label: 'Cancel' },
],
+77 -64
View File
@@ -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<DoorNode> = ({ 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<DoorNode> = ({ 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<DoorNode> = ({ 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<string, unknown>) => {
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<DoorNode> = ({ 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<DoorNode> = ({ 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<DoorNode> = ({ 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<DoorNode> = ({ 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<DoorNode> = ({ node })
if (!lastValid) return
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
id: nodeId,
data: lastValid,
},
])
+97 -73
View File
@@ -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<typeof lastTarget>) => {
// 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])
+6 -2
View File
@@ -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<string, unknown> | 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)}
>
<boxGeometry args={[0, 0, 0]} />
+21 -30
View File
@@ -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
@@ -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<Record<string, AnyNode>>,
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()
+3
View File
@@ -160,6 +160,7 @@ const windowHandles: HandleDescriptor<WindowNodeType>[] = [
*/
export const windowDefinition: NodeDefinition<typeof WindowNode> = {
kind: 'window',
snapProfile: 'item',
schemaVersion: 1,
schema: WindowNode,
category: 'structure',
@@ -229,6 +230,8 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
toolHints: [
{ key: 'Left click', label: 'Place window on wall' },
{ key: 'R', label: 'Flip side' },
{ key: 'Alt', label: 'Force place' },
{ key: 'Esc', label: 'Cancel' },
],
+76 -54
View File
@@ -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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ 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<string, unknown>) => {
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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ nod
if (!lastValid) return
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
id: nodeId,
data: lastValid,
},
])
+102 -77
View File
@@ -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<typeof lastTarget>) => {
// 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])
+6 -2
View File
@@ -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<string, unknown> | 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)}
>
<boxGeometry args={[0, 0, 0]} />
+38 -53
View File
@@ -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