Files
editor/packages/nodes/src/shared/roof-opening-host.ts
T
Aymeric RabotandClaude Fable 5 7879b83df3 feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.

- Children store roofFace + position [u, v, z-from-mid-plane] with
  rotation 0 in-frame — the exact wall-child conventions (the wall
  volume's mid-plane lands on the nominal footprint). A shared
  <RoofFaceHostFrame> derives segment pose + face frame from the
  live-override-merged segment: children follow resize handle drags in
  real time and never jump on commit. No re-anchor cascade needed —
  position is authoritative, the frame is derived. migrateNodes
  converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
  coordinator (surface 'roof-wall'), Shift free-place normalized with
  walls, ItemSystem wall-side push extended to segment hosts, correct
  2D plan glyphs via face→segment→roof pose composition. The roof
  hit resolver + overlap guard moved to @pascal-app/editor (the
  coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
  merged-shell and per-segment CSG paths (full edit mode / painted
  segments used to lose every hole), built from the CURRENT host
  geometry and live-effective children so holes follow segment and
  opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
  into the portal frame instead of composing parent+node registry
  poses — correct for any nesting (the face-frame group broke the
  old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
  duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
  re-anchor clears and every revert restores them.

Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.

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

121 lines
4.1 KiB
TypeScript

import type {
AnyNode,
AnyNodeId,
RoofNode,
RoofSegmentNode,
RoofWallFaceId,
} from '@pascal-app/core'
import {
getMaxRoofRectHeightFromAnchor,
getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace,
roofFacePointToSegment,
} from '@pascal-app/core'
/**
* Host-side helpers for openings (door / window) hosted on a roof-segment
* wall face: resize-handle limits derived from the face profile, and the
* plan-space anchors the 2D floor-plan move path needs. Hosted children
* store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`.
*/
type RoofHostedOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
parentId: string | null
position: [number, number, number]
width: number
height: number
}
type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
if (!(node.roofSegmentId && node.roofFace)) return null
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
}
/**
* Resize-handle width limit for a roof-hosted opening: the opposite edge
* is anchored, `growSign` (+1 = door-local +X arrow) is the direction
* the dragged edge moves. Null when the node is not roof-hosted.
*/
export function readRoofFaceWidthMax(
node: RoofHostedOpening,
scene: SceneReader,
growSign: number,
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorU = node.position[0] - (growSign * node.width) / 2
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
}
/**
* Resize-handle height limit for a roof-hosted opening. `growSign` +1 =
* bottom edge anchored, top grows up; -1 = top anchored, bottom grows
* down. Null when the node is not roof-hosted.
*/
export function readRoofFaceHeightMax(
node: RoofHostedOpening,
scene: SceneReader,
growSign: number,
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorV = node.position[1] - (growSign * node.height) / 2
return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign)
}
/**
* Level hosting a roof-hosted opening's roof (opening → segment → roof →
* level). Null when the parent chain isn't roof-shaped.
*/
export function getRoofHostedOpeningLevelId(
node: { parentId: string | null },
nodes: Record<string, AnyNode | undefined>,
): AnyNodeId | null {
const segment = node.parentId ? nodes[node.parentId] : undefined
if (segment?.type !== 'roof-segment') return null
const roof = segment.parentId ? nodes[segment.parentId] : undefined
if (roof?.type !== 'roof') return null
return (roof.parentId as AnyNodeId | null) ?? null
}
/**
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
* through the face frame, then composed through the segment's and roof's
* yaw + position.
*/
export function getRoofHostedOpeningPlanPoint(
node: {
parentId: string | null
roofFace?: RoofWallFaceId
position: [number, number, number]
},
nodes: Record<string, AnyNode | undefined>,
): [number, number] | null {
const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined
if (segment?.type !== 'roof-segment' || !node.roofFace) return null
const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined
if (roof?.type !== 'roof') return null
const rotate = (x: number, z: number, yaw: number): [number, number] => [
x * Math.cos(yaw) + z * Math.sin(yaw),
-x * Math.sin(yaw) + z * Math.cos(yaw),
]
const segLocal = roofFacePointToSegment(segment, node.roofFace, [
node.position[0],
node.position[1],
node.position[2],
])
const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0)
const segX = sx + segment.position[0]
const segZ = sz + segment.position[2]
const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0)
return [rx + roof.position[0], rz + roof.position[2]]
}