feat: vertical building model — stored level heights, wall inversion, decks (#526)
* feat(core): stored storey heights groundwork — pure slab-support module + level height schema Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free packages/core/src/systems/slab/slab-support.ts (severs level-height -> spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure mesh-free equivalent of the viewer's stacked level height, and add the optional LevelNode.height field plus the storey service (getStoredLevelHeight, getLevelElevations per-building prefix sums). No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): storey height badge + edit popover on level rows Each floating-level-selector row shows its storey height; clicking opens a popover with 2.5/3.0/3.5 presets and a free slider writing level.height. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification Pass 3 in migrateNodes: derive and store each legacy level's exact stacked height (never snapped), compact ordinals per building anchored at zero so basements stay basements, classify wall tops against the derived plane (|plane - top| < 0.20 strictly -> plane-bound, else explicit height materialized), and drop the blind totalRise 2.5 stair default on legacy scenes only. Epsilon and strictness validated by a prod census. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: pin wall tops to the storey plane; stored heights become the only vertical truth Wall-top inversion: a wall without an explicit height now tops out at its storey plane (resolveWallTop); slabs lift only the base. Window/door caps resolve the real top through the same slab election instead of Infinity. All level stacking (viewer, elevator, first-person, stair openings, MCP scene queries) reads stored LevelNode.height; the four divergent live derivations and level.metadata.height are deleted. Stair totalRise becomes optional and derives from the storey height when absent. MCP create-level stops writing its elevation param into the ordinal. Level creation sites write explicit heights; templates carry their true derived heights. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound walls elect the slab (pure clampSlabElevationForWalls + registry handle bounds + shared panels for 2D/3D parity); ceiling heights clamp under the plane and auto-ceilings derive from resolved wall tops. Every remaining wall.height ?? 2.5 fallback resolves through resolveWallTop / resolveWallEffectiveHeight (panels, overlays, measurements, quantities, spatial grid, MCP reports); template walls matching their storey become plane-bound. Wall panel gains a Top control (Follows storey / Custom height) derived purely from height presence; the store update path now deletes keys passed as explicit undefined so plane-binding round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification Floor-placed nodes and walls gain a nullable supportSlabId. Elections surface the winning slab (getSlabSupportForItem, candidates query) and prefer a still-valid persisted host, falling back silently when the host is gone or reshaped away; deleting the host strips references in the same undo commit. Item-side support now tests the rendered slab polygon (like walls) through a per-level cache invalidated by the spatial-grid sync. Also adds the resolveStairTotalRise unit tests from the stage-1 gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: persist support hosts at commit; thread wall host preference everywhere Floor-placed commits (draft pipeline, per-kind creation tools, registry move tool) and wall create/move/endpoint commits persist supportSlabId via shared resolveSupportSlabPatch helpers — only when overlapping supports disagree on elevation, clearing it otherwise or when the node leaves the floor. WallSlabSupport surfaces electedSlabId; every wall support read site passes wall.supportSlabId as the preferred host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: split slab into placement + thickness; pools become explicit recess intent slab.elevation stays the walking surface; new thickness grows downward so the solid occupies [elevation − thickness, elevation]. Migration writes thickness := elevation for solids (byte-identical intervals, including degenerate zero) and recessed: true for legacy negative pools. Geometry branches on recessed instead of the elevation sign; presets keep today's intervals; free elevation edits move the body without coupling thickness (the deck semantic). Dead viewer SlabSystem component deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp ceilings to covering-slab undersides across levels getLevelAbove + getCoveringSlabUndersideAt give the first cross-level query; ceiling writes clamp to min(storey plane, lowest covering underside) − 0.01, and the space-detection reconcile now clamps manual ceilings down (never up) when a deck above intrudes — a flush deck reactively lowers the ceiling below it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: mezzanine and balcony build-tab tools One-gesture composites over the kernel: draw a deck footprint and commit deck slab + railings + stair (mezzanine) or deck + railings (balcony) in a single undo step. Fences gain supportSlabId and lift onto their host deck; railing runs split around the stair mouth; edges near wall centerlines are treated as closed. Stairs target the deck via explicit totalRise with no level-to-level opening sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: adaptive slab vertical editing; level vocabulary in UI copy Dragging a grounded slab's top stretches it (elevation and thickness move together — gaps impossible); floating decks move with thickness preserved and land grounded at zero; pools keep the drag-through-zero gesture. User-facing copy says level, not storey (Follows level, Level height). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity getWallPlaneTop samples the wall span against the level above's slabs, so a thick or flush upper floor shortens the walls below it instead of colliding (automatic attach, no dialog). Level-height edits now dirty the level's walls, stairs, ceilings, and fences; covering-slab changes dirty the level below. The effective-height helper triplicated across editor overlays moved to core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: ceilings follow the level top by default ceiling.height becomes optional — absent means the ceiling tracks min(level top, covering slab underside) − 0.01 live, so level-height edits no longer require ceiling fixes. Ceiling panel gains the same Follows level / Custom height control as walls; auto-from-walls ceilings are created height-less and their height-derivation machinery is deleted; migration drops stored heights within 0.20 of the bound on legacy scenes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: wall plane clamp missed max-side boundary walls Auto slabs derive their polygon from wall centerlines, so covering-clamp samples sat exactly on the boundary where ray-cast point-in-polygon is side-dependent (min edges in, max edges out) — walls clamped or not by orientation. getWallPlaneTop now clips the wall's thickness band against the covering polygon (boundary-inclusive, arc-aware) and the ceiling bound's point sampling gained an explicit on-boundary test. Verified against the reported repro scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs attach to decks; pointer-decided placement surface Stairs gain deckSlabId — rise follows the deck's elevation live (straight flights re-converge via a write-sync mirroring auto-openings), the panel shows a unified To destination with Follows deck / Custom rise, and the mezzanine tool attaches instead of baking a stale rise. Item placement under an elevated deck no longer flickers: grid events fed a feedback loop (the grid plane rode the ghost's elected height), so the support election is now capped at the surface the pointer ray actually hits, with a ground sentinel keeping under-deck commits deterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: remove the mezzanine/balcony composite tools Decks ship as catalog presets instead; the kernel the presets rely on (fence deck-hosting, stair deck attachment, clone remaps, pointer-decided placement) all stays. The tool code lives at e30042db for reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks Floating decks keep their drawn polygon (and stop being seam candidates for grounded neighbors) instead of growing into nearby walls. Panel elevation edits are pure placement; the viewport drag stretches a grounded slab up to 0.4 m then unsticks it into a thin deck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: deck-attached stairs land flush with the deck surface The rise now subtracts the stair's own elected base (same election the visual lift uses), so base + rise always equals the deck walking surface; the auto-sync defers a microtask so it reads a settled spatial grid and re-converges on both deck and base-slab moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: draw walls and fences on elevated decks Wall and fence draw tools now publish the pointed surface, so the draw plane rides the deck top (no more perspective-skewed floor hits) and previews sit on the deck. Fences gain real support election: a pure resolveFenceSupportSlabPatch persists the deck host at draw and reshape commits; wall commits thread the pointer cap so aiming under a deck elects the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: unit-aware height presets Level, ceiling, and slab preset buttons show clean values per display system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead of converted metric labels; metric presets unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stairs converge to their resolved rise; deck attachment disables the cutout syncStairRises now converges every follows-mode straight stair (level or deck) plus deck-attached custom rises — detaching a stair from a deck re-derives its height, and ordinary stairs finally track level-height changes. Attaching via the panel writes slabOpeningMode none and hides the cutout controls; detaching restores the destination cutout and clears the stale explicit rise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: stacked-slab move hopping — one ray, one surface, one XZ The hop was hysteresis: the election consumed the riding grid plane's perspective-skewed hit, giving two self-consistent fixed points for one pointer ray. getPointedSupportSurface now returns the ray's crossing of the pointed surface and both the support cap and the cursor XZ derive from that single computation, so items stay on the surface the pointer aims at and sit exactly under the cursor across storeys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: single stair per click; tools restore select mode on exit The stair tool subscribed to both node clicks (synthesized on pointerup) and the native-click grid event with none of the guards sibling tools carry — one physical click over any node surface committed twice. A commit gate + follow-up click swallow fix the double dispatch, and the stair and column tools now restore select mode on exit instead of leaving the dead build-mode-without-tool state that ignored every click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: compact multi-selection panel with host footer slot Selecting multiple nodes now docks the collapsed-by-default panel on the right: N selected header, kind breakdown, and Duplicate/Delete mirroring the floating pill. A new multiSelectionFooter slot lets the host app dock actions below it, exactly like inspectorFooter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
34b601bb79
commit
bf89b5bcf2
@@ -161,10 +161,9 @@ export {
|
||||
type SurfaceFrame,
|
||||
} from './systems/roof/roof-system'
|
||||
export { ScanSystem } from './systems/scan/scan-system'
|
||||
// Slab system follows the wall + fence re-export pattern — composed into
|
||||
// the registry-driven slab definition's `def.system`. Removed in Phase 6
|
||||
// alongside the legacy slab mount point.
|
||||
export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system'
|
||||
// Pure slab geometry generator — composed into the registry-driven slab
|
||||
// definition's `def.geometry` in `@pascal-app/nodes`.
|
||||
export { generateSlabGeometry } from './systems/slab/slab-system'
|
||||
export {
|
||||
getStairBodyMaterials,
|
||||
getStairRailingMaterial,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type CeilingNode,
|
||||
getEffectiveNode,
|
||||
nodeRegistry,
|
||||
resolveCeilingHeight,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -44,7 +45,7 @@ export const CeilingSystem = () => {
|
||||
// the final value on commit. Mirrors WallSystem / GeometrySystem.
|
||||
const effective = getEffectiveNode(node as CeilingNode)
|
||||
const itemHoles = collectCeilingHoles(effective, nodes)
|
||||
updateCeilingGeometry(effective, mesh, itemHoles)
|
||||
updateCeilingGeometry(effective, mesh, itemHoles, nodes)
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
// If mesh not found, keep it dirty for next frame
|
||||
@@ -88,6 +89,7 @@ function updateCeilingGeometry(
|
||||
node: CeilingNode,
|
||||
mesh: THREE.Mesh,
|
||||
extraHoles: Array<Array<[number, number]>> = [],
|
||||
nodes: SceneNodes = useScene.getState().nodes,
|
||||
) {
|
||||
const newGeo = generateCeilingGeometry(node, extraHoles)
|
||||
|
||||
@@ -108,7 +110,11 @@ function updateCeilingGeometry(
|
||||
const liveTransform = useLiveTransforms.getState().get(node.id)
|
||||
mesh.position.x = liveTransform?.position[0] ?? 0
|
||||
mesh.position.z = liveTransform?.position[2] ?? 0
|
||||
mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs
|
||||
// Resolved height: explicit when stored, else the level-top bound — so a
|
||||
// follows-mode ceiling re-parks under the current plane on every rebuild
|
||||
// (level-height edits / covering-slab changes dirty-mark ceilings).
|
||||
// Slight offset to avoid z-fighting with upper-level slabs.
|
||||
mesh.position.y = resolveCeilingHeight(node, nodes) - 0.01 + (liveTransform?.position[1] ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AnyNodeId,
|
||||
getEffectiveNode,
|
||||
getFloorStackedPosition,
|
||||
type LiveTransform,
|
||||
nodeRegistry,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
@@ -16,8 +17,7 @@ type PositionedNode = AnyNode & {
|
||||
rotation?: [number, number, number] | number
|
||||
}
|
||||
|
||||
function withLiveTransform(node: AnyNode, id: string): AnyNode {
|
||||
const liveTransform = useLiveTransforms.getState().get(id)
|
||||
function withLiveTransform(node: AnyNode, liveTransform: LiveTransform | undefined): AnyNode {
|
||||
if (!liveTransform) return node
|
||||
|
||||
const currentRotation = (node as PositionedNode).rotation
|
||||
@@ -75,7 +75,8 @@ export const FloorElevationSystem = () => {
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||
if (!mesh) return
|
||||
|
||||
const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id)
|
||||
const liveTransform = useLiveTransforms.getState().get(id)
|
||||
const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), liveTransform)
|
||||
const position = (effectiveNode as PositionedNode).position
|
||||
if (!position) return
|
||||
|
||||
@@ -91,6 +92,10 @@ export const FloorElevationSystem = () => {
|
||||
node: effectiveNode,
|
||||
nodes: resolverNodes,
|
||||
position,
|
||||
// 3D drags publish the pointer-decided surface cap with their live
|
||||
// transform; honoring it here keeps this system's per-frame Y in
|
||||
// agreement with the tool's preview (no deck/floor flicker).
|
||||
maxElevation: liveTransform?.supportElevationCap,
|
||||
})
|
||||
mesh.position.y = visualPosition[1]
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// include Bun ambient types in its production declaration build.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getLevelBuildingId, getLevelStackPositions, type LevelStackEntry } from './level-stacking'
|
||||
|
||||
describe('getLevelBuildingId', () => {
|
||||
const buildings = [
|
||||
{ id: 'building_a', children: ['level_a0'] },
|
||||
{ id: 'building_b', children: ['level_b0'] },
|
||||
]
|
||||
|
||||
test('uses an explicit building parent', () => {
|
||||
expect(getLevelBuildingId('level_a0', 'building_a', buildings)).toBe('building_a')
|
||||
})
|
||||
|
||||
test('falls back to building children for legacy levels without a parentId', () => {
|
||||
expect(getLevelBuildingId('level_b0', null, buildings)).toBe('building_b')
|
||||
})
|
||||
|
||||
test('ignores a non-building parent before checking building children', () => {
|
||||
expect(getLevelBuildingId('level_a0', 'site_main', buildings)).toBe('building_a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLevelStackPositions', () => {
|
||||
test('stacks levels within one building by level index', () => {
|
||||
const entries: LevelStackEntry[] = [
|
||||
{ levelId: 'level_second', buildingId: 'building_a', index: 2, height: 3.4 },
|
||||
{ levelId: 'level_ground', buildingId: 'building_a', index: 0, height: 2.5 },
|
||||
{ levelId: 'level_first', buildingId: 'building_a', index: 1, height: 3.1 },
|
||||
]
|
||||
|
||||
expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({
|
||||
level_ground: 0,
|
||||
level_first: 2.5,
|
||||
level_second: 5.6,
|
||||
})
|
||||
})
|
||||
|
||||
test('starts each building on its own ground plane', () => {
|
||||
const entries: LevelStackEntry[] = [
|
||||
{ levelId: 'level_a0', buildingId: 'building_a', index: 0, height: 2.5 },
|
||||
{ levelId: 'level_b0', buildingId: 'building_b', index: 0, height: 3 },
|
||||
{ levelId: 'level_a1', buildingId: 'building_a', index: 1, height: 2.8 },
|
||||
{ levelId: 'level_b1', buildingId: 'building_b', index: 1, height: 3.2 },
|
||||
]
|
||||
|
||||
expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({
|
||||
level_a0: 0,
|
||||
level_b0: 0,
|
||||
level_a1: 2.5,
|
||||
level_b1: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps orphan levels in one legacy stack', () => {
|
||||
const entries: LevelStackEntry[] = [
|
||||
{ levelId: 'level_0', buildingId: null, index: 0, height: 2.7 },
|
||||
{ levelId: 'level_1', buildingId: null, index: 1, height: 3 },
|
||||
]
|
||||
|
||||
expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({
|
||||
level_0: 0,
|
||||
level_1: 2.7,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
export type LevelStackEntry = {
|
||||
levelId: string
|
||||
buildingId: string | null
|
||||
index: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type BuildingOwnership = { id: string; children: readonly string[] }
|
||||
|
||||
export function getLevelBuildingId(
|
||||
levelId: string,
|
||||
parentId: string | null,
|
||||
buildings: readonly BuildingOwnership[],
|
||||
): string | null {
|
||||
const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined
|
||||
if (directParent) return directParent.id
|
||||
|
||||
return buildings.find((building) => building.children.includes(levelId))?.id ?? null
|
||||
}
|
||||
|
||||
export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map<string, number> {
|
||||
const positions = new Map<string, number>()
|
||||
const cumulativeYByBuilding = new Map<string | null, number>()
|
||||
|
||||
for (const entry of [...entries].sort((a, b) => a.index - b.index)) {
|
||||
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
|
||||
positions.set(entry.levelId, baseY)
|
||||
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
|
||||
}
|
||||
|
||||
return positions
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
import {
|
||||
type BuildingNode,
|
||||
getLevelHeight,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type { Object3D } from 'three'
|
||||
import { lerp } from 'three/src/math/MathUtils.js'
|
||||
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { getLevelBuildingId, getLevelStackPositions } from './level-stacking'
|
||||
|
||||
const EXPLODED_GAP = 5
|
||||
|
||||
@@ -26,44 +19,31 @@ export const LevelSystem = () => {
|
||||
const levelMode = useViewer.getState().levelMode
|
||||
const selectedLevel = useViewer.getState().selection.levelId
|
||||
|
||||
// Collect level heights so each building can compute its own cumulative offsets.
|
||||
// Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc.
|
||||
const levelElevations = getLevelElevations(nodes)
|
||||
type LevelEntry = {
|
||||
levelId: string
|
||||
buildingId: string | null
|
||||
index: number
|
||||
height: number
|
||||
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
||||
}
|
||||
const entries: LevelEntry[] = []
|
||||
const buildings = Object.values(nodes).filter(
|
||||
(node): node is BuildingNode => node?.type === 'building',
|
||||
)
|
||||
sceneRegistry.byType.level!.forEach((levelId) => {
|
||||
const obj = sceneRegistry.nodes.get(levelId)
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
if (obj && level) {
|
||||
entries.push({
|
||||
levelId,
|
||||
buildingId: getLevelBuildingId(levelId, level.parentId, buildings),
|
||||
index: level.level,
|
||||
height: getLevelHeight(
|
||||
levelId,
|
||||
nodes,
|
||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||||
),
|
||||
obj,
|
||||
})
|
||||
}
|
||||
})
|
||||
const stackPositions = getLevelStackPositions(entries)
|
||||
|
||||
const selectedIndex = selectedLevel
|
||||
? entries.find((e) => e.levelId === selectedLevel)?.index
|
||||
: undefined
|
||||
for (const { levelId, index, obj } of entries) {
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
const baseY = stackPositions.get(levelId) ?? 0
|
||||
const baseY = levelElevations.get(levelId)?.baseY ?? 0
|
||||
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
|
||||
const targetY = baseY + explodedExtra
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
type BuildingNode,
|
||||
getLevelHeight,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getLevelBuildingId, getLevelStackPositions } from './level-stacking'
|
||||
import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Instantly snaps all level Objects3D to their true stacked Y positions
|
||||
@@ -25,33 +18,20 @@ export function snapLevelsToTruePositions(): () => void {
|
||||
type LevelEntry = {
|
||||
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
||||
levelId: string
|
||||
buildingId: string | null
|
||||
index: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const entries: LevelEntry[] = []
|
||||
const buildings = Object.values(nodes).filter(
|
||||
(node): node is BuildingNode => node?.type === 'building',
|
||||
)
|
||||
sceneRegistry.byType.level!.forEach((levelId) => {
|
||||
const obj = sceneRegistry.nodes.get(levelId)
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
if (obj && level) {
|
||||
entries.push({
|
||||
levelId,
|
||||
buildingId: getLevelBuildingId(levelId, level.parentId, buildings),
|
||||
index: level.level,
|
||||
height: getLevelHeight(
|
||||
levelId,
|
||||
nodes,
|
||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||||
),
|
||||
obj,
|
||||
})
|
||||
}
|
||||
})
|
||||
const stackPositions = getLevelStackPositions(entries)
|
||||
const levelElevations = getLevelElevations(nodes)
|
||||
|
||||
// Snapshot current Y and visibility so we can restore them after the render
|
||||
const snapshot = new Map(
|
||||
@@ -60,7 +40,7 @@ export function snapLevelsToTruePositions(): () => void {
|
||||
|
||||
// Snap to true stacked positions and make all levels visible
|
||||
for (const { levelId, obj } of entries) {
|
||||
obj.position.y = stackPositions.get(levelId) ?? 0
|
||||
obj.position.y = levelElevations.get(levelId)?.baseY ?? 0
|
||||
obj.visible = true
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ import { generateSlabGeometry } from './slab-system'
|
||||
|
||||
const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] }
|
||||
|
||||
const SQUARE: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
]
|
||||
|
||||
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
|
||||
const positions = geometry.getAttribute('position')
|
||||
for (let index = 0; index < positions.count; index += 1) {
|
||||
@@ -17,16 +24,20 @@ function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
|
||||
return false
|
||||
}
|
||||
|
||||
function uniqueSortedYs(geometry: THREE.BufferGeometry): number[] {
|
||||
const positions = geometry.getAttribute('position')
|
||||
const ys = new Set<number>()
|
||||
for (let index = 0; index < positions.count; index += 1) {
|
||||
ys.add(Math.round(positions.getY(index) * 1e4) / 1e4)
|
||||
}
|
||||
return [...ys].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
describe('generateSlabGeometry', () => {
|
||||
test('renders a boundary-overlapping hole as an open indentation', () => {
|
||||
const slab = SlabNode.parse({
|
||||
elevation: 0.05,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
polygon: SQUARE,
|
||||
holes: [
|
||||
[
|
||||
[1, -0.5],
|
||||
@@ -47,12 +58,8 @@ describe('generateSlabGeometry', () => {
|
||||
test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
|
||||
const slab = SlabNode.parse({
|
||||
elevation: -0.2,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
recessed: true,
|
||||
polygon: SQUARE,
|
||||
holes: [
|
||||
[
|
||||
[1, -0.5],
|
||||
@@ -69,4 +76,47 @@ describe('generateSlabGeometry', () => {
|
||||
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
|
||||
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
|
||||
})
|
||||
|
||||
test('solid slab occupies [elevation − thickness, elevation]', () => {
|
||||
const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: SQUARE })
|
||||
|
||||
const ys = uniqueSortedYs(generateSlabGeometry(slab, EMPTY_CONTEXT))
|
||||
|
||||
expect(ys).toEqual([0.2, 0.3])
|
||||
})
|
||||
|
||||
test('migrated legacy slab (thickness = elevation) reproduces the [0, elevation] extrusion', () => {
|
||||
const slab = SlabNode.parse({ elevation: 0.05, thickness: 0.05, polygon: SQUARE })
|
||||
|
||||
const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT)
|
||||
|
||||
// Old-style expectations: extrude-from-zero put the bottom cap at 0 and
|
||||
// the top cap at `elevation`, with 8 cap verts + 4 side quads (16 verts)
|
||||
// and 12 triangles for a plain quad slab.
|
||||
expect(uniqueSortedYs(geometry)).toEqual([0, 0.05])
|
||||
expect(geometry.getAttribute('position').count).toBe(24)
|
||||
expect((geometry.index?.count ?? 0) / 3).toBe(12)
|
||||
for (const [x, z] of SQUARE) {
|
||||
expect(hasVertexAt(geometry, x, z)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('recess is keyed by the flag, not the elevation sign', () => {
|
||||
const belowPlaneSolid = SlabNode.parse({ elevation: -0.2, thickness: 0.05, polygon: SQUARE })
|
||||
|
||||
// Without `recessed`, a negative elevation is just a solid placed below
|
||||
// the level plane: closed body at [-0.25, -0.2], world-space Y baked in.
|
||||
expect(uniqueSortedYs(generateSlabGeometry(belowPlaneSolid, EMPTY_CONTEXT))).toEqual([
|
||||
-0.25, -0.2,
|
||||
])
|
||||
|
||||
// The recessed shell is authored at local Y=0 (floor) up to |elevation|
|
||||
// (rim), with no top cap: 4 floor verts + 4 wall quads = 20 verts,
|
||||
// 2 floor + 8 wall triangles.
|
||||
const pool = SlabNode.parse({ elevation: -0.2, recessed: true, polygon: SQUARE })
|
||||
const poolGeometry = generateSlabGeometry(pool, EMPTY_CONTEXT)
|
||||
expect(uniqueSortedYs(poolGeometry)).toEqual([0, 0.2])
|
||||
expect(poolGeometry.getAttribute('position').count).toBe(20)
|
||||
expect((poolGeometry.index?.count ?? 0) / 3).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,129 +1,33 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveNode,
|
||||
getRenderableSlabPolygon,
|
||||
type PolygonPoint2D,
|
||||
pointInPolygon2D,
|
||||
polygonsIntersect,
|
||||
type SlabNode,
|
||||
type SlabPolygonContext,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
|
||||
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLAB SYSTEM
|
||||
// SLAB GEOMETRY GENERATORS
|
||||
// ============================================================================
|
||||
|
||||
export const SlabSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
const markDirty = useScene((state) => state.markDirty)
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type === 'slab') {
|
||||
markDirty(node.id)
|
||||
}
|
||||
}
|
||||
}, [markDirty])
|
||||
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const contextByLevel = new Map<string | null, SlabPolygonContext>()
|
||||
|
||||
// Process dirty slabs
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (node?.type !== 'slab') return
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (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
|
||||
})
|
||||
}, 1)
|
||||
|
||||
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, context: SlabPolygonContext, mesh: THREE.Mesh) {
|
||||
const newGeo = generateSlabGeometry(node, context)
|
||||
ensureUv2Attribute(newGeo)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeo
|
||||
|
||||
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
|
||||
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
|
||||
const elevation = node.elevation ?? 0.05
|
||||
mesh.position.y = elevation < 0 ? elevation : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates extruded slab geometry from polygon. `context` carries the
|
||||
* slab's level neighbourhood (walls + sibling slabs) driving the per-edge
|
||||
* render offsets — see `getRenderableSlabPolygon`.
|
||||
* Generates slab geometry from polygon. `context` carries the slab's level
|
||||
* neighbourhood (walls + sibling slabs) driving the per-edge render offsets —
|
||||
* see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent:
|
||||
* a recessed slab is an open shell (pool), everything else a solid occupying
|
||||
* `[elevation − thickness, elevation]`.
|
||||
*/
|
||||
export function generateSlabGeometry(
|
||||
slabNode: SlabNode,
|
||||
context: SlabPolygonContext,
|
||||
): THREE.BufferGeometry {
|
||||
const elevation = slabNode.elevation ?? 0.05
|
||||
return elevation < 0
|
||||
return slabNode.recessed
|
||||
? generatePoolGeometry(slabNode, context)
|
||||
: generatePositiveSlabGeometry(slabNode, context)
|
||||
: generateSolidSlabGeometry(slabNode, context)
|
||||
}
|
||||
|
||||
// Earcut normalizes cap triangulation regardless of input winding, but the side
|
||||
@@ -175,7 +79,8 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][])
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
|
||||
* Solid slab occupying `[elevation − thickness, elevation]`: the top cap is
|
||||
* the walking surface at `elevation`, the body grows downward by `thickness`.
|
||||
*
|
||||
* Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side
|
||||
* walls can be emitted double-sided. The slab material is forced to FrontSide
|
||||
@@ -186,12 +91,14 @@ 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(
|
||||
function generateSolidSlabGeometry(
|
||||
slabNode: SlabNode,
|
||||
context: SlabPolygonContext,
|
||||
): THREE.BufferGeometry {
|
||||
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context))
|
||||
const elevation = slabNode.elevation ?? 0.05
|
||||
const thickness = slabNode.thickness ?? 0.05
|
||||
const bottom = elevation - thickness
|
||||
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
|
||||
|
||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||
@@ -207,14 +114,14 @@ function generatePositiveSlabGeometry(
|
||||
const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => {
|
||||
const base = positions.length / 3
|
||||
const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001)
|
||||
positions.push(a.x, 0, a.y)
|
||||
positions.push(a.x, bottom, a.y)
|
||||
uvs.push(0, 0)
|
||||
positions.push(b.x, 0, b.y)
|
||||
positions.push(b.x, bottom, b.y)
|
||||
uvs.push(len, 0)
|
||||
positions.push(b.x, elevation, b.y)
|
||||
uvs.push(len, elevation)
|
||||
uvs.push(len, thickness)
|
||||
positions.push(a.x, elevation, a.y)
|
||||
uvs.push(0, elevation)
|
||||
uvs.push(0, thickness)
|
||||
// Standard winding on a CCW polygon gives inward-facing normals (see pool
|
||||
// path), so the unflipped quad faces outward; flipped is its back face.
|
||||
if (!flipped) {
|
||||
@@ -244,7 +151,7 @@ function generatePositiveSlabGeometry(
|
||||
}
|
||||
const bottomBase = positions.length / 3
|
||||
for (const p of capPoints) {
|
||||
positions.push(p.x, 0, p.y)
|
||||
positions.push(p.x, bottom, p.y)
|
||||
uvs.push(p.x, -p.y)
|
||||
}
|
||||
|
||||
@@ -303,7 +210,7 @@ function generatePoolGeometry(
|
||||
|
||||
const pushFloorVertex = (x: number, y: number, z: number) => {
|
||||
positions.push(x, y, z)
|
||||
// Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's
|
||||
// Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's
|
||||
// cap mapping so a finish tiles at the same world scale on every surface.
|
||||
uvs.push(x, -z)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
getWallFaceBandConfig,
|
||||
getWallPlaneTop,
|
||||
resolveLevelId,
|
||||
resolveWallEffectiveHeight,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -130,8 +134,22 @@ export const WallCutout = () => {
|
||||
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
||||
const isDeleteHighlighted = deleteHoveredWallId === wallId
|
||||
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
|
||||
const levelId = resolveLevelId(wallNode, sceneState.nodes)
|
||||
const support = spatialGridManager.getSlabSupportForWall(
|
||||
levelId,
|
||||
wallNode.start,
|
||||
wallNode.end,
|
||||
wallNode.curveOffset ?? 0,
|
||||
wallNode.thickness,
|
||||
wallNode.supportSlabId,
|
||||
)
|
||||
const effectiveWallHeight = resolveWallEffectiveHeight(
|
||||
wallNode,
|
||||
getWallPlaneTop(wallNode, levelId, sceneState.nodes),
|
||||
support.elevation,
|
||||
)
|
||||
const shouldSelectionHighlight =
|
||||
isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled
|
||||
isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled
|
||||
const materials = getMaterialsForWall(
|
||||
wallNode,
|
||||
shading,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// @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 {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
calculateLevelMiters,
|
||||
getWallPlaneTop,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { generateExtrudedWall } from './wall-system'
|
||||
|
||||
describe('wall support extension', () => {
|
||||
@@ -31,6 +37,96 @@ describe('wall support extension', () => {
|
||||
geometry.dispose()
|
||||
})
|
||||
|
||||
test('plane-bound wall tops out at the storey plane regardless of slab elevation', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1 })
|
||||
|
||||
const flat = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, 3)
|
||||
flat.computeBoundingBox()
|
||||
expect(flat.boundingBox?.max.y).toBeCloseTo(3)
|
||||
expect(flat.boundingBox?.min.y).toBeCloseTo(0)
|
||||
flat.dispose()
|
||||
|
||||
const raised = generateExtrudedWall(
|
||||
wall,
|
||||
[],
|
||||
calculateLevelMiters([wall]),
|
||||
0.6,
|
||||
0.6,
|
||||
undefined,
|
||||
3,
|
||||
)
|
||||
raised.computeBoundingBox()
|
||||
// Mesh sits at Y=0.6, so a 2.4 local top keeps the world top at the 3m
|
||||
// plane — the raised slab shortens the wall instead of lifting its top.
|
||||
expect(raised.boundingBox?.max.y).toBeCloseTo(2.4)
|
||||
expect(raised.boundingBox?.min.y).toBeCloseTo(0)
|
||||
raised.dispose()
|
||||
})
|
||||
|
||||
test('plane-bound wall under a flush thick deck tops out at the deck underside', () => {
|
||||
// level_1 carries a flush deck occupying [-0.3, 0] above the storey
|
||||
// plane: the covering-clamped plane for level_0 is 2.5 − 0.3 = 2.2.
|
||||
const wall = WallNode.parse({
|
||||
start: [0.5, 2],
|
||||
end: [3.5, 2],
|
||||
thickness: 0.1,
|
||||
parentId: 'level_0',
|
||||
})
|
||||
const base = { object: 'node', parentId: null, visible: true, metadata: {}, children: [] }
|
||||
const nodes = {
|
||||
level_0: {
|
||||
...base,
|
||||
id: 'level_0',
|
||||
type: 'level',
|
||||
level: 0,
|
||||
height: 2.5,
|
||||
children: [wall.id],
|
||||
},
|
||||
level_1: {
|
||||
...base,
|
||||
id: 'level_1',
|
||||
type: 'level',
|
||||
level: 1,
|
||||
height: 2.5,
|
||||
children: ['slab_deck'],
|
||||
},
|
||||
slab_deck: {
|
||||
...base,
|
||||
id: 'slab_deck',
|
||||
type: 'slab',
|
||||
parentId: 'level_1',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
],
|
||||
holes: [],
|
||||
elevation: 0,
|
||||
thickness: 0.3,
|
||||
},
|
||||
} as unknown as Record<AnyNodeId, AnyNode>
|
||||
|
||||
const planeTop = getWallPlaneTop(wall, 'level_0', nodes)
|
||||
expect(planeTop).toBeCloseTo(2.2)
|
||||
|
||||
const geometry = generateExtrudedWall(
|
||||
wall,
|
||||
[],
|
||||
calculateLevelMiters([wall]),
|
||||
0,
|
||||
0,
|
||||
undefined,
|
||||
planeTop,
|
||||
)
|
||||
geometry.computeBoundingBox()
|
||||
// Mesh sits at Y=0, so the world top lands at the deck underside instead
|
||||
// of colliding with the slab solid above.
|
||||
expect(geometry.boundingBox?.max.y).toBeCloseTo(2.2)
|
||||
expect(geometry.boundingBox?.min.y).toBeCloseTo(0)
|
||||
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, [
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
calculateLevelMiters,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
type DoorNode,
|
||||
getAdjacentWallIds,
|
||||
getEffectiveNode,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallMiterBoundaryPoints,
|
||||
getWallPlaneTop,
|
||||
getWallPlanFootprint,
|
||||
getWallSurfacePolygon,
|
||||
getWallThickness,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type Point2D,
|
||||
pointToKey,
|
||||
resolveLevelId,
|
||||
resolveWallTop,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveNodeOverrides,
|
||||
@@ -222,15 +224,16 @@ function getWallFaceMaterialIndex(
|
||||
wall: Pick<WallNode, 'frontSide' | 'backSide' | 'height' | 'faceBands'>,
|
||||
face: 'front' | 'back',
|
||||
y: number,
|
||||
effectiveWallHeight: number,
|
||||
): number {
|
||||
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
||||
const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior'
|
||||
const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
|
||||
|
||||
const bands = getWallFaceBandConfig(wall)
|
||||
const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
|
||||
if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side]
|
||||
|
||||
const band = getWallFaceBandForHeight(wall, y)
|
||||
const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight)
|
||||
return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)]
|
||||
}
|
||||
|
||||
@@ -238,6 +241,7 @@ function assignWallMaterialGroups(
|
||||
geometry: THREE.BufferGeometry,
|
||||
wall: WallNode,
|
||||
boundaryEdges: TaggedWallBoundaryEdge[],
|
||||
effectiveWallHeight: number,
|
||||
) {
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position) return
|
||||
@@ -317,7 +321,12 @@ function assignWallMaterialGroups(
|
||||
continue
|
||||
}
|
||||
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y)
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(
|
||||
wall,
|
||||
nearestTag,
|
||||
centroid.y,
|
||||
effectiveWallHeight,
|
||||
)
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
@@ -445,15 +454,15 @@ function splitGeometryAtHorizontalPlanes(
|
||||
return split
|
||||
}
|
||||
|
||||
function getWallBandSplitPlanes(wall: WallNode): number[] {
|
||||
const bands = getWallFaceBandConfig(wall)
|
||||
function getWallBandSplitPlanes(wall: WallNode, effectiveWallHeight: number): number[] {
|
||||
const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
|
||||
if (!bands.enabled) return []
|
||||
const planes = [bands.lowerTop]
|
||||
if (bands.count >= 3) planes.push(bands.middleTop)
|
||||
if (bands.count >= 4) planes.push(bands.upperTop)
|
||||
return planes.filter(
|
||||
(plane) =>
|
||||
plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON,
|
||||
plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -695,12 +704,16 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
if (!mesh) return
|
||||
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
// Covering-clamped plane: a flush/thick slab on the level above shortens
|
||||
// the plane-bound walls below it (explicit-height walls ignore the value).
|
||||
const planeTop = getWallPlaneTop(node, levelId, nodes)
|
||||
const slabSupport = spatialGridManager.getSlabSupportForWall(
|
||||
levelId,
|
||||
node.start,
|
||||
node.end,
|
||||
node.curveOffset ?? 0,
|
||||
node.thickness,
|
||||
node.supportSlabId,
|
||||
)
|
||||
const slabElevation = slabSupport.elevation
|
||||
|
||||
@@ -731,6 +744,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
slabElevation,
|
||||
slabSupport.baseElevation,
|
||||
slabSupport.baseSegments,
|
||||
planeTop,
|
||||
)
|
||||
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).
|
||||
@@ -755,6 +769,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
slabElevation,
|
||||
slabSupport.baseElevation,
|
||||
slabSupport.baseSegments,
|
||||
planeTop,
|
||||
)
|
||||
collisionMesh.geometry.dispose()
|
||||
collisionMesh.geometry = collisionGeo
|
||||
@@ -845,14 +860,20 @@ export function generateExtrudedWall(
|
||||
baseSegments: readonly WallSlabSupportSegment[] = [
|
||||
{ start: 0, end: 1, elevation: baseElevation },
|
||||
],
|
||||
storeyHeight = DEFAULT_LEVEL_HEIGHT,
|
||||
): THREE.BufferGeometry {
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
|
||||
const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight
|
||||
const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation)
|
||||
const effectiveWallHeight = topElevation - slabElevation
|
||||
const effectiveBaseElevation = Math.min(baseElevation, slabElevation)
|
||||
const localBottom = effectiveBaseElevation - slabElevation
|
||||
const height = topElevation - effectiveBaseElevation
|
||||
// A slab at or above the storey plane leaves a plane-bound wall with no
|
||||
// body — bail before ExtrudeGeometry sees a non-positive depth.
|
||||
if (height <= 1e-9) {
|
||||
return new THREE.BufferGeometry()
|
||||
}
|
||||
|
||||
const thickness = getWallThickness(wallNode)
|
||||
|
||||
@@ -913,7 +934,7 @@ export function generateExtrudedWall(
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0)
|
||||
geometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight)
|
||||
ensureRenderableGeometryAttributes(geometry)
|
||||
|
||||
// Start with the lowest required wall prism, then remove the volume below
|
||||
@@ -1015,10 +1036,10 @@ export function generateExtrudedWall(
|
||||
if (cutoutBrushes.length === 0) {
|
||||
const splitGeometry = splitGeometryAtHorizontalPlanes(
|
||||
geometry,
|
||||
getWallBandSplitPlanes(wallNode),
|
||||
getWallBandSplitPlanes(wallNode, effectiveWallHeight),
|
||||
)
|
||||
splitGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges)
|
||||
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight)
|
||||
ensureRenderableGeometryAttributes(splitGeometry)
|
||||
return splitGeometry
|
||||
}
|
||||
@@ -1052,10 +1073,10 @@ export function generateExtrudedWall(
|
||||
const resultGeometry = csgGeometry(resultBrush)
|
||||
const splitResultGeometry = splitGeometryAtHorizontalPlanes(
|
||||
resultGeometry,
|
||||
getWallBandSplitPlanes(wallNode),
|
||||
getWallBandSplitPlanes(wallNode, effectiveWallHeight),
|
||||
)
|
||||
splitResultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges)
|
||||
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight)
|
||||
ensureRenderableGeometryAttributes(splitResultGeometry)
|
||||
|
||||
return splitResultGeometry
|
||||
|
||||
Reference in New Issue
Block a user