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
+2 -2
View File
@@ -115,8 +115,8 @@ export {
getMaxRoofRectWidthFromAnchor, getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace, getRoofSegmentWallFace,
getRoofSegmentWallFaces, getRoofSegmentWallFaces,
getRoofWallFaceIdFromYaw, getRoofWallFaceFrame,
roofWallFaceLocalToSegment, roofFacePointToSegment,
segmentPointToRoofWallFace, segmentPointToRoofWallFace,
} from './nodes/roof-segment-walls' } from './nodes/roof-segment-walls'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
+6 -3
View File
@@ -47,10 +47,13 @@ export const DoorNode = BaseNode.extend({
side: z.enum(['front', 'back']).optional(), side: z.enum(['front', 'back']).optional(),
wallId: z.string().optional(), wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall // Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is the // under the roof or a coplanar gable end). When set, `position` is
// opening center in SEGMENT-LOCAL coords on the outer wall plane and // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. // — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(), roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions // Overall dimensions
width: z.number().default(0.9), width: z.number().default(0.9),
+7
View File
@@ -135,6 +135,13 @@ export const ItemNode = BaseNode.extend({
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(), wallId: z.string().optional(),
wallT: z.number().optional(), // 0-1 parametric position along wall wallT: z.number().optional(), // 0-1 parametric position along wall
// Alternative wall host: a roof-segment's generated wall face. When
// set, `position` is FACE-LOCAL — [u along the face, v = bottom edge,
// z from the wall mid-plane] — exactly the wall-child convention
// (ItemSystem's wall-side push applies the same way); the renderer
// mounts the node inside the face frame (`getRoofWallFaceFrame`).
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Denormalized references to collections this node belongs to // Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(), collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from './roof-segment'
import {
getRoofSegmentWallFace,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
} from './roof-segment-walls'
function segment(overrides: Partial<RoofSegmentNode> = {}): RoofSegmentNode {
return RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.6,
wallThickness: 0.1,
pitch: 40,
...overrides,
})
}
describe('roof wall face frames', () => {
test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => {
const seg = segment()
// front face, u at the face middle, v = 1, mid-plane.
const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0])
expect(point[0]).toBeCloseTo(0)
expect(point[1]).toBeCloseTo(1)
expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane
})
test('frame +z is the outward normal on every face', () => {
const seg = segment()
for (const [faceId, axis, sign] of [
['front', 2, 1],
['back', 2, -1],
['right', 0, 1],
['left', 0, -1],
] as const) {
const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0])
const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5])
expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign)
// The other horizontal axis is unaffected by the push.
const other = axis === 2 ? 0 : 2
expect(pushed[other] - onPlane[other]).toBeCloseTo(0)
}
})
test('face frame agrees with the hit resolver coordinates', () => {
const seg = segment()
// A point on the outer surface (z = +thickness/2 off the mid-plane)
// must read back with the same u/v and dist ≈ 0 off the outer plane.
const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05])
const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal)
expect(u).toBeCloseTo(2.5)
expect(v).toBeCloseTo(1.25)
expect(dist).toBeCloseTo(0)
})
test('resizing the segment moves the frame, not the stored coords', () => {
// The core live-tracking property: the same face-local point maps to
// the new plane after a depth change — children follow by re-render.
const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0])
const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0])
expect(before[2]).toBeCloseTo(3)
expect(after[2]).toBeCloseTo(4)
expect(after[1]).toBeCloseTo(before[1])
})
test('frame yaw matches the face descriptor yaw', () => {
const seg = segment()
for (const faceId of ['front', 'back', 'right', 'left'] as const) {
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
}
})
})
@@ -206,32 +206,6 @@ export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWal
})) }))
} }
/**
* Face coords → segment-local point on the outer wall plane. `inset`
* pushes the point inward along the face normal — openings store their
* center at the wall mid-plane (`inset = wallThickness / 2`) so the
* frame assembly centers inside the wall like on a regular wall host.
*/
export function roofWallFaceLocalToSegment(
node: SegmentWallInputs,
id: RoofWallFaceId,
u: number,
v: number,
inset = 0,
): [number, number, number] {
const { wV, dV } = getWallVolumeFrame(node)
switch (id) {
case 'front':
return [u - wV / 2, v, dV / 2 - inset]
case 'back':
return [wV / 2 - u, v, -dV / 2 + inset]
case 'right':
return [wV / 2 - inset, v, dV / 2 - u]
case 'left':
return [-wV / 2 + inset, v, u - dV / 2]
}
}
/** /**
* Segment-local point → face coords. `dist` is the signed offset off the * Segment-local point → face coords. `dist` is the signed offset off the
* outer wall plane along the face normal (0 = on the plane, positive = * outer wall plane along the face normal (0 = on the plane, positive =
@@ -301,16 +275,47 @@ function getRectCenterConstraints(
})) }))
} }
/** Face id for an opening's stored yaw (`rotation[1]`), or null. */ /**
export function getRoofWallFaceIdFromYaw(yaw: number): RoofWallFaceId | null { * The face's render frame in segment-local space: a group placed at
const tau = Math.PI * 2 * `origin` and yawed by `yaw` maps face coords to segment space —
const normalized = ((yaw % tau) + tau) % tau * frame X = U (along the face), frame Y = V (height), frame Z = the
const eps = 1e-3 * outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of
if (normalized < eps || tau - normalized < eps) return 'front' * the generated wall volume lands exactly on the nominal footprint
if (Math.abs(normalized - Math.PI) < eps) return 'back' * (`±width/2` / `±depth/2`), so hosted children use the same position
if (Math.abs(normalized - Math.PI / 2) < eps) return 'right' * conventions as wall children (openings at z = 0, wall-side items
if (Math.abs(normalized - (3 * Math.PI) / 2) < eps) return 'left' * pushed +thickness/2 at render time). Renderers derive this from the
return null * live-override-merged segment, which is what makes hosted children
* track segment edits live instead of jumping on commit.
*/
export function getRoofWallFaceFrame(
node: SegmentWallInputs,
id: RoofWallFaceId,
): { origin: [number, number, number]; yaw: number } {
const { wV, dV } = getWallVolumeFrame(node)
switch (id) {
case 'front':
return { origin: [-wV / 2, 0, node.depth / 2], yaw: FACE_YAWS.front }
case 'back':
return { origin: [wV / 2, 0, -node.depth / 2], yaw: FACE_YAWS.back }
case 'right':
return { origin: [node.width / 2, 0, dV / 2], yaw: FACE_YAWS.right }
case 'left':
return { origin: [-node.width / 2, 0, -dV / 2], yaw: FACE_YAWS.left }
}
}
/** Face-frame point ([u, v, z-from-mid-plane]) → segment-local point. */
export function roofFacePointToSegment(
node: SegmentWallInputs,
id: RoofWallFaceId,
point: [number, number, number],
): [number, number, number] {
const { origin, yaw } = getRoofWallFaceFrame(node, id)
const cos = Math.cos(yaw)
const sin = Math.sin(yaw)
const [u, v, z] = point
// rotation-y: +x → (cos, 0, -sin), +z → (sin, 0, cos)
return [origin[0] + u * cos + z * sin, origin[1] + v, origin[2] - u * sin + z * cos]
} }
/** /**
+6 -3
View File
@@ -29,10 +29,13 @@ export const WindowNode = BaseNode.extend({
// Wall reference // Wall reference
wallId: z.string().optional(), wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall // Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is the // under the roof or a coplanar gable end). When set, `position` is
// opening center in SEGMENT-LOCAL coords on the outer wall plane and // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. // — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(), roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions // Overall dimensions
width: z.number().default(1.5), width: z.number().default(1.5),
+50
View File
@@ -14,6 +14,7 @@ import {
type RoofSegmentNode, type RoofSegmentNode,
type RoofType, type RoofType,
} from '../schema/nodes/roof-segment' } from '../schema/nodes/roof-segment'
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
import { SiteNode } from '../schema/nodes/site' import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
@@ -539,6 +540,55 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = { ...node, children: [] } as AnyNode patchedNodes[id] = { ...node, children: [] } as AnyNode
} }
// Roof-hosted wall children (door / window / item) originally stored
// SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
// format moved to explicit `roofFace` + FACE-LOCAL coords so the
// renderer's face frame can track segment edits live. Convert in
// place: face from the old cardinal yaw, u/v from the outer-plane
// projection, z re-based from the outer plane to the wall mid-plane.
if (
(node.type === 'door' || node.type === 'window' || node.type === 'item') &&
typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' &&
(node as { roofFace?: unknown }).roofFace === undefined
) {
const current = patchedNodes[id] as AnyNode & {
roofSegmentId: string
position: [number, number, number]
rotation: [number, number, number]
}
const segment = patchedNodes[current.roofSegmentId] as
| (AnyNode & { wallThickness?: number })
| undefined
if (segment?.type === 'roof-segment') {
const tau = Math.PI * 2
const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau
const eps = 1e-3
const face =
yaw < eps || tau - yaw < eps
? ('front' as const)
: Math.abs(yaw - Math.PI) < eps
? ('back' as const)
: Math.abs(yaw - Math.PI / 2) < eps
? ('right' as const)
: Math.abs(yaw - (3 * Math.PI) / 2) < eps
? ('left' as const)
: null
if (face) {
const { u, v, dist } = segmentPointToRoofWallFace(
segment as never,
face,
current.position,
)
patchedNodes[id] = {
...current,
roofFace: face,
position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
rotation: [0, 0, 0],
} as AnyNode
}
}
}
if (node.type === 'roof') { if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
} }
@@ -38,6 +38,7 @@ import {
Vector3, Vector3,
} from 'three' } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api' import { createEditorApi } from '../../lib/editor-api'
@@ -54,6 +55,9 @@ import {
NO_RAYCAST, NO_RAYCAST,
} from './handles/handle-arrow' } from './handles/handle-arrow'
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
// Pooled scratch for the handle rig's world-relative pose mapping.
const _rigRelative = new Matrix4()
const _rigScratchScale = new Vector3()
export { export {
ARROW_COLOR, ARROW_COLOR,
@@ -276,14 +280,32 @@ function NodeArrowHandlesForNode({
// exclusion the wall arrow also goes without. // exclusion the wall arrow also goes without.
useFrame(() => { useFrame(() => {
if (innerRef.current && innerRide && portalObject) {
// Grandparent mode: pose the rig by mapping the node's WORLD pose
// into the portal target's frame. Copying the parent + node
// registry poses (the previous approach) assumed the node mesh is
// a DIRECT child of the parent's registered object — roof-hosted
// openings break that with an intermediate face-frame group, which
// the world-relative mapping absorbs for free. For wall children
// the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local).
if (outerRef.current) {
outerRef.current.position.set(0, 0, 0)
outerRef.current.quaternion.identity()
}
portalObject.updateWorldMatrix(true, false)
innerRide.updateWorldMatrix(true, false)
_rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld)
_rigRelative.decompose(
innerRef.current.position,
innerRef.current.quaternion,
_rigScratchScale,
)
return
}
if (outerRef.current && outerRide) { if (outerRef.current && outerRide) {
outerRef.current.position.copy(outerRide.position) outerRef.current.position.copy(outerRide.position)
outerRef.current.quaternion.copy(outerRide.quaternion) outerRef.current.quaternion.copy(outerRide.quaternion)
} }
if (innerRef.current && innerRide) {
innerRef.current.position.copy(innerRide.position)
innerRef.current.quaternion.copy(innerRide.quaternion)
}
}) })
// Active-drag tracking. When a handle starts dragging, it claims its // Active-drag tracking. When a handle starts dragging, it claims its
@@ -6,19 +6,27 @@ import type {
GridEvent, GridEvent,
ItemEvent, ItemEvent,
ItemNode, ItemNode,
RoofEvent,
RoofNode,
RoofSegmentNode,
RoofWallFaceId,
ShelfEvent, ShelfEvent,
ShelfNode, ShelfNode,
WallEvent, WallEvent,
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
clampRectToRoofWallFace,
getRoofSegmentWallFace,
getScaledDimensions, getScaledDimensions,
isLowProfileItemSurface, isLowProfileItemSurface,
nodeRegistry, nodeRegistry,
roofFacePointToSegment,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import { import {
calculateCursorRotation, calculateCursorRotation,
@@ -211,10 +219,13 @@ export const wallStrategy = {
const adjustedY = validation.adjustedY ?? y const adjustedY = validation.adjustedY ?? y
return { return {
stateUpdate: { surface: 'wall', wallId: event.node.id }, stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
nodeUpdate: { nodeUpdate: {
position: [x, adjustedY, z], position: [x, adjustedY, z],
parentId: event.node.id, parentId: event.node.id,
// The draft may arrive from a roof-segment wall face.
roofSegmentId: undefined,
roofFace: undefined,
side, side,
rotation: [0, itemRotation, 0], rotation: [0, itemRotation, 0],
}, },
@@ -313,6 +324,8 @@ export const wallStrategy = {
nodeUpdate: { nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: event.node.id, parentId: event.node.id,
roofSegmentId: undefined,
roofFace: undefined,
side: ctx.draftItem.side, side: ctx.draftItem.side,
rotation: ctx.draftItem.rotation, rotation: ctx.draftItem.rotation,
metadata: stripTransient(ctx.draftItem.metadata), metadata: stripTransient(ctx.draftItem.metadata),
@@ -342,6 +355,223 @@ export const wallStrategy = {
}, },
} }
// ============================================================================
// ROOF WALL STRATEGY
// ============================================================================
type RoofWallTarget = {
segment: RoofSegmentNode
faceId: RoofWallFaceId
faceYaw: number
/** Stored node position: segment-local, y = bottom edge. */
position: [number, number, number]
/** Face-coord center of the placed rect (for the overlap guard). */
centerU: number
centerV: number
width: number
height: number
cursorPosition: [number, number, number]
cursorRotationY: number
}
/**
* Resolve a roof pointer event to an item placement on a segment wall
* face. Items snap u / bottom-v to the 0.5m grid, then the rect is
* clamped inside the face profile (sliding under the gable slopes).
* Position frame matches wall hosting: y anchors the BOTTOM edge;
* `wall-side` items mount on the outer surface, `wall` items center in
* the wall thickness.
*
* `shiftFree` mirrors the wall flow's Shift override (stubbed
* validators): the profile clamp is skipped, so the rect may overhang
* the face edges — placement follows the snapped cursor as-is.
*/
function resolveRoofWallTarget(
ctx: PlacementContext,
event: RoofEvent,
shiftFree = false,
): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
if (!hit) return null
const rawDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims
const u = snapToHalf(hit.u)
const centerV = snapToHalf(hit.v) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null
const finalU = fitted?.u ?? u
const finalV = fitted?.v ?? centerV
// FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes
// wall-side items to the outer surface, exactly like wall hosting).
// The renderer mounts the node inside the live face frame, so items
// track segment resizes without any re-anchoring.
const position: [number, number, number] = [finalU, finalV - height / 2, 0]
const segObj = sceneRegistry.nodes.get(hit.segment.id)
if (!segObj) return null
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position)
const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2]))
const nodes = useScene.getState().nodes
const roof = hit.segment.parentId
? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
return {
segment: hit.segment,
faceId: hit.face.id,
faceYaw: hit.face.yaw,
position,
centerU: finalU,
centerV: finalV,
width,
height,
cursorPosition: [worldPos.x, worldPos.y, worldPos.z],
cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw,
}
}
/** Validation half of `checkCanPlace` for the roof-wall surface. */
function canPlaceOnRoofWall(ctx: PlacementContext): boolean {
const segmentId = ctx.state.roofSegmentId
if (!(segmentId && ctx.draftItem)) return false
const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') return false
const faceId = ctx.draftItem.roofFace
if (!faceId) return false
const face = getRoofSegmentWallFace(segment, faceId)
const dims = getGridAlignedDimensions(
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo,
)
const [width, height] = dims
// gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z).
const u = ctx.gridPosition.x
const centerV = ctx.gridPosition.y + height / 2
const clamped = clampRectToRoofWallFace(face, u, centerV, width, height)
if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) {
return false
}
return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id)
}
export const roofWallStrategy = {
/**
* Handle roof:enter / first hover — transition onto a segment wall
* face. Returns null when the item doesn't wall-attach or the pointer
* isn't over a placeable face.
*/
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree)
if (!target) return null
return {
stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
nodeUpdate: {
position: target.position,
parentId: target.segment.id,
roofSegmentId: target.segment.id,
roofFace: target.faceId,
wallId: undefined,
side: 'front',
rotation: [0, 0, 0],
},
cursorRotationY: target.cursorRotationY,
gridPosition: target.position,
cursorPosition: target.cursorPosition,
stopPropagation: true,
}
},
/**
* Handle roof:move while on a segment wall face. Returns null when the
* pointer resolves to a DIFFERENT segment (the coordinator re-enters —
* segment transitions inside one roof never re-fire roof:enter) or to
* no placeable face.
*/
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!ctx.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree)
if (!target) return null
if (target.segment.id !== ctx.state.roofSegmentId) return null
return {
gridPosition: target.position,
cursorPosition: target.cursorPosition,
cursorRotationY: target.cursorRotationY,
nodeUpdate: {
position: target.position,
side: 'front',
rotation: [0, 0, 0],
roofFace: target.faceId,
},
stopPropagation: true,
// Items don't cut the roof — no geometry rebuild needed.
dirtyNodeId: null,
}
},
/**
* Handle roof:click — commit placement on the segment wall face.
*/
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
// Shift mirrors the wall flow's stubbed validators: skip profile-fit
// and overlap checks entirely.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.roofSegmentId,
roofSegmentId: ctx.state.roofSegmentId,
roofFace: ctx.draftItem.roofFace,
wallId: undefined,
side: 'front',
rotation: [0, 0, 0],
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle roof:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'roof-wall') return null
return {
stateUpdate: { surface: 'floor', roofSegmentId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
roofSegmentId: undefined,
roofFace: undefined,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================ // ============================================================================
// CEILING STRATEGY // CEILING STRATEGY
// ============================================================================ // ============================================================================
@@ -794,6 +1024,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
} }
if (attachTo === 'wall' || attachTo === 'wall-side') { if (attachTo === 'wall' || attachTo === 'wall-side') {
if (ctx.state.surface === 'roof-wall') {
return canPlaceOnRoofWall(ctx)
}
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
return validators.canPlaceOnWall( return validators.canPlaceOnWall(
ctx.levelId, ctx.levelId,
@@ -12,7 +12,13 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE // PLACEMENT STATE
// ============================================================================ // ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' export type SurfaceType =
| 'floor'
| 'wall'
| 'roof-wall'
| 'ceiling'
| 'item-surface'
| 'shelf-surface'
/** /**
* Tracks which surface the draft item is currently on. * Tracks which surface the draft item is currently on.
@@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf
export interface PlacementState { export interface PlacementState {
surface: SurfaceType surface: SurfaceType
wallId: string | null wallId: string | null
/**
* Active roof-segment when `surface === 'roof-wall'` — wall-attach
* items also host on the vertical wall faces a roof segment generates
* (base walls + coplanar gable ends).
*/
roofSegmentId: string | null
ceilingId: string | null ceilingId: string | null
surfaceItemId: string | null surfaceItemId: string | null
/** /**
@@ -15,6 +15,10 @@ interface OriginalState {
rotation: [number, number, number] rotation: [number, number, number]
side: ItemNode['side'] side: ItemNode['side']
parentId: string | null parentId: string | null
// Roof-segment wall hosting — cleared/changed by surface transitions
// mid-move, so reverts must restore it alongside parentId.
roofSegmentId: ItemNode['roofSegmentId']
roofFace: ItemNode['roofFace']
metadata: ItemNode['metadata'] metadata: ItemNode['metadata']
} }
@@ -92,6 +96,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: [...node.rotation] as [number, number, number], rotation: [...node.rotation] as [number, number, number],
side: node.side, side: node.side,
parentId: node.parentId, parentId: node.parentId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
metadata: node.metadata, metadata: node.metadata,
} }
@@ -121,6 +127,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: original.rotation, rotation: original.rotation,
side: original.side, side: original.side,
parentId: original.parentId, parentId: original.parentId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
@@ -133,6 +141,15 @@ export function useDraftNode(): DraftNodeHandle {
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string, parentId: parentId as string,
// Forward the roof host explicitly: strategies set it on every
// commit (segment id on a roof face, undefined elsewhere), and
// dropping it here strands the item in the roof frame without
// the segment transform.
roofSegmentId: updateProps.roofSegmentId,
roofFace: updateProps.roofFace,
// Only when the strategy decided about wallId (roof commits clear
// it) — floor/ceiling commits never managed the field.
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
}) })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -163,6 +180,11 @@ export function useDraftNode(): DraftNodeHandle {
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale, scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
// Roof host — see the move-mode commit above for why this must be
// forwarded explicitly.
roofSegmentId: updateProps.roofSegmentId,
roofFace: updateProps.roofFace,
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
}) })
useScene.getState().createNode(finalNode, parentId) useScene.getState().createNode(finalNode, parentId)
@@ -207,6 +229,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: original.rotation, rotation: original.rotation,
side: original.side, side: original.side,
parentId: original.parentId, parentId: original.parentId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
@@ -10,6 +10,7 @@ import {
getScaledDimensions, getScaledDimensions,
type ItemEvent, type ItemEvent,
movingFootprintAnchors, movingFootprintAnchors,
type RoofEvent,
resolveLevelId, resolveLevelId,
type ShelfEvent, type ShelfEvent,
sceneRegistry, sceneRegistry,
@@ -56,6 +57,7 @@ import {
checkCanPlace, checkCanPlace,
floorStrategy, floorStrategy,
itemSurfaceStrategy, itemSurfaceStrategy,
roofWallStrategy,
shelfSurfaceStrategy, shelfSurfaceStrategy,
wallStrategy, wallStrategy,
} from './placement-strategies' } from './placement-strategies'
@@ -213,6 +215,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
config.initialState ?? { config.initialState ?? {
surface: 'floor', surface: 'floor',
wallId: null, wallId: null,
roofSegmentId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null, shelfId: null,
@@ -413,6 +416,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
placementState.current = configRef.current.initialState ?? { placementState.current = configRef.current.initialState ?? {
surface: 'floor', surface: 'floor',
wallId: null, wallId: null,
roofSegmentId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null, shelfId: null,
@@ -987,6 +991,146 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
} }
// ---- Roof Wall Handlers ----
// Wall-attach items also host on the vertical wall faces a roof
// segment generates (base walls + coplanar gable ends). Unlike walls,
// crossing between segments inside ONE roof never re-fires
// `roof:enter` (events come from the roof group), so the move handler
// re-enters whenever the strategy reports a segment change.
const enterRoofWall = (event: RoofEvent): boolean => {
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
if (!result) return false
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to the segment
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
return true
}
const onRoofWallEnter = (event: RoofEvent) => {
has3DPointerDrivenMoveRef.current = true
enterRoofWall(event)
}
const onRoofWallMove = (event: RoofEvent) => {
releaseCommit = () => onRoofWallClick(event)
has3DPointerDrivenMoveRef.current = true
if (!cursorGroupRef.current) return
const ctx = getContext()
if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
enterRoofWall(event)
return
}
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
if (!result) {
// Different segment under the pointer (or no placeable face) —
// try a fresh enter; a null resolve leaves the draft where it is.
enterRoofWall(event)
return
}
event.stopPropagation()
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
const wc = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft && result.nodeUpdate) {
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
if ('rotation' in result.nodeUpdate)
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
}
const placeable = revalidate()
if (draft && placeable) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) {
mesh.position.copy(gridPosition.current)
// Wall-side items sit on the outer surface: mirror ItemSystem's
// push (z = thickness/2 off the face frame's mid-plane) so the
// drag preview doesn't sink into the wall until commit.
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
const segment = useScene.getState().nodes[
placementState.current.roofSegmentId as AnyNodeId
]
if (segment?.type === 'roof-segment') {
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
}
}
const rot = result.nodeUpdate?.rotation
if (rot) mesh.rotation.y = rot[1]
}
// The 2D floor-plan live frame is wall-local; a segment-local
// value would render garbage — clear instead of publishing.
useLiveTransforms.getState().clear(draft.id)
}
}
const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onRoofWallLeave = (event: RoofEvent) => {
const result = roofWallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene.getState().updateNode(draft.id, {
parentId: result.nodeUpdate.parentId as string,
roofSegmentId: undefined,
})
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
}
// ---- Item Surface Handlers ---- // ---- Item Surface Handlers ----
const detachItemSurfaceToFloor = (event: ItemEvent) => { const detachItemSurfaceToFloor = (event: ItemEvent) => {
@@ -1499,6 +1643,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current const draft = draftNode.current
if (!draft) return if (!draft) return
// Roof-wall drafts live flat in the host face frame (yaw 0) —
// manual rotation would skew them off the wall plane.
if (placementState.current.surface === 'roof-wall') return
let rotationDelta = 0 let rotationDelta = 0
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey) if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
rotationDelta = ROTATION_STEP rotationDelta = ROTATION_STEP
@@ -1673,6 +1821,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('wall:move', onWallMove) emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick) emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave) emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofWallEnter)
emitter.on('roof:move', onRoofWallMove)
emitter.on('roof:click', onRoofWallClick)
emitter.on('roof:leave', onRoofWallLeave)
emitter.on('ceiling:enter', onCeilingEnter) emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:click', onCeilingClick)
@@ -1704,6 +1856,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick) emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave) emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofWallEnter)
emitter.off('roof:move', onRoofWallMove)
emitter.off('roof:click', onRoofWallClick)
emitter.off('roof:leave', onRoofWallLeave)
emitter.off('ceiling:enter', onCeilingEnter) emitter.off('ceiling:enter', onCeilingEnter)
emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:click', onCeilingClick)
+4
View File
@@ -231,6 +231,10 @@ export {
resolvePlanarCursorPosition, resolvePlanarCursorPosition,
} from './lib/planar-cursor-placement' } from './lib/planar-cursor-placement'
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
// Roof wall-face hit resolution + overlap guard — shared by the
// kind-owned door / window tools in `@pascal-app/nodes` and the item
// placement coordinator's roof-wall strategy.
export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit'
export type { SceneGraph } from './lib/scene' export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
@@ -1,9 +1,12 @@
import { import {
type AnyNodeId, type AnyNodeId,
getRoofSegmentWallFaces, getRoofSegmentWallFaces,
getScaledDimensions,
type ItemNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSegmentWallFace, type RoofSegmentWallFace,
type RoofWallFaceId,
sceneRegistry, sceneRegistry,
segmentPointToRoofWallFace, segmentPointToRoofWallFace,
useScene, useScene,
@@ -42,6 +45,10 @@ const MAX_NORMAL_Y = 0.4
* merged-roof mesh (roof-local frame) or a painted segment mesh * merged-roof mesh (roof-local frame) or a painted segment mesh
* (segment-local frame), so the normal is normalised through world space * (segment-local frame), so the normal is normalised through world space
* here instead of trusting the event frame. * here instead of trusting the event frame.
*
* Lives in `@pascal-app/editor` because both the kind-owned door/window
* tools (in `@pascal-app/nodes`, which depends on editor) and the item
* placement coordinator (in editor itself) consume it.
*/ */
export function resolveRoofWallHit( export function resolveRoofWallHit(
roof: RoofNode, roof: RoofNode,
@@ -100,14 +107,15 @@ export function resolveRoofWallHit(
} }
/** /**
* Overlap guard for openings sharing a roof-segment wall face the * Overlap guard for nodes sharing a roof-segment wall face the
* roof-host analogue of `hasWallChildOverlap`. Only door / window * roof-host analogue of `hasWallChildOverlap`. Hosted children store
* siblings on the same face are compared (other accessories live on the * FACE-LOCAL coords + an explicit `roofFace`, so siblings compare
* sloped surfaces). * directly: doors/windows are center-anchored in v, wall items
* bottom-anchored.
*/ */
export function hasRoofFaceChildOverlap( export function hasRoofFaceChildOverlap(
segment: RoofSegmentNode, segment: RoofSegmentNode,
face: RoofSegmentWallFace, faceId: RoofWallFaceId,
u: number, u: number,
v: number, v: number,
width: number, width: number,
@@ -119,33 +127,37 @@ export function hasRoofFaceChildOverlap(
const newRight = u + width / 2 const newRight = u + width / 2
const newBottom = v - height / 2 const newBottom = v - height / 2
const newTop = 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 ?? []) { for (const childId of segment.children ?? []) {
if (childId === ignoreId) continue if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId] const child = nodes[childId as AnyNodeId]
if (!child || (child.type !== 'door' && child.type !== 'window')) continue if (!child) continue
const opening = child as { if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue
position: [number, number, number] const position = (child as { position?: [number, number, number] }).position
rotation: [number, number, number] if (!position) continue
width: number
height: number let childW: number
let childBottom: number
let childTop: number
if (child.type === 'door' || child.type === 'window') {
const opening = child as { width: number; height: number }
childW = opening.width
childBottom = position[1] - opening.height / 2
childTop = position[1] + opening.height / 2
} else if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childW = w
// Items anchor position[1] at their bottom edge.
childBottom = position[1]
childTop = position[1] + h
} else {
continue
} }
const {
u: childU, const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2
v: childV, const yOverlap = newBottom < childTop && newTop > childBottom
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 if (xOverlap && yOverlap) return true
} }
return false return false
+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 // re-derived from the surface under the cursor when a preset is
// placed. Host apps strip these at preset-save time via // placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`. // `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId'], hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
}, },
parametrics: doorParametrics, parametrics: doorParametrics,
+3 -4
View File
@@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
original: original:
originalWall?.type === 'wall' originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0]) ? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
node.position[0],
0,
]),
metadata: node.metadata, metadata: node.metadata,
}) })
@@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
parentId: string parentId: string
wallId: string wallId: string
roofSegmentId: undefined roofSegmentId: undefined
roofFace: undefined
} | null = null } | null = null
const session: FloorplanMoveTargetSession = { const session: FloorplanMoveTargetSession = {
@@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// Re-anchoring to a wall ends any roof-segment hosting; the // Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted. // overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
} }
// Build the updates atomically — position + rotation + side + // Build the updates atomically — position + rotation + side +
+33 -17
View File
@@ -7,7 +7,7 @@ import {
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofWallFaceLocalToSegment, roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
stripPlacementMetadataFlags, stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' 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 // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host. // must restore the roof host.
roofSegmentId: movingDoorNode.roofSegmentId, roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata, metadata: movingDoorNode.metadata,
} }
@@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId, parentId: target.wallId,
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = target.wallId currentWallId = target.wallId
@@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId, wallId: target.wallId,
parentId: target.wallId, parentId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
useScene.getState().createNode(node, target.wallId as AnyNodeId) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
@@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
{ lockV: true }, { lockV: true },
) )
if (!clamped) return null if (!clamped) return null
const position = roofWallFaceLocalToSegment( // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
hit.segment, // mounts the node inside the live face frame, so it tracks segment
hit.face.id, // resizes without any re-anchoring.
clamped.u, const position: [number, number, number] = [clamped.u, clamped.v, 0]
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
const valid = !hasRoofFaceChildOverlap( const valid = !hasRoofFaceChildOverlap(
hit.segment, hit.segment,
hit.face, hit.face.id,
clamped.u, clamped.u,
clamped.v, clamped.v,
movingDoorNode.width, movingDoorNode.width,
movingDoorNode.height, movingDoorNode.height,
movingDoorNode.id, 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 updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return if (!segObj) return
segObj.updateWorldMatrix(true, false) 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) segObj.localToWorld(roofCursorPoint)
updateCursor( updateCursor(
worldToBuildingLocal(roofCursorPoint), 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, target.valid,
) )
} }
@@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (currentWallId !== target.hit.segment.id) { if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: target.hit.segment.id, parentId: target.hit.segment.id,
wallId: undefined, wallId: undefined,
roofSegmentId: target.hit.segment.id, roofSegmentId: target.hit.segment.id,
roofFace: target.hit.face.id,
}) })
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = target.hit.segment.id currentWallId = target.hit.segment.id
} else { } else {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
roofFace: target.hit.face.id,
}) })
} }
updateRoofCursor(target) updateRoofCursor(target)
@@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const node = DoorNode.parse({ const node = DoorNode.parse({
...cloned, ...cloned,
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id,
parentId: segmentId, parentId: segmentId,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: segmentId, parentId: segmentId,
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id,
metadata: {}, metadata: {},
}) })
@@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
@@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
@@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
+5 -20
View File
@@ -1,15 +1,10 @@
'use client' 'use client'
import { import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
type AnyNodeId,
type DoorNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer' import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three' import { type Mesh, MeshBasicMaterial } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door') const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient 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 = ( const mesh = (
<mesh <mesh
castShadow castShadow
@@ -48,11 +33,11 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
</mesh> </mesh>
) )
if (!segment) return mesh if (!node.roofSegmentId) return mesh
return ( return (
<group position={segment.position} rotation-y={segment.rotation}> <RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh} {mesh}
</group> </RoofFaceHostFrame>
) )
} }
+25 -18
View File
@@ -7,7 +7,7 @@ import {
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofWallFaceLocalToSegment, roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -26,7 +28,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -226,6 +227,7 @@ const DoorTool: React.FC = () => {
wallId: event.node.id, wallId: event.node.id,
// The draft may arrive from a roof-segment face hover. // The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
} }
} }
@@ -374,23 +376,20 @@ const DoorTool: React.FC = () => {
lockV: true, lockV: true,
}) })
if (!clamped) return null if (!clamped) return null
const position = roofWallFaceLocalToSegment( // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
hit.segment, // mounts the node inside the live face frame, so it tracks segment
hit.face.id, // resizes without any re-anchoring.
clamped.u, const position: [number, number, number] = [clamped.u, clamped.v, 0]
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
const valid = !hasRoofFaceChildOverlap( const valid = !hasRoofFaceChildOverlap(
hit.segment, hit.segment,
hit.face, hit.face.id,
clamped.u, clamped.u,
clamped.v, clamped.v,
width, width,
height, height,
draftRef.current?.id, draftRef.current?.id,
) )
return { hit, position, yaw: hit.face.yaw, valid } return { hit, position, valid }
} }
const updateRoofCursor = ( const updateRoofCursor = (
@@ -400,11 +399,16 @@ const DoorTool: React.FC = () => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return if (!segObj) return
segObj.updateWorldMatrix(true, false) 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) segObj.localToWorld(roofCursorPoint)
updateCursor( updateCursor(
worldToBuildingLocal(roofCursorPoint), 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, target.valid,
) )
} }
@@ -420,20 +424,22 @@ const DoorTool: React.FC = () => {
} }
return return
} }
const { hit, position, yaw } = target const { hit, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current) { if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
roofFace: hit.face.id,
}) })
} else { } else {
const node = DoorNode.parse({ const node = DoorNode.parse({
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id, parentId: hit.segment.id,
metadata: { isTransient: true }, metadata: { isTransient: true },
}) })
@@ -448,7 +454,7 @@ const DoorTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) const target = resolveRoofTarget(event)
if (!target?.valid) return if (!target?.valid) return
const { hit, position, yaw } = target const { hit, position } = target
const draft = draftRef.current const draft = draftRef.current
draftRef.current = null draftRef.current = null
@@ -464,9 +470,10 @@ const DoorTool: React.FC = () => {
const node = DoorNode.parse({ const node = DoorNode.parse({
name: `Door ${doorCount + 1}`, name: `Door ${doorCount + 1}`,
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id, parentId: hit.segment.id,
width: draft.width, width: draft.width,
height: draft.height, 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. // siblings of GLB items inside the unified `items` table.
// //
// Items can be hosted on walls (assets with `attachTo: 'wall'`) // Items can be hosted on walls (assets with `attachTo: 'wall'`)
// via `wallId` + `wallT`. When a composition that includes a // via `wallId` + `wallT`, or on a roof-segment wall face via
// wall-hosted item is saved as a preset (a sconce, a hanging // `roofSegmentId`. When a composition that includes a wall-hosted
// shelf, etc.), the host app strips these via `getHostRefFields(def)` // item is saved as a preset (a sconce, a hanging shelf, etc.), the
// so the descendant re-attaches against the new wall geometry at // host app strips these via `getHostRefFields(def)` so the
// descendant re-attaches against the new host geometry at
// placement time. // placement time.
hostRefFields: ['wallId', 'wallT'], hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'],
// Floor items get lifted by slabs underneath via the generic // Floor items get lifted by slabs underneath via the generic
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in // `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
// their parent's local frame and skip the lift via `applies`. // their parent's local frame and skip the lift via `applies`.
+32
View File
@@ -5,9 +5,12 @@ import {
collectAlignmentAnchors, collectAlignmentAnchors,
type FloorplanMoveTarget, type FloorplanMoveTarget,
type FloorplanMoveTargetSession, type FloorplanMoveTargetSession,
getRoofWallFaceFrame,
getScaledDimensions, getScaledDimensions,
type ItemNode, type ItemNode,
movingFootprintAnchors, movingFootprintAnchors,
type RoofSegmentNode,
roofFacePointToSegment,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor' import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
@@ -95,6 +98,31 @@ function resolveItemPlanTransform(
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ], point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
rotation: parentTransform.rotation + localRotation, 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) cache.set(item.id as AnyNodeId, result)
@@ -208,6 +236,10 @@ function buildWallItemSession(
rotation: [0, hit.itemRotation, 0], rotation: [0, hit.itemRotation, 0],
side: hit.side, side: hit.side,
parentId: hit.wall.id, 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 FloorplanGeometry,
type FloorplanPoint, type FloorplanPoint,
type GeometryContext, type GeometryContext,
getRoofWallFaceFrame,
getScaledDimensions, getScaledDimensions,
type ItemNode, type ItemNode,
type RoofSegmentNode,
roofFacePointToSegment,
useLiveTransforms, useLiveTransforms,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -112,6 +115,31 @@ function resolveItemTransform(
y: shelfZ + offsetY, y: shelfZ + offsetY,
rotation: shelfRotationY + localRotation, 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 { } else {
// Level / slab / ceiling parent — item.position is level-local. // Level / slab / ceiling parent — item.position is level-local.
result = { result = {
+14
View File
@@ -38,9 +38,20 @@ import { Vector3 } from 'three'
function getInitialState(node: ItemNode): PlacementState { function getInitialState(node: ItemNode): PlacementState {
const attachTo = node.asset.attachTo const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') { 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 { return {
surface: 'wall', surface: 'wall',
wallId: node.parentId, wallId: node.parentId,
roofSegmentId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null, shelfId: null,
@@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState {
return { return {
surface: 'ceiling', surface: 'ceiling',
wallId: null, wallId: null,
roofSegmentId: null,
ceilingId: node.parentId, ceilingId: node.parentId,
surfaceItemId: null, surfaceItemId: null,
shelfId: null, shelfId: null,
@@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState {
return { return {
surface: 'floor', surface: 'floor',
wallId: null, wallId: null,
roofSegmentId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: null, shelfId: null,
@@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
? { ? {
surface: 'floor', surface: 'floor',
wallId: null, wallId: null,
roofSegmentId: null,
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
shelfId: 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 type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three' import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl' import { positionLocal, smoothstep, time } from 'three/tsl'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
type MutableMaterial = Material & { type MutableMaterial = Material & {
depthTest?: boolean depthTest?: boolean
@@ -92,7 +93,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
[storeNode, liveOverrides], [storeNode, liveOverrides],
) )
return ( const content = (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}> <group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}> <ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
<Suspense fallback={<PreviewModel node={node} />}> <Suspense fallback={<PreviewModel node={node} />}>
@@ -104,6 +105,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
))} ))}
</group> </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()) 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 { import {
getMaxRoofRectHeightFromAnchor, getMaxRoofRectHeightFromAnchor,
getMaxRoofRectWidthFromAnchor, getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace, getRoofSegmentWallFace,
getRoofWallFaceIdFromYaw, roofFacePointToSegment,
segmentPointToRoofWallFace,
} from '@pascal-app/core' } from '@pascal-app/core'
/** /**
* Host-side helpers for openings (door / window) hosted on a roof-segment * Host-side helpers for openings (door / window) hosted on a roof-segment
* wall face: resize-handle limits derived from the face profile, and the * 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 = { type RoofHostedOpening = {
roofSegmentId?: string roofSegmentId?: string
roofFace?: RoofWallFaceId
parentId: string | null parentId: string | null
position: [number, number, number] position: [number, number, number]
rotation: [number, number, number]
width: number width: number
height: number height: number
} }
@@ -25,14 +31,10 @@ type RoofHostedOpening = {
type SceneReader = { get: (id: AnyNodeId) => unknown } type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { 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 const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null if (!segment || segment.type !== 'roof-segment') return null
const faceId = getRoofWallFaceIdFromYaw(node.rotation[1]) return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
if (!faceId) return null
const face = getRoofSegmentWallFace(segment, faceId)
const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position)
return { segment, face, u, v }
} }
/** /**
@@ -47,8 +49,8 @@ export function readRoofFaceWidthMax(
): number | null { ): number | null {
const host = resolveHostFace(node, scene) const host = resolveHostFace(node, scene)
if (!host) return null if (!host) return null
const anchorU = host.u - (growSign * node.width) / 2 const anchorU = node.position[0] - (growSign * node.width) / 2
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height) return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
} }
/** /**
@@ -63,8 +65,8 @@ export function readRoofFaceHeightMax(
): number | null { ): number | null {
const host = resolveHostFace(node, scene) const host = resolveHostFace(node, scene)
if (!host) return null if (!host) return null
const anchorV = host.v - (growSign * node.height) / 2 const anchorV = node.position[1] - (growSign * node.height) / 2
return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign) 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. * level). Null when the parent chain isn't roof-shaped.
*/ */
export function getRoofHostedOpeningLevelId( export function getRoofHostedOpeningLevelId(
node: RoofHostedOpening, node: { parentId: string | null },
nodes: Record<string, AnyNode | undefined>, nodes: Record<string, AnyNode | undefined>,
): AnyNodeId | null { ): AnyNodeId | null {
const segment = node.parentId ? nodes[node.parentId] : undefined 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 * Level-plan [x, z] of a roof-hosted node — its face-local center mapped
* composed through the segment's and roof's yaw + position. * through the face frame, then composed through the segment's and roof's
* yaw + position.
*/ */
export function getRoofHostedOpeningPlanPoint( export function getRoofHostedOpeningPlanPoint(
node: RoofHostedOpening, node: {
parentId: string | null
roofFace?: RoofWallFaceId
position: [number, number, number]
},
nodes: Record<string, AnyNode | undefined>, nodes: Record<string, AnyNode | undefined>,
): [number, number] | null { ): [number, number] | null {
const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined 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 const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined
if (roof?.type !== 'roof') return null if (roof?.type !== 'roof') return null
@@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint(
-x * Math.sin(yaw) + z * Math.cos(yaw), -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 segX = sx + segment.position[0]
const segZ = sz + segment.position[2] const segZ = sz + segment.position[2]
const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0) const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0)
@@ -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' import * as THREE from 'three'
type RoofWallOpening = { type RoofWallOpening = {
roofSegmentId?: string roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number] position: [number, number, number]
rotation: [number, number, number]
width: number width: number
height: number height: number
} }
/** /**
* CSG cut for a door / window hosted on a roof-segment wall face * CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall plane, * (`capabilities.roofAccessory.buildCut`). A box through the wall
* oriented by the opening's face yaw, in segment-local coords — the * mid-plane, derived from the CURRENT host geometry (the opening stores
* roof-merge loop subtracts it from the segment's wall brush. * face-local coords), so the hole follows segment resizes for free.
* *
* Returns null for wall-hosted openings (no `roofSegmentId`): their cut * Returns null for wall-hosted openings: their cut is handled by the
* is handled by the wall system's own cutout pipeline. * wall system's own cutout pipeline.
*/ */
export function buildRoofWallOpeningCut( export function buildRoofWallOpeningCut(
node: RoofWallOpening, node: RoofWallOpening,
hostSegment: RoofSegmentNode, hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null { ): THREE.BufferGeometry | null {
if (!node.roofSegmentId) return null if (!node.roofSegmentId || !node.roofFace) return null
const wallThickness = hostSegment.wallThickness ?? 0.1 const wallThickness = hostSegment.wallThickness ?? 0.1
// Through the wall both ways, but well short of the rake/eave overhang // 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 bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0 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) const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0) geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(node.rotation[1] ?? 0) geo.rotateY(yaw)
geo.translate(node.position[0], node.position[1], node.position[2]) geo.translate(center[0], center[1], center[2])
return geo 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 // `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern. // the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId'], hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
}, },
parametrics: windowParametrics, parametrics: windowParametrics,
+3 -4
View File
@@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
original: original:
originalWall?.type === 'wall' originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0]) ? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
node.position[0],
0,
]),
metadata: node.metadata, metadata: node.metadata,
}) })
@@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
parentId: string parentId: string
wallId: string wallId: string
roofSegmentId: undefined roofSegmentId: undefined
roofFace: undefined
} | null = null } | null = null
const session: FloorplanMoveTargetSession = { const session: FloorplanMoveTargetSession = {
@@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// Re-anchoring to a wall ends any roof-segment hosting; the // Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted. // overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
} }
useScene.getState().updateNodes([ useScene.getState().updateNodes([
+33 -17
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofWallFaceLocalToSegment, roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms, useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
snapToHalf, snapToHalf,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' 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 // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host. // must restore the roof host.
roofSegmentId: movingWindowNode.roofSegmentId, roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata, metadata: movingWindowNode.metadata,
} }
@@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId, parentId: target.wallId,
wallId: target.wallId, wallId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = target.wallId currentWallId = target.wallId
@@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId, wallId: target.wallId,
parentId: target.wallId, parentId: target.wallId,
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
useScene.getState().createNode(node, target.wallId as AnyNodeId) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
@@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
@@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
movingWindowNode.height, movingWindowNode.height,
) )
if (!clamped) return null if (!clamped) return null
const position = roofWallFaceLocalToSegment( // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
hit.segment, // mounts the node inside the live face frame, so it tracks segment
hit.face.id, // resizes without any re-anchoring.
clamped.u, const position: [number, number, number] = [clamped.u, clamped.v, 0]
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
const valid = !hasRoofFaceChildOverlap( const valid = !hasRoofFaceChildOverlap(
hit.segment, hit.segment,
hit.face, hit.face.id,
clamped.u, clamped.u,
clamped.v, clamped.v,
movingWindowNode.width, movingWindowNode.width,
movingWindowNode.height, movingWindowNode.height,
movingWindowNode.id, 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 updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return if (!segObj) return
segObj.updateWorldMatrix(true, false) 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) segObj.localToWorld(roofCursorPoint)
updateCursor( updateCursor(
worldToBuildingLocal(roofCursorPoint), 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, target.valid,
) )
} }
@@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (currentWallId !== target.hit.segment.id) { if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: target.hit.segment.id, parentId: target.hit.segment.id,
wallId: undefined, wallId: undefined,
roofSegmentId: target.hit.segment.id, roofSegmentId: target.hit.segment.id,
roofFace: target.hit.face.id,
}) })
markWallDirty(currentWallId) markWallDirty(currentWallId)
currentWallId = target.hit.segment.id currentWallId = target.hit.segment.id
} else { } else {
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
roofFace: target.hit.face.id,
}) })
} }
updateRoofCursor(target) updateRoofCursor(target)
@@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const node = WindowNode.parse({ const node = WindowNode.parse({
...cloned, ...cloned,
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id,
parentId: segmentId, parentId: segmentId,
}) })
useScene.getState().createNode(node, segmentId as AnyNodeId) useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: target.position, position: target.position,
rotation: [0, target.yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
parentId: segmentId, parentId: segmentId,
wallId: undefined, wallId: undefined,
roofSegmentId: segmentId, roofSegmentId: segmentId,
roofFace: target.hit.face.id,
metadata: {}, metadata: {},
}) })
@@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
@@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
@@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId, parentId: original.parentId,
wallId: original.wallId, wallId: original.wallId,
roofSegmentId: original.roofSegmentId, roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata, metadata: original.metadata,
}) })
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
+2
View File
@@ -215,6 +215,8 @@ export default function WindowPanel() {
rotation: [...node.rotation] as [number, number, number], rotation: [...node.rotation] as [number, number, number],
side: node.side, side: node.side,
wallId: node.wallId, wallId: node.wallId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
parentId: node.parentId, parentId: node.parentId,
width: node.width, width: node.width,
height: node.height, height: node.height,
+5 -20
View File
@@ -1,12 +1,6 @@
'use client' 'use client'
import { import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
type AnyNodeId,
type RoofSegmentNode,
useRegistry,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { import {
createMaterial, createMaterial,
DEFAULT_WINDOW_MATERIAL, DEFAULT_WINDOW_MATERIAL,
@@ -15,6 +9,7 @@ import {
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
export const WindowRenderer = ({ node }: { node: WindowNode }) => { export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
node.material?.texture, 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 = ( const mesh = (
<mesh <mesh
material={material} material={material}
@@ -62,11 +47,11 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
</mesh> </mesh>
) )
if (!segment) return mesh if (!node.roofSegmentId) return mesh
return ( return (
<group position={segment.position} rotation-y={segment.rotation}> <RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh} {mesh}
</group> </RoofFaceHostFrame>
) )
} }
+25 -18
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall, isCurvedWall,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
roofWallFaceLocalToSegment, roofFacePointToSegment,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace, isValidWallSideFace,
resolveRoofWallHit,
snapToHalf, snapToHalf,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -27,7 +29,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -234,6 +235,7 @@ const WindowTool: React.FC = () => {
wallId: event.node.id, wallId: event.node.id,
// The draft may arrive from a roof-segment face hover. // The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined, roofSegmentId: undefined,
roofFace: undefined,
}) })
} }
} }
@@ -384,23 +386,20 @@ const WindowTool: React.FC = () => {
// it down under the gable slopes when needed. // it down under the gable slopes when needed.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height) const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height)
if (!clamped) return null if (!clamped) return null
const position = roofWallFaceLocalToSegment( // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
hit.segment, // mounts the node inside the live face frame, so it tracks segment
hit.face.id, // resizes without any re-anchoring.
clamped.u, const position: [number, number, number] = [clamped.u, clamped.v, 0]
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
const valid = !hasRoofFaceChildOverlap( const valid = !hasRoofFaceChildOverlap(
hit.segment, hit.segment,
hit.face, hit.face.id,
clamped.u, clamped.u,
clamped.v, clamped.v,
width, width,
height, height,
draftRef.current?.id, draftRef.current?.id,
) )
return { hit, position, yaw: hit.face.yaw, valid } return { hit, position, valid }
} }
const updateRoofCursor = ( const updateRoofCursor = (
@@ -410,11 +409,16 @@ const WindowTool: React.FC = () => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return if (!segObj) return
segObj.updateWorldMatrix(true, false) 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) segObj.localToWorld(roofCursorPoint)
updateCursor( updateCursor(
worldToBuildingLocal(roofCursorPoint), 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, target.valid,
) )
} }
@@ -430,20 +434,22 @@ const WindowTool: React.FC = () => {
} }
return return
} }
const { hit, position, yaw } = target const { hit, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current) { if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(draftRef.current.id, {
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
roofFace: hit.face.id,
}) })
} else { } else {
const node = WindowNode.parse({ const node = WindowNode.parse({
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id, parentId: hit.segment.id,
metadata: { isTransient: true }, metadata: { isTransient: true },
}) })
@@ -458,7 +464,7 @@ const WindowTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event) const target = resolveRoofTarget(event)
if (!target?.valid) return if (!target?.valid) return
const { hit, position, yaw } = target const { hit, position } = target
const draft = draftRef.current const draft = draftRef.current
draftRef.current = null draftRef.current = null
@@ -474,9 +480,10 @@ const WindowTool: React.FC = () => {
const node = WindowNode.parse({ const node = WindowNode.parse({
name: `Window ${windowCount + 1}`, name: `Window ${windowCount + 1}`,
position, position,
rotation: [0, yaw, 0], rotation: [0, 0, 0],
side: 'front', side: 'front',
roofSegmentId: hit.segment.id, roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id, parentId: hit.segment.id,
width: draft.width, width: draft.width,
height: draft.height, height: draft.height,
@@ -36,12 +36,20 @@ export const ItemSystem = () => {
if (!mesh) return if (!mesh) return
if (item.asset.attachTo === 'wall-side') { if (item.asset.attachTo === 'wall-side') {
// Wall-attached item: offset Z by half the parent wall's thickness // Wall-attached item: offset Z by half the host wall's thickness.
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined // Roof-segment wall faces share the convention — the face frame's
if (parentWall && parentWall.type === 'wall') { // z = 0 is the wall mid-plane, so the same push lands the item on
const wallThickness = (parentWall as WallNode).thickness ?? 0.1 // the outer surface.
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
const thickness =
parent?.type === 'wall'
? ((parent as WallNode).thickness ?? 0.1)
: parent?.type === 'roof-segment'
? (parent.wallThickness ?? 0.1)
: undefined
if (thickness !== undefined) {
const side = item.side === 'front' ? 1 : -1 const side = item.side === 'front' ? 1 : -1
mesh.position.z = (wallThickness / 2) * side mesh.position.z = (thickness / 2) * side
} }
} }
+103 -76
View File
@@ -113,7 +113,10 @@ export const RoofSystem = () => {
// Kinds with `cascadesViaHostSegment` (door / window) reach the roof // Kinds with `cascadesViaHostSegment` (door / window) reach the roof
// through their own geometry system's parentId cascade instead — // through their own geometry system's parentId cascade instead —
// their dirty marks belong to that system, not to this loop. // their dirty marks belong to that system, not to this loop.
if (def?.capabilities?.roofAccessory && !def.capabilities.roofAccessory.cascadesViaHostSegment) { if (
def?.capabilities?.roofAccessory &&
!def.capabilities.roofAccessory.cascadesViaHostSegment
) {
const segId = (node as { roofSegmentId?: string }).roofSegmentId const segId = (node as { roofSegmentId?: string }).roofSegmentId
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
if (seg?.parentId) { if (seg?.parentId) {
@@ -145,7 +148,7 @@ export const RoofSystem = () => {
mesh.parent?.name === 'segments-wrapper' && mesh.parent?.name === 'segments-wrapper' &&
mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true
if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
updateRoofSegmentGeometry(effectiveSegment, mesh) updateRoofSegmentGeometry(effectiveSegment, mesh, nodes)
segmentsProcessed++ segmentsProcessed++
} else if (isVisible && !revealOnly) { } else if (isVisible && !revealOnly) {
return // Over budget — keep dirty, process next frame return // Over budget — keep dirty, process next frame
@@ -231,8 +234,12 @@ export const RoofSystem = () => {
// GEOMETRY GENERATION // GEOMETRY GENERATION
// ============================================================================ // ============================================================================
function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { function updateRoofSegmentGeometry(
const newGeo = generateRoofSegmentGeometry(node) node: RoofSegmentNode,
mesh: THREE.Mesh,
nodes?: Record<string, AnyNode>,
) {
const newGeo = generateRoofSegmentGeometry(node, nodes)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeo mesh.geometry = newGeo
@@ -242,6 +249,89 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
mesh.rotation.y = node.rotation mesh.rotation.y = node.rotation
} }
/**
* Subtract every hosted accessory cut (`capabilities.roofAccessory.
* buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
* the merged-shell path AND the per-segment path (full edit mode /
* painted segments) — without the latter, selecting a segment used to
* swap the merged shell for uncut per-segment meshes and every door /
* window / skylight hole vanished until deselect. Children are read
* live-effective so an in-flight handle drag carves the live hole.
* Registry-driven so the viewer never names a kind.
*/
function subtractAccessoryCuts(
brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush },
segment: RoofSegmentNode,
nodes: Record<string, AnyNode>,
) {
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of segment.children ?? []) {
const storedChild = nodes[childElemId as AnyNodeId]
if (!storedChild) continue
const childElem = getEffectiveNode(storedChild)
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, segment)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
try {
if (cutScope !== 'wall') {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
}
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
}
function updateMergedRoofGeometry( function updateMergedRoofGeometry(
roofNode: RoofNode, roofNode: RoofNode,
group: THREE.Group, group: THREE.Group,
@@ -282,77 +372,7 @@ function updateMergedRoofGeometry(
const brushes = getRoofSegmentBrushes(child) const brushes = getRoofSegmentBrushes(child)
if (!brushes) continue if (!brushes) continue
// Per-child cuts in SEGMENT-LOCAL space: subtract every accessory subtractAccessoryCuts(brushes, child, nodes)
// that contributes a cut (declares
// `capabilities.roofAccessory.buildCut`) from shin / deck / wall
// before we accumulate. Mirrors roof-system v1 — the cut is built
// in segment-local, then carved out before the segment transform
// stacks on. Registry-driven so the viewer never names a kind.
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of child.children ?? []) {
const childElem = nodes[childElemId as AnyNodeId]
if (!childElem) continue
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, child)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
try {
if (cutScope !== 'wall') {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
}
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
_matrix.compose( _matrix.compose(
_position.set(child.position[0], child.position[1], child.position[2]), _position.set(child.position[0], child.position[1], child.position[2]),
@@ -813,13 +833,20 @@ export function getRoofSegmentBrushes(
return null return null
} }
export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry { export function generateRoofSegmentGeometry(
node: RoofSegmentNode,
nodes?: Record<string, AnyNode>,
): THREE.BufferGeometry {
const brushes = getRoofSegmentBrushes(node) const brushes = getRoofSegmentBrushes(node)
if (!brushes) { if (!brushes) {
// Fallback: simple box // Fallback: simple box
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth)
} }
if (nodes) {
subtractAccessoryCuts(brushes, node, nodes)
}
const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes
let resultGeo = new THREE.BufferGeometry() let resultGeo = new THREE.BufferGeometry()