Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.
UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
(footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
warning) + free-place (lands at the raw cursor but keeps the alignment guides
visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
commit places (cursorRotation was π off for the asymmetric ghost). R ownership
follows the current pointer pane (capture-phase + stopImmediatePropagation in
the 2D overlay) so 3D and 2D never double-flip or go dead.
Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
shared/wall-attach-target.ts — the single source of truth the ghost tint AND
the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
(door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.
Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
type DoorNode,
|
|
type FloorplanMoveTarget,
|
|
type FloorplanMoveTargetSession,
|
|
useLiveTransforms,
|
|
useScene,
|
|
type WallNode,
|
|
WallNode as WallNodeSchema,
|
|
} from '@pascal-app/core'
|
|
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
|
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
|
import {
|
|
findClosestWallInPlan,
|
|
projectWallLocalPointToPlan,
|
|
resolveOpeningPlacement,
|
|
snapLocalXToNeighbors,
|
|
} from '../shared/wall-attach-target'
|
|
import { clampToWall, hasWallChildOverlap } from './door-math'
|
|
|
|
/**
|
|
* 2D floor-plan move handler for door — kicks in when the user clicks
|
|
* "Move" on the door inspector (or action menu) and the floor-plan
|
|
* view is active. Pointer in plan space → snap to nearest wall →
|
|
* project onto wall axis → snap local-X to 0.5m grid → clamp inside
|
|
* wall bounds → commit via `useScene.updateNodes`.
|
|
*
|
|
* Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing:
|
|
* - Re-parents on transition between walls (parentId + wallId).
|
|
* - Adapts `side` + `rotation` from the wall normal under the pointer.
|
|
* - hasWallChildOverlap blocks committing overlapping placements.
|
|
*
|
|
* Curved walls are skipped by `findClosestWallInPlan` — same guardrail
|
|
* as the 3D port and the legacy `DoorTool` / `MoveDoorTool`.
|
|
*/
|
|
|
|
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
|
// Snapshot of the door's "valid" state at move-start — used by
|
|
// canCommit to decide whether the current snapped position is OK.
|
|
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
|
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
|
// Cached at start because the parent chain doesn't change during a move.
|
|
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
|
const originalWall = node.parentId
|
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
|
: undefined
|
|
const resolveCursor = createFloorplanCursorResolver({
|
|
original:
|
|
originalWall?.type === 'wall'
|
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
|
metadata: node.metadata,
|
|
// Absolute: query the wall snap with the TRUE cursor, not the door's
|
|
// original wall position plus a grab delta. A wall-hosted opening always
|
|
// belongs to the wall nearest the cursor (the user's rule), and the 3D
|
|
// move snaps on the wall literally under the ray — relative mode would
|
|
// anchor the search to the old wall and resist hopping to a closer one
|
|
// across a thin gap, picking the "far" wall the user reported. It also
|
|
// makes the 2D Voronoi overlay (classified by cursor) predict the snap.
|
|
mode: 'absolute',
|
|
})
|
|
|
|
// Track the last successful placement so `commit()` can write it
|
|
// atomically — see the comment on `commit` below for why we don't
|
|
// rely on the dispatcher's diff path.
|
|
let lastValid: {
|
|
position: [number, number, number]
|
|
rotation: [number, number, number]
|
|
side: DoorNode['side']
|
|
parentId: string
|
|
wallId: string
|
|
roofSegmentId: undefined
|
|
roofFace: undefined
|
|
} | null = null
|
|
|
|
// R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives
|
|
// the wall-facing side every move, so the flip is a persistent XOR applied on
|
|
// top of the wall hit, plus a π rotation offset (matching the committed R).
|
|
let flipped = false
|
|
// Remember the last apply args so the overlay's R keydown can re-run `apply`
|
|
// (which has no event of its own) to show the flip immediately.
|
|
let lastApply: {
|
|
planPoint: readonly [number, number]
|
|
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
|
|
} | null = null
|
|
// Whether the cursor is currently over a wall. Off-wall the door free-follows
|
|
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
|
|
// needs a wall. Starts true so a click before any move keeps the door put.
|
|
let onWall = true
|
|
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
|
|
// overlapping placement, matching the 3D move. Read in `canCommit` so a Shift-
|
|
// held commit over a collision lands instead of reverting.
|
|
let forcePlace = false
|
|
|
|
// Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click
|
|
// per grid step, identical free-following or sliding on a wall, keyed on the
|
|
// RAW cursor (not the snapped along-wall value). No separate floor→wall cue —
|
|
// that distinct sound was the "double" the user heard. 2D `apply` runs once per
|
|
// pointermove, so the step-key dedup is sufficient (no per-frame guard needed).
|
|
const STEP_M = 0.1
|
|
let lastStepKey: string | null = null
|
|
const tickGridStep = (...coords: number[]) => {
|
|
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
|
if (key !== lastStepKey) {
|
|
lastStepKey = key
|
|
triggerSFX('sfx:grid-snap')
|
|
}
|
|
}
|
|
|
|
// Off-wall: float the faithful door symbol at the cursor (via a synthetic
|
|
// wall fed to the placement-preview layer) and hide the real node, so the
|
|
// ghost follows the cursor in 2D instead of the door staying frozen on its
|
|
// old wall. Mirrors the fresh-placement free-follow.
|
|
const freeFollow = (planPoint: readonly [number, number]) => {
|
|
onWall = false
|
|
lastValid = null
|
|
if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
|
|
}
|
|
const half = node.width / 2 + 0.5
|
|
const wall = WallNodeSchema.parse({
|
|
start: [planPoint[0] - half, planPoint[1]],
|
|
end: [planPoint[0] + half, planPoint[1]],
|
|
thickness: 0.1,
|
|
})
|
|
// Reflect the R-flip on the floating ghost so its swing-arc faces the side
|
|
// that will be committed (the synthetic wall is plan-X aligned, so a back
|
|
// facing is a π yaw; the symbol builder also reads `side`).
|
|
const ghostSide: DoorNode['side'] = flipped
|
|
? node.side === 'front'
|
|
? 'back'
|
|
: 'front'
|
|
: node.side
|
|
const ghost = {
|
|
...node,
|
|
side: ghostSide,
|
|
parentId: wall.id,
|
|
wallId: wall.id,
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
position: [half, node.position[1], 0] as [number, number, number],
|
|
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
|
|
visible: true,
|
|
} as DoorNode
|
|
usePlacementPreview.getState().set(ghost, wall)
|
|
}
|
|
|
|
const session: FloorplanMoveTargetSession = {
|
|
affectedIds: [node.id as AnyNodeId],
|
|
flipSide() {
|
|
flipped = !flipped
|
|
if (lastApply) this.apply(lastApply)
|
|
},
|
|
apply({ planPoint, modifiers }) {
|
|
lastApply = { planPoint, modifiers }
|
|
forcePlace = modifiers.shiftKey === true
|
|
// Drop any stale live transform left by the 3D `MoveDoorTool` (it
|
|
// publishes one on every wall hover). The 2D registry layer renders
|
|
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node,
|
|
// but this 2D move writes the scene node — so a leftover 3D entry would
|
|
// freeze the symbol on its wall and the slide wouldn't show. Only the
|
|
// 2D path runs during an opening move (the panel gates the 3D tool's
|
|
// events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded
|
|
// on existence: `clear` always allocates a new Map + re-renders.
|
|
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
|
|
useLiveTransforms.getState().clear(node.id as AnyNodeId)
|
|
}
|
|
const nodes = useScene.getState().nodes
|
|
const resolvedPlanPoint = resolveCursor(planPoint)
|
|
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
|
if (!hit) {
|
|
// Off any wall — free-follow the cursor (not committable). Click per grid
|
|
// cell as the ghost slides over open floor.
|
|
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
|
|
freeFollow(resolvedPlanPoint)
|
|
return
|
|
}
|
|
// Back on a wall — drop the free-follow ghost + reveal the real node.
|
|
onWall = true
|
|
usePlacementPreview.getState().clear()
|
|
if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
|
}
|
|
|
|
// Figma-style along-wall alignment first (edge-to-edge with other
|
|
// openings / wall ends); it competes with — and wins over — the 0.5m
|
|
// grid snap. Falls back to the grid snap when nothing aligns. Alt
|
|
// bypasses alignment; Shift bypasses all snap.
|
|
const neighborX =
|
|
modifiers.altKey || modifiers.shiftKey
|
|
? null
|
|
: snapLocalXToNeighbors({
|
|
wall: hit.wall,
|
|
localX: hit.localX,
|
|
width: node.width,
|
|
selfId: node.id as AnyNodeId,
|
|
nodes,
|
|
})
|
|
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
|
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
|
|
|
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`,
|
|
// not the snapped value) so the wall slide ticks at the same cadence as the
|
|
// off-wall ghost — the same SFX, no separate snap cue.
|
|
tickGridStep(hit.localX)
|
|
|
|
// Apply the R-flip on top of the wall-derived side.
|
|
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
|
|
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
|
|
|
lastValid = {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: hit.wall.id,
|
|
wallId: hit.wall.id,
|
|
// Re-anchoring to a wall ends any roof-segment hosting; the
|
|
// overlay's snapshot restores it if the move is reverted.
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
}
|
|
|
|
// Build the updates atomically — position + rotation + side +
|
|
// parentId + wallId in a single scene write. The current door's
|
|
// parent might be a different wall; re-anchoring requires moving
|
|
// the node in the parent's children list (the registry's
|
|
// updateNode does this when parentId changes).
|
|
useScene.getState().updateNodes([
|
|
{
|
|
id: node.id as AnyNodeId,
|
|
data: lastValid,
|
|
},
|
|
])
|
|
},
|
|
canCommit() {
|
|
// Off-wall the door is free-following in mid-air — not placeable. The
|
|
// overlay then reverts to the pre-move snapshot (door returns to its
|
|
// original wall), matching the 3D move where an open-floor click commits
|
|
// nothing.
|
|
if (!onWall) return false
|
|
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
|
|
if (!live || live.type !== 'door') return false
|
|
// Block commit if the door overlaps another wall child — UNLESS Shift
|
|
// force-places (same `placeable` rule as the 3D move + the shared
|
|
// `resolveOpeningPlacement`).
|
|
const collides = hasWallChildOverlap(
|
|
live.parentId as string,
|
|
live.position[0],
|
|
live.position[1],
|
|
live.width,
|
|
live.height,
|
|
live.id,
|
|
)
|
|
return resolveOpeningPlacement({ collides, forcePlace }).placeable
|
|
},
|
|
commit() {
|
|
// Own the atomic write so the overlay takes the deterministic
|
|
// commit-path (revert → resume → session.commit()). The dispatcher's
|
|
// diff path would otherwise re-derive the final state by comparing
|
|
// the post-apply scene to the snapshot — that works most of the
|
|
// time, but produces an empty diff (and silent revert) when the
|
|
// committed move happens to land on the same `parentId` AND has
|
|
// been re-applied with identical data. Owning commit removes that
|
|
// foot-gun without forcing the dispatcher to track per-key writes.
|
|
if (!lastValid) return
|
|
useScene.getState().updateNodes([
|
|
{
|
|
id: node.id as AnyNodeId,
|
|
data: lastValid,
|
|
},
|
|
])
|
|
},
|
|
}
|
|
|
|
return session
|
|
}
|