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>
160 lines
6.1 KiB
TypeScript
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
|
|
}
|