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
@@ -206,12 +206,13 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
// siblings of GLB items inside the unified `items` table.
|
||||
//
|
||||
// Items can be hosted on walls (assets with `attachTo: 'wall'`)
|
||||
// via `wallId` + `wallT`. When a composition that includes a
|
||||
// wall-hosted item is saved as a preset (a sconce, a hanging
|
||||
// shelf, etc.), the host app strips these via `getHostRefFields(def)`
|
||||
// so the descendant re-attaches against the new wall geometry at
|
||||
// via `wallId` + `wallT`, or on a roof-segment wall face via
|
||||
// `roofSegmentId`. When a composition that includes a wall-hosted
|
||||
// item is saved as a preset (a sconce, a hanging shelf, etc.), the
|
||||
// host app strips these via `getHostRefFields(def)` so the
|
||||
// descendant re-attaches against the new host geometry at
|
||||
// placement time.
|
||||
hostRefFields: ['wallId', 'wallT'],
|
||||
hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'],
|
||||
// Floor items get lifted by slabs underneath via the generic
|
||||
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
|
||||
// their parent's local frame and skip the lift via `applies`.
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
getRoofWallFaceFrame,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
movingFootprintAnchors,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
|
||||
@@ -95,6 +98,31 @@ function resolveItemPlanTransform(
|
||||
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
|
||||
rotation: parentTransform.rotation + localRotation,
|
||||
}
|
||||
} else if (parent?.type === 'roof-segment') {
|
||||
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
|
||||
// frame, then composed through the segment's and roof's yaw +
|
||||
// position into level-local plan coords — without this the drag seed
|
||||
// jumps off the roof at move start.
|
||||
const segment = parent as RoofSegmentNode
|
||||
const roof = segment.parentId
|
||||
? (nodes[segment.parentId as AnyNodeId] as
|
||||
| (AnyNode & { position: [number, number, number]; rotation: number })
|
||||
| undefined)
|
||||
: undefined
|
||||
if (roof?.type === 'roof' && item.roofFace) {
|
||||
const frame = getRoofWallFaceFrame(segment, item.roofFace)
|
||||
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
|
||||
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
|
||||
const [rx, rz] = rotateVec(
|
||||
sx + segment.position[0],
|
||||
sz + segment.position[2],
|
||||
roof.rotation ?? 0,
|
||||
)
|
||||
result = {
|
||||
point: [rx + roof.position[0], rz + roof.position[2]],
|
||||
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(item.id as AnyNodeId, result)
|
||||
@@ -208,6 +236,10 @@ function buildWallItemSession(
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: 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,
|
||||
roofFace: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getRoofWallFaceFrame,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -112,6 +115,31 @@ function resolveItemTransform(
|
||||
y: shelfZ + offsetY,
|
||||
rotation: shelfRotationY + localRotation,
|
||||
}
|
||||
} else if (parentNode?.type === 'roof-segment') {
|
||||
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
|
||||
// frame, then composed through the segment's and parent roof's poses
|
||||
// into level-local plan coords.
|
||||
const segment = parentNode as RoofSegmentNode
|
||||
const roof = segment.parentId
|
||||
? (ctx.resolve(segment.parentId as AnyNodeId) as
|
||||
| (AnyNode & { position: [number, number, number]; rotation: number })
|
||||
| undefined)
|
||||
: undefined
|
||||
if (roof?.type === 'roof' && item.roofFace) {
|
||||
const frame = getRoofWallFaceFrame(segment, item.roofFace)
|
||||
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
|
||||
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
|
||||
const [rx, rz] = rotateVec(
|
||||
sx + segment.position[0],
|
||||
sz + segment.position[2],
|
||||
roof.rotation ?? 0,
|
||||
)
|
||||
result = {
|
||||
x: rx + roof.position[0],
|
||||
y: rz + roof.position[2],
|
||||
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Level / slab / ceiling parent — item.position is level-local.
|
||||
result = {
|
||||
|
||||
@@ -38,9 +38,20 @@ import { Vector3 } from 'three'
|
||||
function getInitialState(node: ItemNode): PlacementState {
|
||||
const attachTo = node.asset.attachTo
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
if (node.roofSegmentId) {
|
||||
return {
|
||||
surface: 'roof-wall',
|
||||
wallId: null,
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
surface: 'wall',
|
||||
wallId: node.parentId,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState {
|
||||
return {
|
||||
surface: 'ceiling',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: node.parentId,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState {
|
||||
return {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
|
||||
? {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
|
||||
type MutableMaterial = Material & {
|
||||
depthTest?: boolean
|
||||
@@ -92,7 +93,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
||||
[storeNode, liveOverrides],
|
||||
)
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
@@ -104,6 +105,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
|
||||
if (!node.roofSegmentId) return content
|
||||
return (
|
||||
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
|
||||
{content}
|
||||
</RoofFaceHostFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
|
||||
|
||||
Reference in New Issue
Block a user