diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5d5c5a50..60823687 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -115,8 +115,8 @@ export { getMaxRoofRectWidthFromAnchor, getRoofSegmentWallFace, getRoofSegmentWallFaces, - getRoofWallFaceIdFromYaw, - roofWallFaceLocalToSegment, + getRoofWallFaceFrame, + roofFacePointToSegment, segmentPointToRoofWallFace, } from './nodes/roof-segment-walls' export { ScanNode } from './nodes/scan' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 32bca4df..fd26ad68 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -47,10 +47,13 @@ export const DoorNode = BaseNode.extend({ side: z.enum(['front', 'back']).optional(), wallId: z.string().optional(), // Alternative host: a roof-segment's generated wall face (base wall - // under the roof or a coplanar gable end). When set, `position` is the - // opening center in SEGMENT-LOCAL coords on the outer wall plane and - // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — 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(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Overall dimensions width: z.number().default(0.9), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index e37c3dc1..c03aa4b8 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -135,6 +135,13 @@ export const ItemNode = BaseNode.extend({ // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") wallId: z.string().optional(), 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 collectionIds: z.array(z.custom()).optional(), diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts new file mode 100644 index 00000000..7e4d7478 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -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 { + 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) + } + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index 54414ccf..7d783592 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -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 * 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 { - const tau = Math.PI * 2 - const normalized = ((yaw % tau) + tau) % tau - const eps = 1e-3 - if (normalized < eps || tau - normalized < eps) return 'front' - if (Math.abs(normalized - Math.PI) < eps) return 'back' - if (Math.abs(normalized - Math.PI / 2) < eps) return 'right' - if (Math.abs(normalized - (3 * Math.PI) / 2) < eps) return 'left' - return null +/** + * The face's render frame in segment-local space: a group placed at + * `origin` and yawed by `yaw` maps face coords to segment space — + * frame X = U (along the face), frame Y = V (height), frame Z = the + * outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of + * the generated wall volume lands exactly on the nominal footprint + * (`±width/2` / `±depth/2`), so hosted children use the same position + * conventions as wall children (openings at z = 0, wall-side items + * pushed +thickness/2 at render time). Renderers derive this from the + * 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] } /** diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 307d4191..c5a1f5f0 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -29,10 +29,13 @@ export const WindowNode = BaseNode.extend({ // Wall reference wallId: z.string().optional(), // Alternative host: a roof-segment's generated wall face (base wall - // under the roof or a coplanar gable end). When set, `position` is the - // opening center in SEGMENT-LOCAL coords on the outer wall plane and - // `rotation[1]` is the face yaw — see `roof-segment-walls.ts`. + // under the roof or a coplanar gable end). When set, `position` is + // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] + // — 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(), + roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Overall dimensions width: z.number().default(1.5), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 7073857d..c802c9ea 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -14,6 +14,7 @@ import { type RoofSegmentNode, type RoofType, } from '../schema/nodes/roof-segment' +import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls' import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' @@ -539,6 +540,55 @@ function migrateNodes(nodes: Record): Record { 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') { patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index d8723198..7ffd7f99 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -38,6 +38,7 @@ import { Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' @@ -54,6 +55,9 @@ import { NO_RAYCAST, } from './handles/handle-arrow' 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 { ARROW_COLOR, @@ -276,14 +280,32 @@ function NodeArrowHandlesForNode({ // exclusion the wall arrow also goes without. 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) { outerRef.current.position.copy(outerRide.position) 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 diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 047a8b02..67da8030 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,19 +6,27 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, + RoofNode, + RoofSegmentNode, + RoofWallFaceId, ShelfEvent, ShelfNode, WallEvent, WallNode, } from '@pascal-app/core' import { + clampRectToRoofWallFace, + getRoofSegmentWallFace, getScaledDimensions, isLowProfileItemSurface, nodeRegistry, + roofFacePointToSegment, sceneRegistry, useScene, } from '@pascal-app/core' import { Euler, Matrix3, Quaternion, Vector3 } from 'three' +import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit' import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import { calculateCursorRotation, @@ -211,10 +219,13 @@ export const wallStrategy = { const adjustedY = validation.adjustedY ?? y return { - stateUpdate: { surface: 'wall', wallId: event.node.id }, + stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null }, nodeUpdate: { position: [x, adjustedY, z], parentId: event.node.id, + // The draft may arrive from a roof-segment wall face. + roofSegmentId: undefined, + roofFace: undefined, side, rotation: [0, itemRotation, 0], }, @@ -313,6 +324,8 @@ export const wallStrategy = { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], parentId: event.node.id, + roofSegmentId: undefined, + roofFace: undefined, side: ctx.draftItem.side, rotation: ctx.draftItem.rotation, 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 // ============================================================================ @@ -794,6 +1024,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato } 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 return validators.canPlaceOnWall( ctx.levelId, diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index a3eccc11..33290743 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,13 @@ import type { Vector3 } from 'three' // 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. @@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf export interface PlacementState { surface: SurfaceType 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 surfaceItemId: string | null /** diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 67b72149..dfd752fb 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -15,6 +15,10 @@ interface OriginalState { rotation: [number, number, number] side: ItemNode['side'] 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'] } @@ -92,6 +96,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: [...node.rotation] as [number, number, number], side: node.side, parentId: node.parentId, + roofSegmentId: node.roofSegmentId, + roofFace: node.roofFace, metadata: node.metadata, } @@ -121,6 +127,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: original.rotation, side: original.side, parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) @@ -133,6 +141,15 @@ export function useDraftNode(): DraftNodeHandle { side: updateProps.side ?? draft.side, metadata: updateProps.metadata ?? stripTransient(draft.metadata), 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() @@ -163,6 +180,11 @@ export function useDraftNode(): DraftNodeHandle { rotation: updateProps.rotation ?? draft.rotation, scale: updateProps.scale ?? draft.scale, 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), }) useScene.getState().createNode(finalNode, parentId) @@ -207,6 +229,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: original.rotation, side: original.side, parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 84bdfd2d..d91909d1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -10,6 +10,7 @@ import { getScaledDimensions, type ItemEvent, movingFootprintAnchors, + type RoofEvent, resolveLevelId, type ShelfEvent, sceneRegistry, @@ -56,6 +57,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofWallStrategy, shelfSurfaceStrategy, wallStrategy, } from './placement-strategies' @@ -213,6 +215,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea config.initialState ?? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -413,6 +416,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea placementState.current = configRef.current.initialState ?? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: 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 ---- const detachItemSurfaceToFloor = (event: ItemEvent) => { @@ -1499,6 +1643,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current 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 if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey) rotationDelta = ROTATION_STEP @@ -1673,6 +1821,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) 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:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) @@ -1704,6 +1856,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) 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:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 874dea93..97523d1d 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -231,6 +231,10 @@ export { resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' 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 { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' diff --git a/packages/nodes/src/shared/roof-wall-hit.ts b/packages/editor/src/lib/roof-wall-hit.ts similarity index 70% rename from packages/nodes/src/shared/roof-wall-hit.ts rename to packages/editor/src/lib/roof-wall-hit.ts index 2a186e56..e86f9be9 100644 --- a/packages/nodes/src/shared/roof-wall-hit.ts +++ b/packages/editor/src/lib/roof-wall-hit.ts @@ -1,9 +1,12 @@ import { type AnyNodeId, getRoofSegmentWallFaces, + getScaledDimensions, + type ItemNode, type RoofNode, type RoofSegmentNode, type RoofSegmentWallFace, + type RoofWallFaceId, sceneRegistry, segmentPointToRoofWallFace, useScene, @@ -42,6 +45,10 @@ const MAX_NORMAL_Y = 0.4 * merged-roof mesh (roof-local frame) or a painted segment mesh * (segment-local frame), so the normal is normalised through world space * here instead of trusting the event frame. + * + * 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( roof: RoofNode, @@ -100,14 +107,15 @@ export function resolveRoofWallHit( } /** - * Overlap guard for openings sharing a roof-segment wall face — the - * roof-host analogue of `hasWallChildOverlap`. Only door / window - * siblings on the same face are compared (other accessories live on the - * sloped surfaces). + * Overlap guard for nodes sharing a roof-segment wall face — the + * roof-host analogue of `hasWallChildOverlap`. Hosted children store + * FACE-LOCAL coords + an explicit `roofFace`, so siblings compare + * directly: doors/windows are center-anchored in v, wall items + * bottom-anchored. */ export function hasRoofFaceChildOverlap( segment: RoofSegmentNode, - face: RoofSegmentWallFace, + faceId: RoofWallFaceId, u: number, v: number, width: number, @@ -119,33 +127,37 @@ export function hasRoofFaceChildOverlap( const newRight = u + width / 2 const newBottom = v - height / 2 const newTop = v + height / 2 - // Sibling openings store their center at the wall mid-plane (inset by - // wallThickness / 2 from the outer plane this face measures from). - const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE for (const childId of segment.children ?? []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] - if (!child || (child.type !== 'door' && child.type !== 'window')) continue - const opening = child as { - position: [number, number, number] - rotation: [number, number, number] - width: number - height: number + if (!child) continue + if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue + const position = (child as { position?: [number, number, number] }).position + if (!position) continue + + 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, - v: childV, - dist, - } = segmentPointToRoofWallFace(segment, face.id, [ - opening.position[0], - opening.position[1], - opening.position[2], - ]) - // Same face = the opening's mid-plane center sits near this face. - if (Math.abs(dist) > sameFaceTolerance) continue - const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2 - const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2 + + const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2 + const yOverlap = newBottom < childTop && newTop > childBottom if (xOverlap && yOverlap) return true } return false diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 906b1b3b..ae51da31 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -173,7 +173,7 @@ export const doorDefinition: NodeDefinition = { // re-derived from the surface under the cursor when a preset is // placed. Host apps strip these at preset-save time via // `getHostRefFields(def)`. - hostRefFields: ['wallId', 'roofSegmentId'], + hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 5ea4f71f..d6433a7f 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ - node.position[0], - 0, - ]), + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, }) @@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) parentId: string wallId: string roofSegmentId: undefined + roofFace: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // Re-anchoring to a wall ends any roof-segment hosting; the // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, + roofFace: undefined, } // Build the updates atomically — position + rotation + side + diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 701a5fbb..c28c029a 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -7,7 +7,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,7 +19,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, @@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -68,6 +69,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts // must restore the roof host. roofSegmentId: movingDoorNode.roofSegmentId, + roofFace: movingDoorNode.roofFace, metadata: movingDoorNode.metadata, } @@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, parentId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { lockV: true }, ) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer + // mounts the node inside the live face frame, so it tracks segment + // resizes without any re-anchoring. + const position: [number, number, number] = [clamped.u, clamped.v, 0] const valid = !hasRoofFaceChildOverlap( hit.segment, - hit.face, + hit.face.id, clamped.u, clamped.v, movingDoorNode.width, movingDoorNode.height, movingDoorNode.id, ) - return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + return { hit, position, valid, roof: event.node as RoofNode } } const updateRoofCursor = (target: NonNullable>) => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + const segLocal = roofFacePointToSegment( + target.hit.segment, + target.hit.face.id, + target.position, + ) + roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2]) segObj.localToWorld(roofCursorPoint) updateCursor( worldToBuildingLocal(roofCursorPoint), - (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (currentWallId !== target.hit.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: target.hit.segment.id, wallId: undefined, roofSegmentId: target.hit.segment.id, + roofFace: target.hit.face.id, }) markWallDirty(currentWallId) currentWallId = target.hit.segment.id } else { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], + roofFace: target.hit.face.id, }) } updateRoofCursor(target) @@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const node = DoorNode.parse({ ...cloned, position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() useScene.getState().updateNode(movingDoorNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, metadata: {}, }) @@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 2925e78b..031328b9 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,15 +1,10 @@ 'use client' -import { - type AnyNodeId, - type DoorNode, - type RoofSegmentNode, - useRegistry, - useScene, -} from '@pascal-app/core' +import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' +import { RoofFaceHostFrame } from '../shared/roof-face-host' const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) @@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | 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 = ( { ) - if (!segment) return mesh + if (!node.roofSegmentId) return mesh return ( - + {mesh} - + ) } diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index cf00b221..94ab2a1f 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -7,7 +7,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,7 +18,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, triggerSFX, useAlignmentGuides, } from '@pascal-app/editor' @@ -26,7 +28,6 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -226,6 +227,7 @@ const DoorTool: React.FC = () => { wallId: event.node.id, // The draft may arrive from a roof-segment face hover. roofSegmentId: undefined, + roofFace: undefined, }) } } @@ -374,23 +376,20 @@ const DoorTool: React.FC = () => { lockV: true, }) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer + // mounts the node inside the live face frame, so it tracks segment + // resizes without any re-anchoring. + const position: [number, number, number] = [clamped.u, clamped.v, 0] const valid = !hasRoofFaceChildOverlap( hit.segment, - hit.face, + hit.face.id, clamped.u, clamped.v, width, height, draftRef.current?.id, ) - return { hit, position, yaw: hit.face.yaw, valid } + return { hit, position, valid } } const updateRoofCursor = ( @@ -400,11 +399,16 @@ const DoorTool: React.FC = () => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + const segLocal = roofFacePointToSegment( + target.hit.segment, + target.hit.face.id, + target.position, + ) + roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2]) segObj.localToWorld(roofCursorPoint) updateCursor( worldToBuildingLocal(roofCursorPoint), - (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -420,20 +424,22 @@ const DoorTool: React.FC = () => { } return } - const { hit, position, yaw } = target + const { hit, position } = target if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current) { useScene.getState().updateNode(draftRef.current.id, { position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], + roofFace: hit.face.id, }) } else { const node = DoorNode.parse({ position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, metadata: { isTransient: true }, }) @@ -448,7 +454,7 @@ const DoorTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position, yaw } = target + const { hit, position } = target const draft = draftRef.current draftRef.current = null @@ -464,9 +470,10 @@ const DoorTool: React.FC = () => { const node = DoorNode.parse({ name: `Door ${doorCount + 1}`, position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, width: draft.width, height: draft.height, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index e7de994c..2f9dc6fe 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -206,12 +206,13 @@ export const itemDefinition: NodeDefinition = { // siblings of GLB items inside the unified `items` table. // // Items can be hosted on walls (assets with `attachTo: 'wall'`) - // via `wallId` + `wallT`. When a composition that includes a - // wall-hosted item is saved as a preset (a sconce, a hanging - // shelf, etc.), the host app strips these via `getHostRefFields(def)` - // so the descendant re-attaches against the new wall geometry at + // via `wallId` + `wallT`, or on a roof-segment wall face via + // `roofSegmentId`. When a composition that includes a wall-hosted + // item is saved as a preset (a sconce, a hanging shelf, etc.), the + // host app strips these via `getHostRefFields(def)` so the + // descendant re-attaches against the new host geometry at // placement time. - hostRefFields: ['wallId', 'wallT'], + hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'], // Floor items get lifted by slabs underneath via the generic // ``. Wall- / ceiling-attached items live in // their parent's local frame and skip the lift via `applies`. diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index a8c1b747..d8747045 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -5,9 +5,12 @@ import { collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + getRoofWallFaceFrame, getScaledDimensions, type ItemNode, movingFootprintAnchors, + type RoofSegmentNode, + roofFacePointToSegment, useScene, } from '@pascal-app/core' import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor' @@ -95,6 +98,31 @@ function resolveItemPlanTransform( point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ], rotation: parentTransform.rotation + localRotation, } + } else if (parent?.type === 'roof-segment') { + // Roof-hosted wall item: FACE-LOCAL position mapped through the face + // frame, then composed through the segment's and roof's yaw + + // position into level-local plan coords — without this the drag seed + // jumps off the roof at move start. + const segment = parent as RoofSegmentNode + const roof = segment.parentId + ? (nodes[segment.parentId as AnyNodeId] as + | (AnyNode & { position: [number, number, number]; rotation: number }) + | undefined) + : undefined + if (roof?.type === 'roof' && item.roofFace) { + const frame = getRoofWallFaceFrame(segment, item.roofFace) + const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position) + const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0) + const [rx, rz] = rotateVec( + sx + segment.position[0], + sz + segment.position[2], + roof.rotation ?? 0, + ) + result = { + point: [rx + roof.position[0], rz + roof.position[2]], + rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation, + } + } } cache.set(item.id as AnyNodeId, result) @@ -208,6 +236,10 @@ function buildWallItemSession( rotation: [0, hit.itemRotation, 0], side: hit.side, parentId: hit.wall.id, + // Re-anchoring to a wall ends any roof-segment hosting; the + // overlay's snapshot restores it if the move is reverted. + roofSegmentId: undefined, + roofFace: undefined, }, }, ]) diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts index b07b9221..2a01fa4b 100644 --- a/packages/nodes/src/item/floorplan.ts +++ b/packages/nodes/src/item/floorplan.ts @@ -4,8 +4,11 @@ import { type FloorplanGeometry, type FloorplanPoint, type GeometryContext, + getRoofWallFaceFrame, getScaledDimensions, type ItemNode, + type RoofSegmentNode, + roofFacePointToSegment, useLiveTransforms, } from '@pascal-app/core' @@ -112,6 +115,31 @@ function resolveItemTransform( y: shelfZ + offsetY, rotation: shelfRotationY + localRotation, } + } else if (parentNode?.type === 'roof-segment') { + // Roof-hosted wall item: FACE-LOCAL position mapped through the face + // frame, then composed through the segment's and parent roof's poses + // into level-local plan coords. + const segment = parentNode as RoofSegmentNode + const roof = segment.parentId + ? (ctx.resolve(segment.parentId as AnyNodeId) as + | (AnyNode & { position: [number, number, number]; rotation: number }) + | undefined) + : undefined + if (roof?.type === 'roof' && item.roofFace) { + const frame = getRoofWallFaceFrame(segment, item.roofFace) + const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position) + const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0) + const [rx, rz] = rotateVec( + sx + segment.position[0], + sz + segment.position[2], + roof.rotation ?? 0, + ) + result = { + x: rx + roof.position[0], + y: rz + roof.position[2], + rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation, + } + } } else { // Level / slab / ceiling parent — item.position is level-local. result = { diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 236047da..1e7cfd3b 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -38,9 +38,20 @@ import { Vector3 } from 'three' function getInitialState(node: ItemNode): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { + if (node.roofSegmentId) { + return { + surface: 'roof-wall', + wallId: null, + roofSegmentId: node.roofSegmentId, + ceilingId: null, + surfaceItemId: null, + shelfId: null, + } + } return { surface: 'wall', wallId: node.parentId, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState { return { surface: 'ceiling', wallId: null, + roofSegmentId: null, ceilingId: node.parentId, surfaceItemId: null, shelfId: null, @@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState { return { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, @@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { ? { surface: 'floor', wallId: null, + roofSegmentId: null, ceilingId: null, surfaceItemId: null, shelfId: null, diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 5814a986..35b5cc15 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -33,6 +33,7 @@ import { Suspense, useEffect, useMemo, useRef } from 'react' import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' +import { RoofFaceHostFrame } from '../shared/roof-face-host' type MutableMaterial = Material & { depthTest?: boolean @@ -92,7 +93,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { [storeNode, liveOverrides], ) - return ( + const content = ( }> }> @@ -104,6 +105,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { ))} ) + + if (!node.roofSegmentId) return content + return ( + + {content} + + ) } const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract()) diff --git a/packages/nodes/src/shared/roof-face-host.tsx b/packages/nodes/src/shared/roof-face-host.tsx new file mode 100644 index 00000000..1083b015 --- /dev/null +++ b/packages/nodes/src/shared/roof-face-host.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts index 8b604e84..7ac87e7a 100644 --- a/packages/nodes/src/shared/roof-opening-host.ts +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -1,23 +1,29 @@ -import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + RoofNode, + RoofSegmentNode, + RoofWallFaceId, +} from '@pascal-app/core' import { getMaxRoofRectHeightFromAnchor, getMaxRoofRectWidthFromAnchor, getRoofSegmentWallFace, - getRoofWallFaceIdFromYaw, - segmentPointToRoofWallFace, + roofFacePointToSegment, } from '@pascal-app/core' /** * Host-side helpers for openings (door / window) hosted on a roof-segment * wall face: resize-handle limits derived from the face profile, and the - * plan-space anchors the 2D floor-plan move path needs. + * plan-space anchors the 2D floor-plan move path needs. Hosted children + * store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`. */ type RoofHostedOpening = { roofSegmentId?: string + roofFace?: RoofWallFaceId parentId: string | null position: [number, number, number] - rotation: [number, number, number] width: number height: number } @@ -25,14 +31,10 @@ type RoofHostedOpening = { type SceneReader = { get: (id: AnyNodeId) => unknown } function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { - if (!node.roofSegmentId) return null + if (!(node.roofSegmentId && node.roofFace)) return null const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined if (!segment || segment.type !== 'roof-segment') return null - const faceId = getRoofWallFaceIdFromYaw(node.rotation[1]) - if (!faceId) return null - const face = getRoofSegmentWallFace(segment, faceId) - const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position) - return { segment, face, u, v } + return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) } } /** @@ -47,8 +49,8 @@ export function readRoofFaceWidthMax( ): number | null { const host = resolveHostFace(node, scene) if (!host) return null - const anchorU = host.u - (growSign * node.width) / 2 - return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height) + const anchorU = node.position[0] - (growSign * node.width) / 2 + return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height) } /** @@ -63,8 +65,8 @@ export function readRoofFaceHeightMax( ): number | null { const host = resolveHostFace(node, scene) if (!host) return null - const anchorV = host.v - (growSign * node.height) / 2 - return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign) + const anchorV = node.position[1] - (growSign * node.height) / 2 + return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign) } /** @@ -72,7 +74,7 @@ export function readRoofFaceHeightMax( * level). Null when the parent chain isn't roof-shaped. */ export function getRoofHostedOpeningLevelId( - node: RoofHostedOpening, + node: { parentId: string | null }, nodes: Record, ): AnyNodeId | null { const segment = node.parentId ? nodes[node.parentId] : undefined @@ -83,15 +85,20 @@ export function getRoofHostedOpeningLevelId( } /** - * Level-plan [x, z] of a roof-hosted opening — its segment-local center - * composed through the segment's and roof's yaw + position. + * Level-plan [x, z] of a roof-hosted node — its face-local center mapped + * through the face frame, then composed through the segment's and roof's + * yaw + position. */ export function getRoofHostedOpeningPlanPoint( - node: RoofHostedOpening, + node: { + parentId: string | null + roofFace?: RoofWallFaceId + position: [number, number, number] + }, nodes: Record, ): [number, number] | null { const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined - if (segment?.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment' || !node.roofFace) return null const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined if (roof?.type !== 'roof') return null @@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint( -x * Math.sin(yaw) + z * Math.cos(yaw), ] - const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0) + const segLocal = roofFacePointToSegment(segment, node.roofFace, [ + node.position[0], + node.position[1], + node.position[2], + ]) + const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0) const segX = sx + segment.position[0] const segZ = sz + segment.position[2] const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0) diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts index f9e3d525..41f189bf 100644 --- a/packages/nodes/src/shared/roof-wall-opening-cut.ts +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -1,28 +1,29 @@ -import type { RoofSegmentNode } from '@pascal-app/core' +import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core' +import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core' import * as THREE from 'three' type RoofWallOpening = { roofSegmentId?: string + roofFace?: RoofWallFaceId position: [number, number, number] - rotation: [number, number, number] width: number height: number } /** * CSG cut for a door / window hosted on a roof-segment wall face - * (`capabilities.roofAccessory.buildCut`). A box through the wall plane, - * oriented by the opening's face yaw, in segment-local coords — the - * roof-merge loop subtracts it from the segment's wall brush. + * (`capabilities.roofAccessory.buildCut`). A box through the wall + * mid-plane, derived from the CURRENT host geometry (the opening stores + * face-local coords), so the hole follows segment resizes for free. * - * Returns null for wall-hosted openings (no `roofSegmentId`): their cut - * is handled by the wall system's own cutout pipeline. + * Returns null for wall-hosted openings: their cut is handled by the + * wall system's own cutout pipeline. */ export function buildRoofWallOpeningCut( node: RoofWallOpening, hostSegment: RoofSegmentNode, ): THREE.BufferGeometry | null { - if (!node.roofSegmentId) return null + if (!node.roofSegmentId || !node.roofFace) return null const wallThickness = hostSegment.wallThickness ?? 0.1 // Through the wall both ways, but well short of the rake/eave overhang @@ -34,9 +35,16 @@ export function buildRoofWallOpeningCut( const bottom = node.position[1] - node.height / 2 const bottomPad = bottom < 0.005 ? 0.02 : 0 + const center = roofFacePointToSegment(hostSegment, node.roofFace, [ + node.position[0], + node.position[1], + 0, + ]) + const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace) + const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth) geo.translate(0, -bottomPad / 2, 0) - geo.rotateY(node.rotation[1] ?? 0) - geo.translate(node.position[0], node.position[1], node.position[2]) + geo.rotateY(yaw) + geo.translate(center[0], center[1], center[2]) return geo } diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 8d5bbb79..83bbd778 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -161,7 +161,7 @@ export const windowDefinition: NodeDefinition = { }, // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. - hostRefFields: ['wallId', 'roofSegmentId'], + hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index bcd7c82e..24a9c8a0 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ - node.position[0], - 0, - ]), + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, }) @@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod parentId: string wallId: string roofSegmentId: undefined + roofFace: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // Re-anchoring to a wall ends any roof-segment hosting; the // overlay's snapshot restores it if the move is reverted. roofSegmentId: undefined, + roofFace: undefined, } useScene.getState().updateNodes([ diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 15936a6d..ed776e76 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -6,7 +6,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -19,7 +19,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, snapToHalf, triggerSFX, useAlignmentGuides, @@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -81,6 +82,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts // must restore the roof host. roofSegmentId: movingWindowNode.roofSegmentId, + roofFace: movingWindowNode.roofFace, metadata: movingWindowNode.metadata, } @@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, parentId: target.wallId, roofSegmentId: undefined, + roofFace: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode movingWindowNode.height, ) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer + // mounts the node inside the live face frame, so it tracks segment + // resizes without any re-anchoring. + const position: [number, number, number] = [clamped.u, clamped.v, 0] const valid = !hasRoofFaceChildOverlap( hit.segment, - hit.face, + hit.face.id, clamped.u, clamped.v, movingWindowNode.width, movingWindowNode.height, movingWindowNode.id, ) - return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + return { hit, position, valid, roof: event.node as RoofNode } } const updateRoofCursor = (target: NonNullable>) => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + const segLocal = roofFacePointToSegment( + target.hit.segment, + target.hit.face.id, + target.position, + ) + roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2]) segObj.localToWorld(roofCursorPoint) updateCursor( worldToBuildingLocal(roofCursorPoint), - (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode if (currentWallId !== target.hit.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: target.hit.segment.id, wallId: undefined, roofSegmentId: target.hit.segment.id, + roofFace: target.hit.face.id, }) markWallDirty(currentWallId) currentWallId = target.hit.segment.id } else { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], + roofFace: target.hit.face.id, }) } updateRoofCursor(target) @@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const node = WindowNode.parse({ ...cloned, position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, parentId: segmentId, }) useScene.getState().createNode(node, segmentId as AnyNodeId) @@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) useScene.temporal.getState().resume() useScene.getState().updateNode(movingWindowNode.id, { position: target.position, - rotation: [0, target.yaw, 0], + rotation: [0, 0, 0], side: 'front', parentId: segmentId, wallId: undefined, roofSegmentId: segmentId, + roofFace: target.hit.face.id, metadata: {}, }) @@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, }) if (original.parentId) markWallDirty(original.parentId) } @@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: original.parentId, wallId: original.wallId, roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index bdcfc82a..b1bd3b8f 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -215,6 +215,8 @@ export default function WindowPanel() { rotation: [...node.rotation] as [number, number, number], side: node.side, wallId: node.wallId, + roofSegmentId: node.roofSegmentId, + roofFace: node.roofFace, parentId: node.parentId, width: node.width, height: node.height, diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 412ba635..0c670b28 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -1,12 +1,6 @@ 'use client' -import { - type AnyNodeId, - type RoofSegmentNode, - useRegistry, - useScene, - type WindowNode, -} from '@pascal-app/core' +import { useRegistry, useScene, type WindowNode } from '@pascal-app/core' import { createMaterial, DEFAULT_WINDOW_MATERIAL, @@ -15,6 +9,7 @@ import { } from '@pascal-app/viewer' import { useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' +import { RoofFaceHostFrame } from '../shared/roof-face-host' export const WindowRenderer = ({ node }: { node: WindowNode }) => { const ref = useRef(null!) @@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { node.material?.texture, ]) - // Roof-hosted windows mount under the roof's `roof-elements` group (roof - // frame), so the host segment's transform is applied here — wall-hosted - // windows get it for free from the wall mesh they're nested in. - const segment = useScene((state) => - node.roofSegmentId - ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) - : undefined, - ) - if (node.roofSegmentId && segment?.type !== 'roof-segment') return null - const mesh = ( { ) - if (!segment) return mesh + if (!node.roofSegmentId) return mesh return ( - + {mesh} - + ) } diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 44da2d34..124a9dcc 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -6,7 +6,7 @@ import { isCurvedWall, type RoofEvent, type RoofNode, - roofWallFaceLocalToSegment, + roofFacePointToSegment, sceneRegistry, spatialGridManager, useScene, @@ -18,7 +18,9 @@ import { calculateItemRotation, EDITOR_LAYER, getSideFromNormal, + hasRoofFaceChildOverlap, isValidWallSideFace, + resolveRoofWallHit, snapToHalf, triggerSFX, useAlignmentGuides, @@ -27,7 +29,6 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' -import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -234,6 +235,7 @@ const WindowTool: React.FC = () => { wallId: event.node.id, // The draft may arrive from a roof-segment face hover. roofSegmentId: undefined, + roofFace: undefined, }) } } @@ -384,23 +386,20 @@ const WindowTool: React.FC = () => { // it down under the gable slopes when needed. const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, height) if (!clamped) return null - const position = roofWallFaceLocalToSegment( - hit.segment, - hit.face.id, - clamped.u, - clamped.v, - (hit.segment.wallThickness ?? 0.1) / 2, - ) + // FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer + // mounts the node inside the live face frame, so it tracks segment + // resizes without any re-anchoring. + const position: [number, number, number] = [clamped.u, clamped.v, 0] const valid = !hasRoofFaceChildOverlap( hit.segment, - hit.face, + hit.face.id, clamped.u, clamped.v, width, height, draftRef.current?.id, ) - return { hit, position, yaw: hit.face.yaw, valid } + return { hit, position, valid } } const updateRoofCursor = ( @@ -410,11 +409,16 @@ const WindowTool: React.FC = () => { const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) if (!segObj) return segObj.updateWorldMatrix(true, false) - roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + const segLocal = roofFacePointToSegment( + target.hit.segment, + target.hit.face.id, + target.position, + ) + roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2]) segObj.localToWorld(roofCursorPoint) updateCursor( worldToBuildingLocal(roofCursorPoint), - (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw, target.valid, ) } @@ -430,20 +434,22 @@ const WindowTool: React.FC = () => { } return } - const { hit, position, yaw } = target + const { hit, position } = target if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft() if (draftRef.current) { useScene.getState().updateNode(draftRef.current.id, { position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], + roofFace: hit.face.id, }) } else { const node = WindowNode.parse({ position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, metadata: { isTransient: true }, }) @@ -458,7 +464,7 @@ const WindowTool: React.FC = () => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) if (!target?.valid) return - const { hit, position, yaw } = target + const { hit, position } = target const draft = draftRef.current draftRef.current = null @@ -474,9 +480,10 @@ const WindowTool: React.FC = () => { const node = WindowNode.parse({ name: `Window ${windowCount + 1}`, position, - rotation: [0, yaw, 0], + rotation: [0, 0, 0], side: 'front', roofSegmentId: hit.segment.id, + roofFace: hit.face.id, parentId: hit.segment.id, width: draft.width, height: draft.height, diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index 8b86454e..25cb03b4 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -36,12 +36,20 @@ export const ItemSystem = () => { if (!mesh) return if (item.asset.attachTo === 'wall-side') { - // Wall-attached item: offset Z by half the parent wall's thickness - const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined - if (parentWall && parentWall.type === 'wall') { - const wallThickness = (parentWall as WallNode).thickness ?? 0.1 + // Wall-attached item: offset Z by half the host wall's thickness. + // Roof-segment wall faces share the convention — the face frame's + // z = 0 is the wall mid-plane, so the same push lands the item on + // 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 - mesh.position.z = (wallThickness / 2) * side + mesh.position.z = (thickness / 2) * side } } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 57e74b18..1a5d7991 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -113,7 +113,10 @@ export const RoofSystem = () => { // Kinds with `cascadesViaHostSegment` (door / window) reach the roof // through their own geometry system's parentId cascade instead — // 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 seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (seg?.parentId) { @@ -145,7 +148,7 @@ export const RoofSystem = () => { mesh.parent?.name === 'segments-wrapper' && mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { - updateRoofSegmentGeometry(effectiveSegment, mesh) + updateRoofSegmentGeometry(effectiveSegment, mesh, nodes) segmentsProcessed++ } else if (isVisible && !revealOnly) { return // Over budget — keep dirty, process next frame @@ -231,8 +234,12 @@ export const RoofSystem = () => { // GEOMETRY GENERATION // ============================================================================ -function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { - const newGeo = generateRoofSegmentGeometry(node) +function updateRoofSegmentGeometry( + node: RoofSegmentNode, + mesh: THREE.Mesh, + nodes?: Record, +) { + const newGeo = generateRoofSegmentGeometry(node, nodes) mesh.geometry.dispose() mesh.geometry = newGeo @@ -242,6 +249,89 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { 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, +) { + 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) + : 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( roofNode: RoofNode, group: THREE.Group, @@ -282,77 +372,7 @@ function updateMergedRoofGeometry( const brushes = getRoofSegmentBrushes(child) if (!brushes) continue - // Per-child cuts in SEGMENT-LOCAL space: subtract every accessory - // 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) - : 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 + subtractAccessoryCuts(brushes, child, nodes) _matrix.compose( _position.set(child.position[0], child.position[1], child.position[2]), @@ -813,13 +833,20 @@ export function getRoofSegmentBrushes( return null } -export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry { +export function generateRoofSegmentGeometry( + node: RoofSegmentNode, + nodes?: Record, +): THREE.BufferGeometry { const brushes = getRoofSegmentBrushes(node) if (!brushes) { // Fallback: simple box return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) } + if (nodes) { + subtractAccessoryCuts(brushes, node, nodes) + } + const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes let resultGeo = new THREE.BufferGeometry()