fix: make room walls and slabs join cleanly (#497)

* fix(editor): wall endpoint move — detach/attach modifier sync and zero-move drop

- restore linked walls to original positions the moment alt-detach engages
- re-run the endpoint preview on alt keydown/keyup so re-attach doesn't wait for a mousemove; preview, HUD badge, and commit share one alt source
- second click at an unmoved position cleanly drops the endpoint (no history entry) instead of leaving the interaction stuck
- 2D floorplan parity: clear stale linked overrides on detach, re-apply drag sessions on modifier changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): demote orphaned auto slabs/ceilings to manual instead of deleting

Deleting a wall that encloses a room no longer destroys the room's
slab/ceiling (paint, holes, elevation). Unmatched auto surfaces are only
deleted when >=60% of their footprint is still covered by a detected room
(rooms-merged case); otherwise they are demoted to manual nodes.

- demoted slab polygons are baked (inset by SLAB_OUTSET + AUTO_SLAB_INSET)
  so the rendered footprint doesn't jump between the auto and manual paths
- auto-creation suppression now also matches manual nodes by mutual
  footprint coverage, so re-closing the room doesn't stack a duplicate
  auto surface on the demoted one

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): hide alt-to-detach hint when the moving endpoint has no linked walls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): split host walls in every snap mode, stop wall chain on existing walls

Splitting a room with a wall now splits the walls it lands on and migrates
hosted doors/windows/items — matching what already happened to the slab and
ceiling. The split machinery existed but was gated on magnetic snapping
('lines' mode) while the wall tool defaults to 'grid'; split resolution now
always runs, with the join radius scoped to the active mode (0.35 magnetic,
0.05 connect snap otherwise), and the whole commit lands as one undo step.

The drawing chain now terminates when a committed segment ends on a wall
outside the current chain (T-junction), like the room auto-close — users
don't draw overlapping walls. Applied in 3D and 2D, and the 2D-only path
gains the previously missing wallClosesRoom parity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): per-edge build-time slab offsets — slabs tile at wall centerlines

Replaces the stored-polygon render offsets (auto: centroid inset 0.02,
manual: flat outset 0.05) with one uniform per-edge rule computed at
geometry build time, for auto and manual slabs alike:

- edge shared with a sibling slab (collinear-overlap test, T-junction
  sub-segments included) → small relief inset; adjacent rooms tile
  exactly and can never overlap
- edge on a wall centerline with no slab neighbor → expand outward by
  that wall's thickness/2, flush with the facade
- free edge → rendered exactly as drawn

Slab demotion no longer bakes polygons (offsets never live in node
data); no stored-data migration. New slab system marks level slabs
dirty when wall geometry/thickness or sibling slab footprints change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): import KTX2Loader from its deep path, not the jsm aggregate

The Addons.js aggregate re-exports LottieLoader/TTFLoader whose CDN URL
imports (lottie-web, opentype.js) abort bun test in every package that
transitively imports the viewer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): split host wall when an endpoint move lands on its interior

Moving a wall endpoint onto another wall's mid-span now splits that wall
and migrates its doors/windows/items, matching the draw tool — previously
the room closed (detection planarizes internally) but the wall node stayed
whole. New resolveEndpointWallSplit reuses the draw path's split pipeline;
endpoint write + split commit as one undo step. Applied in 3D and 2D.
Corner drops still join without splitting; straddling openings skip the
split; alt-detached commits split the stationary former sibling correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): slab-wall adoption — absolute edge projection, band snapping, rendered-footprint wall elevation

Slab edges near a wall now render projected onto the wall's exact
geometry instead of getting relative offsets, healing legacy scenes
(face-aligned edges, old baked outsets, hand-drag slop) with no data
migration:

- adoption band: an edge collinear with a wall centerline within
  thickness/2 + 6cm (both sides) is wall-backed; nearest centerline
  wins, a sibling slab in the same band forces the interior seam
