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
@@ -1,12 +1,9 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene'
import {
getWallCurveFrameAt,
isCurvedWall,
sampleWallCenterline,
} from '../../systems/wall/wall-curve'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid'
@@ -332,23 +329,70 @@ function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number,
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Length of the sub-intervals of segment (ax,az)→(bx,bz) that lie inside the
* polygon or on its boundary. The segment is split at every crossing with a
* polygon edge and each sub-interval is classified by its midpoint, so no
* test point ever sits on a crossing.
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideLength(
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
): number {
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return 0
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
@@ -365,7 +409,7 @@ function segmentInsideLength(
}
ts.sort((a, b) => a - b)
let inside = 0
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
@@ -373,27 +417,58 @@ function segmentInsideLength(
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
if (pointOnPolygonBoundary(mx, mz, polygon) || pointInPolygon(mx, mz, polygon)) {
inside += (t1 - t0) * length
}
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
function polylineInsideLength(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
): number {
let total = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
total += segmentInsideLength(a.x, a.y, b.x, b.y, polygon)
}
return total
return intervalsLength(polylineInsideIntervals(points, polygon))
}
type WallOverlapInput = {
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
@@ -503,11 +578,7 @@ export function wallOverlapsPolygon(
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const center = polylines[0]!
let centerLength = 0
for (let i = 1; i < center.length; i++) {
centerLength += Math.hypot(center[i]!.x - center[i - 1]!.x, center[i]!.y - center[i - 1]!.y)
}
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
@@ -518,6 +589,220 @@ export function wallOverlapsPolygon(
return overlap >= threshold
}
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
elevation: number
}
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] }
const groups: ElevationGroup[] = []
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, perPolyline: polylines.map(() => []) }
groups.push(group)
}
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of evaluatedGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, baseElevation, baseSegments }
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
}
export class SpatialGridManager {
private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private readonly wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
@@ -959,7 +1244,6 @@ export class SpatialGridManager {
/**
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
* Returns the highest slab elevation found, or 0 if none.
*
* Accepts an optional `curveOffset` so curved walls evaluate overlap
@@ -972,55 +1256,66 @@ export class SpatialGridManager {
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
): number {
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation
}
getSlabSupportForWall(
levelId: string,
start: [number, number],
end: [number, number],
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
): WallSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0
const wallLike: WallOverlapInput = { start, end, curveOffset, thickness }
const isCurved = curveOffset !== 0 && isCurvedWall(wallLike)
const holeSamplePoints: Array<{ x: number; y: number }> = isCurved
? sampleWallCenterline(wallLike, 8)
: [0, 0.25, 0.5, 0.75, 1].map((t) => ({
x: start[0] + (end[0] - start[0]) * t,
y: start[1] + (end[1] - start[1]) * t,
}))
let maxElevation = Number.NEGATIVE_INFINITY
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
const holes = slab.holes || []
if (holes.length === 0) {
// No holes: wall is on this slab
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) maxElevation = elevation
continue
}
// Sample multiple points along the wall to check whether any portion lies on
// solid slab (not inside any hole). Checking only the midpoint fails when the
// midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
let hasValidPoint = false
for (const sample of holeSamplePoints) {
let inHole = false
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) {
inHole = true
break
}
}
if (!inHole) {
hasValidPoint = true
break
}
}
if (hasValidPoint) {
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) maxElevation = elevation
if (!slabMap) {
return {
elevation: 0,
baseElevation: 0,
baseSegments: [{ start: 0, end: 1, elevation: 0 }],
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
return computeWallSlabSupport(
{ start, end, curveOffset, thickness },
[...slabMap.values()],
this.getLevelWallNodes(levelId),
)
}
/**
* Walls on a level, resolved fresh from the scene store (the manager's
* own wall map is only maintained on create/delete, not on updates).
* Cached per scene `nodes` record so per-pointer-tick callers
* (door/window move) don't rescan the node map.
*/
private readonly levelWallsCache = new WeakMap<object, Map<string, WallNode[]>>()
private getLevelWallNodes(levelId: string): WallNode[] {
const nodes = useScene.getState().nodes
let byLevel = this.levelWallsCache.get(nodes)
if (!byLevel) {
byLevel = new Map()
this.levelWallsCache.set(nodes, byLevel)
}
const cached = byLevel.get(levelId)
if (cached) return cached
const walls: WallNode[] = []
for (const node of Object.values(nodes)) {
if (node.type !== 'wall') continue
// Walk the parent chain to the owning level (guarded against cycles).
let current: AnyNode | undefined = node
let guard = 0
while (current && current.type !== 'level' && guard < 16) {
current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined
guard += 1
}
if (current?.type === 'level' && current.id === levelId) {
walls.push(node as WallNode)
}
}
byLevel.set(levelId, walls)
return walls
}
/**
@@ -1,3 +1,4 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
@@ -189,6 +190,25 @@ function markNodesOverlappingSlab(
if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes)
// Walls follow the slab's RENDERED footprint (band-adopted edges reach
// the wall's outer face), so the dirty gate must test the same polygon
// `getSlabElevationForWall` will re-evaluate — a stored polygon that
// stops short of the wall body would otherwise never re-elevate it.
const levelWalls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) {
levelWalls.push(node as WallNode)
} else if (
node.type === 'slab' &&
node.id !== slab.id &&
resolveLevelId(node, nodes) === slabLevelId
) {
siblingSlabs.push(node as SlabNode)
}
}
const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs })
for (const node of Object.values(nodes)) {
if (node.type === 'wall') {
const wall = node as WallNode
@@ -201,7 +221,7 @@ function markNodesOverlappingSlab(
curveOffset: wall.curveOffset ?? 0,
thickness: wall.thickness,
},
slab.polygon,
renderedPolygon,
)
) {
markDirty(node.id)
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'bun:test'
import { wallOverlapsPolygon } from './spatial-grid-manager'
import { SlabNode, WallNode } from '../../schema'
import {
computeWallSlabElevation,
computeWallSlabSupport,
wallOverlapsPolygon,
} from './spatial-grid-manager'
// 4×4 square slab, like an auto-slab derived from a room's wall centerlines.
const SLAB: Array<[number, number]> = [
@@ -73,3 +78,402 @@ describe('wallOverlapsPolygon', () => {
expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false)
})
})
describe('computeWallSlabElevation', () => {
const parseWall = (start: [number, number], end: [number, number], thickness = 0.1) =>
WallNode.parse({ start, end, thickness })
it('lifts a wall standing on an auto slab stored at the centerlines', () => {
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([4, 0], [4, 4]),
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
const bottom = walls[0]!
expect(
computeWallSlabElevation(
{ start: bottom.start, end: bottom.end, thickness: bottom.thickness },
[slab],
walls,
),
).toBeCloseTo(0.1)
})
it('lifts a wall whose body a legacy stored polygon falls short of', () => {
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
// 1cm short of even the inner faces, so the STORED polygon never
// touches the wall body and the old stored-polygon test returned 0.
// The rendered footprint band-adopts the edges out to the outer
// faces, so the wall stands on the slab.
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([4, 0], [4, 4]),
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({
polygon: [
[0.06, 0.06],
[3.94, 0.06],
[3.94, 3.94],
[0.06, 3.94],
],
elevation: 0.1,
})
const bottom = walls[0]!
expect(
computeWallSlabElevation(
{ start: bottom.start, end: bottom.end, thickness: bottom.thickness },
[slab],
walls,
),
).toBeCloseTo(0.1)
})
it('does not lift a wall clearly off the slab', () => {
const walls = [parseWall([0, 0], [4, 0])]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
expect(
computeWallSlabElevation({ start: [0, -1], end: [4, -1], thickness: 0.1 }, [slab], walls),
).toBe(0)
})
it('ignores a slab when the wall runs entirely inside a hole', () => {
const walls = [parseWall([1, 2], [3, 2])]
const slab = SlabNode.parse({
polygon: SLAB,
elevation: 0.1,
holes: [
[
[0.5, 0.5],
[3.5, 0.5],
[3.5, 3.5],
[0.5, 3.5],
],
],
})
expect(
computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [slab], walls),
).toBe(0)
})
it('keeps a wall on the lower slab when a higher slab only reaches one endpoint', () => {
// The floating-wall bug: a wall standing on the low room whose far
// endpoint pokes 0.2m onto a raised platform must NOT lift wholesale.
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation({ start: [0.5, 2], end: [4.2, 2], thickness: 0.1 }, [low, high], []),
).toBeCloseTo(0.05)
})
it('keeps a curved wall on the lower slab when a higher slab only reaches its end', () => {
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation(
{ start: [0.5, 2], end: [4.2, 2], curveOffset: 0.5, thickness: 0.1 },
[low, high],
[],
),
).toBeCloseTo(0.05)
})
it('lifts a wall standing fully on a raised platform', () => {
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [platform], []),
).toBeCloseTo(0.6)
})
it('lifts a wall half on a raised platform, half in the air, onto the platform', () => {
// No elevation reaches majority (only 39% supported), so the
// best-covered slab wins — the only alternative would bury the
// supported half inside the platform.
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabElevation({ start: [0.1, 2], end: [10.1, 2], thickness: 0.1 }, [platform], []),
).toBeCloseTo(0.6)
})
it('pools same-elevation slabs so a shared wall follows their common level', () => {
// Rooms A and B at the same elevation each cover exactly half the
// wall (interior edges seam at the x=2 midline); a raised slab covers
// just under half. Pooled, the common level covers 100% and must
// win — without pooling the raised slab's 0.4975 would beat either
// half alone.
const roomA = SlabNode.parse({
polygon: [
[0, 0],
[2, 0],
[2, 4],
[0, 4],
],
elevation: 0.1,
})
const roomB = SlabNode.parse({
polygon: [
[2, 0],
[4, 0],
[4, 4],
[2, 4],
],
elevation: 0.1,
})
const raised = SlabNode.parse({
polygon: [
[1.0, 1],
[2.99, 1],
[2.99, 3],
[1.0, 3],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation(
{ start: [0, 2], end: [4, 2], thickness: 0.1 },
[roomA, roomB, raised],
[],
),
).toBeCloseTo(0.1)
})
it('prefers the higher of two majority-supporting slabs (platform stacked on a floor)', () => {
const floor = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.05,
})
const platform = SlabNode.parse({
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
elevation: 0.6,
})
// Wall 6m long: floor covers all of it, platform covers ~2/3 — both
// majorities, and the wall physically rests on the platform.
expect(
computeWallSlabElevation(
{ start: [1, 2], end: [7, 2], thickness: 0.1 },
[floor, platform],
[],
),
).toBeCloseTo(0.6)
})
it('keeps a wall pushed up on a raised platform above a coincident floor', () => {
const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
).toEqual({
elevation: 0.6,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
})
it('fills down only when a lower support is exposed beyond a partial platform', () => {
const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const platform = SlabNode.parse({
polygon: [
[0, 0],
[2.5, 0],
[2.5, 4],
[0, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabSupport(
{ start: [0.5, 2], end: [3.5, 2], thickness: 0.1 },
[floor, platform],
[],
),
).toEqual({
elevation: 0.6,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 2 / 3, elevation: 0.6 },
{ start: 2 / 3, end: 1, elevation: 0.05 },
],
})
})
it('keeps a shared wall on the higher slab that carries the full wall band', () => {
const sharedWall = parseWall([4, 0], [4, 4])
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabSupport(
{ start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness },
[low, high],
[sharedWall],
),
).toEqual({
elevation: 0.6,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
})
it('profiles an offset-room wall as high-only, shared, then low-only', () => {
const sharedWall = parseWall([4, 0], [4, 4.5])
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([0, 3], [0, 0]),
parseWall([0, 3], [4, 3]),
sharedWall,
parseWall([4, 1.5], [8, 1.5]),
parseWall([8, 1.5], [8, 4.5]),
parseWall([8, 4.5], [4, 4.5]),
]
const high = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
elevation: 0.6,
})
const low = SlabNode.parse({
polygon: [
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
],
elevation: 0.05,
})
expect(
computeWallSlabSupport(
{ start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness },
[high, low],
walls,
),
).toEqual({
elevation: 0.6,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 },
{ start: 3.05 / 4.5, end: 1, elevation: 0.05 },
],
})
})
it('lifts a tiny stub wall standing fully on a slab', () => {
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.3 })
expect(
computeWallSlabElevation({ start: [2, 2], end: [2.08, 2], thickness: 0.1 }, [slab], []),
).toBeCloseTo(0.3)
})
it('does not lift a wall whose run over a higher slab is mostly inside a hole', () => {
const low = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.05,
})
const high = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.6,
holes: [
[
[0.5, 0],
[8, 0],
[8, 4],
[0.5, 4],
],
],
})
// Net high support is only x ∈ [0, 0.5]; the low slab carries the wall.
expect(
computeWallSlabElevation({ start: [0, 2], end: [8, 2], thickness: 0.1 }, [low, high], []),
).toBeCloseTo(0.05)
})
it('keeps support for a wall running along a hole rim', () => {
// Hole boundaries count as solid: the wall ringing a stairwell sits
// on the rim, its outer face on solid slab.
const slab = SlabNode.parse({
polygon: [
[0, 0],
[6, 0],
[6, 6],
[0, 6],
],
elevation: 0.4,
holes: [
[
[2, 2],
[4, 2],
[4, 4],
[2, 4],
],
],
})
expect(
computeWallSlabElevation({ start: [2, 2], end: [4, 2], thickness: 0.1 }, [slab], []),
).toBeCloseTo(0.4)
})
})
+12 -2
View File
@@ -48,7 +48,11 @@ export {
getFloorPlacedFootprints,
getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation'
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export {
pointInPolygon,
spatialGridManager,
type WallSlabSupportSegment,
} from './hooks/spatial-grid/spatial-grid-manager'
export {
findLevelAncestorId,
initSpatialGridSync,
@@ -75,7 +79,13 @@ export {
segmentsIntersect,
} from './lib/polygon-relations'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
getRenderableSlabPolygon,
type SlabEdgeWallBandSnap,
type SlabPolygonContext,
slabPolygonContextFromGeometry,
snapSlabEdgeToWallBand,
} from './lib/slab-polygon'
export {
deriveSlotId,
isSlotMaterialName,
-25
View File
@@ -1,28 +1,3 @@
export function insetPolygonFromCentroid(
polygon: Array<[number, number]>,
inset: number,
): Array<[number, number]> {
if (inset <= 0) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1)
return polygon.map(([x, z]) => {
const dx = x - centroid.x
const dz = z - centroid.z
const length = Math.hypot(dx, dz)
if (length <= inset + 1e-6) {
return [x, z] as [number, number]
}
const scale = (length - inset) / length
return [centroid.x + dx * scale, centroid.z + dz * scale] as [number, number]
})
}
function pointLineDistance(
point: [number, number],
start: [number, number],
+792
View File
@@ -0,0 +1,792 @@
import { describe, expect, test } from 'bun:test'
import { SlabNode, WallNode } from '../schema'
import { pointInPolygon } from './polygon-relations'
import { getRenderableSlabPolygon, snapSlabEdgeToWallBand } from './slab-polygon'
function wallOf(start: [number, number], end: [number, number], thickness = 0.1) {
return WallNode.parse({ start, end, thickness })
}
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) {
return SlabNode.parse(
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation },
)
}
function xs(polygon: Array<[number, number]>) {
return polygon.map((point) => point[0])
}
function zs(polygon: Array<[number, number]>) {
return polygon.map((point) => point[1])
}
/** Assert the ring contains every expected vertex (order-independent). */
function expectRingToInclude(polygon: Array<[number, number]>, points: Array<[number, number]>) {
const missing = points.filter(
([x, z]) => !polygon.some((p) => Math.abs(p[0] - x) < 1e-6 && Math.abs(p[1] - z) < 1e-6),
)
expect(missing).toEqual([])
}
const roomA: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const roomB: Array<[number, number]> = [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
]
// Two rooms side by side sharing the centerline wall at x=4.
const twoRoomWalls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [4, 3]),
wallOf([4, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3]),
]
describe('getRenderableSlabPolygon', () => {
test('adjacent room slabs share the exact centerline seam without gap or overlap', () => {
const slabA = slabOf(roomA)
const slabB = slabOf(roomB)
const polyA = getRenderableSlabPolygon(slabA, {
walls: twoRoomWalls,
siblingSlabs: [slabB],
})
const polyB = getRenderableSlabPolygon(slabB, {
walls: twoRoomWalls,
siblingSlabs: [slabA],
})
// A: exterior edges flush with the 0.1-thick facade (+0.05), shared
// edge exactly on the wall centerline x=4.
expect(Math.min(...xs(polyA))).toBeCloseTo(-0.05)
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...zs(polyA))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polyA))).toBeCloseTo(3.05)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
expect(Math.max(...xs(polyB))).toBeCloseTo(8.05)
// No overlap across the shared wall (FP noise only)...
expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9)
// ...and the seam is EXACTLY shared: both rings project the shared
// edge onto the same centerline x=4 with matching endpoints.
const seamZs = (poly: Array<[number, number]>) =>
poly
.filter((point) => point[0] === 4)
.map((point) => point[1])
.sort((left, right) => left - right)
const seamA = seamZs(polyA)
const seamB = seamZs(polyB)
expect(seamA).toHaveLength(2)
expect(seamB).toHaveLength(2)
expect(seamA[0]!).toBeCloseTo(seamB[0]!, 12)
expect(seamA[1]!).toBeCloseTo(seamB[1]!, 12)
// Grid-sample the strip under the shared wall band: every point is
// inside at least one slab — the old relief slit is gone, so deleting
// the wall would expose a continuous floor.
for (let x = 3.95; x <= 4.0501; x += 0.01) {
for (let z = 0; z <= 3.001; z += 0.15) {
expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true)
}
}
})
test('exterior edge expands by half of THAT wall thickness', () => {
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
wallOf([4, 3], [0, 3]),
// Non-default thickness on the left facade only.
wallOf([0, 3], [0, 0], 0.3),
]
const poly = getRenderableSlabPolygon(slabOf(roomA), { walls, siblingSlabs: [] })
expect(Math.min(...xs(poly))).toBeCloseTo(-0.15)
expect(Math.max(...xs(poly))).toBeCloseTo(4.05)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
expect(Math.max(...zs(poly))).toBeCloseTo(3.05)
})
test('freehand edges away from any wall render exactly as drawn', () => {
const drawn: Array<[number, number]> = [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
]
const poly = getRenderableSlabPolygon(slabOf(drawn, false), {
walls: twoRoomWalls,
siblingSlabs: [],
})
expect(poly).toEqual(drawn)
})
test('manual slabs follow the same per-edge rule as auto slabs', () => {
const poly = getRenderableSlabPolygon(slabOf(roomA, false), {
walls: twoRoomWalls,
siblingSlabs: [slabOf(roomB)],
})
expect(Math.max(...xs(poly))).toBeCloseTo(4)
expect(Math.min(...xs(poly))).toBeCloseTo(-0.05)
})
test('T-junction: a neighbour edge that is a sub-segment still reads as interior', () => {
// Big 6×5 room; a 2×2 bay hangs below, sealed against the interior of
// the big room's bottom wall between x=1 and x=3.
const big = slabOf([
[0, 0],
[6, 0],
[6, 5],
[0, 5],
])
const bay = slabOf([
[1, -2],
[3, -2],
[3, 0],
[1, 0],
])
const walls = [
wallOf([0, 0], [6, 0]),
wallOf([6, 0], [6, 5]),
wallOf([6, 5], [0, 5]),
wallOf([0, 5], [0, 0]),
wallOf([1, 0], [1, -2]),
wallOf([1, -2], [3, -2]),
wallOf([3, -2], [3, 0]),
]
// The bay's top edge lies on a sub-segment of the big slab's bottom
// edge — interior, seamed on the shared wall centerline z=0, while
// its free-standing sides stay on-wall.
const bayPoly = getRenderableSlabPolygon(bay, { walls, siblingSlabs: [big] })
expect(Math.max(...zs(bayPoly))).toBeCloseTo(0)
expect(Math.min(...zs(bayPoly))).toBeCloseTo(-2.05)
expect(Math.min(...xs(bayPoly))).toBeCloseTo(0.95)
expect(Math.max(...xs(bayPoly))).toBeCloseTo(3.05)
// The big slab's bottom edge is backed differently along its span:
// centerline seam across the bay (z=0), facade-flush elsewhere
// (z=-0.05), joined by step connectors at the bay junction walls
// x=1 and x=3 (the old whole-edge rule pulled the entire edge back).
const bigPoly = getRenderableSlabPolygon(big, { walls, siblingSlabs: [bay] })
expect(Math.min(...zs(bigPoly))).toBeCloseTo(-0.05)
expect(Math.max(...zs(bigPoly))).toBeCloseTo(5.05)
expectRingToInclude(bigPoly, [
[1, -0.05],
[1, 0],
[3, 0],
[3, -0.05],
])
})
test('an edge on a wall longer than itself still reaches the facade', () => {
// Slab edge [1,0]→[3,0] sits mid-span on a 6m wall.
const poly = getRenderableSlabPolygon(
slabOf([
[1, 0],
[3, 0],
[3, 2],
[1, 2],
]),
{ walls: [wallOf([0, 0], [6, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
// The other three edges are free — rendered as drawn.
expect(Math.max(...zs(poly))).toBeCloseTo(2)
expect(Math.min(...xs(poly))).toBeCloseTo(1)
expect(Math.max(...xs(poly))).toBeCloseTo(3)
})
test('legacy edge stored at the inner wall face projects to the outer face', () => {
// Wall centerline z=0, thickness 0.1 — the legacy slab edge sits at the
// inner face z=0.05. Absolute projection must land the rendered edge on
// the OUTER face (-0.05), not at inner face + t/2 (= centerline).
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.05],
[4, 0.05],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('legacy edge stored at the outer wall face stays at the face (no overshoot)', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, -0.05],
[4, -0.05],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a thick wall adopts a face-aligned edge beyond the old fixed tolerance', () => {
// t=0.3: inner face is 0.15 off the centerline — past the old fixed 0.1
// tolerance, so this edge used to classify FREE and never expand.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.15],
[4, 0.15],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0], 0.3)], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.15)
})
test('edges outside the adoption band stay free', () => {
// Band for t=0.1 is half + 0.06 = 0.11 — an edge 0.12 away is kept as drawn.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.12],
[4, 0.12],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(0.12)
})
test('two parallel close walls: the nearest centerline wins', () => {
// Thin wall at z=0 (band 0.11) and thick wall at z=0.3 (t=0.3, band
// 0.21). An edge at z=0.1 is inside BOTH bands (laterals 0.1 and 0.2);
// it must adopt the nearer thin wall and land on ITS outer face.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.1],
[4, 0.1],
[4, 3],
[0, 3],
],
false,
),
{
walls: [wallOf([0, 0], [4, 0]), wallOf([0, 0.3], [4, 0.3], 0.3)],
siblingSlabs: [],
},
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('span overlap below the minimum leaves the edge free', () => {
// The wall only overlaps the last 2cm of the edge span — under the 5cm
// classification minimum.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([3.98, 0], [4.5, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(0)
})
test('legacy face-aligned rooms across a thick wall still tile without overlap', () => {
// Both rooms stored at the INNER faces of the shared t=0.3 wall at x=4
// (edges 0.3 apart — far beyond the direct sibling tolerance). Each edge
// is inside the wall band with the sibling across the same band, so both
// classify interior and land exactly on the CENTERLINE instead of
// projecting to opposite outer faces (which would overlap by a full
// thickness).
const walls = [
wallOf([0, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3], 0.3),
]
const legacyA = slabOf(
[
[0, 0],
[3.85, 0],
[3.85, 3],
[0, 3],
],
false,
)
const legacyB = slabOf(
[
[4.15, 0],
[8, 0],
[8, 3],
[4.15, 3],
],
false,
)
const polyA = getRenderableSlabPolygon(legacyA, { walls, siblingSlabs: [legacyB] })
const polyB = getRenderableSlabPolygon(legacyB, { walls, siblingSlabs: [legacyA] })
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9)
})
test('the higher room carries the wall band to the lower room face', () => {
// Real user repro shape: two rooms in an L/offset arrangement share the
// z=0 wall over x ∈ [-1, 0.5] only; the north room's floor is raised
// (0.34) above the south room's (0.05). Slabs extrude 0 → elevation and
// the higher slab closes the full wall band while the lower slab meets
// it at its own wall face. The reach comes from the matched wall's real
// thickness, not a default-width expansion.
const walls = [
wallOf([-1, 3], [-1, 0]),
wallOf([-1, 0], [0.5, 0]),
wallOf([0.5, 0], [2, 0]),
wallOf([2, 0], [2, 3]),
wallOf([2, 3], [-1, 3]),
wallOf([0.5, 0], [0.5, -4]),
wallOf([0.5, -4], [-1, -4]),
wallOf([-1, -4], [-1, 0]),
]
const high = slabOf(
[
[-1, 3],
[-1, 0],
[2, 0],
[2, 3],
],
false,
0.34,
)
const low = slabOf(
[
[0.5, 0],
[-1, 0],
[-1, -4],
[0.5, -4],
],
true,
0.05,
)
const polyHigh = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] })
const polyLow = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] })
// The shared and facade spans fuse because both land on z=-0.05.
expectRingToInclude(polyHigh, [
[0.5, -0.05],
[2.05, -0.05],
])
expect(Math.min(...zs(polyHigh))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polyLow))).toBeCloseTo(-0.05)
// The high slab owns the entire band; the lower room begins at its face.
for (let x = -0.95; x <= 0.4501; x += 0.05) {
for (let z = -0.045; z <= 0.0451; z += 0.015) {
expect(pointInPolygon([x, z], polyHigh, { includeBoundary: false })).toBe(true)
}
}
})
test('legacy face-aligned unequal rooms self-heal to the lower room face', () => {
// Same stored-at-inner-faces legacy data as above (edges a full 0.3
// apart across the t=0.3 wall at x=4), but with the west room raised.
// Both edges classify interior through the band-sibling rule and adopt
// the east/lower room face despite being stored at opposite faces.
const walls = [
wallOf([0, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3], 0.3),
]
const legacyHigh = slabOf(
[
[0, 0],
[3.85, 0],
[3.85, 3],
[0, 3],
],
false,
0.4,
)
const legacyLow = slabOf(
[
[4.15, 0],
[8, 0],
[8, 3],
[4.15, 3],
],
false,
0.05,
)
const polyHigh = getRenderableSlabPolygon(legacyHigh, { walls, siblingSlabs: [legacyLow] })
const polyLow = getRenderableSlabPolygon(legacyLow, { walls, siblingSlabs: [legacyHigh] })
expect(Math.max(...xs(polyHigh))).toBeCloseTo(4.15)
expect(Math.min(...xs(polyLow))).toBeCloseTo(4.15)
expect(Math.max(...xs(polyHigh))).toBeLessThanOrEqual(Math.min(...xs(polyLow)) + 1e-9)
})
test('stacked slabs are not mistaken for rooms across a wall', () => {
const floor = slabOf(roomA, false, 0.05)
const platform = slabOf(roomA, false, 0.4)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
wallOf([4, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
]
const floorPolygon = getRenderableSlabPolygon(floor, {
walls,
siblingSlabs: [platform],
})
const platformPolygon = getRenderableSlabPolygon(platform, {
walls,
siblingSlabs: [floor],
})
for (const polygon of [floorPolygon, platformPolygon]) {
expect(Math.min(...xs(polygon))).toBeCloseTo(-0.05)
expect(Math.max(...xs(polygon))).toBeCloseTo(4.05)
expect(Math.min(...zs(polygon))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polygon))).toBeCloseTo(3.05)
}
})
test('sibling winding does not change a shared seam decision', () => {
const slabA = slabOf(roomA)
const slabB = slabOf([...roomB].reverse())
const polyA = getRenderableSlabPolygon(slabA, {
walls: twoRoomWalls,
siblingSlabs: [slabB],
})
const polyB = getRenderableSlabPolygon(slabB, {
walls: twoRoomWalls,
siblingSlabs: [slabA],
})
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
})
test('wall-less butted slabs at different elevations keep the midline seam', () => {
// No wall backs the seam, so there is no band to hide a pocket under —
// the exposed step face at the joint is correct. Elevation must not
// move a wall-less seam off the midline.
const stepHigh = slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
0.3,
)
const stepLow = slabOf(
[
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
false,
0.05,
)
const polyHigh = getRenderableSlabPolygon(stepHigh, { walls: [], siblingSlabs: [stepLow] })
const polyLow = getRenderableSlabPolygon(stepLow, { walls: [], siblingSlabs: [stepHigh] })
expect(Math.max(...xs(polyHigh))).toBeCloseTo(4)
expect(Math.min(...xs(polyLow))).toBeCloseTo(4)
})
test('offset rooms sharing a partial wall span: interior beside the sibling, facade elsewhere', () => {
// Rooms offset diagonally share the x=4 wall only for z ∈ [1.5, 3].
// Each room's long edge is interior for the shared span and exterior
// (its own facade) for the rest — the case sub-edge classification
// exists for.
const offsetA = slabOf([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
const offsetB = slabOf([
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
])
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([0, 3], [0, 0]),
wallOf([0, 3], [4, 3]),
wallOf([4, 0], [4, 4.5]),
wallOf([4, 1.5], [8, 1.5]),
wallOf([8, 1.5], [8, 4.5]),
wallOf([8, 4.5], [4, 4.5]),
]
const polyA = getRenderableSlabPolygon(offsetA, { walls, siblingSlabs: [offsetB] })
const polyB = getRenderableSlabPolygon(offsetB, { walls, siblingSlabs: [offsetA] })
// A's right edge: facade-flush below the junction, exactly on the
// centerline beside B, joined by the step connector at the junction z=1.5.
expectRingToInclude(polyA, [
[4.05, -0.05],
[4.05, 1.5],
[4, 1.5],
[4, 3.05],
])
// B's left edge mirrors it: centerline seam beside A, facade-flush
// above, step at the junction z=3.
expectRingToInclude(polyB, [
[4, 1.45],
[4, 3],
[3.95, 3],
[3.95, 4.55],
])
// Along the shared span both slabs reach exactly the wall centerline:
// the strip under the shared wall is fully covered with no interior
// overlap — deleting the wall would expose a continuous floor...
for (let z = 1.6; z <= 2.95; z += 0.1) {
expect(pointInPolygon([3.99, z], polyA, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.01, z], polyB, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.01, z], polyA, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([3.99, z], polyB, { includeBoundary: false })).toBe(false)
for (let x = 3.96; x <= 4.0401; x += 0.01) {
expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true)
}
}
// ...while each unshared portion reaches its own facade face.
expect(pointInPolygon([4.04, 0.75], polyA, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([3.96, 3.75], polyB, { includeBoundary: false })).toBe(true)
// Any residual overlap is confined to the shared wall's footprint
// right at the junction corners — hidden under the wall bodies, the
// same corner pockets the outer-face projection has always produced
// where two rooms' facades meet a wall junction.
for (let x = 3.5; x <= 4.5; x += 0.02) {
for (let z = -0.2; z <= 4.7; z += 0.02) {
const overlapping =
pointInPolygon([x, z], polyA, { includeBoundary: false }) &&
pointInPolygon([x, z], polyB, { includeBoundary: false })
if (!overlapping) continue
expect(Math.abs(x - 4)).toBeLessThanOrEqual(0.05 + 1e-9)
expect(Math.min(Math.abs(z - 1.5), Math.abs(z - 3))).toBeLessThanOrEqual(0.05 + 1e-9)
}
}
})
test('offset unequal rooms give the higher slab the full shared wall band', () => {
const high = slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
true,
0.4,
)
const low = slabOf(
[
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
],
true,
0.05,
)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([0, 3], [0, 0]),
wallOf([0, 3], [4, 3]),
wallOf([4, 0], [4, 4.5]),
wallOf([4, 1.5], [8, 1.5]),
wallOf([8, 1.5], [8, 4.5]),
wallOf([8, 4.5], [4, 4.5]),
]
const highPolygon = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] })
const lowPolygon = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] })
expect(Math.max(...xs(highPolygon))).toBeCloseTo(4.05)
expectRingToInclude(lowPolygon, [
[4.05, 1.45],
[4.05, 3],
[3.95, 3],
])
for (let z = 1.6; z <= 2.95; z += 0.1) {
expect(pointInPolygon([3.975, z], highPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([3.975, z], lowPolygon, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([4.025, z], highPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.025, z], lowPolygon, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([4.075, z], lowPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.075, z], highPolygon, { includeBoundary: false })).toBe(false)
}
})
test('a wall backing only part of an edge: flush over the wall, as drawn beyond it', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [] },
)
expectRingToInclude(poly, [
[0, -0.05],
[2, -0.05],
[2, 0],
[4, 0],
])
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
expect(Math.max(...xs(poly))).toBeCloseTo(4)
})
test('two collinear walls of different thickness along one edge: each face wins its own span', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0]), wallOf([2, 0], [4, 0], 0.3)], siblingSlabs: [] },
)
// Thin-wall face for x < 2, thick-wall face beyond, stepped at x=2
// (the old whole-edge rule let one wall win the entire edge).
expectRingToInclude(poly, [
[0, -0.05],
[2, -0.05],
[2, -0.15],
[4, -0.15],
])
})
test('breakpoints within the minimum sub-edge length merge into one step', () => {
// The wall ends at x=2; the sibling starts at x=2.02 — the two
// breakpoints are 2cm apart, under the 5cm minimum, so they collapse
// into a single step at x=2 instead of leaving a sliver sub-edge.
const sibling = slabOf([
[2.02, -2],
[4, -2],
[4, 0],
[2.02, 0],
])
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [sibling] },
)
expectRingToInclude(poly, [
[2, -0.05],
[2, 0],
])
expect(poly).toHaveLength(6)
})
})
describe('snapSlabEdgeToWallBand', () => {
test('an edge inside the band snaps onto the wall centerline', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])])
expect(snap).not.toBeNull()
expect(snap!.edge[0][1]).toBeCloseTo(0)
expect(snap!.edge[1][1]).toBeCloseTo(0)
// Tangential positions are preserved — pure perpendicular translation.
expect(snap!.edge[0][0]).toBeCloseTo(0.5)
expect(snap!.edge[1][0]).toBeCloseTo(3.5)
})
test('an edge outside the band does not snap', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.2], [3.5, 0.2], [wallOf([0, 0], [4, 0])])
expect(snap).toBeNull()
})
test('maxLateral tightens the stick distance', () => {
const walls = [wallOf([0, 0], [4, 0])]
expect(snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], walls, { maxLateral: 0.05 })).toBeNull()
expect(
snapSlabEdgeToWallBand([0.5, 0.04], [3.5, 0.04], walls, { maxLateral: 0.05 }),
).not.toBeNull()
})
test('the nearest of two candidate walls wins', () => {
const near = wallOf([0, 0], [4, 0])
const far = wallOf([0, 0.3], [4, 0.3], 0.3)
const snap = snapSlabEdgeToWallBand([0.5, 0.1], [3.5, 0.1], [far, near])
expect(snap).not.toBeNull()
expect(snap!.wallId).toBe(near.id)
expect(snap!.edge[0][1]).toBeCloseTo(0)
})
})
+744 -35
View File
@@ -1,27 +1,700 @@
import type { SlabNode } from '../schema'
import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry'
import type { GeometryContext } from '../registry/types'
import type { AnyNodeId, SlabNode, WallNode } from '../schema'
import { isCurvedWall, sampleWallCenterline } from '../systems/wall/wall-curve'
import { getWallThickness } from '../systems/wall/wall-footprint'
/** Half of default wall thickness — used to extend slab geometry under walls */
const SLAB_OUTSET = 0.05
const AUTO_SLAB_INSET = 0.02
const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08
/**
* Render-time slab polygon rules.
*
* Slab nodes store the wall-centerline polygon (auto slabs) or the drawn
* polygon (manual slabs) — render offsets are NEVER stored in node data.
* At geometry build time each polygon edge is SPLIT at the clipped span
* boundaries of every overlapping candidate (sibling slab edges and wall
* centerlines in the adoption band) and each sub-edge is classified
* independently — a single stored edge can be backed differently along
* its span (two rooms offset diagonally share a wall only where they
* overlap, so one room's edge is interior beside the sibling and its own
* facade elsewhere). Each sub-edge is PROJECTED onto an absolute target
* line:
*
* - INTERIOR — a sibling slab has a collinear, overlapping edge across
* it (directly, or across the same wall's footprint band). Projected
* EXACTLY onto a shared line so both neighbours emit the same seam
* and tile with no gap or overlap. Equal-height rooms partition the
* real wall band at its centerline. At an unequal-height boundary, the
* higher slab carries the full band to the lower room's wall face and
* the lower slab terminates at that same plane. This closes the step
* below the raised wall without lowering the wall or exposing a pocket.
* Without a wall, the target is the midline between the stored edges.
* The coincident vertical seam faces carry opposite outward
* normals and every slab material is front-side, so at most one face
* renders per view — no z-fighting.
* - WALL-BACKED — no slab neighbour across the edge, but the sub-edge
* lies inside a wall's footprint band (lateral distance from the
* centerline within half-thickness + adoption tolerance). Projected
* to the wall's OUTER face line so the slab reaches flush with the
* facade regardless of where the stored edge sits inside the band —
* this self-heals legacy data stored at wall faces or with old baked
* render offsets.
* - FREE — no neighbour, no wall. Rendered exactly as drawn.
*
* Sub-edges of one edge with different projections are joined by a
* perpendicular STEP connector at the breakpoint. Breakpoints sit on
* candidate span boundaries — wall junctions — so the step's vertical
* face lands inside the crossing wall's footprint and stays hidden
* under the wall body.
*/
export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> {
return slabNode.autoFromWalls
? simplifyClosedPolygon(
insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET),
AUTO_SLAB_SIMPLIFY_TOLERANCE,
)
: outsetPolygon(slabNode.polygon, SLAB_OUTSET)
/** Lateral distance within which a sibling slab edge counts as directly "across" an edge. */
const SLAB_NEIGHBOR_LATERAL_TOLERANCE = 0.05
/**
* Extra lateral tolerance beyond a wall's half-thickness for band
* adoption. 0.06 keeps every previously-adopted edge adopted (the old
* fixed 0.1 tolerance equals half-thickness + 0.05 for the thinnest
* 0.1 walls, and auto slab polygons simplified with a 0.08 tolerance on
* curved walls stay inside half + 0.06), while giving hand-adjusted
* edges ~6cm of slack on either side of the wall faces.
*/
const WALL_ADOPTION_TOLERANCE = 0.06
/** Minimum collinear overlap (m) before a candidate drives the classification. */
const MIN_CLASSIFYING_OVERLAP = 0.05
/**
* Minimum sub-edge length (m) when splitting an edge at candidate span
* boundaries. Breakpoints closer than this to the previous one or to an
* edge endpoint are dropped, so slivers never survive into the ring.
*/
const MIN_SUBEDGE_LENGTH = 0.05
/**
* Two candidate walls whose centerlines sit within this lateral
* difference count as equally near; the larger span overlap wins
* (collinear runs of different-thickness walls along one edge).
* Otherwise the nearest centerline wins (parallel close walls).
*/
const WALL_LATERAL_TIE_EPSILON = 0.02
const CURVED_WALL_SAMPLE_SEGMENTS = 32
const SLAB_SEAM_ELEVATION_EPSILON = 1e-4
const DEFAULT_SLAB_ELEVATION = 0.05
export type SlabPolygonContext = {
/** Walls on the slab's level. */
walls: WallNode[]
/** Other slabs on the same level — the slab itself must be excluded. */
siblingSlabs: SlabNode[]
}
/** [ax, az, bx, bz] segment in plan space. */
type Segment = [number, number, number, number]
/** A sibling slab edge and the direction of its polygon interior. */
type NeighborSegment = {
segment: Segment
elevation: number
/** Unit normal pointing into the sibling polygon at this edge. */
inwardX: number
inwardZ: number
}
function polygonWindingSign(polygon: Array<[number, number]>): 1 | -1 {
let area2 = 0
for (let index = 0; index < polygon.length; index += 1) {
const next = (index + 1) % polygon.length
area2 += polygon[index]![0] * polygon[next]![1] - polygon[next]![0] * polygon[index]![1]
}
return area2 >= 0 ? 1 : -1
}
/**
* Expand a polygon outward by a uniform distance.
* Offsets each edge outward then intersects consecutive offset edges.
* Derive a {@link SlabPolygonContext} from a registry `GeometryContext`:
* sibling slabs come from `ctx.siblings` (same kind, same parent, self
* excluded) and walls from the parent level's children.
*/
function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> {
export function slabPolygonContextFromGeometry(
ctx: GeometryContext | undefined,
): SlabPolygonContext {
if (!ctx) return { walls: [], siblingSlabs: [] }
const siblingSlabs = ctx.siblings.filter(
(node): node is SlabNode => node.type === 'slab',
) as SlabNode[]
const walls: WallNode[] = []
const parentChildIds = (ctx.parent as { children?: AnyNodeId[] } | null)?.children
if (Array.isArray(parentChildIds)) {
for (const childId of parentChildIds) {
const child = ctx.resolve(childId)
if ((child as { type?: string } | undefined)?.type === 'wall') {
walls.push(child as WallNode)
}
}
}
return { walls, siblingSlabs }
}
export function getRenderableSlabPolygon(
slabNode: SlabNode,
context: SlabPolygonContext,
): Array<[number, number]> {
const polygon = slabNode.polygon
if (polygon.length < 3) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
const subSpans = computeEdgeSubSpans(
polygon,
slabNode.elevation ?? DEFAULT_SLAB_ELEVATION,
context,
)
if (subSpans.every((spans) => spans.length === 1 && spans[0]!.offset === 0)) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
return offsetPolygonPerEdge(polygon, subSpans)
}
type SegmentMatch = {
/** Overlap (m) between the candidate and the edge span, along the edge axis. */
overlap: number
/**
* Signed lateral distance of the candidate from the edge's infinite
* line, measured at the middle of the clipped span, along the edge's
* left normal `n = (dirZ, -dirX)`.
*/
lateral: number
/** Clipped span start along the edge axis (param from the edge start). */
start: number
/** Clipped span end along the edge axis (param from the edge start). */
end: number
}
/**
* Clip the candidate segment `(px, pz) → (qx, qz)` against the span of
* the edge starting at `(ax, az)` with normalized direction
* `(dirX, dirZ)`, counting only when the candidate is collinear with
* the edge within `lateralTolerance` (both candidate endpoints within
* that distance of the edge's infinite line — testing against the
* infinite line, not the edge span, is what lets a T-junction
* sub-segment match its longer host edge).
*/
function clipCollinearSegment(
ax: number,
az: number,
dirX: number,
dirZ: number,
edgeLength: number,
px: number,
pz: number,
qx: number,
qz: number,
lateralTolerance: number,
): SegmentMatch | null {
const relPX = px - ax
const relPZ = pz - az
const latP = relPX * dirZ - relPZ * dirX
if (Math.abs(latP) > lateralTolerance) return null
const relQX = qx - ax
const relQZ = qz - az
const latQ = relQX * dirZ - relQZ * dirX
if (Math.abs(latQ) > lateralTolerance) return null
const t0 = relPX * dirX + relPZ * dirZ
const t1 = relQX * dirX + relQZ * dirZ
const low = Math.max(Math.min(t0, t1), 0)
const high = Math.min(Math.max(t0, t1), edgeLength)
const overlap = high - low
if (overlap <= 0) return null
const tMid = (low + high) / 2
const u = Math.abs(t1 - t0) < 1e-12 ? 0.5 : (tMid - t0) / (t1 - t0)
return { overlap, lateral: latP + (latQ - latP) * u, start: low, end: high }
}
function wallCenterlineSegments(wall: WallNode): Segment[] {
if (!isCurvedWall(wall)) {
return [[wall.start[0], wall.start[1], wall.end[0], wall.end[1]]]
}
const points = sampleWallCenterline(wall, CURVED_WALL_SAMPLE_SEGMENTS)
const segments: Segment[] = []
for (let index = 0; index < points.length - 1; index += 1) {
const from = points[index]!
const to = points[index + 1]!
segments.push([from.x, from.y, to.x, to.y])
}
return segments
}
type WallCandidate = {
wall: WallNode
segments: Segment[]
halfThickness: number
}
type WallBandMatch = {
wall: WallNode
halfThickness: number
/** Total collinear overlap (m) of the wall centerline with the edge span. */
overlap: number
/**
* Overlap-weighted mean signed lateral distance of the wall
* centerline from the edge line, along `n = (dirZ, -dirX)`.
*/
lateral: number
}
/**
* Best wall whose footprint band contains the edge: the centerline is
* collinear within half-thickness + {@link WALL_ADOPTION_TOLERANCE} and
* span-overlaps the edge by at least `requiredOverlap`. Nearest
* centerline wins; near-ties fall back to the larger overlap.
*/
function matchEdgeWallBand(
ax: number,
az: number,
dirX: number,
dirZ: number,
edgeLength: number,
requiredOverlap: number,
candidates: readonly WallCandidate[],
): WallBandMatch | null {
let best: WallBandMatch | null = null
for (const candidate of candidates) {
const tolerance = candidate.halfThickness + WALL_ADOPTION_TOLERANCE
let overlap = 0
let weightedLateral = 0
for (const [px, pz, qx, qz] of candidate.segments) {
const match = clipCollinearSegment(ax, az, dirX, dirZ, edgeLength, px, pz, qx, qz, tolerance)
if (!match) continue
overlap += match.overlap
weightedLateral += match.lateral * match.overlap
}
if (overlap < requiredOverlap) continue
const lateral = weightedLateral / overlap
if (!best) {
best = { wall: candidate.wall, halfThickness: candidate.halfThickness, overlap, lateral }
continue
}
const bestAbs = Math.abs(best.lateral)
const thisAbs = Math.abs(lateral)
const nearTie = Math.abs(thisAbs - bestAbs) <= WALL_LATERAL_TIE_EPSILON
if (nearTie ? overlap > best.overlap : thisAbs < bestAbs) {
best = { wall: candidate.wall, halfThickness: candidate.halfThickness, overlap, lateral }
}
}
return best
}
export type SlabEdgeWallBandSnap = {
wallId: WallNode['id']
/** The candidate edge translated perpendicular onto the wall centerline. */
edge: [[number, number], [number, number]]
}
/**
* Reshape-snap counterpart of the render band rule: when the edge
* `a → b` lies inside a wall's footprint band, return the edge
* translated onto that wall's CENTERLINE — the canonical stored
* position (matching what auto slabs store); the render rule then
* places it at the face. `maxLateral` optionally tightens the stick
* distance (non-magnetic modes keep only a connect-radius stick).
*/
export function snapSlabEdgeToWallBand(
a: [number, number],
b: [number, number],
walls: readonly WallNode[],
options?: { maxLateral?: number },
): SlabEdgeWallBandSnap | null {
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const edgeLength = Math.hypot(dx, dz)
if (edgeLength < 1e-9) return null
const dirX = dx / edgeLength
const dirZ = dz / edgeLength
const requiredOverlap = Math.min(MIN_CLASSIFYING_OVERLAP, edgeLength * 0.5)
const candidates: WallCandidate[] = walls.map((wall) => ({
wall,
segments: wallCenterlineSegments(wall),
halfThickness: getWallThickness(wall) / 2,
}))
const match = matchEdgeWallBand(a[0], a[1], dirX, dirZ, edgeLength, requiredOverlap, candidates)
if (!match) return null
if (options?.maxLateral !== undefined && Math.abs(match.lateral) > options.maxLateral) return null
const nx = dirZ * match.lateral
const nz = -dirX * match.lateral
return {
wallId: match.wall.id,
edge: [
[a[0] + nx, a[1] + nz],
[b[0] + nx, b[1] + nz],
],
}
}
/**
* A contiguous run of one polygon edge sharing a single classification.
* `start`/`end` are arc-length params from the edge start; `offset` is
* along the edge's outward normal (negative insets). `key` names the
* classification + target so same-target neighbours re-fuse.
*/
type EdgeSubSpan = {
start: number
end: number
offset: number
key: string
}
/**
* Split every polygon edge at candidate span boundaries and classify
* each sub-span independently. Returns one non-empty span list per
* edge, covering [0, edgeLength] without gaps.
*/
function computeEdgeSubSpans(
polygon: Array<[number, number]>,
selfElevation: number,
context: SlabPolygonContext,
): EdgeSubSpan[][] {
const n = polygon.length
if (n < 3) return polygon
// Winding sign: the outward normal of an edge with direction `dir` is
// `s * (dirZ, -dirX)`, so a target lateral `L` measured along
// `(dirZ, -dirX)` is `s * L` along the outward normal.
const s = polygonWindingSign(polygon)
const neighborSegments: NeighborSegment[] = []
for (const sibling of context.siblingSlabs) {
const siblingPolygon = sibling.polygon
if (siblingPolygon.length < 2) continue
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
const siblingWinding = polygonWindingSign(siblingPolygon)
for (let index = 0; index < siblingPolygon.length; index += 1) {
const from = siblingPolygon[index]!
const to = siblingPolygon[(index + 1) % siblingPolygon.length]!
const dx = to[0] - from[0]
const dz = to[1] - from[1]
const length = Math.hypot(dx, dz)
if (length < 1e-9) continue
neighborSegments.push({
segment: [from[0], from[1], to[0], to[1]],
elevation,
inwardX: (-siblingWinding * dz) / length,
inwardZ: (siblingWinding * dx) / length,
})
}
}
const wallCandidates: WallCandidate[] = context.walls.map((wall) => ({
wall,
segments: wallCenterlineSegments(wall),
halfThickness: getWallThickness(wall) / 2,
}))
// Sibling breakpoints also matter for legacy face-aligned polygons up
// to a full wall band away from the edge (the band-sibling interior
// rule in classifySpan), so clip them with the widest band reach any
// level wall allows. Over-collection is harmless — same-target
// neighbours re-fuse after classification.
let siblingBreakTolerance = SLAB_NEIGHBOR_LATERAL_TOLERANCE
for (const candidate of wallCandidates) {
siblingBreakTolerance = Math.max(
siblingBreakTolerance,
2 * (candidate.halfThickness + WALL_ADOPTION_TOLERANCE),
)
}
const subSpans: EdgeSubSpan[][] = []
for (let index = 0; index < n; index += 1) {
const a = polygon[index]!
const b = polygon[(index + 1) % n]!
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const edgeLength = Math.hypot(dx, dz)
if (edgeLength < 1e-9) {
subSpans.push([{ start: 0, end: edgeLength, offset: 0, key: 'free' }])
continue
}
const dirX = dx / edgeLength
const dirZ = dz / edgeLength
const rawBreakpoints: number[] = []
for (const candidate of wallCandidates) {
const tolerance = candidate.halfThickness + WALL_ADOPTION_TOLERANCE
for (const [px, pz, qx, qz] of candidate.segments) {
const match = clipCollinearSegment(
a[0],
a[1],
dirX,
dirZ,
edgeLength,
px,
pz,
qx,
qz,
tolerance,
)
if (match) rawBreakpoints.push(match.start, match.end)
}
}
const inwardX = -s * dirZ
const inwardZ = s * dirX
for (const {
segment: [px, pz, qx, qz],
inwardX: siblingInwardX,
inwardZ: siblingInwardZ,
} of neighborSegments) {
// Coincident/stacked slabs have their interiors on the same side of
// the edge. Only an edge whose sibling interior is across this edge
// can form a room-to-room seam.
if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue
const match = clipCollinearSegment(
a[0],
a[1],
dirX,
dirZ,
edgeLength,
px,
pz,
qx,
qz,
siblingBreakTolerance,
)
if (match) rawBreakpoints.push(match.start, match.end)
}
rawBreakpoints.sort((left, right) => left - right)
const breakpoints: number[] = []
let previous = 0
for (const t of rawBreakpoints) {
if (t - previous < MIN_SUBEDGE_LENGTH) continue
// Sorted ascending — every later breakpoint is even closer to the end.
if (edgeLength - t < MIN_SUBEDGE_LENGTH) break
breakpoints.push(t)
previous = t
}
const bounds = [0, ...breakpoints, edgeLength]
const spans: EdgeSubSpan[] = []
for (let k = 0; k + 1 < bounds.length; k += 1) {
spans.push(
classifySpan(
a,
dirX,
dirZ,
bounds[k]!,
bounds[k + 1]!,
s,
selfElevation,
wallCandidates,
neighborSegments,
),
)
}
// Re-fuse same-target neighbours, reclassifying over the fused span:
// curved-wall sampling collapses back to one span per contiguous
// band, and an edge fully backed by one target reproduces the
// whole-edge projection exactly.
let fused = true
while (fused && spans.length > 1) {
fused = false
for (let k = 0; k + 1 < spans.length; k += 1) {
if (spans[k]!.key !== spans[k + 1]!.key) continue
const merged = classifySpan(
a,
dirX,
dirZ,
spans[k]!.start,
spans[k + 1]!.end,
s,
selfElevation,
wallCandidates,
neighborSegments,
)
spans.splice(k, 2, merged)
fused = true
break
}
}
subSpans.push(spans)
}
return subSpans
}
/**
* Classify one span of the edge `a + t·dir`, `t ∈ [start, end]`, with
* the whole-edge rules: direct sibling wins, sibling across the same
* wall band forces interior, otherwise the matched wall's outer face,
* otherwise free. Interior spans backed by a wall project onto the
* wall centerline at equal heights. At unequal heights both slabs target
* the lower room's wall face, giving the higher slab the full band.
*/
function classifySpan(
a: [number, number],
dirX: number,
dirZ: number,
start: number,
end: number,
s: number,
selfElevation: number,
wallCandidates: readonly WallCandidate[],
neighborSegments: readonly NeighborSegment[],
): EdgeSubSpan {
const sx = a[0] + dirX * start
const sz = a[1] + dirZ * start
const spanLength = end - start
// Short spans still need classification — require at most half their span.
const requiredOverlap = Math.min(MIN_CLASSIFYING_OVERLAP, spanLength * 0.5)
const inwardX = -s * dirZ
const inwardZ = s * dirX
const wallMatch = matchEdgeWallBand(
sx,
sz,
dirX,
dirZ,
spanLength,
requiredOverlap,
wallCandidates,
)
// Direct neighbour: a sibling edge collinear within the tight
// tolerance regardless of any wall (slabs butted against each other).
let directOverlap = 0
let directWeightedLateral = 0
let directSiblingElevation: number | null = null
for (const {
segment: [px, pz, qx, qz],
elevation,
inwardX: siblingInwardX,
inwardZ: siblingInwardZ,
} of neighborSegments) {
if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue
const match = clipCollinearSegment(
sx,
sz,
dirX,
dirZ,
spanLength,
px,
pz,
qx,
qz,
SLAB_NEIGHBOR_LATERAL_TOLERANCE,
)
if (!match) continue
directOverlap += match.overlap
directWeightedLateral += match.lateral * match.overlap
directSiblingElevation =
directSiblingElevation === null ? elevation : Math.max(directSiblingElevation, elevation)
}
let interiorLateral: number | null = null
let siblingElevation: number | null = null
if (directOverlap >= requiredOverlap) {
// No-wall fallback: half the mean sibling separation — the midline
// between the two stored edges. Symmetric: the sibling measures the
// same separation with opposite sign from its own line, so both
// project onto the same line and the seam stays gapless.
interiorLateral = wallMatch ? wallMatch.lateral : directWeightedLateral / directOverlap / 2
siblingElevation = directSiblingElevation
} else if (wallMatch) {
// Rooms across a shared wall: legacy face-aligned polygons sit a
// full thickness apart — far beyond the direct tolerance — but
// both edges live inside the same wall band. Both sides must
// classify interior, otherwise each would project to the opposite
// outer face and overlap under the wall.
const bandTolerance = wallMatch.halfThickness + WALL_ADOPTION_TOLERANCE
const looseTolerance = Math.abs(wallMatch.lateral) + bandTolerance
let bandOverlap = 0
for (const {
segment: [px, pz, qx, qz],
elevation,
inwardX: siblingInwardX,
inwardZ: siblingInwardZ,
} of neighborSegments) {
if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue
const match = clipCollinearSegment(
sx,
sz,
dirX,
dirZ,
spanLength,
px,
pz,
qx,
qz,
looseTolerance,
)
if (!match) continue
if (Math.abs(match.lateral - wallMatch.lateral) > bandTolerance) continue
bandOverlap += match.overlap
siblingElevation =
siblingElevation === null ? elevation : Math.max(siblingElevation, elevation)
}
if (bandOverlap >= requiredOverlap) {
interiorLateral = wallMatch.lateral
} else {
siblingElevation = null
}
}
if (interiorLateral !== null) {
if (wallMatch && siblingElevation !== null) {
const elevationDelta = selfElevation - siblingElevation
if (elevationDelta > SLAB_SEAM_ELEVATION_EPSILON) {
return {
start,
end,
offset: s * wallMatch.lateral + wallMatch.halfThickness,
key: `interior|${wallMatch.wall.id}|higher`,
}
}
if (elevationDelta < -SLAB_SEAM_ELEVATION_EPSILON) {
return {
start,
end,
offset: s * wallMatch.lateral - wallMatch.halfThickness,
key: `interior|${wallMatch.wall.id}|lower`,
}
}
}
return {
start,
end,
offset: s * interiorLateral,
key: `interior|${wallMatch ? wallMatch.wall.id : '~'}`,
}
}
if (wallMatch) {
return {
start,
end,
offset: s * wallMatch.lateral + wallMatch.halfThickness,
key: `wall|${wallMatch.wall.id}`,
}
}
return { start, end, offset: 0, key: 'free' }
}
/**
* Offset each edge's sub-spans along the edge's outward normal by their
* own amounts (negative insets), then rebuild the ring: consecutive
* sub-spans of one edge with different offsets are joined by a
* perpendicular step connector at the breakpoint (both boundary points
* share the breakpoint param), and corners between different edges
* intersect the offset lines of the adjoining sub-spans.
*/
function offsetPolygonPerEdge(
polygon: Array<[number, number]>,
subSpans: EdgeSubSpan[][],
): Array<[number, number]> {
const n = polygon.length
if (n < 3) return polygon.map(([x, z]) => [x, z] as [number, number])
// Determine winding via signed area
let area2 = 0
@@ -31,35 +704,71 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
}
const s = area2 >= 0 ? 1 : -1
// Offset each edge outward by amount
const offEdges: Array<[number, number, number, number]> = []
type EdgeFrame = { ax: number; az: number; dx: number; dz: number; dirX: number; dirZ: number }
const frames: EdgeFrame[] = []
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const dx = polygon[j]![0] - polygon[i]![0]
const dz = polygon[j]![1] - polygon[i]![1]
const len = Math.sqrt(dx * dx + dz * dz)
if (len < 1e-9) {
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])
continue
}
const nx = ((s * dz) / len) * amount
const nz = ((s * -dx) / len) * amount
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])
const length = Math.hypot(dx, dz)
const dirX = length < 1e-9 ? 0 : dx / length
const dirZ = length < 1e-9 ? 0 : dz / length
frames.push({ ax: polygon[i]![0], az: polygon[i]![1], dx, dz, dirX, dirZ })
}
const pointAt = (edge: number, t: number, offset: number): [number, number] => {
const frame = frames[edge]!
return [
frame.ax + frame.dirX * t + s * frame.dirZ * offset,
frame.az + frame.dirZ * t - s * frame.dirX * offset,
]
}
// Intersect consecutive offset edges to get new vertices
const result: Array<[number, number]> = []
const push = (point: [number, number]) => {
const previous = result[result.length - 1]
if (previous && Math.hypot(previous[0] - point[0], previous[1] - point[1]) < 1e-9) return
result.push(point)
}
for (let i = 0; i < n; i++) {
const spans = subSpans[i]!
// Step connectors between differently-projected sub-spans of edge i.
// Equal offsets (different walls sharing a face line) collapse to a
// single collinear vertex via the coincident-point guard in `push`.
for (let k = 1; k < spans.length; k += 1) {
push(pointAt(i, spans[k - 1]!.end, spans[k - 1]!.offset))
push(pointAt(i, spans[k]!.start, spans[k]!.offset))
}
// Corner between edge i and edge j: intersect the offset lines of
// edge i's last sub-span and edge j's first sub-span.
const j = (i + 1) % n
const [ax, az, adx, adz] = offEdges[i]!
const [bx, bz, bdx, bdz] = offEdges[j]!
const denom = adx * bdz - adz * bdx
const last = spans[spans.length - 1]!
const first = subSpans[j]![0]!
const [ax, az] = pointAt(i, last.start, last.offset)
const [bx, bz] = pointAt(j, first.start, first.offset)
const frameI = frames[i]!
const frameJ = frames[j]!
const denom = frameI.dx * frameJ.dz - frameI.dz * frameJ.dx
if (Math.abs(denom) < 1e-9) {
// Parallel edges — use offset endpoint
result.push([ax + adx, az + adz])
// Parallel edges have no unique intersection. Emit both offset
// endpoints — collinear edges with different offsets need the step
// between them.
push(pointAt(i, last.end, last.offset))
push([bx, bz])
} else {
const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom
result.push([ax + t * adx, az + t * adz])
const t = ((bx - ax) * frameJ.dz - (bz - az) * frameJ.dx) / denom
push([ax + t * frameI.dx, az + t * frameI.dz])
}
}
if (result.length > 1) {
const firstPoint = result[0]!
const lastPoint = result[result.length - 1]!
if (Math.hypot(firstPoint[0] - lastPoint[0], firstPoint[1] - lastPoint[1]) < 1e-9) {
result.pop()
}
}
@@ -94,6 +94,79 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
test('demotes an orphaned auto ceiling to manual with its polygon untouched', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([], [ceiling])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(ceiling.id)
// Ceilings render the stored polygon in both modes, so no polygon bake.
expect(plan.update[0]?.data).toEqual({ autoFromWalls: false })
})
test('deletes an unmatched auto ceiling absorbed by a room merge', () => {
const leftCeiling = CeilingNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
autoFromWalls: true,
})
const rightCeiling = CeilingNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
autoFromWalls: true,
})
const mergedRoom = [
{ x: 0, y: 0 },
{ x: 8, y: 0 },
{ x: 8, y: 3 },
{ x: 0, y: 3 },
]
const plan = planAutoCeilingsForLevel([mergedRoom], [leftCeiling, rightCeiling])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
const survivorId = plan.update[0]?.id
expect([leftCeiling.id, rightCeiling.id]).toContain(plan.delete[0]!)
expect(plan.delete[0]).not.toBe(survivorId)
})
test('a demoted ceiling suppresses re-creating an auto ceiling when the room re-forms', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const demotion = planAutoCeilingsForLevel([], [ceiling]).update[0]
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data })
expect(demoted.autoFromWalls).toBe(false)
const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], {
walls: squareWalls(),
slabs: [slab(0.05)],
})
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
})
describe('detectSpacesForLevel', () => {
@@ -188,4 +261,76 @@ describe('planAutoSlabsForLevel', () => {
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
})
test('demotes an orphaned auto slab to manual when its room disappears', () => {
const painted = SlabNode.parse({
polygon: square,
elevation: 0.4,
autoFromWalls: true,
})
const plan = planAutoSlabsForLevel([], [painted])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(1)
const update = plan.update[0]
expect(update?.id).toBe(painted.id)
// Demotion flips only the flag — the stored polygon stays untouched
// (render offsets derive from level context at geometry build time).
expect(update?.data).toEqual({ autoFromWalls: false })
})
test('deletes an unmatched auto slab whose area was absorbed by a room merge', () => {
const leftSlab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
autoFromWalls: true,
})
const rightSlab = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
autoFromWalls: true,
})
const mergedRoom = [
{ x: 0, y: 0 },
{ x: 8, y: 0 },
{ x: 8, y: 3 },
{ x: 0, y: 3 },
]
const plan = planAutoSlabsForLevel([mergedRoom], [leftSlab, rightSlab])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
expect(plan.update).toHaveLength(1)
const survivorId = plan.update[0]?.id
expect([leftSlab.id, rightSlab.id]).toContain(plan.delete[0]!)
expect(plan.delete[0]).not.toBe(survivorId)
// The survivor stays auto — updated to the merged polygon, not demoted.
expect(plan.update[0]?.data.autoFromWalls).toBeUndefined()
})
test('a demoted slab suppresses re-creating an auto slab when the room re-forms', () => {
const auto = slab(0.05)
const demotion = planAutoSlabsForLevel([], [auto]).update[0]
const demoted = SlabNode.parse({ ...auto, ...demotion?.data })
expect(demoted.autoFromWalls).toBe(false)
const plan = planAutoSlabsForLevel([roomPolygon()], [demoted])
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
})
+121 -34
View File
@@ -67,6 +67,13 @@ const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08
// A wall endpoint within this distance of another wall's interior is treated as a
// T-junction and splits that wall (see `splitStraightWallAtVertices`).
const WALL_JUNCTION_TOLERANCE = 0.08
// An unmatched auto slab/ceiling whose polygon is still substantially covered
// by a detected room was absorbed by a room merge — the surviving auto surface
// owns that area, so keeping it would z-fight and it is deleted. Below this
// coverage the room genuinely ceased to exist (e.g. an enclosing wall was
// deleted) and the node is demoted to manual so user data survives.
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6
const COVERAGE_SAMPLE_STEPS = 12
export type AutoCeilingPlanningContext = {
walls?: WallNode[]
@@ -200,6 +207,50 @@ function bboxOverlapArea(a: ReturnType<typeof bboxOf>, b: ReturnType<typeof bbox
return ix * iy
}
// Fraction of `subject`'s area lying inside any of `covers`, estimated by
// sampling a grid of cell centers over the subject's bbox. Cheap and robust
// enough for the merge-vs-demote decision; exact polygon clipping would be a
// heavy dependency for a 60% threshold.
function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) {
if (subject.length < 3 || covers.length === 0) return 0
const bbox = bboxOf(subject)
const width = bbox.maxX - bbox.minX
const height = bbox.maxY - bbox.minY
let inside = 0
let covered = 0
for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) {
for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) {
const point = {
x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width,
y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height,
}
if (!pointInPolygon(point, subject)) continue
inside += 1
if (pointInAnyPolygon(point, covers)) covered += 1
}
}
if (inside === 0) {
return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0
}
return covered / inside
}
// Demoted auto surfaces keep their polygon untouched, so a re-closed room
// usually hits the exact-signature manual check. Mutual footprint coverage
// still guards the case where the user edited the demoted surface's polygon
// afterwards — a fresh auto surface must not stack on top of it.
function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
return manualPolygons.some(
(manual) =>
polygonCoverageRatio(roomPolygon, [manual]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD &&
polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
)
}
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
let minDistance = Number.POSITIVE_INFINITY
for (let index = 0; index < polygon.length; index += 1) {
@@ -728,8 +779,9 @@ export function planAutoSlabsForLevel(
const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
)
const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple))
const detected: DetectedRoom[] = roomPolygons
const detectedAll: DetectedRoom[] = roomPolygons
.map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple,
@@ -746,7 +798,10 @@ export function planAutoSlabsForLevel(
area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly),
}))
.filter(({ sig }) => !manualSignatures.has(sig))
const detected = detectedAll.filter(
({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons),
)
const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls)
const existingAutoMeta = existingAuto.map((slab) => {
@@ -815,18 +870,33 @@ export function planAutoSlabsForLevel(
updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple))
}
const slabsToDelete = existingAuto
.filter((slab) => !updatesById.has(slab.id))
.map((slab) => slab.id)
const detectedRoomPolygons = detectedAll.map((room) => room.poly)
const slabsToDelete: Array<SlabNodeType['id']> = []
const slabDemotions: AutoSlabSyncPlan['update'] = []
for (const slab of existingAuto) {
if (updatesById.has(slab.id)) continue
const slabsToUpdate = existingAuto
.filter((slab) => updatesById.has(slab.id))
.flatMap((slab) => {
const polygon = updatesById.get(slab.id)
if (!polygon) return []
const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons)
if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
slabsToDelete.push(slab.id)
} else {
// Render offsets derive from level context at geometry build time, so
// demotion leaves the stored polygon untouched (same as ceilings).
slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } })
}
}
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }]
})
const slabsToUpdate = [
...existingAuto
.filter((slab) => updatesById.has(slab.id))
.flatMap((slab) => {
const polygon = updatesById.get(slab.id)
if (!polygon) return []
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }]
}),
...slabDemotions,
]
const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs]
const slabsToCreate: SlabNodeType[] = []
@@ -912,8 +982,9 @@ export function planAutoCeilingsForLevel(
const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
)
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple))
const detected: DetectedCeilingRoom[] = roomPolygons
const detectedAll: DetectedCeilingRoom[] = roomPolygons
.map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple,
@@ -931,7 +1002,10 @@ export function planAutoCeilingsForLevel(
bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
}))
.filter(({ sig }) => !manualSignatures.has(sig))
const detected = detectedAll.filter(
({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons),
)
const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls)
const existingAutoMeta = existingAuto.map((ceiling) => {
@@ -1006,29 +1080,42 @@ export function planAutoCeilingsForLevel(
})
}
const ceilingsToDelete = existingAuto
.filter((ceiling) => !updatesById.has(ceiling.id))
.map((ceiling) => ceiling.id)
const detectedRoomPolygons = detectedAll.map((room) => room.poly)
const ceilingsToDelete: Array<CeilingNodeType['id']> = []
const ceilingDemotions: AutoCeilingSyncPlan['update'] = []
for (const ceiling of existingAuto) {
if (updatesById.has(ceiling.id)) continue
const ceilingsToUpdate = existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const update = updatesById.get(ceiling.id)
if (!update) return []
const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons)
if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
ceilingsToDelete.push(ceiling.id)
} else {
ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } })
}
}
const data: Partial<CeilingNodeType> = {}
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
const ceilingsToUpdate = [
...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const update = updatesById.get(ceiling.id)
if (!update) return []
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
})
const data: Partial<CeilingNodeType> = {}
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
}),
...ceilingDemotions,
]
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
const ceilingsToCreate: CeilingNodeType[] = []