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:
Aymeric Rabot
2026-06-10 14:21:14 -04:00
co-authored by Claude Fable 5
parent 5fd9be05df
commit 7879b83df3
35 changed files with 1131 additions and 315 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// re-derived from the surface under the cursor when a preset is
// placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId'],
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: doorParametrics,
+3 -4
View File
@@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
node.position[0],
0,
]),
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
})
@@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// 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,
}
// Build the updates atomically — position + rotation + side +
+33 -17
View File
@@ -7,7 +7,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -68,6 +69,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata,
}
@@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
@@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
{ lockV: true },
)
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
hit.face.id,
clamped.u,
clamped.v,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.id,
)
return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode }
return { hit, position, valid, roof: event.node as RoofNode }
}
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: target.hit.segment.id,
wallId: undefined,
roofSegmentId: target.hit.segment.id,
roofFace: target.hit.face.id,
})
markWallDirty(currentWallId)
currentWallId = target.hit.segment.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
roofFace: target.hit.face.id,
})
}
updateRoofCursor(target)
@@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const node = DoorNode.parse({
...cloned,
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
metadata: {},
})
@@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
+5 -20
View File
@@ -1,15 +1,10 @@
'use client'
import {
type AnyNodeId,
type DoorNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
// Roof-hosted doors mount under the roof's `roof-elements` group (roof
// frame), so the host segment's transform is applied here — wall-hosted
// doors get it for free from the wall mesh they're nested in.
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
if (node.roofSegmentId && segment?.type !== 'roof-segment') return null
const mesh = (
<mesh
castShadow
@@ -48,11 +33,11 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
</mesh>
)
if (!segment) return mesh
if (!node.roofSegmentId) return mesh
return (
<group position={segment.position} rotation-y={segment.rotation}>
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh}
</group>
</RoofFaceHostFrame>
)
}
+25 -18
View File
@@ -7,7 +7,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
triggerSFX,
useAlignmentGuides,
} from '@pascal-app/editor'
@@ -26,7 +28,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -226,6 +227,7 @@ const DoorTool: React.FC = () => {
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -374,23 +376,20 @@ const DoorTool: React.FC = () => {
lockV: true,
})
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, yaw: hit.face.yaw, valid }
return { hit, position, valid }
}
const updateRoofCursor = (
@@ -400,11 +399,16 @@ const DoorTool: React.FC = () => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -420,20 +424,22 @@ const DoorTool: React.FC = () => {
}
return
}
const { hit, position, yaw } = target
const { hit, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
roofFace: hit.face.id,
})
} else {
const node = DoorNode.parse({
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
metadata: { isTransient: true },
})
@@ -448,7 +454,7 @@ const DoorTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { hit, position, yaw } = target
const { hit, position } = target
const draft = draftRef.current
draftRef.current = null
@@ -464,9 +470,10 @@ const DoorTool: React.FC = () => {
const node = DoorNode.parse({
name: `Door ${doorCount + 1}`,
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
width: draft.width,
height: draft.height,
+6 -5
View File
@@ -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`.
+32
View File
@@ -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,
},
},
])
+28
View File
@@ -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 = {
+14
View File
@@ -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,
+9 -1
View File
@@ -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())
@@ -0,0 +1,53 @@
'use client'
import {
type AnyNodeId,
getRoofWallFaceFrame,
type RoofSegmentNode,
type RoofWallFaceId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type ReactNode, useMemo } from 'react'
/**
* Mounts a roof-hosted wall child inside its host face frame. Children
* of roof segments render under the roof's `roof-elements` group (roof
* frame); this wrapper applies the segment transform plus the face
* frame, both derived from the LIVE-override-merged segment — hosted
* nodes therefore track segment handle drags in real time instead of
* jumping to their new spot on commit. Inside the frame, children use
* plain wall-child position conventions ([u, v, z-from-mid-plane]).
*/
export function RoofFaceHostFrame({
roofSegmentId,
roofFace,
children,
}: {
roofSegmentId: string
roofFace: RoofWallFaceId | undefined
children: ReactNode
}) {
const storeSegment = useScene(
(state) => state.nodes[roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined,
)
const liveOverride = useLiveNodeOverrides((s) => s.get(roofSegmentId as AnyNodeId))
const segment = useMemo(
() =>
storeSegment && liveOverride
? ({ ...storeSegment, ...liveOverride } as RoofSegmentNode)
: storeSegment,
[storeSegment, liveOverride],
)
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
const frame = getRoofWallFaceFrame(segment, roofFace)
return (
<group position={segment.position} rotation-y={segment.rotation}>
<group position={frame.origin} rotation-y={frame.yaw}>
{children}
</group>
</group>
)
}
+33 -21
View File
@@ -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)
-152
View File
@@ -1,152 +0,0 @@
import {
type AnyNodeId,
getRoofSegmentWallFaces,
type RoofNode,
type RoofSegmentNode,
type RoofSegmentWallFace,
sceneRegistry,
segmentPointToRoofWallFace,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
const worldPoint = new THREE.Vector3()
const worldNormal = new THREE.Vector3()
const localPoint = new THREE.Vector3()
const localNormal = new THREE.Vector3()
const inverseMatrix = new THREE.Matrix4()
export type RoofWallHit = {
segment: RoofSegmentNode
face: RoofSegmentWallFace
/** Face coords of the hit (u along the face, v above the segment base). */
u: number
v: number
}
/** Pointer hits more than this far off the wall plane are not wall hits. */
const PLANE_TOLERANCE = 0.06
/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */
const NORMAL_ALIGNMENT = 0.7
/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */
const MAX_NORMAL_Y = 0.4
/**
* Resolve a pointer hit on a roof to one of its segments' vertical wall
* faces (base walls under the roof + the coplanar gable/shed/gambrel end
* faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the
* sloped top surface instead.
*
* `normal` must be the raw `NodeEvent.normal` (hit-object-local) together
* with the `object` it came from — roof events can originate from the
* merged-roof mesh (roof-local frame) or a painted segment mesh
* (segment-local frame), so the normal is normalised through world space
* here instead of trusting the event frame.
*/
export function resolveRoofWallHit(
roof: RoofNode,
position: [number, number, number],
normal: [number, number, number] | undefined,
object: THREE.Object3D | undefined,
): RoofWallHit | null {
if (!normal || !object) return null
worldPoint.set(position[0], position[1], position[2])
worldNormal.set(normal[0], normal[1], normal[2])
object.updateWorldMatrix(true, false)
worldNormal.transformDirection(object.matrixWorld)
const state = useScene.getState()
let best: { hit: RoofWallHit; score: number } | null = null
for (const childId of roof.children ?? []) {
const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const segObj = sceneRegistry.nodes.get(segment.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
localPoint.copy(worldPoint)
segObj.worldToLocal(localPoint)
inverseMatrix.copy(segObj.matrixWorld).invert()
localNormal.copy(worldNormal).transformDirection(inverseMatrix)
if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue
for (const face of getRoofSegmentWallFaces(segment)) {
const alignment =
localNormal.x * face.normal[0] +
localNormal.y * face.normal[1] +
localNormal.z * face.normal[2]
if (alignment < NORMAL_ALIGNMENT) continue
const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [
localPoint.x,
localPoint.y,
localPoint.z,
])
if (Math.abs(dist) > PLANE_TOLERANCE) continue
if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue
if (v < -PLANE_TOLERANCE) continue
const score = Math.abs(dist)
if (!best || score < best.score) {
best = { hit: { segment, face, u, v }, score }
}
}
}
return best?.hit ?? null
}
/**
* Overlap guard for openings sharing a roof-segment wall face — the
* roof-host analogue of `hasWallChildOverlap`. Only door / window
* siblings on the same face are compared (other accessories live on the
* sloped surfaces).
*/
export function hasRoofFaceChildOverlap(
segment: RoofSegmentNode,
face: RoofSegmentWallFace,
u: number,
v: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const newLeft = u - width / 2
const newRight = u + width / 2
const newBottom = v - height / 2
const newTop = v + height / 2
// Sibling openings store their center at the wall mid-plane (inset by
// wallThickness / 2 from the outer plane this face measures from).
const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE
for (const childId of segment.children ?? []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child || (child.type !== 'door' && child.type !== 'window')) continue
const opening = child as {
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
const {
u: childU,
v: childV,
dist,
} = segmentPointToRoofWallFace(segment, face.id, [
opening.position[0],
opening.position[1],
opening.position[2],
])
// Same face = the opening's mid-plane center sits near this face.
if (Math.abs(dist) > sameFaceTolerance) continue
const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2
const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2
if (xOverlap && yOverlap) return true
}
return false
}
@@ -1,28 +1,29 @@
import type { RoofSegmentNode } from '@pascal-app/core'
import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
/**
* CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall plane,
* oriented by the opening's face yaw, in segment-local coords — the
* roof-merge loop subtracts it from the segment's wall brush.
* (`capabilities.roofAccessory.buildCut`). A box through the wall
* mid-plane, derived from the CURRENT host geometry (the opening stores
* face-local coords), so the hole follows segment resizes for free.
*
* Returns null for wall-hosted openings (no `roofSegmentId`): their cut
* is handled by the wall system's own cutout pipeline.
* Returns null for wall-hosted openings: their cut is handled by the
* wall system's own cutout pipeline.
*/
export function buildRoofWallOpeningCut(
node: RoofWallOpening,
hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null {
if (!node.roofSegmentId) return null
if (!node.roofSegmentId || !node.roofFace) return null
const wallThickness = hostSegment.wallThickness ?? 0.1
// Through the wall both ways, but well short of the rake/eave overhang
@@ -34,9 +35,16 @@ export function buildRoofWallOpeningCut(
const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0],
node.position[1],
0,
])
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(node.rotation[1] ?? 0)
geo.translate(node.position[0], node.position[1], node.position[2])
geo.rotateY(yaw)
geo.translate(center[0], center[1], center[2])
return geo
}
+1 -1
View File
@@ -161,7 +161,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
},
// `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId'],
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: windowParametrics,
+3 -4
View File
@@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
node.position[0],
0,
]),
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
})
@@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// 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,
}
useScene.getState().updateNodes([
+33 -17
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
snapToHalf,
triggerSFX,
useAlignmentGuides,
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -81,6 +82,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata,
}
@@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
@@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
movingWindowNode.height,
)
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
hit.face.id,
clamped.u,
clamped.v,
movingWindowNode.width,
movingWindowNode.height,
movingWindowNode.id,
)
return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode }
return { hit, position, valid, roof: event.node as RoofNode }
}
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: target.hit.segment.id,
wallId: undefined,
roofSegmentId: target.hit.segment.id,
roofFace: target.hit.face.id,
})
markWallDirty(currentWallId)
currentWallId = target.hit.segment.id
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
roofFace: target.hit.face.id,
})
}
updateRoofCursor(target)
@@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const node = WindowNode.parse({
...cloned,
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
metadata: {},
})
@@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
+2
View File
@@ -215,6 +215,8 @@ export default function WindowPanel() {
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
parentId: node.parentId,
width: node.width,
height: node.height,
+5 -20
View File
@@ -1,12 +1,6 @@
'use client'
import {
type AnyNodeId,
type RoofSegmentNode,
useRegistry,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import {
createMaterial,
DEFAULT_WINDOW_MATERIAL,
@@ -15,6 +9,7 @@ import {
} from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
@@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
node.material?.texture,
])
// Roof-hosted windows mount under the roof's `roof-elements` group (roof
// frame), so the host segment's transform is applied here — wall-hosted
// windows get it for free from the wall mesh they're nested in.
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
if (node.roofSegmentId && segment?.type !== 'roof-segment') return null
const mesh = (
<mesh
material={material}
@@ -62,11 +47,11 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
</mesh>
)
if (!segment) return mesh
if (!node.roofSegmentId) return mesh
return (
<group position={segment.position} rotation-y={segment.rotation}>
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh}
</group>
</RoofFaceHostFrame>
)
}
+25 -18
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
snapToHalf,
triggerSFX,
useAlignmentGuides,
@@ -27,7 +29,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -234,6 +235,7 @@ const WindowTool: React.FC = () => {
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -384,23 +386,20 @@ const WindowTool: React.FC = () => {
// it down under the gable slopes when needed.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height)
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, yaw: hit.face.yaw, valid }
return { hit, position, valid }
}
const updateRoofCursor = (
@@ -410,11 +409,16 @@ const WindowTool: React.FC = () => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -430,20 +434,22 @@ const WindowTool: React.FC = () => {
}
return
}
const { hit, position, yaw } = target
const { hit, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
roofFace: hit.face.id,
})
} else {
const node = WindowNode.parse({
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
metadata: { isTransient: true },
})
@@ -458,7 +464,7 @@ const WindowTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { hit, position, yaw } = target
const { hit, position } = target
const draft = draftRef.current
draftRef.current = null
@@ -474,9 +480,10 @@ const WindowTool: React.FC = () => {
const node = WindowNode.parse({
name: `Window ${windowCount + 1}`,
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
width: draft.width,
height: draft.height,