feat: doors and windows on roof-segment wall faces

Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.

- core: roof-segment-walls.ts models the four vertical faces as 2D
  frames (u along face, v height) with convex profile polygons that
  mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
  clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
  segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
  subtracts from the wall brush only. cascadesViaHostSegment keeps the
  roof-merge loop from consuming door/window dirty marks (their own
  systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
  preset path), with roofSegmentId cleared/restored across every
  roof<->wall re-anchor and revert; shared hit resolver normalizes
  normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
  mode — the uncut rebuild used to draw over the merged shell's fresh
  opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
  visibility; stale uncut segment CSG inside the hidden segments-wrapper
  blocked the player at openings the merged shell had cut through.

Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-10 00:39:13 -04:00
co-authored by Claude Fable 5
parent 1487328ec7
commit bdfee058bd
22 changed files with 1771 additions and 48 deletions
+28 -5
View File
@@ -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<DoorNodeType>
// '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<DoorNodeType> {
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<typeof DoorNode> = {
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,
+19 -6
View File
@@ -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<DoorNode> = ({ 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<DoorNode> = ({ 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<DoorNode> = ({ node })
side: DoorNode['side']
parentId: string
wallId: string
roofSegmentId: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -94,6 +104,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ 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 +
+202 -1
View File
@@ -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<Group>(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<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])
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])
+25 -2
View File
@@ -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<string, unknown> | 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 = (
<mesh
castShadow
material={doorHitboxMaterial}
@@ -31,6 +47,13 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
if (!segment) return mesh
return (
<group position={segment.position} rotation-y={segment.rotation}>
{mesh}
</group>
)
}
export default DoorRenderer
+184 -3
View File
@@ -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<DoorNode | null>(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<ReturnType<typeof resolveRoofTarget>>,
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)
}
}, [])
@@ -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<string, AnyNode | undefined>,
): 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<string, AnyNode | undefined>,
): [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]]
}
+152
View File
@@ -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
}
@@ -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
}
+24 -4
View File
@@ -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<WindowNodeT
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_WINDOW_WIDTH,
max: (n, scene) => 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<WindowNode
anchor: edge === 'top' ? 'min' : 'max',
min: MIN_WINDOW_HEIGHT,
max: (n, scene) => {
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<typeof WindowNode> = {
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,
+18 -2
View File
@@ -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<WindowNode> = ({ 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<WindowNode> = ({ 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<WindowNode> = ({ nod
side: WindowNode['side']
parentId: string
wallId: string
roofSegmentId: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -93,6 +106,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ 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([
+208 -1
View File
@@ -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<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])
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])
+25 -2
View File
@@ -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 = (
<mesh
material={material}
position={node.position}
@@ -45,6 +61,13 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
if (!segment) return mesh
return (
<group position={segment.position} rotation-y={segment.rotation}>
{mesh}
</group>
)
}
export default WindowRenderer
+179 -2
View File
@@ -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<ReturnType<typeof resolveRoofTarget>>,
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)
}
}, [])