Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Read the relevant page in `wiki/architecture/` **before** writing code. The page

- Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md`
- Adding a tool → `tools.md`, `spatial-queries.md`, `events.md`
- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR)
- Adding a system → `systems.md`, `scene-registry.md`
- Anything in `packages/viewer` → `viewer-isolation.md`, `layers.md`
- Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() {
// to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now.
let hasMovedSinceStart = false
// Live cursor location — updated on EVERY pointermove (even over the 3D
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
let pointerOverFloorplan = false
const onPointerTrack = (event: PointerEvent) => {
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
}

const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only
Expand Down Expand Up @@ -288,18 +296,24 @@ export function FloorplanRegistryMoveOverlay() {
// apply so the 2D symbol updates immediately; kinds without a facing
// leave `flipSide` unset and R falls through to the global handler.
//
// Only when THIS overlay is the active mover — i.e. the user has moved
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool
// also listens for R while a door/window `movingNode` is placed (split
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from
// both flipping (or double-playing the cue) on a single R press.
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
// move tool ALSO listens for R on `window`. We own R only while the
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
// first) so the 3D tool can't also flip. When the cursor is over the 3D
// pane we yield — the 3D tool owns R there. (The old sticky
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
// the first 2D move, killing the 3D flip.)
if (event.key === 'r' || event.key === 'R') {
if (!(session.flipSide && hasMovedSinceStart)) return
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
if (event.repeat) return
const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
session.flipSide()
sfxEmitter.emit('sfx:item-rotate')
return
Expand Down Expand Up @@ -349,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() {
setMovingNode(null)
}

window.addEventListener('pointermove', onPointerTrack)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey)
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
// then pre-empts it so only one handler flips.
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('pointermove', onPointerTrack)
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
window.removeEventListener('keydown', onKey, true)
// Unmount cleanup. `historyPaused === true` here means none of
// our terminal paths (commit, Esc) ran in this overlay — they
// each call `resumeSceneHistory` and flip the flag.
Expand Down
74 changes: 5 additions & 69 deletions packages/nodes/src/door/door-math.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import type { WallNode } from '@pascal-app/core'

/**
* Keep the door handle at the same relative height when the door is resized:
Expand Down Expand Up @@ -64,63 +56,7 @@ export function clampToWall(
return { clampedX, clampedY }
}

/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW

for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue

let childLeft: number, childRight: number, childBottom: number, childTop: number

if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1]
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2
childTop = door.position[1] + door.height / 2
} else {
continue
}

const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}

return false
}
// Wall-child overlap is shared by door + window placement (one source of
// truth in `shared/wall-attach-target.ts`). Re-exported here so existing
// `./door-math` importers don't change.
export { hasWallChildOverlap } from '../shared/wall-attach-target'
52 changes: 45 additions & 7 deletions packages/nodes/src/door/floorplan-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import {
type WallNode,
WallNode as WallNodeSchema,
} from '@pascal-app/core'
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
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'
Expand Down Expand Up @@ -87,6 +88,25 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
// needs a wall. Starts true so a click before any move keeps the door put.
let onWall = true
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move. Read in `canCommit` so a Shift-
// 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
Expand All @@ -104,14 +124,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
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, 0, 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)
Expand All @@ -125,6 +154,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
},
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,
Expand All @@ -140,7 +170,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) {
// Off any wall — free-follow the cursor (not committable).
// 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
}
Expand Down Expand Up @@ -168,6 +200,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
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)
Expand Down Expand Up @@ -204,17 +241,18 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its
// current position. The 3D port has the same guard.
const overlapping = hasWallChildOverlap(
// 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 !overlapping
return resolveOpeningPlacement({ collides, forcePlace }).placeable
},
commit() {
// Own the atomic write so the overlay takes the deterministic
Expand Down
Loading
Loading