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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user