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:
co-authored by
Claude Fable 5
parent
a590747748
commit
4fca38a3ef
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user