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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5fd9be05df
commit
7879b83df3
@@ -1,23 +1,29 @@
|
||||
import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
RoofWallFaceId,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getMaxRoofRectHeightFromAnchor,
|
||||
getMaxRoofRectWidthFromAnchor,
|
||||
getRoofSegmentWallFace,
|
||||
getRoofWallFaceIdFromYaw,
|
||||
segmentPointToRoofWallFace,
|
||||
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.
|
||||
* 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]
|
||||
rotation: [number, number, number]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
@@ -25,14 +31,10 @@ type RoofHostedOpening = {
|
||||
type SceneReader = { get: (id: AnyNodeId) => unknown }
|
||||
|
||||
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
|
||||
if (!node.roofSegmentId) return null
|
||||
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
|
||||
const faceId = getRoofWallFaceIdFromYaw(node.rotation[1])
|
||||
if (!faceId) return null
|
||||
const face = getRoofSegmentWallFace(segment, faceId)
|
||||
const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position)
|
||||
return { segment, face, u, v }
|
||||
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +49,8 @@ export function readRoofFaceWidthMax(
|
||||
): number | null {
|
||||
const host = resolveHostFace(node, scene)
|
||||
if (!host) return null
|
||||
const anchorU = host.u - (growSign * node.width) / 2
|
||||
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height)
|
||||
const anchorU = node.position[0] - (growSign * node.width) / 2
|
||||
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +65,8 @@ export function readRoofFaceHeightMax(
|
||||
): number | null {
|
||||
const host = resolveHostFace(node, scene)
|
||||
if (!host) return null
|
||||
const anchorV = host.v - (growSign * node.height) / 2
|
||||
return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign)
|
||||
const anchorV = node.position[1] - (growSign * node.height) / 2
|
||||
return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +74,7 @@ export function readRoofFaceHeightMax(
|
||||
* level). Null when the parent chain isn't roof-shaped.
|
||||
*/
|
||||
export function getRoofHostedOpeningLevelId(
|
||||
node: RoofHostedOpening,
|
||||
node: { parentId: string | null },
|
||||
nodes: Record<string, AnyNode | undefined>,
|
||||
): AnyNodeId | null {
|
||||
const segment = node.parentId ? nodes[node.parentId] : undefined
|
||||
@@ -83,15 +85,20 @@ export function getRoofHostedOpeningLevelId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-plan [x, z] of a roof-hosted opening — its segment-local center
|
||||
* composed through the segment's and roof's yaw + position.
|
||||
* 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: RoofHostedOpening,
|
||||
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') return null
|
||||
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
|
||||
|
||||
@@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint(
|
||||
-x * Math.sin(yaw) + z * Math.cos(yaw),
|
||||
]
|
||||
|
||||
const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user