feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and the storage model moves to FACE-LOCAL coordinates so hosted children track segment edits live. - Children store roofFace + position [u, v, z-from-mid-plane] with rotation 0 in-frame — the exact wall-child conventions (the wall volume's mid-plane lands on the nominal footprint). A shared <RoofFaceHostFrame> derives segment pose + face frame from the live-override-merged segment: children follow resize handle drags in real time and never jump on commit. No re-anchor cascade needed — position is authoritative, the frame is derived. migrateNodes converts branch-era segment-local data. - Items: roofWallStrategy + roof:* handlers in the placement coordinator (surface 'roof-wall'), Shift free-place normalized with walls, ItemSystem wall-side push extended to segment hosts, correct 2D plan glyphs via face→segment→roof pose composition. The roof hit resolver + overlap guard moved to @pascal-app/editor (the coordinator lives there; nodes already depends on editor). - Cuts: subtractAccessoryCuts extracted and applied in BOTH the merged-shell and per-segment CSG paths (full edit mode / painted segments used to lose every hole), built from the CURRENT host geometry and live-effective children so holes follow segment and opening drags. - Handle rig: the grandparent portal now maps the node's world pose into the portal frame instead of composing parent+node registry poses — correct for any nesting (the face-frame group broke the old assumption), identical for walls. - Host-field hygiene: useDraftNode.commit/adopt and the window panel duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall re-anchor clears and every revert restores them. Codex-reviewed (design consultation, adversarial rounds on the replaced cascade and on this refactor); frame conventions locked by unit tests. Record: private-editor plans/editor-roof-wall-openings.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5fd9be05df
commit
7879b83df3
@@ -115,8 +115,8 @@ export {
|
||||
getMaxRoofRectWidthFromAnchor,
|
||||
getRoofSegmentWallFace,
|
||||
getRoofSegmentWallFaces,
|
||||
getRoofWallFaceIdFromYaw,
|
||||
roofWallFaceLocalToSegment,
|
||||
getRoofWallFaceFrame,
|
||||
roofFacePointToSegment,
|
||||
segmentPointToRoofWallFace,
|
||||
} from './nodes/roof-segment-walls'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<CollectionId>()).optional(),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { RoofSegmentNode } from './roof-segment'
|
||||
import {
|
||||
getRoofSegmentWallFace,
|
||||
getRoofWallFaceFrame,
|
||||
roofFacePointToSegment,
|
||||
segmentPointToRoofWallFace,
|
||||
} from './roof-segment-walls'
|
||||
|
||||
function segment(overrides: Partial<RoofSegmentNode> = {}): RoofSegmentNode {
|
||||
return RoofSegmentNode.parse({
|
||||
id: 'rseg_test',
|
||||
type: 'roof-segment',
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
wallHeight: 2.6,
|
||||
wallThickness: 0.1,
|
||||
pitch: 40,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('roof wall face frames', () => {
|
||||
test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => {
|
||||
const seg = segment()
|
||||
// front face, u at the face middle, v = 1, mid-plane.
|
||||
const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0])
|
||||
expect(point[0]).toBeCloseTo(0)
|
||||
expect(point[1]).toBeCloseTo(1)
|
||||
expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane
|
||||
})
|
||||
|
||||
test('frame +z is the outward normal on every face', () => {
|
||||
const seg = segment()
|
||||
for (const [faceId, axis, sign] of [
|
||||
['front', 2, 1],
|
||||
['back', 2, -1],
|
||||
['right', 0, 1],
|
||||
['left', 0, -1],
|
||||
] as const) {
|
||||
const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0])
|
||||
const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5])
|
||||
expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign)
|
||||
// The other horizontal axis is unaffected by the push.
|
||||
const other = axis === 2 ? 0 : 2
|
||||
expect(pushed[other] - onPlane[other]).toBeCloseTo(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('face frame agrees with the hit resolver coordinates', () => {
|
||||
const seg = segment()
|
||||
// A point on the outer surface (z = +thickness/2 off the mid-plane)
|
||||
// must read back with the same u/v and dist ≈ 0 off the outer plane.
|
||||
const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05])
|
||||
const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal)
|
||||
expect(u).toBeCloseTo(2.5)
|
||||
expect(v).toBeCloseTo(1.25)
|
||||
expect(dist).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
test('resizing the segment moves the frame, not the stored coords', () => {
|
||||
// The core live-tracking property: the same face-local point maps to
|
||||
// the new plane after a depth change — children follow by re-render.
|
||||
const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0])
|
||||
const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0])
|
||||
expect(before[2]).toBeCloseTo(3)
|
||||
expect(after[2]).toBeCloseTo(4)
|
||||
expect(after[1]).toBeCloseTo(before[1])
|
||||
})
|
||||
|
||||
test('frame yaw matches the face descriptor yaw', () => {
|
||||
const seg = segment()
|
||||
for (const faceId of ['front', 'back', 'right', 'left'] as const) {
|
||||
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -206,32 +206,6 @@ export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWal
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Face coords → segment-local point on the outer wall plane. `inset`
|
||||
* pushes the point inward along the face normal — openings store their
|
||||
* center at the wall mid-plane (`inset = wallThickness / 2`) so the
|
||||
* frame assembly centers inside the wall like on a regular wall host.
|
||||
*/
|
||||
export function roofWallFaceLocalToSegment(
|
||||
node: SegmentWallInputs,
|
||||
id: RoofWallFaceId,
|
||||
u: number,
|
||||
v: number,
|
||||
inset = 0,
|
||||
): [number, number, number] {
|
||||
const { wV, dV } = getWallVolumeFrame(node)
|
||||
switch (id) {
|
||||
case 'front':
|
||||
return [u - wV / 2, v, dV / 2 - inset]
|
||||
case 'back':
|
||||
return [wV / 2 - u, v, -dV / 2 + inset]
|
||||
case 'right':
|
||||
return [wV / 2 - inset, v, dV / 2 - u]
|
||||
case 'left':
|
||||
return [-wV / 2 + inset, v, u - dV / 2]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment-local point → face coords. `dist` is the signed offset off the
|
||||
* 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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, any>): Record<string, AnyNode> {
|
||||
patchedNodes[id] = { ...node, children: [] } as AnyNode
|
||||
}
|
||||
|
||||
// Roof-hosted wall children (door / window / item) originally stored
|
||||
// SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
|
||||
// format moved to explicit `roofFace` + FACE-LOCAL coords so the
|
||||
// renderer's face frame can track segment edits live. Convert in
|
||||
// place: face from the old cardinal yaw, u/v from the outer-plane
|
||||
// projection, z re-based from the outer plane to the wall mid-plane.
|
||||
if (
|
||||
(node.type === 'door' || node.type === 'window' || node.type === 'item') &&
|
||||
typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' &&
|
||||
(node as { roofFace?: unknown }).roofFace === undefined
|
||||
) {
|
||||
const current = patchedNodes[id] as AnyNode & {
|
||||
roofSegmentId: string
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
}
|
||||
const segment = patchedNodes[current.roofSegmentId] as
|
||||
| (AnyNode & { wallThickness?: number })
|
||||
| undefined
|
||||
if (segment?.type === 'roof-segment') {
|
||||
const tau = Math.PI * 2
|
||||
const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau
|
||||
const eps = 1e-3
|
||||
const face =
|
||||
yaw < eps || tau - yaw < eps
|
||||
? ('front' as const)
|
||||
: Math.abs(yaw - Math.PI) < eps
|
||||
? ('back' as const)
|
||||
: Math.abs(yaw - Math.PI / 2) < eps
|
||||
? ('right' as const)
|
||||
: Math.abs(yaw - (3 * Math.PI) / 2) < eps
|
||||
? ('left' as const)
|
||||
: null
|
||||
if (face) {
|
||||
const { u, v, dist } = segmentPointToRoofWallFace(
|
||||
segment as never,
|
||||
face,
|
||||
current.position,
|
||||
)
|
||||
patchedNodes[id] = {
|
||||
...current,
|
||||
roofFace: face,
|
||||
position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
|
||||
rotation: [0, 0, 0],
|
||||
} as AnyNode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'roof') {
|
||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
+39
-27
@@ -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
|
||||
@@ -173,7 +173,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
// re-derived from the surface under the cursor when a preset is
|
||||
// placed. Host apps strip these at preset-save time via
|
||||
// `getHostRefFields(def)`.
|
||||
hostRefFields: ['wallId', 'roofSegmentId'],
|
||||
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
|
||||
},
|
||||
|
||||
parametrics: doorParametrics,
|
||||
|
||||
@@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
original:
|
||||
originalWall?.type === 'wall'
|
||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
|
||||
node.position[0],
|
||||
0,
|
||||
]),
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||
metadata: node.metadata,
|
||||
})
|
||||
|
||||
@@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
parentId: string
|
||||
wallId: string
|
||||
roofSegmentId: undefined
|
||||
roofFace: undefined
|
||||
} | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
@@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
}
|
||||
|
||||
// Build the updates atomically — position + rotation + side +
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
roofWallFaceLocalToSegment,
|
||||
roofFacePointToSegment,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
hasRoofFaceChildOverlap,
|
||||
isValidWallSideFace,
|
||||
resolveRoofWallHit,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
@@ -68,6 +69,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
|
||||
// must restore the roof host.
|
||||
roofSegmentId: movingDoorNode.roofSegmentId,
|
||||
roofFace: movingDoorNode.roofFace,
|
||||
metadata: movingDoorNode.metadata,
|
||||
}
|
||||
|
||||
@@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.wallId
|
||||
@@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
wallId: target.wallId,
|
||||
parentId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
@@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
{ lockV: true },
|
||||
)
|
||||
if (!clamped) return null
|
||||
const position = roofWallFaceLocalToSegment(
|
||||
hit.segment,
|
||||
hit.face.id,
|
||||
clamped.u,
|
||||
clamped.v,
|
||||
(hit.segment.wallThickness ?? 0.1) / 2,
|
||||
)
|
||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
||||
// mounts the node inside the live face frame, so it tracks segment
|
||||
// resizes without any re-anchoring.
|
||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
||||
const valid = !hasRoofFaceChildOverlap(
|
||||
hit.segment,
|
||||
hit.face,
|
||||
hit.face.id,
|
||||
clamped.u,
|
||||
clamped.v,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode }
|
||||
return { hit, position, valid, roof: event.node as RoofNode }
|
||||
}
|
||||
|
||||
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
|
||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
||||
if (!segObj) return
|
||||
segObj.updateWorldMatrix(true, false)
|
||||
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
|
||||
const segLocal = roofFacePointToSegment(
|
||||
target.hit.segment,
|
||||
target.hit.face.id,
|
||||
target.position,
|
||||
)
|
||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
||||
segObj.localToWorld(roofCursorPoint)
|
||||
updateCursor(
|
||||
worldToBuildingLocal(roofCursorPoint),
|
||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
|
||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
||||
target.valid,
|
||||
)
|
||||
}
|
||||
@@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
if (currentWallId !== target.hit.segment.id) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
parentId: target.hit.segment.id,
|
||||
wallId: undefined,
|
||||
roofSegmentId: target.hit.segment.id,
|
||||
roofFace: target.hit.face.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.hit.segment.id
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
roofFace: target.hit.face.id,
|
||||
})
|
||||
}
|
||||
updateRoofCursor(target)
|
||||
@@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const node = DoorNode.parse({
|
||||
...cloned,
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
wallId: undefined,
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.hit.face.id,
|
||||
parentId: segmentId,
|
||||
})
|
||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||
@@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
parentId: segmentId,
|
||||
wallId: undefined,
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.hit.face.id,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
@@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
@@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
type RoofSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { type Mesh, MeshBasicMaterial } from 'three'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
|
||||
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
|
||||
|
||||
@@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||
const handlers = useNodeEvents(node, 'door')
|
||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||
|
||||
// Roof-hosted doors mount under the roof's `roof-elements` group (roof
|
||||
// frame), so the host segment's transform is applied here — wall-hosted
|
||||
// doors get it for free from the wall mesh they're nested in.
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
if (node.roofSegmentId && segment?.type !== 'roof-segment') return null
|
||||
|
||||
const mesh = (
|
||||
<mesh
|
||||
castShadow
|
||||
@@ -48,11 +33,11 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||
</mesh>
|
||||
)
|
||||
|
||||
if (!segment) return mesh
|
||||
if (!node.roofSegmentId) return mesh
|
||||
return (
|
||||
<group position={segment.position} rotation-y={segment.rotation}>
|
||||
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
|
||||
{mesh}
|
||||
</group>
|
||||
</RoofFaceHostFrame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -206,12 +206,13 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
// siblings of GLB items inside the unified `items` table.
|
||||
//
|
||||
// Items can be hosted on walls (assets with `attachTo: 'wall'`)
|
||||
// via `wallId` + `wallT`. When a composition that includes a
|
||||
// wall-hosted item is saved as a preset (a sconce, a hanging
|
||||
// shelf, etc.), the host app strips these via `getHostRefFields(def)`
|
||||
// so the descendant re-attaches against the new wall geometry at
|
||||
// via `wallId` + `wallT`, or on a roof-segment wall face via
|
||||
// `roofSegmentId`. When a composition that includes a wall-hosted
|
||||
// item is saved as a preset (a sconce, a hanging shelf, etc.), the
|
||||
// host app strips these via `getHostRefFields(def)` so the
|
||||
// descendant re-attaches against the new host geometry at
|
||||
// placement time.
|
||||
hostRefFields: ['wallId', 'wallT'],
|
||||
hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'],
|
||||
// Floor items get lifted by slabs underneath via the generic
|
||||
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
|
||||
// their parent's local frame and skip the lift via `applies`.
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
getRoofWallFaceFrame,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
movingFootprintAnchors,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
|
||||
@@ -95,6 +98,31 @@ function resolveItemPlanTransform(
|
||||
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
|
||||
rotation: parentTransform.rotation + localRotation,
|
||||
}
|
||||
} else if (parent?.type === 'roof-segment') {
|
||||
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
|
||||
// frame, then composed through the segment's and roof's yaw +
|
||||
// position into level-local plan coords — without this the drag seed
|
||||
// jumps off the roof at move start.
|
||||
const segment = parent as RoofSegmentNode
|
||||
const roof = segment.parentId
|
||||
? (nodes[segment.parentId as AnyNodeId] as
|
||||
| (AnyNode & { position: [number, number, number]; rotation: number })
|
||||
| undefined)
|
||||
: undefined
|
||||
if (roof?.type === 'roof' && item.roofFace) {
|
||||
const frame = getRoofWallFaceFrame(segment, item.roofFace)
|
||||
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
|
||||
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
|
||||
const [rx, rz] = rotateVec(
|
||||
sx + segment.position[0],
|
||||
sz + segment.position[2],
|
||||
roof.rotation ?? 0,
|
||||
)
|
||||
result = {
|
||||
point: [rx + roof.position[0], rz + roof.position[2]],
|
||||
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(item.id as AnyNodeId, result)
|
||||
@@ -208,6 +236,10 @@ function buildWallItemSession(
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getRoofWallFaceFrame,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -112,6 +115,31 @@ function resolveItemTransform(
|
||||
y: shelfZ + offsetY,
|
||||
rotation: shelfRotationY + localRotation,
|
||||
}
|
||||
} else if (parentNode?.type === 'roof-segment') {
|
||||
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
|
||||
// frame, then composed through the segment's and parent roof's poses
|
||||
// into level-local plan coords.
|
||||
const segment = parentNode as RoofSegmentNode
|
||||
const roof = segment.parentId
|
||||
? (ctx.resolve(segment.parentId as AnyNodeId) as
|
||||
| (AnyNode & { position: [number, number, number]; rotation: number })
|
||||
| undefined)
|
||||
: undefined
|
||||
if (roof?.type === 'roof' && item.roofFace) {
|
||||
const frame = getRoofWallFaceFrame(segment, item.roofFace)
|
||||
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
|
||||
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
|
||||
const [rx, rz] = rotateVec(
|
||||
sx + segment.position[0],
|
||||
sz + segment.position[2],
|
||||
roof.rotation ?? 0,
|
||||
)
|
||||
result = {
|
||||
x: rx + roof.position[0],
|
||||
y: rz + roof.position[2],
|
||||
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Level / slab / ceiling parent — item.position is level-local.
|
||||
result = {
|
||||
|
||||
@@ -38,9 +38,20 @@ import { Vector3 } from 'three'
|
||||
function getInitialState(node: ItemNode): PlacementState {
|
||||
const attachTo = node.asset.attachTo
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
if (node.roofSegmentId) {
|
||||
return {
|
||||
surface: 'roof-wall',
|
||||
wallId: null,
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
surface: 'wall',
|
||||
wallId: node.parentId,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState {
|
||||
return {
|
||||
surface: 'ceiling',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: node.parentId,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState {
|
||||
return {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
|
||||
? {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
|
||||
type MutableMaterial = Material & {
|
||||
depthTest?: boolean
|
||||
@@ -92,7 +93,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
||||
[storeNode, liveOverrides],
|
||||
)
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
@@ -104,6 +105,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
|
||||
if (!node.roofSegmentId) return content
|
||||
return (
|
||||
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
|
||||
{content}
|
||||
</RoofFaceHostFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
getRoofWallFaceFrame,
|
||||
type RoofSegmentNode,
|
||||
type RoofWallFaceId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type ReactNode, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Mounts a roof-hosted wall child inside its host face frame. Children
|
||||
* of roof segments render under the roof's `roof-elements` group (roof
|
||||
* frame); this wrapper applies the segment transform plus the face
|
||||
* frame, both derived from the LIVE-override-merged segment — hosted
|
||||
* nodes therefore track segment handle drags in real time instead of
|
||||
* jumping to their new spot on commit. Inside the frame, children use
|
||||
* plain wall-child position conventions ([u, v, z-from-mid-plane]).
|
||||
*/
|
||||
export function RoofFaceHostFrame({
|
||||
roofSegmentId,
|
||||
roofFace,
|
||||
children,
|
||||
}: {
|
||||
roofSegmentId: string
|
||||
roofFace: RoofWallFaceId | undefined
|
||||
children: ReactNode
|
||||
}) {
|
||||
const storeSegment = useScene(
|
||||
(state) => state.nodes[roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined,
|
||||
)
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.get(roofSegmentId as AnyNodeId))
|
||||
const segment = useMemo(
|
||||
() =>
|
||||
storeSegment && liveOverride
|
||||
? ({ ...storeSegment, ...liveOverride } as RoofSegmentNode)
|
||||
: storeSegment,
|
||||
[storeSegment, liveOverride],
|
||||
)
|
||||
|
||||
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
|
||||
const frame = getRoofWallFaceFrame(segment, roofFace)
|
||||
|
||||
return (
|
||||
<group position={segment.position} rotation-y={segment.rotation}>
|
||||
<group position={frame.origin} rotation-y={frame.yaw}>
|
||||
{children}
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
RoofWallFaceId,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getMaxRoofRectHeightFromAnchor,
|
||||
getMaxRoofRectWidthFromAnchor,
|
||||
getRoofSegmentWallFace,
|
||||
getRoofWallFaceIdFromYaw,
|
||||
segmentPointToRoofWallFace,
|
||||
roofFacePointToSegment,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Host-side helpers for openings (door / window) hosted on a roof-segment
|
||||
* wall face: resize-handle limits derived from the face profile, and the
|
||||
* plan-space anchors the 2D floor-plan move path needs.
|
||||
* plan-space anchors the 2D floor-plan move path needs. Hosted children
|
||||
* store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`.
|
||||
*/
|
||||
|
||||
type RoofHostedOpening = {
|
||||
roofSegmentId?: string
|
||||
roofFace?: RoofWallFaceId
|
||||
parentId: string | null
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
@@ -25,14 +31,10 @@ type RoofHostedOpening = {
|
||||
type SceneReader = { get: (id: AnyNodeId) => unknown }
|
||||
|
||||
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
|
||||
if (!node.roofSegmentId) return null
|
||||
if (!(node.roofSegmentId && node.roofFace)) return null
|
||||
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
|
||||
if (!segment || segment.type !== 'roof-segment') return null
|
||||
const faceId = getRoofWallFaceIdFromYaw(node.rotation[1])
|
||||
if (!faceId) return null
|
||||
const face = getRoofSegmentWallFace(segment, faceId)
|
||||
const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position)
|
||||
return { segment, face, u, v }
|
||||
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +49,8 @@ export function readRoofFaceWidthMax(
|
||||
): number | null {
|
||||
const host = resolveHostFace(node, scene)
|
||||
if (!host) return null
|
||||
const anchorU = host.u - (growSign * node.width) / 2
|
||||
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height)
|
||||
const anchorU = node.position[0] - (growSign * node.width) / 2
|
||||
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +65,8 @@ export function readRoofFaceHeightMax(
|
||||
): number | null {
|
||||
const host = resolveHostFace(node, scene)
|
||||
if (!host) return null
|
||||
const anchorV = host.v - (growSign * node.height) / 2
|
||||
return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign)
|
||||
const anchorV = node.position[1] - (growSign * node.height) / 2
|
||||
return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +74,7 @@ export function readRoofFaceHeightMax(
|
||||
* level). Null when the parent chain isn't roof-shaped.
|
||||
*/
|
||||
export function getRoofHostedOpeningLevelId(
|
||||
node: RoofHostedOpening,
|
||||
node: { parentId: string | null },
|
||||
nodes: Record<string, AnyNode | undefined>,
|
||||
): AnyNodeId | null {
|
||||
const segment = node.parentId ? nodes[node.parentId] : undefined
|
||||
@@ -83,15 +85,20 @@ export function getRoofHostedOpeningLevelId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-plan [x, z] of a roof-hosted opening — its segment-local center
|
||||
* composed through the segment's and roof's yaw + position.
|
||||
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
|
||||
* through the face frame, then composed through the segment's and roof's
|
||||
* yaw + position.
|
||||
*/
|
||||
export function getRoofHostedOpeningPlanPoint(
|
||||
node: RoofHostedOpening,
|
||||
node: {
|
||||
parentId: string | null
|
||||
roofFace?: RoofWallFaceId
|
||||
position: [number, number, number]
|
||||
},
|
||||
nodes: Record<string, AnyNode | undefined>,
|
||||
): [number, number] | null {
|
||||
const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined
|
||||
if (segment?.type !== 'roof-segment') return null
|
||||
if (segment?.type !== 'roof-segment' || !node.roofFace) return null
|
||||
const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined
|
||||
if (roof?.type !== 'roof') return null
|
||||
|
||||
@@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint(
|
||||
-x * Math.sin(yaw) + z * Math.cos(yaw),
|
||||
]
|
||||
|
||||
const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0)
|
||||
const segLocal = roofFacePointToSegment(segment, node.roofFace, [
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
])
|
||||
const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0)
|
||||
const segX = sx + segment.position[0]
|
||||
const segZ = sz + segment.position[2]
|
||||
const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
},
|
||||
// `wallId` / `roofSegmentId` are re-derived from the surface under
|
||||
// the cursor at preset placement time — see door for the pattern.
|
||||
hostRefFields: ['wallId', 'roofSegmentId'],
|
||||
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
|
||||
},
|
||||
|
||||
parametrics: windowParametrics,
|
||||
|
||||
@@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
original:
|
||||
originalWall?.type === 'wall'
|
||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
|
||||
node.position[0],
|
||||
0,
|
||||
]),
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||
metadata: node.metadata,
|
||||
})
|
||||
|
||||
@@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
parentId: string
|
||||
wallId: string
|
||||
roofSegmentId: undefined
|
||||
roofFace: undefined
|
||||
} | null = null
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
@@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
}
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
roofWallFaceLocalToSegment,
|
||||
roofFacePointToSegment,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
hasRoofFaceChildOverlap,
|
||||
isValidWallSideFace,
|
||||
resolveRoofWallHit,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
@@ -81,6 +82,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
|
||||
// must restore the roof host.
|
||||
roofSegmentId: movingWindowNode.roofSegmentId,
|
||||
roofFace: movingWindowNode.roofFace,
|
||||
metadata: movingWindowNode.metadata,
|
||||
}
|
||||
|
||||
@@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: target.wallId,
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.wallId
|
||||
@@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
wallId: target.wallId,
|
||||
parentId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
@@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
movingWindowNode.height,
|
||||
)
|
||||
if (!clamped) return null
|
||||
const position = roofWallFaceLocalToSegment(
|
||||
hit.segment,
|
||||
hit.face.id,
|
||||
clamped.u,
|
||||
clamped.v,
|
||||
(hit.segment.wallThickness ?? 0.1) / 2,
|
||||
)
|
||||
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
|
||||
// mounts the node inside the live face frame, so it tracks segment
|
||||
// resizes without any re-anchoring.
|
||||
const position: [number, number, number] = [clamped.u, clamped.v, 0]
|
||||
const valid = !hasRoofFaceChildOverlap(
|
||||
hit.segment,
|
||||
hit.face,
|
||||
hit.face.id,
|
||||
clamped.u,
|
||||
clamped.v,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode }
|
||||
return { hit, position, valid, roof: event.node as RoofNode }
|
||||
}
|
||||
|
||||
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
|
||||
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
|
||||
if (!segObj) return
|
||||
segObj.updateWorldMatrix(true, false)
|
||||
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
|
||||
const segLocal = roofFacePointToSegment(
|
||||
target.hit.segment,
|
||||
target.hit.face.id,
|
||||
target.position,
|
||||
)
|
||||
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
|
||||
segObj.localToWorld(roofCursorPoint)
|
||||
updateCursor(
|
||||
worldToBuildingLocal(roofCursorPoint),
|
||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
|
||||
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
|
||||
target.valid,
|
||||
)
|
||||
}
|
||||
@@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
if (currentWallId !== target.hit.segment.id) {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
parentId: target.hit.segment.id,
|
||||
wallId: undefined,
|
||||
roofSegmentId: target.hit.segment.id,
|
||||
roofFace: target.hit.face.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = target.hit.segment.id
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
roofFace: target.hit.face.id,
|
||||
})
|
||||
}
|
||||
updateRoofCursor(target)
|
||||
@@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const node = WindowNode.parse({
|
||||
...cloned,
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
wallId: undefined,
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.hit.face.id,
|
||||
parentId: segmentId,
|
||||
})
|
||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||
@@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: target.position,
|
||||
rotation: [0, target.yaw, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
parentId: segmentId,
|
||||
wallId: undefined,
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.hit.face.id,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
@@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
@@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type RoofSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
createMaterial,
|
||||
DEFAULT_WINDOW_MATERIAL,
|
||||
@@ -15,6 +9,7 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
|
||||
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
@@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
// Roof-hosted windows mount under the roof's `roof-elements` group (roof
|
||||
// frame), so the host segment's transform is applied here — wall-hosted
|
||||
// windows get it for free from the wall mesh they're nested in.
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
if (node.roofSegmentId && segment?.type !== 'roof-segment') return null
|
||||
|
||||
const mesh = (
|
||||
<mesh
|
||||
material={material}
|
||||
@@ -62,11 +47,11 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
</mesh>
|
||||
)
|
||||
|
||||
if (!segment) return mesh
|
||||
if (!node.roofSegmentId) return mesh
|
||||
return (
|
||||
<group position={segment.position} rotation-y={segment.rotation}>
|
||||
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
|
||||
{mesh}
|
||||
</group>
|
||||
</RoofFaceHostFrame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, AnyNode>,
|
||||
) {
|
||||
const newGeo = generateRoofSegmentGeometry(node, nodes)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
@@ -242,58 +249,28 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
|
||||
mesh.rotation.y = node.rotation
|
||||
}
|
||||
|
||||
function updateMergedRoofGeometry(
|
||||
roofNode: RoofNode,
|
||||
group: THREE.Group,
|
||||
/**
|
||||
* Subtract every hosted accessory cut (`capabilities.roofAccessory.
|
||||
* buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
|
||||
* the merged-shell path AND the per-segment path (full edit mode /
|
||||
* painted segments) — without the latter, selecting a segment used to
|
||||
* swap the merged shell for uncut per-segment meshes and every door /
|
||||
* window / skylight hole vanished until deselect. Children are read
|
||||
* live-effective so an in-flight handle drag carves the live hole.
|
||||
* Registry-driven so the viewer never names a kind.
|
||||
*/
|
||||
function subtractAccessoryCuts(
|
||||
brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush },
|
||||
segment: RoofSegmentNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined
|
||||
if (!mergedMesh) return
|
||||
|
||||
// Segments that carry their own material / preset (catch-all or any of
|
||||
// the role-specific fields) are rendered as their own per-segment mesh
|
||||
// in `RoofRenderer` so the painted material is preserved. Exclude them
|
||||
// from the merged shell — otherwise the merged mesh would draw on top
|
||||
// with the roof's default material.
|
||||
//
|
||||
// Merge each child through `getEffectiveNode` so an in-flight handle
|
||||
// drag (live override on width / depth / wallHeight / pitch / rotation)
|
||||
// is reflected in the merged shell during the drag, not only on commit.
|
||||
const children = (roofNode.children ?? [])
|
||||
.map((id) => {
|
||||
const scn = nodes[id] as RoofSegmentNode | undefined
|
||||
return scn ? getEffectiveNode(scn) : undefined
|
||||
})
|
||||
.filter((n): n is RoofSegmentNode => n !== undefined && !hasSegmentMaterialOverride(n))
|
||||
|
||||
if (children.length === 0) {
|
||||
mergedMesh.geometry.dispose()
|
||||
// Keep a valid position attribute so Drei's BVH can index safely.
|
||||
mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
let totalShinSlab: Brush | null = null
|
||||
let totalDeckSlab: Brush | null = null
|
||||
let totalWall: Brush | null = null
|
||||
let totalInner: Brush | null = null
|
||||
|
||||
for (const child of children) {
|
||||
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
|
||||
for (const childElemId of segment.children ?? []) {
|
||||
const storedChild = nodes[childElemId as AnyNodeId]
|
||||
if (!storedChild) continue
|
||||
const childElem = getEffectiveNode(storedChild)
|
||||
const meta =
|
||||
typeof childElem.metadata === 'object' && childElem.metadata !== null
|
||||
? (childElem.metadata as Record<string, unknown>)
|
||||
@@ -304,7 +281,7 @@ function updateMergedRoofGeometry(
|
||||
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
|
||||
if (!buildCut) continue
|
||||
|
||||
const cutGeo = buildCut(childElem, child)
|
||||
const cutGeo = buildCut(childElem, segment)
|
||||
if (!cutGeo) continue
|
||||
|
||||
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
|
||||
@@ -353,6 +330,49 @@ function updateMergedRoofGeometry(
|
||||
brushes.shinSlab = workingShin
|
||||
brushes.deckSlab = workingDeck
|
||||
brushes.wallBrush = workingWall
|
||||
}
|
||||
|
||||
function updateMergedRoofGeometry(
|
||||
roofNode: RoofNode,
|
||||
group: THREE.Group,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined
|
||||
if (!mergedMesh) return
|
||||
|
||||
// Segments that carry their own material / preset (catch-all or any of
|
||||
// the role-specific fields) are rendered as their own per-segment mesh
|
||||
// in `RoofRenderer` so the painted material is preserved. Exclude them
|
||||
// from the merged shell — otherwise the merged mesh would draw on top
|
||||
// with the roof's default material.
|
||||
//
|
||||
// Merge each child through `getEffectiveNode` so an in-flight handle
|
||||
// drag (live override on width / depth / wallHeight / pitch / rotation)
|
||||
// is reflected in the merged shell during the drag, not only on commit.
|
||||
const children = (roofNode.children ?? [])
|
||||
.map((id) => {
|
||||
const scn = nodes[id] as RoofSegmentNode | undefined
|
||||
return scn ? getEffectiveNode(scn) : undefined
|
||||
})
|
||||
.filter((n): n is RoofSegmentNode => n !== undefined && !hasSegmentMaterialOverride(n))
|
||||
|
||||
if (children.length === 0) {
|
||||
mergedMesh.geometry.dispose()
|
||||
// Keep a valid position attribute so Drei's BVH can index safely.
|
||||
mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
let totalShinSlab: Brush | null = null
|
||||
let totalDeckSlab: Brush | null = null
|
||||
let totalWall: Brush | null = null
|
||||
let totalInner: Brush | null = null
|
||||
|
||||
for (const child of children) {
|
||||
const brushes = getRoofSegmentBrushes(child)
|
||||
if (!brushes) continue
|
||||
|
||||
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<string, AnyNode>,
|
||||
): 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user