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:
co-authored by
Claude Fable 5
parent
1487328ec7
commit
bdfee058bd
@@ -118,7 +118,12 @@ export function BuildTab() {
|
|||||||
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
||||||
const roofFeatures = useMemo<RoofFeature[]>(
|
const roofFeatures = useMemo<RoofFeature[]>(
|
||||||
() =>
|
() =>
|
||||||
getRoofAccessoryKinds().map((kind) => {
|
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
|
const icon = nodeRegistry.get(kind)?.presentation?.icon
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -1205,6 +1205,23 @@ export type PaintEffectiveMaterialArgs = {
|
|||||||
*/
|
*/
|
||||||
export type RoofAccessoryConfig = {
|
export type RoofAccessoryConfig = {
|
||||||
buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -108,6 +108,17 @@ export {
|
|||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
RoofType,
|
RoofType,
|
||||||
} from './nodes/roof-segment'
|
} 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 { ScanNode } from './nodes/scan'
|
||||||
export { ShelfNode } from './nodes/shelf'
|
export { ShelfNode } from './nodes/shelf'
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const DoorNode = BaseNode.extend({
|
|||||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
side: z.enum(['front', 'back']).optional(),
|
side: z.enum(['front', 'back']).optional(),
|
||||||
wallId: z.string().optional(),
|
wallId: z.string().optional(),
|
||||||
|
// Alternative host: a roof-segment's generated wall face (base wall
|
||||||
|
// 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
|
// Overall dimensions
|
||||||
width: z.number().default(0.9),
|
width: z.number().default(0.9),
|
||||||
|
|||||||
@@ -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<RoofWallFaceId, [number, number, number]> = {
|
||||||
|
front: [0, 0, 1],
|
||||||
|
back: [0, 0, -1],
|
||||||
|
right: [1, 0, 0],
|
||||||
|
left: [-1, 0, 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_YAWS: Record<RoofWallFaceId, number> = {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ export const WindowNode = BaseNode.extend({
|
|||||||
|
|
||||||
// Wall reference
|
// Wall reference
|
||||||
wallId: z.string().optional(),
|
wallId: z.string().optional(),
|
||||||
|
// Alternative host: a roof-segment's generated wall face (base wall
|
||||||
|
// 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
|
// Overall dimensions
|
||||||
width: z.number().default(1.5),
|
width: z.number().default(1.5),
|
||||||
|
|||||||
@@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
|||||||
world?.dispose()
|
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', () => {
|
test('leaves elevators to their dedicated dynamic collider meshes', () => {
|
||||||
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
|||||||
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
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[]) {
|
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
|
||||||
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
||||||
}
|
}
|
||||||
@@ -319,9 +335,12 @@ function collectColliderGeometriesFromNode(
|
|||||||
if (visitedMeshes.has(object)) return
|
if (visitedMeshes.has(object)) return
|
||||||
visitedMeshes.add(object)
|
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 (
|
if (
|
||||||
isMesh(object) &&
|
isMesh(object) &&
|
||||||
object.visible &&
|
|
||||||
isColliderMaterialVisible(object.material) &&
|
isColliderMaterialVisible(object.material) &&
|
||||||
!SKIPPED_MESH_NAMES.has(object.name)
|
!SKIPPED_MESH_NAMES.has(object.name)
|
||||||
) {
|
) {
|
||||||
@@ -364,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
|||||||
const root = sceneRegistry.nodes.get(nodeId)
|
const root = sceneRegistry.nodes.get(nodeId)
|
||||||
if (!root) continue
|
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') {
|
if (node.type === 'door') {
|
||||||
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
||||||
if (doorGeometry) {
|
if (doorGeometry) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import type {
|
|||||||
DoorNode as DoorNodeType,
|
DoorNode as DoorNodeType,
|
||||||
HandleDescriptor,
|
HandleDescriptor,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
|
RoofSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} 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 { scaleHandleHeight } from './door-math'
|
||||||
import { buildDoorFloorplan } from './floorplan'
|
import { buildDoorFloorplan } from './floorplan'
|
||||||
import { doorWidthAffordance } from './floorplan-affordances'
|
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).
|
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
|
||||||
anchor: side === 'right' ? 'min' : 'max',
|
anchor: side === 'right' ? 'min' : 'max',
|
||||||
min: MIN_DOOR_WIDTH,
|
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,
|
currentValue: (n) => n.width,
|
||||||
apply: (initial, newWidth) => {
|
apply: (initial, newWidth) => {
|
||||||
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
// 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
|
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
|
||||||
min: MIN_DOOR_HEIGHT,
|
min: MIN_DOOR_HEIGHT,
|
||||||
max: (n, scene) => {
|
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
|
const bottom = n.position[1] - n.height / 2
|
||||||
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
||||||
},
|
},
|
||||||
@@ -147,10 +158,22 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
wallOpeningPlacement: true,
|
wallOpeningPlacement: true,
|
||||||
// `wallId` ties the door to its host wall and is re-derived from
|
// Doors also host on roof-segment wall faces (base walls under the
|
||||||
// the wall under the cursor when a preset is placed. Host apps
|
// roof, gable ends). `buildCut` punches the opening into the
|
||||||
// strip this at preset-save time via `getHostRefFields(def)`.
|
// segment's wall brush; `cascadesViaHostSegment` keeps the roof-merge
|
||||||
hostRefFields: ['wallId'],
|
// 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,
|
parametrics: doorParametrics,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { snapToHalf } from '@pascal-app/editor'
|
import { snapToHalf } from '@pascal-app/editor'
|
||||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
|
import {
|
||||||
|
getRoofHostedOpeningLevelId,
|
||||||
|
getRoofHostedOpeningPlanPoint,
|
||||||
|
} from '../shared/roof-opening-host'
|
||||||
import {
|
import {
|
||||||
findClosestWallInPlan,
|
findClosestWallInPlan,
|
||||||
projectWallLocalPointToPlan,
|
projectWallLocalPointToPlan,
|
||||||
@@ -35,11 +39,13 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
// Snapshot of the door's "valid" state at move-start — used by
|
// Snapshot of the door's "valid" state at move-start — used by
|
||||||
// canCommit to decide whether the current snapped position is OK.
|
// canCommit to decide whether the current snapped position is OK.
|
||||||
const startLevelId = (() => {
|
const startLevelId = (() => {
|
||||||
// Walk up via parentId until we hit a node whose type isn't 'wall'
|
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
||||||
// — that's the level (or null). The door is wall-hosted, so the
|
// segment → roof → level. Cached at start because the parent chain
|
||||||
// wall's parent is the level. Cached at start because the parent
|
// doesn't change during a move.
|
||||||
// chain doesn't change during a move.
|
const nodes = useScene.getState().nodes
|
||||||
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
|
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||||
|
if (roofLevelId) return roofLevelId
|
||||||
|
const wall = nodes[node.parentId as AnyNodeId]
|
||||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||||
})()
|
})()
|
||||||
const originalWall = node.parentId
|
const originalWall = node.parentId
|
||||||
@@ -49,7 +55,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
original:
|
original:
|
||||||
originalWall?.type === 'wall'
|
originalWall?.type === 'wall'
|
||||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||||
: [node.position[0], 0],
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
|
||||||
|
node.position[0],
|
||||||
|
0,
|
||||||
|
]),
|
||||||
metadata: node.metadata,
|
metadata: node.metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,6 +71,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
side: DoorNode['side']
|
side: DoorNode['side']
|
||||||
parentId: string
|
parentId: string
|
||||||
wallId: string
|
wallId: string
|
||||||
|
roofSegmentId: undefined
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
const session: FloorplanMoveTargetSession = {
|
const session: FloorplanMoveTargetSession = {
|
||||||
@@ -94,6 +104,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
side: hit.side,
|
side: hit.side,
|
||||||
parentId: hit.wall.id,
|
parentId: hit.wall.id,
|
||||||
wallId: 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 +
|
// Build the updates atomically — position + rotation + side +
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
clampRectToRoofWallFace,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
roofWallFaceLocalToSegment,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
@@ -23,8 +27,9 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
@@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const roofCursorPoint = new Vector3()
|
||||||
|
|
||||||
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||||
const cursorGroupRef = useRef<Group>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
|
|
||||||
@@ -57,6 +64,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: movingDoorNode.side,
|
side: movingDoorNode.side,
|
||||||
parentId: movingDoorNode.parentId,
|
parentId: movingDoorNode.parentId,
|
||||||
wallId: movingDoorNode.wallId,
|
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,
|
metadata: movingDoorNode.metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +218,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markWallDirty(currentWallId)
|
||||||
currentWallId = target.wallId
|
currentWallId = target.wallId
|
||||||
@@ -284,6 +296,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||||
placedId = node.id
|
placedId = node.id
|
||||||
@@ -294,6 +307,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -304,6 +318,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -340,6 +355,182 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
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)
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
@@ -356,6 +547,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
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:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('roof:enter', onRoofHover)
|
||||||
|
emitter.on('roof:move', onRoofHover)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -387,6 +583,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
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:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('roof:enter', onRoofHover)
|
||||||
|
emitter.off('roof:move', onRoofHover)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [movingDoorNode, exitMoveMode])
|
}, [movingDoorNode, exitMoveMode])
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'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 { useNodeEvents } from '@pascal-app/viewer'
|
||||||
import { useLayoutEffect, useRef } from 'react'
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
import { type Mesh, MeshBasicMaterial } from 'three'
|
import { type Mesh, MeshBasicMaterial } from 'three'
|
||||||
@@ -17,7 +23,17 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'door')
|
const handlers = useNodeEvents(node, 'door')
|
||||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||||
|
|
||||||
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
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
material={doorHitboxMaterial}
|
material={doorHitboxMaterial}
|
||||||
@@ -31,6 +47,13 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
|||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!segment) return mesh
|
||||||
|
return (
|
||||||
|
<group position={segment.position} rotation-y={segment.rotation}>
|
||||||
|
{mesh}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DoorRenderer
|
export default DoorRenderer
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
clampRectToRoofWallFace,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
roofWallFaceLocalToSegment,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -20,8 +24,9 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
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 { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
@@ -32,9 +37,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const roofCursorPoint = new Vector3()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Door tool — places DoorNodes on walls only.
|
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||||
* Doors always sit at floor level (clampedY = height/2).
|
* (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 DoorTool: React.FC = () => {
|
||||||
const draftRef = useRef<DoorNode | null>(null)
|
const draftRef = useRef<DoorNode | null>(null)
|
||||||
@@ -215,6 +224,8 @@ const DoorTool: React.FC = () => {
|
|||||||
side,
|
side,
|
||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
wallId: 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()
|
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 = () => {
|
const onCancel = () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -344,6 +517,10 @@ const DoorTool: React.FC = () => {
|
|||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('roof:enter', onRoofHover)
|
||||||
|
emitter.on('roof:move', onRoofHover)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -355,6 +532,10 @@ const DoorTool: React.FC = () => {
|
|||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('roof:enter', onRoofHover)
|
||||||
|
emitter.off('roof:move', onRoofHover)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
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]]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import type {
|
|||||||
AnyNodeId,
|
AnyNodeId,
|
||||||
HandleDescriptor,
|
HandleDescriptor,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
|
RoofSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
WindowNode as WindowNodeType,
|
WindowNode as WindowNodeType,
|
||||||
} from '@pascal-app/core'
|
} 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 { buildWindowFloorplan } from './floorplan'
|
||||||
import { windowWidthAffordance } from './floorplan-affordances'
|
import { windowWidthAffordance } from './floorplan-affordances'
|
||||||
import { windowFloorplanMoveTarget } from './floorplan-move'
|
import { windowFloorplanMoveTarget } from './floorplan-move'
|
||||||
@@ -36,7 +39,13 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
|
|||||||
axis: 'x',
|
axis: 'x',
|
||||||
anchor: side === 'right' ? 'min' : 'max',
|
anchor: side === 'right' ? 'min' : 'max',
|
||||||
min: MIN_WINDOW_WIDTH,
|
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,
|
currentValue: (n) => n.width,
|
||||||
apply: (initial, newWidth) => {
|
apply: (initial, newWidth) => {
|
||||||
const rotY = initial.rotation[1]
|
const rotY = initial.rotation[1]
|
||||||
@@ -73,6 +82,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
|
|||||||
anchor: edge === 'top' ? 'min' : 'max',
|
anchor: edge === 'top' ? 'min' : 'max',
|
||||||
min: MIN_WINDOW_HEIGHT,
|
min: MIN_WINDOW_HEIGHT,
|
||||||
max: (n, scene) => {
|
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
|
// Maximum: distance from the anchored edge to the wall's allowed Y
|
||||||
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
|
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
|
||||||
// at top (positive Y room above the floor).
|
// at top (positive Y room above the floor).
|
||||||
@@ -139,9 +150,18 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
|||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
wallOpeningPlacement: true,
|
wallOpeningPlacement: true,
|
||||||
// `wallId` is re-derived from the wall under the cursor at preset
|
// Windows also host on roof-segment wall faces (base walls under the
|
||||||
// placement time — see the door capability for the same pattern.
|
// roof, gable ends) — same wiring as door; see the door capability
|
||||||
hostRefFields: ['wallId'],
|
// 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,
|
parametrics: windowParametrics,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { snapToHalf } from '@pascal-app/editor'
|
import { snapToHalf } from '@pascal-app/editor'
|
||||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
|
import {
|
||||||
|
getRoofHostedOpeningLevelId,
|
||||||
|
getRoofHostedOpeningPlanPoint,
|
||||||
|
} from '../shared/roof-opening-host'
|
||||||
import {
|
import {
|
||||||
findClosestWallInPlan,
|
findClosestWallInPlan,
|
||||||
projectWallLocalPointToPlan,
|
projectWallLocalPointToPlan,
|
||||||
@@ -29,7 +33,12 @@ import { clampToWall, hasWallChildOverlap } from './window-math'
|
|||||||
|
|
||||||
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
||||||
const startLevelId = (() => {
|
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
|
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||||
})()
|
})()
|
||||||
const originalWall = node.parentId
|
const originalWall = node.parentId
|
||||||
@@ -39,7 +48,10 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
original:
|
original:
|
||||||
originalWall?.type === 'wall'
|
originalWall?.type === 'wall'
|
||||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||||
: [node.position[0], 0],
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
|
||||||
|
node.position[0],
|
||||||
|
0,
|
||||||
|
]),
|
||||||
metadata: node.metadata,
|
metadata: node.metadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,6 +68,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
side: WindowNode['side']
|
side: WindowNode['side']
|
||||||
parentId: string
|
parentId: string
|
||||||
wallId: string
|
wallId: string
|
||||||
|
roofSegmentId: undefined
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
const session: FloorplanMoveTargetSession = {
|
const session: FloorplanMoveTargetSession = {
|
||||||
@@ -93,6 +106,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
side: hit.side,
|
side: hit.side,
|
||||||
parentId: hit.wall.id,
|
parentId: hit.wall.id,
|
||||||
wallId: 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([
|
useScene.getState().updateNodes([
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
clampRectToRoofWallFace,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
roofWallFaceLocalToSegment,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
@@ -23,8 +27,9 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
|
|
||||||
@@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const roofCursorPoint = new Vector3()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
* 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,
|
side: movingWindowNode.side,
|
||||||
parentId: movingWindowNode.parentId,
|
parentId: movingWindowNode.parentId,
|
||||||
wallId: movingWindowNode.wallId,
|
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,
|
metadata: movingWindowNode.metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +241,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
})
|
})
|
||||||
markWallDirty(currentWallId)
|
markWallDirty(currentWallId)
|
||||||
currentWallId = target.wallId
|
currentWallId = target.wallId
|
||||||
@@ -315,6 +327,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||||
placedId = node.id
|
placedId = node.id
|
||||||
@@ -327,6 +340,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -337,6 +351,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: target.side,
|
side: target.side,
|
||||||
parentId: target.wallId,
|
parentId: target.wallId,
|
||||||
wallId: target.wallId,
|
wallId: target.wallId,
|
||||||
|
roofSegmentId: undefined,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,6 +389,188 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
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)
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
@@ -390,6 +587,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
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:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('roof:enter', onRoofHover)
|
||||||
|
emitter.on('roof:move', onRoofHover)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -422,6 +624,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
side: original.side,
|
side: original.side,
|
||||||
parentId: original.parentId,
|
parentId: original.parentId,
|
||||||
wallId: original.wallId,
|
wallId: original.wallId,
|
||||||
|
roofSegmentId: original.roofSegmentId,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
if (original.parentId) markWallDirty(original.parentId)
|
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:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('roof:enter', onRoofHover)
|
||||||
|
emitter.off('roof:move', onRoofHover)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [movingWindowNode, exitMoveMode])
|
}, [movingWindowNode, exitMoveMode])
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'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 {
|
import {
|
||||||
createMaterial,
|
createMaterial,
|
||||||
DEFAULT_WINDOW_MATERIAL,
|
DEFAULT_WINDOW_MATERIAL,
|
||||||
@@ -33,7 +39,17 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
|||||||
node.material?.texture,
|
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
|
<mesh
|
||||||
material={material}
|
material={material}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
@@ -45,6 +61,13 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
|||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!segment) return mesh
|
||||||
|
return (
|
||||||
|
<group position={segment.position} rotation-y={segment.rotation}>
|
||||||
|
{mesh}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WindowRenderer
|
export default WindowRenderer
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
clampRectToRoofWallFace,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
roofWallFaceLocalToSegment,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -21,8 +25,9 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
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 { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
|
|
||||||
@@ -34,8 +39,12 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
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.
|
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
||||||
*/
|
*/
|
||||||
const WindowTool: React.FC = () => {
|
const WindowTool: React.FC = () => {
|
||||||
@@ -223,6 +232,8 @@ const WindowTool: React.FC = () => {
|
|||||||
side,
|
side,
|
||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
wallId: 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()
|
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 = () => {
|
const onCancel = () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
@@ -352,6 +521,10 @@ const WindowTool: React.FC = () => {
|
|||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('roof:enter', onRoofHover)
|
||||||
|
emitter.on('roof:move', onRoofHover)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -363,6 +536,10 @@ const WindowTool: React.FC = () => {
|
|||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('roof:enter', onRoofHover)
|
||||||
|
emitter.off('roof:move', onRoofHover)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ export const RoofSystem = () => {
|
|||||||
// previous cut shape (stale CSG) once the user exits segment
|
// previous cut shape (stale CSG) once the user exits segment
|
||||||
// edit mode. Registry-driven so the viewer stays kind-agnostic.
|
// edit mode. Registry-driven so the viewer stays kind-agnostic.
|
||||||
const def = nodeRegistry.get(node.type)
|
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 segId = (node as { roofSegmentId?: string }).roofSegmentId
|
||||||
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
|
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
|
||||||
if (seg?.parentId) {
|
if (seg?.parentId) {
|
||||||
@@ -131,10 +134,20 @@ export const RoofSystem = () => {
|
|||||||
// Only compute expensive individual CSG when the segment is actually rendered
|
// Only compute expensive individual CSG when the segment is actually rendered
|
||||||
// (its parent group is visible = the roof is selected for editing)
|
// (its parent group is visible = the roof is selected for editing)
|
||||||
const isVisible = mesh.parent?.visible !== false
|
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)
|
updateRoofSegmentGeometry(effectiveSegment, mesh)
|
||||||
segmentsProcessed++
|
segmentsProcessed++
|
||||||
} else if (isVisible) {
|
} else if (isVisible && !revealOnly) {
|
||||||
return // Over budget — keep dirty, process next frame
|
return // Over budget — keep dirty, process next frame
|
||||||
} else {
|
} else {
|
||||||
// Just sync transform, skip CSG — the merged roof handles visuals.
|
// Just sync transform, skip CSG — the merged roof handles visuals.
|
||||||
@@ -313,7 +326,9 @@ function updateMergedRoofGeometry(
|
|||||||
const cut = new Brush(welded, dummyMats[0])
|
const cut = new Brush(welded, dummyMats[0])
|
||||||
cut.updateMatrixWorld()
|
cut.updateMatrixWorld()
|
||||||
|
|
||||||
|
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
|
||||||
try {
|
try {
|
||||||
|
if (cutScope !== 'wall') {
|
||||||
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
|
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
|
||||||
workingShin.geometry.dispose()
|
workingShin.geometry.dispose()
|
||||||
prepareBrushForCSG(nextShin)
|
prepareBrushForCSG(nextShin)
|
||||||
@@ -323,6 +338,7 @@ function updateMergedRoofGeometry(
|
|||||||
workingDeck.geometry.dispose()
|
workingDeck.geometry.dispose()
|
||||||
prepareBrushForCSG(nextDeck)
|
prepareBrushForCSG(nextDeck)
|
||||||
workingDeck = nextDeck
|
workingDeck = nextDeck
|
||||||
|
}
|
||||||
|
|
||||||
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
|
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
|
||||||
workingWall.geometry.dispose()
|
workingWall.geometry.dispose()
|
||||||
|
|||||||
Reference in New Issue
Block a user