Files
editor/packages/nodes/src/door/floorplan-move.ts
T
Aymeric RabotandClaude Fable 5 bdfee058bd feat: doors and windows on roof-segment wall faces
Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.

- core: roof-segment-walls.ts models the four vertical faces as 2D
  frames (u along face, v height) with convex profile polygons that
  mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
  clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
  segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
  subtracts from the wall brush only. cascadesViaHostSegment keeps the
  roof-merge loop from consuming door/window dirty marks (their own
  systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
  preset path), with roofSegmentId cleared/restored across every
  roof<->wall re-anchor and revert; shared hit resolver normalizes
  normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
  mode — the uncut rebuild used to draw over the merged shell's fresh
  opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
  visibility; stale uncut segment CSG inside the hidden segments-wrapper
  blocked the player at openings the merged shell had cut through.

Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:39:13 -04:00

160 lines
6.1 KiB
TypeScript

import {
type AnyNodeId,
type DoorNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
type WallNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
getRoofHostedOpeningLevelId,
getRoofHostedOpeningPlanPoint,
} from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
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.
const startLevelId = (() => {
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
// segment → roof → level. Cached at start because the parent chain
// doesn't change during a move.
const nodes = useScene.getState().nodes
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
if (roofLevelId) return roofLevelId
const wall = nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
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,
})
// 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
} | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position
// 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; Shift drops the grid snap for fine positioning.
const neighborX = modifiers.altKey
? 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)
lastValid = {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.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,
}
// 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() {
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(
live.parentId as string,
live.position[0],
live.position[1],
live.width,
live.height,
live.id,
)
return !overlapping
},
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
}