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
@@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
||||
world?.dispose()
|
||||
})
|
||||
|
||||
test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => {
|
||||
registerColliderDefinition('column', ColumnNode, 'structure')
|
||||
|
||||
// Mirror the roof's segments-wrapper shape: the registered mesh's own
|
||||
// visible flag stays true while a hidden wrapper hides it at render
|
||||
// time. The collider must match the render, not the own-flag.
|
||||
const column = ColumnNode.parse({ id: 'column_test' })
|
||||
const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] })
|
||||
setSceneNodes([column, visibleColumn])
|
||||
|
||||
const wrapper = new Group()
|
||||
wrapper.visible = false
|
||||
const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial())
|
||||
wrapper.add(hiddenMesh)
|
||||
wrapper.updateMatrixWorld(true)
|
||||
sceneRegistry.nodes.set(column.id, hiddenMesh)
|
||||
sceneRegistry.byType[column.type]!.add(column.id)
|
||||
|
||||
mountNode(visibleColumn, [1, 2, 1], [3, 1, 0])
|
||||
|
||||
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||
|
||||
expect(world).not.toBeNull()
|
||||
// Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh
|
||||
// under the hidden wrapper contributed no geometry.
|
||||
expect(world?.bounds?.min.x).toBeCloseTo(2.5)
|
||||
expect(world?.bounds?.max.x).toBeCloseTo(3.5)
|
||||
world?.dispose()
|
||||
})
|
||||
|
||||
test('leaves elevators to their dedicated dynamic collider meshes', () => {
|
||||
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
||||
|
||||
|
||||
@@ -50,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
||||
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
||||
}
|
||||
|
||||
// Renderer-effective visibility: an invisible ancestor hides the whole
|
||||
// subtree at render time even when the object's own flag is true. The
|
||||
// collider world must match what's rendered — the roof keeps stale,
|
||||
// UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit
|
||||
// exit hides the wrapper without stripping geometry), and cloning those
|
||||
// meshes would block the walkthrough player at openings the visible
|
||||
// merged shell has cut through.
|
||||
function isEffectivelyVisible(object: THREE.Object3D) {
|
||||
let current: THREE.Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
|
||||
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
||||
}
|
||||
@@ -319,9 +335,12 @@ function collectColliderGeometriesFromNode(
|
||||
if (visitedMeshes.has(object)) return
|
||||
visitedMeshes.add(object)
|
||||
|
||||
// Prune hidden subtrees — children of an invisible group never render,
|
||||
// so they must not collide either (see isEffectivelyVisible).
|
||||
if (!object.visible) return
|
||||
|
||||
if (
|
||||
isMesh(object) &&
|
||||
object.visible &&
|
||||
isColliderMaterialVisible(object.material) &&
|
||||
!SKIPPED_MESH_NAMES.has(object.name)
|
||||
) {
|
||||
@@ -364,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
||||
const root = sceneRegistry.nodes.get(nodeId)
|
||||
if (!root) continue
|
||||
|
||||
// Registered objects can sit inside a hidden wrapper (roof segments
|
||||
// under `segments-wrapper`) — the per-node traversal starts AT the
|
||||
// object, so the ancestor chain must be checked here.
|
||||
if (!isEffectivelyVisible(root)) continue
|
||||
|
||||
if (node.type === 'door') {
|
||||
const doorGeometry = createDoorLeafColliderGeometry(root, node)
|
||||
if (doorGeometry) {
|
||||
|
||||
Reference in New Issue
Block a user