- wall-backed exterior edges project to the outer face; interior edges
  to the centerline minus the relief gap; free edges render as drawn
- slab edge resize: wall snapping is now edge-based, not cursor-based —
  fixes the drop landing short by the grab offset (0.34m arrow gap in
  3D, hit-stroke slop in 2D); the snap translates the edge onto the
  wall centerline (canonical stored position), beacon/preview/commit
  agree, full band in lines mode, 5cm stick otherwise; 2D shows a
  dashed stored-boundary skeleton when it diverges from the fill
- wall elevation tests the slab's rendered footprint instead of the
  stored polygon with a 0.1mm epsilon, so walls sit on legacy slabs
  and re-elevate when slabs are reshaped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): subcut slab edges per backing span

A slab edge backed differently along its length (offset rooms sharing a
wall over part of the span, a wall shorter than the edge, collinear
walls of different thickness) is now subdivided at the backing-span
breakpoints; each sub-span classifies and projects independently, with
a perpendicular step connector at intra-edge transitions that lands
inside the crossing wall's footprint. Breakpoints closer than 5cm merge
so no sliver geometry reaches the ring, and same-target spans re-fuse
(curved-wall sampling doesn't balloon vertex counts; whole-edge cases
render bit-identically to before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): wall elevation picks the slab that supports it, not the highest graze

A wall touching a raised slab only at an endpoint no longer lifts
entirely to that slab. Elevation selection is now coverage-based:
per-slab support is the wall's centerline+face length covered by the
slab's rendered footprint minus holes; slabs within 0.1mm of elevation
pool their support (party walls spanning two rooms still lift); the
wall sits on the highest elevation covering >=50% of its length, else
the best-covered group (ties prefer higher), else 0. Sub-5cm grazes
are ignored outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): interior slab edges meet exactly at the wall centerline

Removes the 2cm interior relief inset: both rooms' seam edges project
onto the same centerline (or the symmetric sibling midline when no wall
backs the seam), so adjacent slabs tile with a shared edge — the 4cm
slit under shared walls, visible at its open end on the facade, is
gone. Safe against z-fighting because slab side quads are single-sided
(FrontSide is enforced repo-wide for the MRT scene pass) and the
coincident seam faces have opposite normals; junction step pockets grow
to 5cm but remain strictly inside the intersecting wall footprints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): elevation-aware interior slab seams — close the vertical band slit

With unequal room elevations, both slabs stopping at the shared wall's
centerline left the lower room's half of the wall band open between the
lower slab top and the wall base (the wall seats on the higher slab).
Interior seams across a wall are now elevation-aware: equal elevations
keep the exact centerline seam; unequal elevations project BOTH slabs
to the wall face on the lower side — the higher slab runs through the
band under the wall, the lower butts the same plane. Wall-less unequal
seams keep the sibling midline (a visible step face is correct there).
Slab elevation joins the level dependency signature so height edits
rebuild sibling slabs live.

Verified against real local scenes: all wall bands solid where the
seam rule applies (remaining pockets are the known flat-wall-base
limitation, tracked separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): run wind displacement before the instance transform

The r184->r185 upgrade broke the plant wind: r185 fixed TSL's statement
emission order so a material's positionNode now runs *after* the
instancing transform (r184 emitted it before — verified in the compiled
WGSL of both versions). The wind nodes were tuned against the r184
order, reading positionLocal as geometry-local coordinates, so on r185
the displacement moved into level space: sway no longer scaled with the
per-instance scale (scaled-down trees thrashed like a storm, leaf cards
visibly detaching from branches), leaf phase followed world placement,
and STEM_BEND's height term read the floor elevation, so grass/flowers
on upper levels slid around rigidly.

Restore the r184 semantics explicitly: WindNodeMaterial assigns the wind
node to positionLocal inside setupPosition() before super applies the
instance transform, instead of using positionNode. The emitted WGSL is
statement-for-statement identical to r184's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): resolve slab joins across floor elevations

