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