diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 330628c3..80e5144b 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -118,14 +118,19 @@ export function BuildTab() { // app bootstrap, so enumerating earlier would race it and see no kinds. const roofFeatures = useMemo( () => - getRoofAccessoryKinds().map((kind) => { - const icon = nodeRegistry.get(kind)?.presentation?.icon - return { - kind, - label: nodeRegistry.get(kind)?.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - } - }), + getRoofAccessoryKinds() + // Door / window declare `roofAccessory` for the wall-face cut but + // already have their own Build tiles — listing them here too + // would duplicate the entry under Roof → Features. + .filter((kind) => !nodeRegistry.get(kind)?.capabilities?.wallOpeningPlacement) + .map((kind) => { + const icon = nodeRegistry.get(kind)?.presentation?.icon + return { + kind, + label: nodeRegistry.get(kind)?.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, + } + }), [], ) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6f57ff0b..0ccf97a9 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1205,6 +1205,23 @@ export type PaintEffectiveMaterialArgs = { */ export type RoofAccessoryConfig = { buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null + /** + * Which segment brushes `buildCut` subtracts from. Wall-face openings + * (door / window) cut only the wall brush — subtracting the same box + * from the shin / deck slabs is pointless work and creates tangential + * / coplanar CSG cases near the gable and shed slopes. Defaults to + * all three (skylight / dormer genuinely poke through the deck). + */ + cutScope?: 'all' | 'wall' + /** + * Set when the kind runs its own dirty-driven geometry system that + * already cascades to the host segment (door / window via the + * DoorSystem / WindowSystem `parentId` cascade). The roof-merge loop + * must then leave the kind's dirty marks alone — consuming them here + * would starve that system whenever it defers a rebuild (mesh not + * mounted yet, per-frame rebuild budget exhausted). + */ + cascadesViaHostSegment?: boolean } /** diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 777f3456..5d5c5a50 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -108,6 +108,17 @@ export { RoofSegmentNode, RoofType, } from './nodes/roof-segment' +export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls' +export { + clampRectToRoofWallFace, + getMaxRoofRectHeightFromAnchor, + getMaxRoofRectWidthFromAnchor, + getRoofSegmentWallFace, + getRoofSegmentWallFaces, + getRoofWallFaceIdFromYaw, + roofWallFaceLocalToSegment, + segmentPointToRoofWallFace, +} from './nodes/roof-segment-walls' export { ScanNode } from './nodes/scan' export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index b64938c7..32bca4df 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -46,6 +46,11 @@ export const DoorNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), 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`. + roofSegmentId: z.string().optional(), // Overall dimensions width: z.number().default(0.9), diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts new file mode 100644 index 00000000..54414ccf --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -0,0 +1,424 @@ +import type { RoofSegmentNode } from './roof-segment' +import { getSegmentSlopeFrame } from './roof-segment' + +/** + * Wall-face math for roof segments — the vertical surfaces a wall-mounted + * opening (door / window) can attach to. A segment's generated volume has + * four vertical faces; on gable-family roofs the end faces extend past the + * eave line into the gable (rect + triangle/pentagon, coplanar with the + * base wall). These helpers describe each face as a 2D frame + * (`u` along the face, `v` height above the segment base) plus the + * placeable profile polygon, so placement tools, renderers, and CSG cut + * builders all share one definition of "the wall under the roof". + * + * The numbers MUST mirror the outer wall volume built by + * `getRoofSegmentBrushes` in the viewer's roof system + * (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment + * footprint extended outward by `wallThickness / 2`, which drops the eave + * line by `(wallThickness / 2) · tanθ` and raises the ridge by the same + * amount so the apex stays at `wallHeight + activeRh`. + */ + +export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left' + +export type RoofSegmentWallFace = { + id: RoofWallFaceId + /** Outward normal in segment-local space. */ + normal: [number, number, number] + /** + * Yaw (radians, rotation-y) mapping opening-local +Z to the outward + * normal and opening-local +X to the face's +U direction — the same + * frame a wall-hosted door/window uses relative to its wall mesh. + */ + yaw: number + /** Face length along U. */ + length: number + /** + * Placeable region, CCW polygon in face coords. `u ∈ [0, length]`, + * `v` is height above the segment base (segment-local Y). + */ + profile: [number, number][] +} + +type SegmentWallInputs = Pick< + RoofSegmentNode, + 'roofType' | 'width' | 'depth' | 'wallHeight' | 'wallThickness' | 'pitch' +> & + Partial< + Pick< + RoofSegmentNode, + | 'gambrelLowerWidthRatio' + | 'gambrelLowerHeightRatio' + | 'mansardSteepWidthRatio' + | 'mansardSteepHeightRatio' + | 'dutchHipWidthRatio' + | 'dutchHipHeightRatio' + > + > + +type WallVolumeFrame = { + /** Outer wall plane extents (footprint + wallThickness). */ + wV: number + dV: number + /** Eave height of the outer volume. */ + eaveY: number + /** Ridge/peak height of the outer volume. */ + peakY: number + /** tan(pitch) of the primary slope. */ + tanTheta: number + hasSlope: boolean +} + +function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame { + const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + const wallThickness = node.wallThickness ?? 0.1 + const autoDrop = (wallThickness / 2) * tanTheta + const wV = Math.max(0.01, node.width + wallThickness) + const dV = Math.max(0.01, node.depth + wallThickness) + const eaveY = Math.max(0.01, node.wallHeight - autoDrop) + let rh = activeRh + if (activeRh > 0) { + rh = activeRh + autoDrop + if (node.roofType === 'shed') rh = activeRh + 2 * autoDrop + } + return { + wV, + dV, + eaveY, + peakY: eaveY + Math.max(0.001, rh), + tanTheta, + hasSlope: activeRh > 0, + } +} + +const FACE_NORMALS: Record = { + front: [0, 0, 1], + back: [0, 0, -1], + right: [1, 0, 0], + left: [-1, 0, 0], +} + +const FACE_YAWS: Record = { + front: 0, + back: Math.PI, + right: Math.PI / 2, + left: -Math.PI / 2, +} + +function rectProfile(length: number, top: number): [number, number][] { + return [ + [0, 0], + [length, 0], + [length, top], + [0, top], + ] +} + +function buildFaceProfile( + node: SegmentWallInputs, + frame: WallVolumeFrame, + id: RoofWallFaceId, +): [number, number][] { + const { wV, dV, eaveY, peakY, tanTheta, hasSlope } = frame + const isEnd = id === 'right' || id === 'left' + const length = isEnd ? dV : wV + + if (!hasSlope) return rectProfile(length, eaveY) + + switch (node.roofType) { + case 'gable': { + if (!isEnd) return rectProfile(length, eaveY) + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2, peakY], + [0, eaveY], + ] + } + case 'gambrel': { + if (!isEnd) return rectProfile(length, eaveY) + // Kink ring sits at z = ±mz on the nominal footprint (see + // getModuleFaces); both end faces are symmetric about u = length/2. + const ratio = node.gambrelLowerWidthRatio ?? 0.5 + const mz = Math.min((node.depth / 2) * ratio, length / 2) + const kinkY = eaveY + (length / 2 - mz) * tanTheta + return [ + [0, 0], + [length, 0], + [length, eaveY], + [length / 2 + mz, kinkY], + [length / 2, peakY], + [length / 2 - mz, kinkY], + [0, eaveY], + ] + } + case 'shed': { + // Slope falls toward +Z: 'back' is the full-height wall, the end + // faces are right trapezoids rising toward the back edge. + if (id === 'front') return rectProfile(length, eaveY) + if (id === 'back') return rectProfile(length, peakY) + if (id === 'right') { + return [ + [0, 0], + [length, 0], + [length, peakY], + [0, eaveY], + ] + } + return [ + [0, 0], + [length, 0], + [length, eaveY], + [0, peakY], + ] + } + // hip / mansard / dutch slope on every side (dutch gablets are + // recessed from the wall plane), so only the base rect is placeable. + default: + return rectProfile(length, eaveY) + } +} + +export function getRoofSegmentWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, +): RoofSegmentWallFace { + const frame = getWallVolumeFrame(node) + const isEnd = id === 'right' || id === 'left' + return { + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: isEnd ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + } +} + +export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWallFace[] { + const frame = getWallVolumeFrame(node) + return (['front', 'back', 'right', 'left'] as const).map((id) => ({ + id, + normal: FACE_NORMALS[id], + yaw: FACE_YAWS[id], + length: id === 'right' || id === 'left' ? frame.dV : frame.wV, + profile: buildFaceProfile(node, frame, id), + })) +} + +/** + * 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 = + * outside the volume). + */ +export function segmentPointToRoofWallFace( + node: SegmentWallInputs, + id: RoofWallFaceId, + point: [number, number, number], +): { u: number; v: number; dist: number } { + const { wV, dV } = getWallVolumeFrame(node) + const [x, y, z] = point + switch (id) { + case 'front': + return { u: x + wV / 2, v: y, dist: z - dV / 2 } + case 'back': + return { u: wV / 2 - x, v: y, dist: -z - dV / 2 } + case 'right': + return { u: dV / 2 - z, v: y, dist: x - wV / 2 } + case 'left': + return { u: z + dV / 2, v: y, dist: -x - wV / 2 } + } +} + +type FaceConstraint = { + nu: number + nv: number + c: number +} + +/** + * Inward half-plane constraints of the raw profile polygon (CCW → + * interior is to the left of each edge): a point p is inside when + * `nu·p.u + nv·p.v ≥ c` for every constraint. + */ +function getProfileConstraints(face: RoofSegmentWallFace): FaceConstraint[] { + const constraints: FaceConstraint[] = [] + const pts = face.profile + for (let i = 0; i < pts.length; i++) { + const a = pts[i]! + const b = pts[(i + 1) % pts.length]! + const du = b[0] - a[0] + const dv = b[1] - a[1] + const len = Math.hypot(du, dv) + if (len < 1e-9) continue + const nu = -dv / len + const nv = du / len + constraints.push({ nu, nv, c: nu * a[0] + nv * a[1] }) + } + return constraints +} + +/** + * Half-plane constraints for the CENTER of a `width × height` rect that + * must fit inside the face profile — the raw constraints eroded by the + * rect's half-extents projected on each edge normal. + */ +function getRectCenterConstraints( + face: RoofSegmentWallFace, + width: number, + height: number, +): FaceConstraint[] { + return getProfileConstraints(face).map(({ nu, nv, c }) => ({ + nu, + nv, + c: c + (Math.abs(nu) * width) / 2 + (Math.abs(nv) * height) / 2, + })) +} + +/** 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 +} + +/** + * Max width of a rect growing from an anchored vertical edge (`anchorU`) + * in direction `growSign` (±1 along U) while staying inside the face + * profile at the fixed vertical center `vCenter`. Resize-handle limit: + * the anchored-edge model matches the handles' apply math (opposite + * edge stays put, center re-derives). + */ +export function getMaxRoofRectWidthFromAnchor( + face: RoofSegmentWallFace, + anchorU: number, + growSign: number, + vCenter: number, + height: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + // Center at anchorU + growSign·w/2, eroded by |nu|·w/2 + |nv|·h/2: + // base + k·w ≥ 0 with k ≤ 0 only when growth approaches the edge. + const k = (nu * growSign - Math.abs(nu)) / 2 + if (k >= -1e-9) continue + const base = nu * anchorU + nv * vCenter - c - (Math.abs(nv) * height) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +/** + * Max height of a rect growing from an anchored horizontal edge + * (`anchorV`) in direction `growSign` (+1 = bottom anchored, grows up) + * while staying inside the face profile at the fixed horizontal center + * `uCenter`. + */ +export function getMaxRoofRectHeightFromAnchor( + face: RoofSegmentWallFace, + uCenter: number, + width: number, + anchorV: number, + growSign: number, +): number { + let max = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of getProfileConstraints(face)) { + const k = (nv * growSign - Math.abs(nv)) / 2 + if (k >= -1e-9) continue + const base = nu * uCenter + nv * anchorV - c - (Math.abs(nu) * width) / 2 + max = Math.min(max, Math.max(0, base / -k)) + } + return max +} + +const CLAMP_EPSILON = 1e-4 + +/** + * Clamp a rect center so the rect fits inside the face profile. + * + * - `lockV: true` (doors): `v` is fixed; only `u` slides. Returns null + * when no `u` keeps the rect inside at that height. + * - otherwise (windows): the center is projected into the eroded convex + * region (cyclic projection — profiles are convex by construction). + * + * Returns null when the rect cannot fit anywhere on the face. + */ +export function clampRectToRoofWallFace( + face: RoofSegmentWallFace, + u: number, + v: number, + width: number, + height: number, + opts?: { lockV?: boolean }, +): { u: number; v: number } | null { + const constraints = getRectCenterConstraints(face, width, height) + if (constraints.length < 3) return null + + if (opts?.lockV) { + let lo = Number.NEGATIVE_INFINITY + let hi = Number.POSITIVE_INFINITY + for (const { nu, nv, c } of constraints) { + const rhs = c - nv * v + if (Math.abs(nu) < 1e-9) { + if (rhs > CLAMP_EPSILON) return null + continue + } + if (nu > 0) lo = Math.max(lo, rhs / nu) + else hi = Math.min(hi, rhs / nu) + } + if (lo > hi + CLAMP_EPSILON) return null + return { u: Math.min(Math.max(u, lo), hi), v } + } + + let pu = u + let pv = v + for (let iter = 0; iter < 32; iter++) { + let worst: FaceConstraint | null = null + let worstViolation = CLAMP_EPSILON + for (const constraint of constraints) { + const violation = constraint.c - (constraint.nu * pu + constraint.nv * pv) + if (violation > worstViolation) { + worstViolation = violation + worst = constraint + } + } + if (!worst) return { u: pu, v: pv } + pu += worst.nu * worstViolation + pv += worst.nv * worstViolation + } + for (const { nu, nv, c } of constraints) { + if (nu * pu + nv * pv < c - 1e-3) return null + } + return { u: pu, v: pv } +} diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 50fdcbb4..307d4191 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -28,6 +28,11 @@ 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`. + roofSegmentId: z.string().optional(), // Overall dimensions width: z.number().default(1.5), diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 2a5d2ae1..24f0ad46 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => { + registerColliderDefinition('column', ColumnNode, 'structure') + + // Mirror the roof's segments-wrapper shape: the registered mesh's own + // visible flag stays true while a hidden wrapper hides it at render + // time. The collider must match the render, not the own-flag. + const column = ColumnNode.parse({ id: 'column_test' }) + const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] }) + setSceneNodes([column, visibleColumn]) + + const wrapper = new Group() + wrapper.visible = false + const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial()) + wrapper.add(hiddenMesh) + wrapper.updateMatrixWorld(true) + sceneRegistry.nodes.set(column.id, hiddenMesh) + sceneRegistry.byType[column.type]!.add(column.id) + + mountNode(visibleColumn, [1, 2, 1], [3, 1, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh + // under the hidden wrapper contributed no geometry. + expect(world?.bounds?.min.x).toBeCloseTo(2.5) + expect(world?.bounds?.max.x).toBeCloseTo(3.5) + world?.dispose() + }) + test('leaves elevators to their dedicated dynamic collider meshes', () => { registerColliderDefinition('elevator', ElevatorNode, 'structure') diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 0e439eaa..24fafaa6 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -50,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh { return 'isMesh' in object && (object as THREE.Mesh).isMesh } +// Renderer-effective visibility: an invisible ancestor hides the whole +// subtree at render time even when the object's own flag is true. The +// collider world must match what's rendered — the roof keeps stale, +// UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit +// exit hides the wrapper without stripping geometry), and cloning those +// meshes would block the walkthrough player at openings the visible +// merged shell has cut through. +function isEffectivelyVisible(object: THREE.Object3D) { + let current: THREE.Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } @@ -319,9 +335,12 @@ function collectColliderGeometriesFromNode( if (visitedMeshes.has(object)) return visitedMeshes.add(object) + // Prune hidden subtrees — children of an invisible group never render, + // so they must not collide either (see isEffectivelyVisible). + if (!object.visible) return + if ( isMesh(object) && - object.visible && isColliderMaterialVisible(object.material) && !SKIPPED_MESH_NAMES.has(object.name) ) { @@ -364,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider const root = sceneRegistry.nodes.get(nodeId) if (!root) continue + // Registered objects can sit inside a hidden wrapper (roof segments + // under `segments-wrapper`) — the per-node traversal starts AT the + // object, so the ancestor chain must be checked here. + if (!isEffectivelyVisible(root)) continue + if (node.type === 'door') { const doorGeometry = createDoorLeafColliderGeometry(root, node) if (doorGeometry) { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index d1978c9a..906b1b3b 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -3,8 +3,11 @@ import type { DoorNode as DoorNodeType, HandleDescriptor, NodeDefinition, + RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' +import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' @@ -42,7 +45,13 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor // 'max' = +X edge anchored (left arrow grows the -X edge outward). anchor: side === 'right' ? 'min' : 'max', min: MIN_DOOR_WIDTH, - max: (n, scene) => readWallLength(n, scene), + max: (n, scene) => { + // Roof-hosted doors clamp against the face profile (the wall-based + // limits read Infinity when wallId is unset). + const roofMax = readRoofFaceWidthMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax) + return readWallLength(n, scene) + }, currentValue: (n) => n.width, apply: (initial, newWidth) => { // Anchored edge stays fixed in wall-local coords. Door rotation is @@ -80,6 +89,8 @@ function doorHeightHandle(): HandleDescriptor { anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2 min: MIN_DOOR_HEIGHT, max: (n, scene) => { + const roofMax = readRoofFaceHeightMax(n, scene, 1) + if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) }, @@ -147,10 +158,22 @@ export const doorDefinition: NodeDefinition = { duplicable: true, deletable: true, wallOpeningPlacement: true, - // `wallId` ties the door to its host wall and is re-derived from - // the wall under the cursor when a preset is placed. Host apps - // strip this at preset-save time via `getHostRefFields(def)`. - hostRefFields: ['wallId'], + // Doors also host on roof-segment wall faces (base walls under the + // roof, gable ends). `buildCut` punches the opening into the + // segment's wall brush; `cascadesViaHostSegment` keeps the roof-merge + // loop from consuming door dirty marks (DoorSystem owns them and + // already cascades to the host via parentId). + roofAccessory: { + buildCut: (node, hostSegment) => + buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode), + cutScope: 'wall', + cascadesViaHostSegment: true, + }, + // `wallId` / `roofSegmentId` tie the door to its host and are + // 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'], }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 0aca81ca..5ea4f71f 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -8,6 +8,10 @@ import { } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + getRoofHostedOpeningLevelId, + getRoofHostedOpeningPlanPoint, +} from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -35,11 +39,13 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // Snapshot of the door's "valid" state at move-start — used by // canCommit to decide whether the current snapped position is OK. const startLevelId = (() => { - // Walk up via parentId until we hit a node whose type isn't 'wall' - // — that's the level (or null). The door is wall-hosted, so the - // wall's parent is the level. Cached at start because the parent - // chain doesn't change during a move. - const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + // Wall-hosted: the wall's parent is the level. Roof-hosted: walk + // segment → roof → level. Cached at start because the parent chain + // doesn't change during a move. + const nodes = useScene.getState().nodes + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const wall = nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() const originalWall = node.parentId @@ -49,7 +55,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : [node.position[0], 0], + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ + node.position[0], + 0, + ]), metadata: node.metadata, }) @@ -62,6 +71,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) side: DoorNode['side'] parentId: string wallId: string + roofSegmentId: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -94,6 +104,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) side: hit.side, parentId: hit.wall.id, wallId: 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, } // Build the updates atomically — position + rotation + side + diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index a8647288..701a5fbb 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,9 +1,13 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -23,8 +27,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group } from 'three' +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' @@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) @@ -57,6 +64,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: movingDoorNode.side, parentId: movingDoorNode.parentId, wallId: movingDoorNode.wallId, + // Doors can be hosted on a roof-segment wall face. Moving onto a + // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts + // must restore the roof host. + roofSegmentId: movingDoorNode.roofSegmentId, metadata: movingDoorNode.metadata, } @@ -207,6 +218,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -284,6 +296,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, wallId: target.wallId, parentId: target.wallId, + roofSegmentId: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -294,6 +307,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -304,6 +318,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, metadata: {}, }) @@ -340,6 +355,182 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + // ── Roof-segment wall faces ───────────────────────────────────── + // Mirrors the wall flow for the segments' vertical wall faces (base + // walls under the roof + coplanar gable ends). This is also the + // placement path preset tiles take (`metadata.isNew` clones). + + const worldToBuildingLocal = (point: Vector3): [number, number, number] => { + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const resolveRoofMoveTarget = (event: RoofEvent) => { + const hit = resolveRoofWallHit( + event.node as RoofNode, + event.position, + event.normal, + event.object, + ) + if (!hit) return null + // Doors sit on the segment base: v locked to height/2, only u slides. + const clamped = clampRectToRoofWallFace( + hit.face, + hit.u, + movingDoorNode.height / 2, + movingDoorNode.width, + movingDoorNode.height, + { lockV: true }, + ) + if (!clamped) return null + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + movingDoorNode.width, + movingDoorNode.height, + movingDoorNode.id, + ) + return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + } + + const updateRoofCursor = (target: NonNullable>) => { + const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) + if (!segObj) return + segObj.updateWorldMatrix(true, false) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target) return + // Wall-frame drag anchor / live transform don't apply on a roof face. + dragAnchor = null + lastTarget = null + useLiveTransforms.getState().clear(movingDoorNode.id) + if (currentWallId !== target.hit.segment.id) { + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: target.hit.segment.id, + wallId: undefined, + roofSegmentId: target.hit.segment.id, + }) + markWallDirty(currentWallId) + currentWallId = target.hit.segment.id + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + }) + } + updateRoofCursor(target) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target?.valid) return + const segmentId = target.hit.segment.id + + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingDoorNode.id) + useScene.temporal.getState().resume() + + const cloned = structuredClone(movingDoorNode) as any + delete cloned.id + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) + const node = DoorNode.parse({ + ...cloned, + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + wallId: undefined, + roofSegmentId: segmentId, + parentId: segmentId, + }) + useScene.getState().createNode(node, segmentId as AnyNodeId) + placedId = node.id + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingDoorNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: segmentId, + wallId: undefined, + roofSegmentId: segmentId, + metadata: {}, + }) + + if (original.parentId && original.parentId !== segmentId) { + markWallDirty(original.parentId) + } + placedId = movingDoorNode.id + } + + markWallDirty(segmentId) + useLiveTransforms.getState().clear(movingDoorNode.id) + useScene.temporal.getState().pause() + + triggerSFX('sfx:structure-build') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onRoofLeave = () => { + hideCursor() + useLiveTransforms.getState().clear(movingDoorNode.id) + dragAnchor = null + lastTarget = null + if (isNew) return + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingDoorNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, }) if (original.parentId) markWallDirty(original.parentId) } @@ -356,6 +547,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -369,6 +561,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -387,6 +583,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -399,6 +596,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, [movingDoorNode, exitMoveMode]) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 17cb3c3e..2925e78b 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -1,6 +1,12 @@ 'use client' -import { type DoorNode, useRegistry, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type DoorNode, + type RoofSegmentNode, + useRegistry, + useScene, +} from '@pascal-app/core' import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' @@ -17,7 +23,17 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { const handlers = useNodeEvents(node, 'door') const isTransient = !!(node.metadata as Record | null)?.isTransient - return ( + // Roof-hosted doors mount under the roof's `roof-elements` group (roof + // frame), so the host segment's transform is applied here — wall-hosted + // doors get it for free from the wall mesh they're nested in. + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + if (node.roofSegmentId && segment?.type !== 'roof-segment') return null + + const mesh = ( { ) + + if (!segment) return mesh + return ( + + {mesh} + + ) } export default DoorRenderer diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index eaec7683..cf00b221 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,9 +1,13 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useScene, @@ -20,8 +24,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +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' @@ -32,9 +37,13 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** - * Door tool — places DoorNodes on walls only. - * Doors always sit at floor level (clampedY = height/2). + * Door tool — places DoorNodes on walls and on roof-segment wall faces + * (the generated base walls under a roof, including coplanar gable ends). + * Doors always sit at floor level (clampedY = height/2 — segment base for + * roof-hosted doors). */ const DoorTool: React.FC = () => { const draftRef = useRef(null) @@ -215,6 +224,8 @@ const DoorTool: React.FC = () => { side, parentId: event.node.id, wallId: event.node.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, }) } } @@ -335,6 +346,168 @@ const DoorTool: React.FC = () => { hideCursor() } + // ── Roof-segment wall faces ───────────────────────────────────── + // The merged roof mesh emits `roof:*`; hits are resolved against the + // segments' vertical wall faces (base walls + coplanar gable ends). + + const worldToBuildingLocal = (point: Vector3): [number, number, number] => { + // The tool's cursor group renders in the building's local frame — + // same conversion as the roof accessory tools (e.g. SkylightTool). + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const resolveRoofTarget = (event: RoofEvent) => { + const hit = resolveRoofWallHit( + event.node as RoofNode, + event.position, + event.normal, + event.object, + ) + if (!hit) return null + const width = draftRef.current?.width ?? 0.9 + const height = draftRef.current?.height ?? 2.1 + // Doors sit on the segment base: v locked to height/2, only u slides. + const clamped = clampRectToRoofWallFace(hit.face, hit.u, height / 2, width, height, { + lockV: true, + }) + if (!clamped) return null + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + width, + height, + draftRef.current?.id, + ) + return { hit, position, yaw: hit.face.yaw, valid } + } + + const updateRoofCursor = ( + target: NonNullable>, + roof: RoofNode, + ) => { + 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]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofTarget(event) + if (!target) { + // On the roof but not over a placeable wall face (slope, soffit, + // or a face the door cannot fit on). + if (draftRef.current?.roofSegmentId) { + destroyDraft() + hideCursor() + } + return + } + const { hit, position, yaw } = 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], + }) + } else { + const node = DoorNode.parse({ + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + draftRef.current = node + } + updateRoofCursor(target, event.node as RoofNode) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + if (!draftRef.current?.roofSegmentId) return + const target = resolveRoofTarget(event) + if (!target?.valid) return + const { hit, position, yaw } = target + + const draft = draftRef.current + draftRef.current = null + + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const state = useScene.getState() + const doorCount = Object.values(state.nodes).filter( + (n) => n.type === 'door' && (n as DoorNode).roofSegmentId !== undefined, + ).length + + const node = DoorNode.parse({ + name: `Door ${doorCount + 1}`, + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + width: draft.width, + height: draft.height, + doorCategory: draft.doorCategory, + doorType: draft.doorType, + leafCount: draft.leafCount, + operationState: draft.operationState, + slideDirection: draft.slideDirection, + trackStyle: draft.trackStyle, + garagePanelCount: draft.garagePanelCount, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + threshold: draft.threshold, + thresholdHeight: draft.thresholdHeight, + hingesSide: draft.hingesSide, + swingDirection: draft.swingDirection, + segments: draft.segments, + handle: draft.handle, + handleHeight: draft.handleHeight, + handleSide: draft.handleSide, + doorCloser: draft.doorCloser, + panicBar: draft.panicBar, + panicBarHeight: draft.panicBarHeight, + }) + + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + // Rebuild the segment (and the merged roof) so the wall brush + // picks up the new opening cut. + useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().pause() + triggerSFX('sfx:structure-build') + event.stopPropagation() + } + + const onRoofLeave = () => { + if (!draftRef.current?.roofSegmentId) return + destroyDraft() + hideCursor() + } + const onCancel = () => { destroyDraft() hideCursor() @@ -344,6 +517,10 @@ const DoorTool: React.FC = () => { emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -355,6 +532,10 @@ const DoorTool: React.FC = () => { emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, []) diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts new file mode 100644 index 00000000..8b604e84 --- /dev/null +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -0,0 +1,108 @@ +import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core' +import { + getMaxRoofRectHeightFromAnchor, + getMaxRoofRectWidthFromAnchor, + getRoofSegmentWallFace, + getRoofWallFaceIdFromYaw, + segmentPointToRoofWallFace, +} 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. + */ + +type RoofHostedOpening = { + roofSegmentId?: string + parentId: string | null + position: [number, number, number] + rotation: [number, number, number] + width: number + height: number +} + +type SceneReader = { get: (id: AnyNodeId) => unknown } + +function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { + if (!node.roofSegmentId) 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 } +} + +/** + * Resize-handle width limit for a roof-hosted opening: the opposite edge + * is anchored, `growSign` (+1 = door-local +X arrow) is the direction + * the dragged edge moves. Null when the node is not roof-hosted. + */ +export function readRoofFaceWidthMax( + node: RoofHostedOpening, + scene: SceneReader, + growSign: number, +): 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) +} + +/** + * Resize-handle height limit for a roof-hosted opening. `growSign` +1 = + * bottom edge anchored, top grows up; -1 = top anchored, bottom grows + * down. Null when the node is not roof-hosted. + */ +export function readRoofFaceHeightMax( + node: RoofHostedOpening, + scene: SceneReader, + growSign: number, +): 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) +} + +/** + * Level hosting a roof-hosted opening's roof (opening → segment → roof → + * level). Null when the parent chain isn't roof-shaped. + */ +export function getRoofHostedOpeningLevelId( + node: RoofHostedOpening, + nodes: Record, +): AnyNodeId | null { + const segment = node.parentId ? nodes[node.parentId] : undefined + if (segment?.type !== 'roof-segment') return null + const roof = segment.parentId ? nodes[segment.parentId] : undefined + if (roof?.type !== 'roof') return null + return (roof.parentId as AnyNodeId | null) ?? null +} + +/** + * Level-plan [x, z] of a roof-hosted opening — its segment-local center + * composed through the segment's and roof's yaw + position. + */ +export function getRoofHostedOpeningPlanPoint( + node: RoofHostedOpening, + nodes: Record, +): [number, number] | null { + const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined + if (segment?.type !== 'roof-segment') return null + const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined + if (roof?.type !== 'roof') return null + + const rotate = (x: number, z: number, yaw: number): [number, number] => [ + x * Math.cos(yaw) + z * Math.sin(yaw), + -x * Math.sin(yaw) + z * Math.cos(yaw), + ] + + const [sx, sz] = rotate(node.position[0], node.position[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) + return [rx + roof.position[0], rz + roof.position[2]] +} diff --git a/packages/nodes/src/shared/roof-wall-hit.ts b/packages/nodes/src/shared/roof-wall-hit.ts new file mode 100644 index 00000000..2a186e56 --- /dev/null +++ b/packages/nodes/src/shared/roof-wall-hit.ts @@ -0,0 +1,152 @@ +import { + type AnyNodeId, + getRoofSegmentWallFaces, + type RoofNode, + type RoofSegmentNode, + type RoofSegmentWallFace, + sceneRegistry, + segmentPointToRoofWallFace, + useScene, +} from '@pascal-app/core' +import * as THREE from 'three' + +const worldPoint = new THREE.Vector3() +const worldNormal = new THREE.Vector3() +const localPoint = new THREE.Vector3() +const localNormal = new THREE.Vector3() +const inverseMatrix = new THREE.Matrix4() + +export type RoofWallHit = { + segment: RoofSegmentNode + face: RoofSegmentWallFace + /** Face coords of the hit (u along the face, v above the segment base). */ + u: number + v: number +} + +/** Pointer hits more than this far off the wall plane are not wall hits. */ +const PLANE_TOLERANCE = 0.06 +/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */ +const NORMAL_ALIGNMENT = 0.7 +/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */ +const MAX_NORMAL_Y = 0.4 + +/** + * Resolve a pointer hit on a roof to one of its segments' vertical wall + * faces (base walls under the roof + the coplanar gable/shed/gambrel end + * faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the + * sloped top surface instead. + * + * `normal` must be the raw `NodeEvent.normal` (hit-object-local) together + * with the `object` it came from — roof events can originate from the + * merged-roof mesh (roof-local frame) or a painted segment mesh + * (segment-local frame), so the normal is normalised through world space + * here instead of trusting the event frame. + */ +export function resolveRoofWallHit( + roof: RoofNode, + position: [number, number, number], + normal: [number, number, number] | undefined, + object: THREE.Object3D | undefined, +): RoofWallHit | null { + if (!normal || !object) return null + + worldPoint.set(position[0], position[1], position[2]) + worldNormal.set(normal[0], normal[1], normal[2]) + object.updateWorldMatrix(true, false) + worldNormal.transformDirection(object.matrixWorld) + + const state = useScene.getState() + let best: { hit: RoofWallHit; score: number } | null = null + + for (const childId of roof.children ?? []) { + const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') continue + const segObj = sceneRegistry.nodes.get(segment.id) + if (!segObj) continue + segObj.updateWorldMatrix(true, false) + + localPoint.copy(worldPoint) + segObj.worldToLocal(localPoint) + inverseMatrix.copy(segObj.matrixWorld).invert() + localNormal.copy(worldNormal).transformDirection(inverseMatrix) + + if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue + + for (const face of getRoofSegmentWallFaces(segment)) { + const alignment = + localNormal.x * face.normal[0] + + localNormal.y * face.normal[1] + + localNormal.z * face.normal[2] + if (alignment < NORMAL_ALIGNMENT) continue + + const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [ + localPoint.x, + localPoint.y, + localPoint.z, + ]) + if (Math.abs(dist) > PLANE_TOLERANCE) continue + if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue + if (v < -PLANE_TOLERANCE) continue + + const score = Math.abs(dist) + if (!best || score < best.score) { + best = { hit: { segment, face, u, v }, score } + } + } + } + + return best?.hit ?? null +} + +/** + * Overlap guard for openings sharing a roof-segment wall face — the + * roof-host analogue of `hasWallChildOverlap`. Only door / window + * siblings on the same face are compared (other accessories live on the + * sloped surfaces). + */ +export function hasRoofFaceChildOverlap( + segment: RoofSegmentNode, + face: RoofSegmentWallFace, + u: number, + v: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const newLeft = u - width / 2 + const newRight = u + width / 2 + const newBottom = v - height / 2 + const newTop = v + height / 2 + // Sibling openings store their center at the wall mid-plane (inset by + // wallThickness / 2 from the outer plane this face measures from). + const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE + + for (const childId of segment.children ?? []) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child || (child.type !== 'door' && child.type !== 'window')) continue + const opening = child as { + position: [number, number, number] + rotation: [number, number, number] + width: number + height: number + } + const { + u: childU, + v: childV, + dist, + } = segmentPointToRoofWallFace(segment, face.id, [ + opening.position[0], + opening.position[1], + opening.position[2], + ]) + // Same face = the opening's mid-plane center sits near this face. + if (Math.abs(dist) > sameFaceTolerance) continue + const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2 + const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2 + if (xOverlap && yOverlap) return true + } + return false +} diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts new file mode 100644 index 00000000..f9e3d525 --- /dev/null +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -0,0 +1,42 @@ +import type { RoofSegmentNode } from '@pascal-app/core' +import * as THREE from 'three' + +type RoofWallOpening = { + roofSegmentId?: string + 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. + * + * Returns null for wall-hosted openings (no `roofSegmentId`): 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 + + const wallThickness = hostSegment.wallThickness ?? 0.1 + // Through the wall both ways, but well short of the rake/eave overhang + // so the cut never nicks the soffit or fascia bands. + const depth = wallThickness * 2 + 0.04 + + // A door's cut bottom is coplanar with the wall brush base — extend it + // slightly downward so three-bvh-csg never has to clip coplanar faces. + const bottom = node.position[1] - node.height / 2 + const bottomPad = bottom < 0.005 ? 0.02 : 0 + + 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]) + return geo +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index d083a4bd..8d5bbb79 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -2,9 +2,12 @@ import type { AnyNodeId, HandleDescriptor, NodeDefinition, + RoofSegmentNode, WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' +import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' import { windowFloorplanMoveTarget } from './floorplan-move' @@ -36,7 +39,13 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor readWallLength(n, scene), + max: (n, scene) => { + // Roof-hosted windows clamp against the face profile (the + // wall-based limits read Infinity when wallId is unset). + const roofMax = readRoofFaceWidthMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax) + return readWallLength(n, scene) + }, currentValue: (n) => n.width, apply: (initial, newWidth) => { const rotY = initial.rotation[1] @@ -73,6 +82,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + const roofMax = readRoofFaceHeightMax(n, scene, sign) + if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax) // Maximum: distance from the anchored edge to the wall's allowed Y // bounds. Top arrow caps at wall.height - bottom; bottom arrow caps // at top (positive Y room above the floor). @@ -139,9 +150,18 @@ export const windowDefinition: NodeDefinition = { duplicable: true, deletable: true, wallOpeningPlacement: true, - // `wallId` is re-derived from the wall under the cursor at preset - // placement time — see the door capability for the same pattern. - hostRefFields: ['wallId'], + // Windows also host on roof-segment wall faces (base walls under the + // roof, gable ends) — same wiring as door; see the door capability + // for why `cascadesViaHostSegment` is required. + roofAccessory: { + buildCut: (node, hostSegment) => + buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode), + cutScope: 'wall', + cascadesViaHostSegment: true, + }, + // `wallId` / `roofSegmentId` are re-derived from the surface under + // the cursor at preset placement time — see door for the pattern. + hostRefFields: ['wallId', 'roofSegmentId'], }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 9428a47e..bcd7c82e 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -8,6 +8,10 @@ import { } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + getRoofHostedOpeningLevelId, + getRoofHostedOpeningPlanPoint, +} from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, @@ -29,7 +33,12 @@ import { clampToWall, hasWallChildOverlap } from './window-math' export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { const startLevelId = (() => { - const wall = useScene.getState().nodes[node.parentId as AnyNodeId] + // Wall-hosted: the wall's parent is the level. Roof-hosted: walk + // segment → roof → level. + const nodes = useScene.getState().nodes + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const wall = nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() const originalWall = node.parentId @@ -39,7 +48,10 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod original: originalWall?.type === 'wall' ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : [node.position[0], 0], + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [ + node.position[0], + 0, + ]), metadata: node.metadata, }) @@ -56,6 +68,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod side: WindowNode['side'] parentId: string wallId: string + roofSegmentId: undefined } | null = null const session: FloorplanMoveTargetSession = { @@ -93,6 +106,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod side: hit.side, parentId: hit.wall.id, wallId: 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, } useScene.getState().updateNodes([ diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 59b39dee..15936a6d 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,8 +1,12 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -23,8 +27,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group } from 'three' +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' @@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. * @@ -70,6 +77,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: movingWindowNode.side, parentId: movingWindowNode.parentId, wallId: movingWindowNode.wallId, + // Windows can be hosted on a roof-segment wall face. Moving onto a + // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts + // must restore the roof host. + roofSegmentId: movingWindowNode.roofSegmentId, metadata: movingWindowNode.metadata, } @@ -230,6 +241,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, }) markWallDirty(currentWallId) currentWallId = target.wallId @@ -315,6 +327,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, wallId: target.wallId, parentId: target.wallId, + roofSegmentId: undefined, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -327,6 +340,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) useScene.temporal.getState().resume() @@ -337,6 +351,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: target.side, parentId: target.wallId, wallId: target.wallId, + roofSegmentId: undefined, metadata: {}, }) @@ -374,6 +389,188 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + // ── Roof-segment wall faces ───────────────────────────────────── + // Mirrors the wall flow for the segments' vertical wall faces (base + // walls under the roof + coplanar gable ends — a window can sit in + // the gable pediment). This is also the placement path preset tiles + // take (`metadata.isNew` clones). + + const worldToBuildingLocal = (point: Vector3): [number, number, number] => { + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const resolveRoofMoveTarget = (event: RoofEvent) => { + const hit = resolveRoofWallHit( + event.node as RoofNode, + event.position, + event.normal, + event.object, + ) + if (!hit) return null + // Free vertical placement (0.5m grid like walls); the clamp + // projects the window inside the face profile, sliding it down + // under the gable slopes when needed. + const clamped = clampRectToRoofWallFace( + hit.face, + hit.u, + snapToHalf(hit.v), + movingWindowNode.width, + movingWindowNode.height, + ) + if (!clamped) return null + const position = roofWallFaceLocalToSegment( + hit.segment, + hit.face.id, + clamped.u, + clamped.v, + (hit.segment.wallThickness ?? 0.1) / 2, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + movingWindowNode.width, + movingWindowNode.height, + movingWindowNode.id, + ) + return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode } + } + + const updateRoofCursor = (target: NonNullable>) => { + const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId) + if (!segObj) return + segObj.updateWorldMatrix(true, false) + roofCursorPoint.set(target.position[0], target.position[1], target.position[2]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target) return + // Wall-frame drag anchor / live transform don't apply on a roof face. + dragAnchor = null + lastTarget = null + useLiveTransforms.getState().clear(movingWindowNode.id) + if (currentWallId !== target.hit.segment.id) { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: target.hit.segment.id, + wallId: undefined, + roofSegmentId: target.hit.segment.id, + }) + markWallDirty(currentWallId) + currentWallId = target.hit.segment.id + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + }) + } + updateRoofCursor(target) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const target = resolveRoofMoveTarget(event) + if (!target?.valid) return + const segmentId = target.hit.segment.id + + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + useScene.temporal.getState().resume() + + const cloned = structuredClone(movingWindowNode) as any + delete cloned.id + if (cloned.metadata && typeof cloned.metadata === 'object') { + delete cloned.metadata.isNew + delete cloned.metadata.isTransient + } + + const node = WindowNode.parse({ + ...cloned, + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + wallId: undefined, + roofSegmentId: segmentId, + parentId: segmentId, + }) + useScene.getState().createNode(node, segmentId as AnyNodeId) + placedId = node.id + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation: [0, target.yaw, 0], + side: 'front', + parentId: segmentId, + wallId: undefined, + roofSegmentId: segmentId, + metadata: {}, + }) + + if (original.parentId && original.parentId !== segmentId) { + markWallDirty(original.parentId) + } + placedId = movingWindowNode.id + } + + markWallDirty(segmentId) + useLiveTransforms.getState().clear(movingWindowNode.id) + useScene.temporal.getState().pause() + + triggerSFX('sfx:structure-build') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onRoofLeave = () => { + hideCursor() + useLiveTransforms.getState().clear(movingWindowNode.id) + dragAnchor = null + lastTarget = null + if (isNew) return + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + roofSegmentId: original.roofSegmentId, }) if (original.parentId) markWallDirty(original.parentId) } @@ -390,6 +587,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -403,6 +601,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -422,6 +624,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + roofSegmentId: original.roofSegmentId, metadata: original.metadata, }) if (original.parentId) markWallDirty(original.parentId) @@ -434,6 +637,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, [movingWindowNode, exitMoveMode]) diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 9f72748d..412ba635 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -1,6 +1,12 @@ 'use client' -import { useRegistry, useScene, type WindowNode } from '@pascal-app/core' +import { + type AnyNodeId, + type RoofSegmentNode, + useRegistry, + useScene, + type WindowNode, +} from '@pascal-app/core' import { createMaterial, DEFAULT_WINDOW_MATERIAL, @@ -33,7 +39,17 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { node.material?.texture, ]) - return ( + // Roof-hosted windows mount under the roof's `roof-elements` group (roof + // frame), so the host segment's transform is applied here — wall-hosted + // windows get it for free from the wall mesh they're nested in. + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + if (node.roofSegmentId && segment?.type !== 'roof-segment') return null + + const mesh = ( { ) + + if (!segment) return mesh + return ( + + {mesh} + + ) } export default WindowRenderer diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 7abcfbe5..44da2d34 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,8 +1,12 @@ import { type AnyNodeId, + clampRectToRoofWallFace, collectAlignmentAnchors, emitter, isCurvedWall, + type RoofEvent, + type RoofNode, + roofWallFaceLocalToSegment, sceneRegistry, spatialGridManager, useScene, @@ -21,8 +25,9 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +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' @@ -34,8 +39,12 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) +const roofCursorPoint = new Vector3() + /** - * Window tool — places WindowNodes on walls only. + * Window tool — places WindowNodes on walls and on roof-segment wall + * faces (the generated base walls under a roof, including coplanar gable + * ends — a window can sit in the gable pediment). * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. */ const WindowTool: React.FC = () => { @@ -223,6 +232,8 @@ const WindowTool: React.FC = () => { side, parentId: event.node.id, wallId: event.node.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, }) } } @@ -343,6 +354,164 @@ const WindowTool: React.FC = () => { hideCursor() } + // ── Roof-segment wall faces ───────────────────────────────────── + // The merged roof mesh emits `roof:*`; hits are resolved against the + // segments' vertical wall faces (base walls + coplanar gable ends), + // so a window can sit anywhere inside the face profile — including + // the gable pediment triangle. + + const worldToBuildingLocal = (point: Vector3): [number, number, number] => { + // The tool's cursor group renders in the building's local frame — + // same conversion as the roof accessory tools (e.g. SkylightTool). + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const resolveRoofTarget = (event: RoofEvent) => { + const hit = resolveRoofWallHit( + event.node as RoofNode, + event.position, + event.normal, + event.object, + ) + if (!hit) return null + const width = draftRef.current?.width ?? 1.5 + const height = draftRef.current?.height ?? 1.5 + // Free vertical placement (snapped to the 0.5m grid like walls); + // the clamp projects the window inside the face profile, sliding + // 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, + ) + const valid = !hasRoofFaceChildOverlap( + hit.segment, + hit.face, + clamped.u, + clamped.v, + width, + height, + draftRef.current?.id, + ) + return { hit, position, yaw: hit.face.yaw, valid } + } + + const updateRoofCursor = ( + target: NonNullable>, + roof: RoofNode, + ) => { + 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]) + segObj.localToWorld(roofCursorPoint) + updateCursor( + worldToBuildingLocal(roofCursorPoint), + (roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw, + target.valid, + ) + } + + const onRoofHover = (event: RoofEvent) => { + const target = resolveRoofTarget(event) + if (!target) { + // On the roof but not over a placeable wall face (slope, soffit, + // or a face the window cannot fit on). + if (draftRef.current?.roofSegmentId) { + destroyDraft() + hideCursor() + } + return + } + const { hit, position, yaw } = 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], + }) + } else { + const node = WindowNode.parse({ + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + draftRef.current = node + } + updateRoofCursor(target, event.node as RoofNode) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + if (!draftRef.current?.roofSegmentId) return + const target = resolveRoofTarget(event) + if (!target?.valid) return + const { hit, position, yaw } = target + + const draft = draftRef.current + draftRef.current = null + + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const state = useScene.getState() + const windowCount = Object.values(state.nodes).filter( + (n) => n.type === 'window' && (n as WindowNode).roofSegmentId !== undefined, + ).length + + const node = WindowNode.parse({ + name: `Window ${windowCount + 1}`, + position, + rotation: [0, yaw, 0], + side: 'front', + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + width: draft.width, + height: draft.height, + windowType: draft.windowType, + operationState: draft.operationState, + awningDirection: draft.awningDirection, + casementStyle: draft.casementStyle, + hingesSide: draft.hingesSide, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + columnRatios: draft.columnRatios, + rowRatios: draft.rowRatios, + columnDividerThickness: draft.columnDividerThickness, + rowDividerThickness: draft.rowDividerThickness, + sill: draft.sill, + sillDepth: draft.sillDepth, + sillThickness: draft.sillThickness, + }) + + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + // Rebuild the segment (and the merged roof) so the wall brush + // picks up the new opening cut. + useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().pause() + triggerSFX('sfx:structure-build') + event.stopPropagation() + } + + const onRoofLeave = () => { + if (!draftRef.current?.roofSegmentId) return + destroyDraft() + hideCursor() + } + const onCancel = () => { destroyDraft() hideCursor() @@ -352,6 +521,10 @@ const WindowTool: React.FC = () => { emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('roof:enter', onRoofHover) + emitter.on('roof:move', onRoofHover) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) emitter.on('tool:cancel', onCancel) return () => { @@ -363,6 +536,10 @@ const WindowTool: React.FC = () => { emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) + emitter.off('roof:enter', onRoofHover) + emitter.off('roof:move', onRoofHover) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) } }, []) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 79dca73e..57e74b18 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -110,7 +110,10 @@ export const RoofSystem = () => { // previous cut shape (stale CSG) once the user exits segment // edit mode. Registry-driven so the viewer stays kind-agnostic. const def = nodeRegistry.get(node.type) - if (def?.capabilities?.roofAccessory) { + // 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) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined if (seg?.parentId) { @@ -131,10 +134,20 @@ export const RoofSystem = () => { // Only compute expensive individual CSG when the segment is actually rendered // (its parent group is visible = the roof is selected for editing) const isVisible = mesh.parent?.visible !== false - if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { + // Accessory-reveal mode (RoofEditSystem): the wrapper is shown so + // portaled handles render, but the merged shell stays visible and + // the segment meshes are stripped to empty placeholders. Rebuilding + // per-segment CSG here would draw UNCUT geometry on top of the + // merged shell — hiding a freshly cut opening (door / window / + // skylight) until the next deselect. Full edit mode hides the + // merged mesh, so gate the rebuild on its visibility. + const revealOnly = + mesh.parent?.name === 'segments-wrapper' && + mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true + if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { updateRoofSegmentGeometry(effectiveSegment, mesh) segmentsProcessed++ - } else if (isVisible) { + } else if (isVisible && !revealOnly) { return // Over budget — keep dirty, process next frame } else { // Just sync transform, skip CSG — the merged roof handles visuals. @@ -313,16 +326,19 @@ function updateMergedRoofGeometry( const cut = new Brush(welded, dummyMats[0]) cut.updateMatrixWorld() + const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all' try { - const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush - workingShin.geometry.dispose() - prepareBrushForCSG(nextShin) - workingShin = nextShin + if (cutScope !== 'wall') { + const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush + workingShin.geometry.dispose() + prepareBrushForCSG(nextShin) + workingShin = nextShin - const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush - workingDeck.geometry.dispose() - prepareBrushForCSG(nextDeck) - workingDeck = nextDeck + const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush + workingDeck.geometry.dispose() + prepareBrushForCSG(nextDeck) + workingDeck = nextDeck + } const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush workingWall.geometry.dispose()