* fix(editor): keep slab resize arrows visible

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-15 13:28:29 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent a590747748
commit 4fca38a3ef
38 changed files with 4079 additions and 316 deletions
+4 -1
View File
@@ -6,7 +6,10 @@ import {
RGBAFormat,
UnsignedByteType,
} from 'three'
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
// Deep path, NOT the `Addons.js` aggregate: the aggregate re-exports
// LottieLoader/TTFLoader, whose CDN URL imports (`lottie-web`, `opentype.js`)
// crash bun's test runtime for every consumer of the viewer barrel.
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js'
/** The private KTX2Loader surface this module relies on (stable across three
* releases but not part of the public types). */
@@ -1,10 +1,12 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { SlabNode } from '@pascal-app/core'
import { SlabNode, type SlabPolygonContext } from '@pascal-app/core'
import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system'
const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] }
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) {
@@ -35,7 +37,7 @@ describe('generateSlabGeometry', () => {
],
})
const geometry = generateSlabGeometry(slab)
const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
@@ -61,7 +63,7 @@ describe('generateSlabGeometry', () => {
],
})
const geometry = generateSlabGeometry(slab)
const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
@@ -1,4 +1,5 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveNode,
getRenderableSlabPolygon,
@@ -6,8 +7,10 @@ import {
pointInPolygon2D,
polygonsIntersect,
type SlabNode,
type SlabPolygonContext,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
@@ -44,6 +47,7 @@ export const SlabSystem = () => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
const contextByLevel = new Map<string | null, SlabPolygonContext>()
// Process dirty slabs
dirtyNodes.forEach((id) => {
@@ -52,7 +56,15 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) {
updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh)
const slab = node as SlabNode
const levelContext =
contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes)
contextByLevel.set(slab.parentId, levelContext)
updateSlabGeometry(
getEffectiveNode(slab),
excludeSlabFromContext(levelContext, slab.id),
mesh,
)
clearDirty(id as AnyNodeId)
}
// If mesh not found, keep it dirty for next frame
@@ -62,11 +74,32 @@ export const SlabSystem = () => {
return null
}
function buildLevelSlabContext(
levelId: string | null,
nodes: Record<string, AnyNode>,
): SlabPolygonContext {
const walls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
if (node.parentId !== levelId) continue
if (node.type === 'wall') walls.push(node as WallNode)
else if (node.type === 'slab') siblingSlabs.push(node as SlabNode)
}
return { walls, siblingSlabs }
}
function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext {
return {
walls: context.walls,
siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId),
}
}
/**
* Updates the geometry for a single slab
*/
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node)
function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node, context)
ensureUv2Attribute(newGeo)
mesh.geometry.dispose()
@@ -92,11 +125,18 @@ function coplanarityEpsilon(id: string): number {
}
/**
* Generates extruded slab geometry from polygon
* Generates extruded slab geometry from polygon. `context` carries the
* slab's level neighbourhood (walls + sibling slabs) driving the per-edge
* render offsets — see `getRenderableSlabPolygon`.
*/
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
export function generateSlabGeometry(
slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const elevation = slabNode.elevation ?? 0.05
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode)
return elevation < 0
? generatePoolGeometry(slabNode, context)
: generatePositiveSlabGeometry(slabNode, context)
}
// Earcut normalizes cap triangulation regardless of input winding, but the side
@@ -159,8 +199,11 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][])
* thickness visible from any angle: the two coincident triangles never z-fight
* because exactly one faces the camera under FrontSide culling.
*/
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode))
function generatePositiveSlabGeometry(
slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context))
const elevation = slabNode.elevation ?? 0.05
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
@@ -257,8 +300,11 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode))
function generatePoolGeometry(
slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context))
const depth = Math.abs(slabNode.elevation ?? 0.05)
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
@@ -0,0 +1,54 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { calculateLevelMiters, WallNode } from '@pascal-app/core'
import { generateExtrudedWall } from './wall-system'
describe('wall support extension', () => {
test('preserves the raised wall origin and top while filling down to the lower support', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05)
geometry.computeBoundingBox()
expect(geometry.boundingBox?.min.y).toBeCloseTo(-0.55)
expect(geometry.boundingBox?.max.y).toBeCloseTo(2.5)
// With mesh.position.y = 0.6, the wall spans world Y=0.05..3.1.
expect((geometry.boundingBox?.min.y ?? 0) + 0.6).toBeCloseTo(0.05)
expect((geometry.boundingBox?.max.y ?? 0) + 0.6).toBeCloseTo(3.1)
geometry.dispose()
})
test('retains the existing negative-slab top constraint', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), -0.4, -0.4)
geometry.computeBoundingBox()
expect(geometry.boundingBox?.min.y).toBeCloseTo(0)
expect(geometry.boundingBox?.max.y).toBeCloseTo(2.9)
expect((geometry.boundingBox?.max.y ?? 0) - 0.4).toBeCloseTo(2.5)
geometry.dispose()
})
test('raises only the high-supported part of a mixed wall run', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [
{ start: 0, end: 0.5, elevation: 0.6 },
{ start: 0.5, end: 1, elevation: 0.05 },
])
const position = geometry.getAttribute('position')
let highSpanMinY = Number.POSITIVE_INFINITY
let lowSpanMinY = Number.POSITIVE_INFINITY
for (let index = 0; index < position.count; index++) {
const x = position.getX(index)
const y = position.getY(index)
if (x < 1.9) highSpanMinY = Math.min(highSpanMinY, y)
if (x > 2.1) lowSpanMinY = Math.min(lowSpanMinY, y)
}
expect(highSpanMinY).toBeCloseTo(0)
expect(lowSpanMinY).toBeCloseTo(-0.55)
geometry.dispose()
})
})
@@ -25,6 +25,7 @@ import {
useScene,
type WallMiterData,
type WallNode,
type WallSlabSupportSegment,
type WallSurfaceSide,
type WallSurfaceSlotId,
type WindowNode,
@@ -691,13 +692,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
if (!mesh) return
const levelId = resolveLevelId(node, nodes)
const slabElevation = spatialGridManager.getSlabElevationForWall(
const slabSupport = spatialGridManager.getSlabSupportForWall(
levelId,
node.start,
node.end,
node.curveOffset ?? 0,
node.thickness,
)
const slabElevation = slabSupport.elevation
const childrenIds = node.children || []
// Merge live overrides into door / window children so cutouts track an
@@ -720,7 +722,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
return { ...effective, position: live.position }
})
const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)
const builtGeo = generateExtrudedWall(
node,
childrenNodes,
miterData,
slabElevation,
slabSupport.baseElevation,
slabSupport.baseSegments,
)
const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
// World transform the render mesh will apply (position + Y-rotation below).
// Reproduce it here so the UVs can be projected in WORLD space — see
@@ -737,7 +746,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
// Update collision mesh
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation)
const collisionGeo = generateExtrudedWall(
node,
[],
miterData,
slabElevation,
slabSupport.baseElevation,
slabSupport.baseSegments,
)
collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo
}
@@ -823,13 +839,18 @@ export function generateExtrudedWall(
childrenNodes: AnyNode[],
miterData: WallMiterData,
slabElevation = 0,
baseElevation = slabElevation,
baseSegments: readonly WallSlabSupportSegment[] = [
{ start: 0, end: 1, elevation: baseElevation },
],
): THREE.BufferGeometry {
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Positive slab: shift the whole wall up (full height preserved)
// Negative slab: extend wall downward so top stays fixed at wallNode.height
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight
const effectiveBaseElevation = Math.min(baseElevation, slabElevation)
const localBottom = effectiveBaseElevation - slabElevation
const height = topElevation - effectiveBaseElevation
const thickness = getWallThickness(wallNode)
@@ -888,12 +909,107 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2)
if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0)
geometry.computeVertexNormals()
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
ensureRenderableGeometryAttributes(geometry)
// Apply CSG subtraction for cutouts (doors/windows)
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
// Start with the lowest required wall prism, then remove the volume below
// each higher-supported run. This keeps the existing mitered footprint and
// opening CSG while giving one wall a stepped longitudinal base.
const baseProfileCutouts: Brush[] = []
for (const segment of baseSegments) {
const segmentElevation = Math.min(segment.elevation, slabElevation)
const cutHeight = segmentElevation - effectiveBaseElevation
if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7) continue
const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1)
const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1)
const cutHalfWidth = Math.max(thickness * 2, 0.2)
const worldCutoutPoints: Point2D[] = []
if (isCurvedWall(wallNode)) {
const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24))
const left: Point2D[] = []
const right: Point2D[] = []
for (let index = 0; index <= sampleCount; index++) {
const t = segmentStart + ((segmentEnd - segmentStart) * index) / sampleCount
const frame = getWallCurveFrameAt(wallNode, t)
const endpointExtension =
index === 0 && segmentStart <= 1e-7
? -cutHalfWidth
: index === sampleCount && segmentEnd >= 1 - 1e-7
? cutHalfWidth
: 0
const center = {
x: frame.point.x + frame.tangent.x * endpointExtension,
y: frame.point.y + frame.tangent.y * endpointExtension,
}
left.push({
x: center.x + frame.normal.x * cutHalfWidth,
y: center.y + frame.normal.y * cutHalfWidth,
})
right.push({
x: center.x - frame.normal.x * cutHalfWidth,
y: center.y - frame.normal.y * cutHalfWidth,
})
}
worldCutoutPoints.push(...left, ...right.reverse())
} else {
const tangentX = v.x / L
const tangentY = v.y / L
const normalX = -tangentY
const normalY = tangentX
const startExtension = segmentStart <= 1e-7 ? cutHalfWidth : 0
const endExtension = segmentEnd >= 1 - 1e-7 ? cutHalfWidth : 0
const startPoint = {
x: wallStart.x + tangentX * (segmentStart * L - startExtension),
y: wallStart.y + tangentY * (segmentStart * L - startExtension),
}
const endPoint = {
x: wallStart.x + tangentX * (segmentEnd * L + endExtension),
y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
}
worldCutoutPoints.push(
{
x: startPoint.x + normalX * cutHalfWidth,
y: startPoint.y + normalY * cutHalfWidth,
},
{ x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth },
{ x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth },
{
x: startPoint.x - normalX * cutHalfWidth,
y: startPoint.y - normalY * cutHalfWidth,
},
)
}
const localCutoutPoints = worldCutoutPoints.map(worldToLocal)
if (localCutoutPoints.length < 3) continue
const cutoutShape = new THREE.Shape()
cutoutShape.moveTo(localCutoutPoints[0]!.x, -localCutoutPoints[0]!.z)
for (let index = 1; index < localCutoutPoints.length; index++) {
cutoutShape.lineTo(localCutoutPoints[index]!.x, -localCutoutPoints[index]!.z)
}
cutoutShape.closePath()
const cutoutBottom = localBottom - 0.01
const cutoutTop = segmentElevation - slabElevation
const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
depth: cutoutTop - cutoutBottom,
bevelEnabled: false,
})
cutoutGeometry.rotateX(-Math.PI / 2)
cutoutGeometry.translate(0, cutoutBottom, 0)
computeGeometryBoundsTree(cutoutGeometry)
baseProfileCutouts.push(new Brush(cutoutGeometry))
}
// Apply base-profile and opening cutouts in one CSG pass.
const cutoutBrushes = [
...baseProfileCutouts,
...collectCutoutBrushes(wallNode, childrenNodes, thickness),
]
if (cutoutBrushes.length === 0) {
const splitGeometry = splitGeometryAtHorizontalPlanes(
geometry,