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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(editor): keep slab resize arrows visible

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-15 13:28:29 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent a590747748
commit 4fca38a3ef
38 changed files with 4079 additions and 316 deletions
@@ -1,12 +1,9 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry' import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
getWallCurveFrameAt,
isCurvedWall,
sampleWallCenterline,
} from '../../systems/wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { getFloorPlacedFootprints } from './floor-placed-elevation' import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
@@ -332,23 +329,70 @@ function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number,
return false 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 * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* polygon or on its boundary. The segment is split at every crossing with a * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* polygon edge and each sub-interval is classified by its midpoint, so no * segment. The segment is split at every crossing with a polygon edge and
* test point ever sits on a crossing. * each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/ */
function segmentInsideLength( function segmentInsideIntervals(
ax: number, ax: number,
az: number, az: number,
bx: number, bx: number,
bz: number, bz: number,
polygon: Array<[number, number]>, polygon: Array<[number, number]>,
): number { includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax const dx = bx - ax
const dz = bz - az const dz = bz - az
const length = Math.hypot(dx, dz) const length = Math.hypot(dx, dz)
if (length < 1e-9) return 0 if (length < 1e-9) return []
const ts = [0, 1] const ts = [0, 1]
const n = polygon.length const n = polygon.length
@@ -365,7 +409,7 @@ function segmentInsideLength(
} }
ts.sort((a, b) => a - b) ts.sort((a, b) => a - b)
let inside = 0 const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) { for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]! const t0 = ts[i - 1]!
const t1 = ts[i]! const t1 = ts[i]!
@@ -373,27 +417,58 @@ function segmentInsideLength(
const tm = (t0 + t1) / 2 const tm = (t0 + t1) / 2
const mx = ax + dx * tm const mx = ax + dx * tm
const mz = az + dz * tm const mz = az + dz * tm
if (pointOnPolygonBoundary(mx, mz, polygon) || pointInPolygon(mx, mz, polygon)) { const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
inside += (t1 - t0) * length ? includeBoundary
} : pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
} }
return inside 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( function polylineInsideLength(
points: Array<{ x: number; y: number }>, points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>, polygon: Array<[number, number]>,
): number { ): number {
let total = 0 return intervalsLength(polylineInsideIntervals(points, polygon))
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
} }
type WallOverlapInput = { export type WallOverlapInput = {
start: [number, number] start: [number, number]
end: [number, number] end: [number, number]
curveOffset?: number curveOffset?: number
@@ -503,11 +578,7 @@ export function wallOverlapsPolygon(
const halfThickness = Math.max(thickness / 2, 0) const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const center = polylines[0]! const centerLength = polylineLength(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)
}
if (centerLength < 1e-9) return false if (centerLength < 1e-9) return false
let overlap = 0 let overlap = 0
@@ -518,6 +589,220 @@ export function wallOverlapsPolygon(
return overlap >= threshold 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 { export class SpatialGridManager {
private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private readonly wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall 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). * 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. * Returns the highest slab elevation found, or 0 if none.
* *
* Accepts an optional `curveOffset` so curved walls evaluate overlap * Accepts an optional `curveOffset` so curved walls evaluate overlap
@@ -972,55 +1256,66 @@ export class SpatialGridManager {
curveOffset = 0, curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS, thickness = DEFAULT_WALL_THICKNESS,
): number { ): 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) const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0 if (!slabMap) {
return {
const wallLike: WallOverlapInput = { start, end, curveOffset, thickness } elevation: 0,
const isCurved = curveOffset !== 0 && isCurvedWall(wallLike) baseElevation: 0,
const holeSamplePoints: Array<{ x: number; y: number }> = isCurved baseSegments: [{ start: 0, end: 1, elevation: 0 }],
? 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) { return computeWallSlabSupport(
const elevation = slab.elevation ?? 0.05 { start, end, curveOffset, thickness },
if (elevation > maxElevation) maxElevation = elevation [...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)
} }
} }
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation byLevel.set(levelId, walls)
return walls
} }
/** /**
@@ -1,3 +1,4 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry' import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
@@ -189,6 +190,25 @@ function markNodesOverlappingSlab(
if (slab.polygon.length < 3) return if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes) 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)) { for (const node of Object.values(nodes)) {
if (node.type === 'wall') { if (node.type === 'wall') {
const wall = node as WallNode const wall = node as WallNode
@@ -201,7 +221,7 @@ function markNodesOverlappingSlab(
curveOffset: wall.curveOffset ?? 0, curveOffset: wall.curveOffset ?? 0,
thickness: wall.thickness, thickness: wall.thickness,
}, },
slab.polygon, renderedPolygon,
) )
) { ) {
markDirty(node.id) markDirty(node.id)
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'bun:test' 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. // 4×4 square slab, like an auto-slab derived from a room's wall centerlines.
const SLAB: Array<[number, number]> = [ const SLAB: Array<[number, number]> = [
@@ -73,3 +78,402 @@ describe('wallOverlapsPolygon', () => {
expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false) expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false)
}) })
}) })
describe('computeWallSlabElevation', () => {
const parseWall = (start: [number, number], end: [number, number], thickness = 0.1) =>
WallNode.parse({ start, end, thickness })
it('lifts a wall standing on an auto slab stored at the centerlines', () => {
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([4, 0], [4, 4]),
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
const bottom = walls[0]!
expect(
computeWallSlabElevation(
{ start: bottom.start, end: bottom.end, thickness: bottom.thickness },
[slab],
walls,
),
).toBeCloseTo(0.1)
})
it('lifts a wall whose body a legacy stored polygon falls short of', () => {
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
// 1cm short of even the inner faces, so the STORED polygon never
// touches the wall body and the old stored-polygon test returned 0.
// The rendered footprint band-adopts the edges out to the outer
// faces, so the wall stands on the slab.
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([4, 0], [4, 4]),
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({
polygon: [
[0.06, 0.06],
[3.94, 0.06],
[3.94, 3.94],
[0.06, 3.94],
],
elevation: 0.1,
})
const bottom = walls[0]!
expect(
computeWallSlabElevation(
{ start: bottom.start, end: bottom.end, thickness: bottom.thickness },
[slab],
walls,
),
).toBeCloseTo(0.1)
})
it('does not lift a wall clearly off the slab', () => {
const walls = [parseWall([0, 0], [4, 0])]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
expect(
computeWallSlabElevation({ start: [0, -1], end: [4, -1], thickness: 0.1 }, [slab], walls),
).toBe(0)
})
it('ignores a slab when the wall runs entirely inside a hole', () => {
const walls = [parseWall([1, 2], [3, 2])]
const slab = SlabNode.parse({
polygon: SLAB,
elevation: 0.1,
holes: [
[
[0.5, 0.5],
[3.5, 0.5],
[3.5, 3.5],
[0.5, 3.5],
],
],
})
expect(
computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [slab], walls),
).toBe(0)
})
it('keeps a wall on the lower slab when a higher slab only reaches one endpoint', () => {
// The floating-wall bug: a wall standing on the low room whose far
// endpoint pokes 0.2m onto a raised platform must NOT lift wholesale.
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation({ start: [0.5, 2], end: [4.2, 2], thickness: 0.1 }, [low, high], []),
).toBeCloseTo(0.05)
})
it('keeps a curved wall on the lower slab when a higher slab only reaches its end', () => {
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation(
{ start: [0.5, 2], end: [4.2, 2], curveOffset: 0.5, thickness: 0.1 },
[low, high],
[],
),
).toBeCloseTo(0.05)
})
it('lifts a wall standing fully on a raised platform', () => {
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [platform], []),
).toBeCloseTo(0.6)
})
it('lifts a wall half on a raised platform, half in the air, onto the platform', () => {
// No elevation reaches majority (only 39% supported), so the
// best-covered slab wins — the only alternative would bury the
// supported half inside the platform.
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabElevation({ start: [0.1, 2], end: [10.1, 2], thickness: 0.1 }, [platform], []),
).toBeCloseTo(0.6)
})
it('pools same-elevation slabs so a shared wall follows their common level', () => {
// Rooms A and B at the same elevation each cover exactly half the
// wall (interior edges seam at the x=2 midline); a raised slab covers
// just under half. Pooled, the common level covers 100% and must
// win — without pooling the raised slab's 0.4975 would beat either
// half alone.
const roomA = SlabNode.parse({
polygon: [
[0, 0],
[2, 0],
[2, 4],
[0, 4],
],
elevation: 0.1,
})
const roomB = SlabNode.parse({
polygon: [
[2, 0],
[4, 0],
[4, 4],
[2, 4],
],
elevation: 0.1,
})
const raised = SlabNode.parse({
polygon: [
[1.0, 1],
[2.99, 1],
[2.99, 3],
[1.0, 3],
],
elevation: 0.6,
})
expect(
computeWallSlabElevation(
{ start: [0, 2], end: [4, 2], thickness: 0.1 },
[roomA, roomB, raised],
[],
),
).toBeCloseTo(0.1)
})
it('prefers the higher of two majority-supporting slabs (platform stacked on a floor)', () => {
const floor = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.05,
})
const platform = SlabNode.parse({
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
elevation: 0.6,
})
// Wall 6m long: floor covers all of it, platform covers ~2/3 — both
// majorities, and the wall physically rests on the platform.
expect(
computeWallSlabElevation(
{ start: [1, 2], end: [7, 2], thickness: 0.1 },
[floor, platform],
[],
),
).toBeCloseTo(0.6)
})
it('keeps a wall pushed up on a raised platform above a coincident floor', () => {
const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 })
expect(
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
).toEqual({
elevation: 0.6,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
})
it('fills down only when a lower support is exposed beyond a partial platform', () => {
const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const platform = SlabNode.parse({
polygon: [
[0, 0],
[2.5, 0],
[2.5, 4],
[0, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabSupport(
{ start: [0.5, 2], end: [3.5, 2], thickness: 0.1 },
[floor, platform],
[],
),
).toEqual({
elevation: 0.6,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 2 / 3, elevation: 0.6 },
{ start: 2 / 3, end: 1, elevation: 0.05 },
],
})
})
it('keeps a shared wall on the higher slab that carries the full wall band', () => {
const sharedWall = parseWall([4, 0], [4, 4])
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
const high = SlabNode.parse({
polygon: [
[4, 0],
[8, 0],
[8, 4],
[4, 4],
],
elevation: 0.6,
})
expect(
computeWallSlabSupport(
{ start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness },
[low, high],
[sharedWall],
),
).toEqual({
elevation: 0.6,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
})
it('profiles an offset-room wall as high-only, shared, then low-only', () => {
const sharedWall = parseWall([4, 0], [4, 4.5])
const walls = [
parseWall([0, 0], [4, 0]),
parseWall([0, 3], [0, 0]),
parseWall([0, 3], [4, 3]),
sharedWall,
parseWall([4, 1.5], [8, 1.5]),
parseWall([8, 1.5], [8, 4.5]),
parseWall([8, 4.5], [4, 4.5]),
]
const high = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
elevation: 0.6,
})
const low = SlabNode.parse({
polygon: [
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
],
elevation: 0.05,
})
expect(
computeWallSlabSupport(
{ start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness },
[high, low],
walls,
),
).toEqual({
elevation: 0.6,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 },
{ start: 3.05 / 4.5, end: 1, elevation: 0.05 },
],
})
})
it('lifts a tiny stub wall standing fully on a slab', () => {
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.3 })
expect(
computeWallSlabElevation({ start: [2, 2], end: [2.08, 2], thickness: 0.1 }, [slab], []),
).toBeCloseTo(0.3)
})
it('does not lift a wall whose run over a higher slab is mostly inside a hole', () => {
const low = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.05,
})
const high = SlabNode.parse({
polygon: [
[0, 0],
[8, 0],
[8, 4],
[0, 4],
],
elevation: 0.6,
holes: [
[
[0.5, 0],
[8, 0],
[8, 4],
[0.5, 4],
],
],
})
// Net high support is only x ∈ [0, 0.5]; the low slab carries the wall.
expect(
computeWallSlabElevation({ start: [0, 2], end: [8, 2], thickness: 0.1 }, [low, high], []),
).toBeCloseTo(0.05)
})
it('keeps support for a wall running along a hole rim', () => {
// Hole boundaries count as solid: the wall ringing a stairwell sits
// on the rim, its outer face on solid slab.
const slab = SlabNode.parse({
polygon: [
[0, 0],
[6, 0],
[6, 6],
[0, 6],
],
elevation: 0.4,
holes: [
[
[2, 2],
[4, 2],
[4, 4],
[2, 4],
],
],
})
expect(
computeWallSlabElevation({ start: [2, 2], end: [4, 2], thickness: 0.1 }, [slab], []),
).toBeCloseTo(0.4)
})
})
+12 -2
View File
@@ -48,7 +48,11 @@ export {
getFloorPlacedFootprints, getFloorPlacedFootprints,
getFloorStackedPosition, getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation' } 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 { export {
findLevelAncestorId, findLevelAncestorId,
initSpatialGridSync, initSpatialGridSync,
@@ -75,7 +79,13 @@ export {
segmentsIntersect, segmentsIntersect,
} from './lib/polygon-relations' } from './lib/polygon-relations'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' 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 { export {
deriveSlotId, deriveSlotId,
isSlotMaterialName, isSlotMaterialName,
-25
View File
@@ -1,28 +1,3 @@
export function insetPolygonFromCentroid(
polygon: Array<[number, number]>,
inset: number,
): Array<[number, number]> {
if (inset <= 0) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1)
return polygon.map(([x, z]) => {
const dx = x - centroid.x
const dz = z - centroid.z
const length = Math.hypot(dx, dz)
if (length <= inset + 1e-6) {
return [x, z] as [number, number]
}
const scale = (length - inset) / length
return [centroid.x + dx * scale, centroid.z + dz * scale] as [number, number]
})
}
function pointLineDistance( function pointLineDistance(
point: [number, number], point: [number, number],
start: [number, number], start: [number, number],
+792
View File
@@ -0,0 +1,792 @@
import { describe, expect, test } from 'bun:test'
import { SlabNode, WallNode } from '../schema'
import { pointInPolygon } from './polygon-relations'
import { getRenderableSlabPolygon, snapSlabEdgeToWallBand } from './slab-polygon'
function wallOf(start: [number, number], end: [number, number], thickness = 0.1) {
return WallNode.parse({ start, end, thickness })
}
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) {
return SlabNode.parse(
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation },
)
}
function xs(polygon: Array<[number, number]>) {
return polygon.map((point) => point[0])
}
function zs(polygon: Array<[number, number]>) {
return polygon.map((point) => point[1])
}
/** Assert the ring contains every expected vertex (order-independent). */
function expectRingToInclude(polygon: Array<[number, number]>, points: Array<[number, number]>) {
const missing = points.filter(
([x, z]) => !polygon.some((p) => Math.abs(p[0] - x) < 1e-6 && Math.abs(p[1] - z) < 1e-6),
)
expect(missing).toEqual([])
}
const roomA: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const roomB: Array<[number, number]> = [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
]
// Two rooms side by side sharing the centerline wall at x=4.
const twoRoomWalls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [4, 3]),
wallOf([4, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3]),
]
describe('getRenderableSlabPolygon', () => {
test('adjacent room slabs share the exact centerline seam without gap or overlap', () => {
const slabA = slabOf(roomA)
const slabB = slabOf(roomB)
const polyA = getRenderableSlabPolygon(slabA, {
walls: twoRoomWalls,
siblingSlabs: [slabB],
})
const polyB = getRenderableSlabPolygon(slabB, {
walls: twoRoomWalls,
siblingSlabs: [slabA],
})
// A: exterior edges flush with the 0.1-thick facade (+0.05), shared
// edge exactly on the wall centerline x=4.
expect(Math.min(...xs(polyA))).toBeCloseTo(-0.05)
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...zs(polyA))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polyA))).toBeCloseTo(3.05)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
expect(Math.max(...xs(polyB))).toBeCloseTo(8.05)
// No overlap across the shared wall (FP noise only)...
expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9)
// ...and the seam is EXACTLY shared: both rings project the shared
// edge onto the same centerline x=4 with matching endpoints.
const seamZs = (poly: Array<[number, number]>) =>
poly
.filter((point) => point[0] === 4)
.map((point) => point[1])
.sort((left, right) => left - right)
const seamA = seamZs(polyA)
const seamB = seamZs(polyB)
expect(seamA).toHaveLength(2)
expect(seamB).toHaveLength(2)
expect(seamA[0]!).toBeCloseTo(seamB[0]!, 12)
expect(seamA[1]!).toBeCloseTo(seamB[1]!, 12)
// Grid-sample the strip under the shared wall band: every point is
// inside at least one slab — the old relief slit is gone, so deleting
// the wall would expose a continuous floor.
for (let x = 3.95; x <= 4.0501; x += 0.01) {
for (let z = 0; z <= 3.001; z += 0.15) {
expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true)
}
}
})
test('exterior edge expands by half of THAT wall thickness', () => {
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
wallOf([4, 3], [0, 3]),
// Non-default thickness on the left facade only.
wallOf([0, 3], [0, 0], 0.3),
]
const poly = getRenderableSlabPolygon(slabOf(roomA), { walls, siblingSlabs: [] })
expect(Math.min(...xs(poly))).toBeCloseTo(-0.15)
expect(Math.max(...xs(poly))).toBeCloseTo(4.05)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
expect(Math.max(...zs(poly))).toBeCloseTo(3.05)
})
test('freehand edges away from any wall render exactly as drawn', () => {
const drawn: Array<[number, number]> = [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
]
const poly = getRenderableSlabPolygon(slabOf(drawn, false), {
walls: twoRoomWalls,
siblingSlabs: [],
})
expect(poly).toEqual(drawn)
})
test('manual slabs follow the same per-edge rule as auto slabs', () => {
const poly = getRenderableSlabPolygon(slabOf(roomA, false), {
walls: twoRoomWalls,
siblingSlabs: [slabOf(roomB)],
})
expect(Math.max(...xs(poly))).toBeCloseTo(4)
expect(Math.min(...xs(poly))).toBeCloseTo(-0.05)
})
test('T-junction: a neighbour edge that is a sub-segment still reads as interior', () => {
// Big 6×5 room; a 2×2 bay hangs below, sealed against the interior of
// the big room's bottom wall between x=1 and x=3.
const big = slabOf([
[0, 0],
[6, 0],
[6, 5],
[0, 5],
])
const bay = slabOf([
[1, -2],
[3, -2],
[3, 0],
[1, 0],
])
const walls = [
wallOf([0, 0], [6, 0]),
wallOf([6, 0], [6, 5]),
wallOf([6, 5], [0, 5]),
wallOf([0, 5], [0, 0]),
wallOf([1, 0], [1, -2]),
wallOf([1, -2], [3, -2]),
wallOf([3, -2], [3, 0]),
]
// The bay's top edge lies on a sub-segment of the big slab's bottom
// edge — interior, seamed on the shared wall centerline z=0, while
// its free-standing sides stay on-wall.
const bayPoly = getRenderableSlabPolygon(bay, { walls, siblingSlabs: [big] })
expect(Math.max(...zs(bayPoly))).toBeCloseTo(0)
expect(Math.min(...zs(bayPoly))).toBeCloseTo(-2.05)
expect(Math.min(...xs(bayPoly))).toBeCloseTo(0.95)
expect(Math.max(...xs(bayPoly))).toBeCloseTo(3.05)
// The big slab's bottom edge is backed differently along its span:
// centerline seam across the bay (z=0), facade-flush elsewhere
// (z=-0.05), joined by step connectors at the bay junction walls
// x=1 and x=3 (the old whole-edge rule pulled the entire edge back).
const bigPoly = getRenderableSlabPolygon(big, { walls, siblingSlabs: [bay] })
expect(Math.min(...zs(bigPoly))).toBeCloseTo(-0.05)
expect(Math.max(...zs(bigPoly))).toBeCloseTo(5.05)
expectRingToInclude(bigPoly, [
[1, -0.05],
[1, 0],
[3, 0],
[3, -0.05],
])
})
test('an edge on a wall longer than itself still reaches the facade', () => {
// Slab edge [1,0]→[3,0] sits mid-span on a 6m wall.
const poly = getRenderableSlabPolygon(
slabOf([
[1, 0],
[3, 0],
[3, 2],
[1, 2],
]),
{ walls: [wallOf([0, 0], [6, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
// The other three edges are free — rendered as drawn.
expect(Math.max(...zs(poly))).toBeCloseTo(2)
expect(Math.min(...xs(poly))).toBeCloseTo(1)
expect(Math.max(...xs(poly))).toBeCloseTo(3)
})
test('legacy edge stored at the inner wall face projects to the outer face', () => {
// Wall centerline z=0, thickness 0.1 — the legacy slab edge sits at the
// inner face z=0.05. Absolute projection must land the rendered edge on
// the OUTER face (-0.05), not at inner face + t/2 (= centerline).
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.05],
[4, 0.05],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('legacy edge stored at the outer wall face stays at the face (no overshoot)', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, -0.05],
[4, -0.05],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a thick wall adopts a face-aligned edge beyond the old fixed tolerance', () => {
// t=0.3: inner face is 0.15 off the centerline — past the old fixed 0.1
// tolerance, so this edge used to classify FREE and never expand.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.15],
[4, 0.15],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0], 0.3)], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.15)
})
test('edges outside the adoption band stay free', () => {
// Band for t=0.1 is half + 0.06 = 0.11 — an edge 0.12 away is kept as drawn.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.12],
[4, 0.12],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(0.12)
})
test('two parallel close walls: the nearest centerline wins', () => {
// Thin wall at z=0 (band 0.11) and thick wall at z=0.3 (t=0.3, band
// 0.21). An edge at z=0.1 is inside BOTH bands (laterals 0.1 and 0.2);
// it must adopt the nearer thin wall and land on ITS outer face.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0.1],
[4, 0.1],
[4, 3],
[0, 3],
],
false,
),
{
walls: [wallOf([0, 0], [4, 0]), wallOf([0, 0.3], [4, 0.3], 0.3)],
siblingSlabs: [],
},
)
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('span overlap below the minimum leaves the edge free', () => {
// The wall only overlaps the last 2cm of the edge span — under the 5cm
// classification minimum.
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([3.98, 0], [4.5, 0])], siblingSlabs: [] },
)
expect(Math.min(...zs(poly))).toBeCloseTo(0)
})
test('legacy face-aligned rooms across a thick wall still tile without overlap', () => {
// Both rooms stored at the INNER faces of the shared t=0.3 wall at x=4
// (edges 0.3 apart — far beyond the direct sibling tolerance). Each edge
// is inside the wall band with the sibling across the same band, so both
// classify interior and land exactly on the CENTERLINE instead of
// projecting to opposite outer faces (which would overlap by a full
// thickness).
const walls = [
wallOf([0, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3], 0.3),
]
const legacyA = slabOf(
[
[0, 0],
[3.85, 0],
[3.85, 3],
[0, 3],
],
false,
)
const legacyB = slabOf(
[
[4.15, 0],
[8, 0],
[8, 3],
[4.15, 3],
],
false,
)
const polyA = getRenderableSlabPolygon(legacyA, { walls, siblingSlabs: [legacyB] })
const polyB = getRenderableSlabPolygon(legacyB, { walls, siblingSlabs: [legacyA] })
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9)
})
test('the higher room carries the wall band to the lower room face', () => {
// Real user repro shape: two rooms in an L/offset arrangement share the
// z=0 wall over x ∈ [-1, 0.5] only; the north room's floor is raised
// (0.34) above the south room's (0.05). Slabs extrude 0 → elevation and
// the higher slab closes the full wall band while the lower slab meets
// it at its own wall face. The reach comes from the matched wall's real
// thickness, not a default-width expansion.
const walls = [
wallOf([-1, 3], [-1, 0]),
wallOf([-1, 0], [0.5, 0]),
wallOf([0.5, 0], [2, 0]),
wallOf([2, 0], [2, 3]),
wallOf([2, 3], [-1, 3]),
wallOf([0.5, 0], [0.5, -4]),
wallOf([0.5, -4], [-1, -4]),
wallOf([-1, -4], [-1, 0]),
]
const high = slabOf(
[
[-1, 3],
[-1, 0],
[2, 0],
[2, 3],
],
false,
0.34,
)
const low = slabOf(
[
[0.5, 0],
[-1, 0],
[-1, -4],
[0.5, -4],
],
true,
0.05,
)
const polyHigh = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] })
const polyLow = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] })
// The shared and facade spans fuse because both land on z=-0.05.
expectRingToInclude(polyHigh, [
[0.5, -0.05],
[2.05, -0.05],
])
expect(Math.min(...zs(polyHigh))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polyLow))).toBeCloseTo(-0.05)
// The high slab owns the entire band; the lower room begins at its face.
for (let x = -0.95; x <= 0.4501; x += 0.05) {
for (let z = -0.045; z <= 0.0451; z += 0.015) {
expect(pointInPolygon([x, z], polyHigh, { includeBoundary: false })).toBe(true)
}
}
})
test('legacy face-aligned unequal rooms self-heal to the lower room face', () => {
// Same stored-at-inner-faces legacy data as above (edges a full 0.3
// apart across the t=0.3 wall at x=4), but with the west room raised.
// Both edges classify interior through the band-sibling rule and adopt
// the east/lower room face despite being stored at opposite faces.
const walls = [
wallOf([0, 0], [8, 0]),
wallOf([8, 0], [8, 3]),
wallOf([8, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
wallOf([4, 0], [4, 3], 0.3),
]
const legacyHigh = slabOf(
[
[0, 0],
[3.85, 0],
[3.85, 3],
[0, 3],
],
false,
0.4,
)
const legacyLow = slabOf(
[
[4.15, 0],
[8, 0],
[8, 3],
[4.15, 3],
],
false,
0.05,
)
const polyHigh = getRenderableSlabPolygon(legacyHigh, { walls, siblingSlabs: [legacyLow] })
const polyLow = getRenderableSlabPolygon(legacyLow, { walls, siblingSlabs: [legacyHigh] })
expect(Math.max(...xs(polyHigh))).toBeCloseTo(4.15)
expect(Math.min(...xs(polyLow))).toBeCloseTo(4.15)
expect(Math.max(...xs(polyHigh))).toBeLessThanOrEqual(Math.min(...xs(polyLow)) + 1e-9)
})
test('stacked slabs are not mistaken for rooms across a wall', () => {
const floor = slabOf(roomA, false, 0.05)
const platform = slabOf(roomA, false, 0.4)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
wallOf([4, 3], [0, 3]),
wallOf([0, 3], [0, 0]),
]
const floorPolygon = getRenderableSlabPolygon(floor, {
walls,
siblingSlabs: [platform],
})
const platformPolygon = getRenderableSlabPolygon(platform, {
walls,
siblingSlabs: [floor],
})
for (const polygon of [floorPolygon, platformPolygon]) {
expect(Math.min(...xs(polygon))).toBeCloseTo(-0.05)
expect(Math.max(...xs(polygon))).toBeCloseTo(4.05)
expect(Math.min(...zs(polygon))).toBeCloseTo(-0.05)
expect(Math.max(...zs(polygon))).toBeCloseTo(3.05)
}
})
test('sibling winding does not change a shared seam decision', () => {
const slabA = slabOf(roomA)
const slabB = slabOf([...roomB].reverse())
const polyA = getRenderableSlabPolygon(slabA, {
walls: twoRoomWalls,
siblingSlabs: [slabB],
})
const polyB = getRenderableSlabPolygon(slabB, {
walls: twoRoomWalls,
siblingSlabs: [slabA],
})
expect(Math.max(...xs(polyA))).toBeCloseTo(4)
expect(Math.min(...xs(polyB))).toBeCloseTo(4)
})
test('wall-less butted slabs at different elevations keep the midline seam', () => {
// No wall backs the seam, so there is no band to hide a pocket under —
// the exposed step face at the joint is correct. Elevation must not
// move a wall-less seam off the midline.
const stepHigh = slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
0.3,
)
const stepLow = slabOf(
[
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
false,
0.05,
)
const polyHigh = getRenderableSlabPolygon(stepHigh, { walls: [], siblingSlabs: [stepLow] })
const polyLow = getRenderableSlabPolygon(stepLow, { walls: [], siblingSlabs: [stepHigh] })
expect(Math.max(...xs(polyHigh))).toBeCloseTo(4)
expect(Math.min(...xs(polyLow))).toBeCloseTo(4)
})
test('offset rooms sharing a partial wall span: interior beside the sibling, facade elsewhere', () => {
// Rooms offset diagonally share the x=4 wall only for z ∈ [1.5, 3].
// Each room's long edge is interior for the shared span and exterior
// (its own facade) for the rest — the case sub-edge classification
// exists for.
const offsetA = slabOf([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
const offsetB = slabOf([
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
])
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([0, 3], [0, 0]),
wallOf([0, 3], [4, 3]),
wallOf([4, 0], [4, 4.5]),
wallOf([4, 1.5], [8, 1.5]),
wallOf([8, 1.5], [8, 4.5]),
wallOf([8, 4.5], [4, 4.5]),
]
const polyA = getRenderableSlabPolygon(offsetA, { walls, siblingSlabs: [offsetB] })
const polyB = getRenderableSlabPolygon(offsetB, { walls, siblingSlabs: [offsetA] })
// A's right edge: facade-flush below the junction, exactly on the
// centerline beside B, joined by the step connector at the junction z=1.5.
expectRingToInclude(polyA, [
[4.05, -0.05],
[4.05, 1.5],
[4, 1.5],
[4, 3.05],
])
// B's left edge mirrors it: centerline seam beside A, facade-flush
// above, step at the junction z=3.
expectRingToInclude(polyB, [
[4, 1.45],
[4, 3],
[3.95, 3],
[3.95, 4.55],
])
// Along the shared span both slabs reach exactly the wall centerline:
// the strip under the shared wall is fully covered with no interior
// overlap — deleting the wall would expose a continuous floor...
for (let z = 1.6; z <= 2.95; z += 0.1) {
expect(pointInPolygon([3.99, z], polyA, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.01, z], polyB, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.01, z], polyA, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([3.99, z], polyB, { includeBoundary: false })).toBe(false)
for (let x = 3.96; x <= 4.0401; x += 0.01) {
expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true)
}
}
// ...while each unshared portion reaches its own facade face.
expect(pointInPolygon([4.04, 0.75], polyA, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([3.96, 3.75], polyB, { includeBoundary: false })).toBe(true)
// Any residual overlap is confined to the shared wall's footprint
// right at the junction corners — hidden under the wall bodies, the
// same corner pockets the outer-face projection has always produced
// where two rooms' facades meet a wall junction.
for (let x = 3.5; x <= 4.5; x += 0.02) {
for (let z = -0.2; z <= 4.7; z += 0.02) {
const overlapping =
pointInPolygon([x, z], polyA, { includeBoundary: false }) &&
pointInPolygon([x, z], polyB, { includeBoundary: false })
if (!overlapping) continue
expect(Math.abs(x - 4)).toBeLessThanOrEqual(0.05 + 1e-9)
expect(Math.min(Math.abs(z - 1.5), Math.abs(z - 3))).toBeLessThanOrEqual(0.05 + 1e-9)
}
}
})
test('offset unequal rooms give the higher slab the full shared wall band', () => {
const high = slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
true,
0.4,
)
const low = slabOf(
[
[4, 1.5],
[8, 1.5],
[8, 4.5],
[4, 4.5],
],
true,
0.05,
)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([0, 3], [0, 0]),
wallOf([0, 3], [4, 3]),
wallOf([4, 0], [4, 4.5]),
wallOf([4, 1.5], [8, 1.5]),
wallOf([8, 1.5], [8, 4.5]),
wallOf([8, 4.5], [4, 4.5]),
]
const highPolygon = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] })
const lowPolygon = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] })
expect(Math.max(...xs(highPolygon))).toBeCloseTo(4.05)
expectRingToInclude(lowPolygon, [
[4.05, 1.45],
[4.05, 3],
[3.95, 3],
])
for (let z = 1.6; z <= 2.95; z += 0.1) {
expect(pointInPolygon([3.975, z], highPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([3.975, z], lowPolygon, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([4.025, z], highPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.025, z], lowPolygon, { includeBoundary: false })).toBe(false)
expect(pointInPolygon([4.075, z], lowPolygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon([4.075, z], highPolygon, { includeBoundary: false })).toBe(false)
}
})
test('a wall backing only part of an edge: flush over the wall, as drawn beyond it', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [] },
)
expectRingToInclude(poly, [
[0, -0.05],
[2, -0.05],
[2, 0],
[4, 0],
])
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
expect(Math.max(...xs(poly))).toBeCloseTo(4)
})
test('two collinear walls of different thickness along one edge: each face wins its own span', () => {
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0]), wallOf([2, 0], [4, 0], 0.3)], siblingSlabs: [] },
)
// Thin-wall face for x < 2, thick-wall face beyond, stepped at x=2
// (the old whole-edge rule let one wall win the entire edge).
expectRingToInclude(poly, [
[0, -0.05],
[2, -0.05],
[2, -0.15],
[4, -0.15],
])
})
test('breakpoints within the minimum sub-edge length merge into one step', () => {
// The wall ends at x=2; the sibling starts at x=2.02 — the two
// breakpoints are 2cm apart, under the 5cm minimum, so they collapse
// into a single step at x=2 instead of leaving a sliver sub-edge.
const sibling = slabOf([
[2.02, -2],
[4, -2],
[4, 0],
[2.02, 0],
])
const poly = getRenderableSlabPolygon(
slabOf(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
false,
),
{ walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [sibling] },
)
expectRingToInclude(poly, [
[2, -0.05],
[2, 0],
])
expect(poly).toHaveLength(6)
})
})
describe('snapSlabEdgeToWallBand', () => {
test('an edge inside the band snaps onto the wall centerline', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])])
expect(snap).not.toBeNull()
expect(snap!.edge[0][1]).toBeCloseTo(0)
expect(snap!.edge[1][1]).toBeCloseTo(0)
// Tangential positions are preserved — pure perpendicular translation.
expect(snap!.edge[0][0]).toBeCloseTo(0.5)
expect(snap!.edge[1][0]).toBeCloseTo(3.5)
})
test('an edge outside the band does not snap', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.2], [3.5, 0.2], [wallOf([0, 0], [4, 0])])
expect(snap).toBeNull()
})
test('maxLateral tightens the stick distance', () => {
const walls = [wallOf([0, 0], [4, 0])]
expect(snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], walls, { maxLateral: 0.05 })).toBeNull()
expect(
snapSlabEdgeToWallBand([0.5, 0.04], [3.5, 0.04], walls, { maxLateral: 0.05 }),
).not.toBeNull()
})
test('the nearest of two candidate walls wins', () => {
const near = wallOf([0, 0], [4, 0])
const far = wallOf([0, 0.3], [4, 0.3], 0.3)
const snap = snapSlabEdgeToWallBand([0.5, 0.1], [3.5, 0.1], [far, near])
expect(snap).not.toBeNull()
expect(snap!.wallId).toBe(near.id)
expect(snap!.edge[0][1]).toBeCloseTo(0)
})
})
+743 -34
View File
@@ -1,27 +1,700 @@
import type { SlabNode } from '../schema' import type { GeometryContext } from '../registry/types'
import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry' 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 * Render-time slab polygon rules.
const AUTO_SLAB_INSET = 0.02 *
const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 * 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]> { /** Lateral distance within which a sibling slab edge counts as directly "across" an edge. */
return slabNode.autoFromWalls const SLAB_NEIGHBOR_LATERAL_TOLERANCE = 0.05
? simplifyClosedPolygon( /**
insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), * Extra lateral tolerance beyond a wall's half-thickness for band
AUTO_SLAB_SIMPLIFY_TOLERANCE, * adoption. 0.06 keeps every previously-adopted edge adopted (the old
) * fixed 0.1 tolerance equals half-thickness + 0.05 for the thinnest
: outsetPolygon(slabNode.polygon, SLAB_OUTSET) * 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. * Derive a {@link SlabPolygonContext} from a registry `GeometryContext`:
* Offsets each edge outward then intersects consecutive offset edges. * 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 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 // Determine winding via signed area
let area2 = 0 let area2 = 0
@@ -31,35 +704,71 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
} }
const s = area2 >= 0 ? 1 : -1 const s = area2 >= 0 ? 1 : -1
// Offset each edge outward by amount type EdgeFrame = { ax: number; az: number; dx: number; dz: number; dirX: number; dirZ: number }
const offEdges: Array<[number, number, number, number]> = [] const frames: EdgeFrame[] = []
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const dx = polygon[j]![0] - polygon[i]![0] const dx = polygon[j]![0] - polygon[i]![0]
const dz = polygon[j]![1] - polygon[i]![1] const dz = polygon[j]![1] - polygon[i]![1]
const len = Math.sqrt(dx * dx + dz * dz) const length = Math.hypot(dx, dz)
if (len < 1e-9) { const dirX = length < 1e-9 ? 0 : dx / length
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) const dirZ = length < 1e-9 ? 0 : dz / length
continue frames.push({ ax: polygon[i]![0], az: polygon[i]![1], dx, dz, dirX, dirZ })
} }
const nx = ((s * dz) / len) * amount
const nz = ((s * -dx) / len) * amount const pointAt = (edge: number, t: number, offset: number): [number, number] => {
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) 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 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++) { 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 j = (i + 1) % n
const [ax, az, adx, adz] = offEdges[i]! const last = spans[spans.length - 1]!
const [bx, bz, bdx, bdz] = offEdges[j]! const first = subSpans[j]![0]!
const denom = adx * bdz - adz * bdx 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) { if (Math.abs(denom) < 1e-9) {
// Parallel edges — use offset endpoint // Parallel edges have no unique intersection. Emit both offset
result.push([ax + adx, az + adz]) // endpoints — collinear edges with different offsets need the step
// between them.
push(pointAt(i, last.end, last.offset))
push([bx, bz])
} else { } else {
const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom const t = ((bx - ax) * frameJ.dz - (bz - az) * frameJ.dx) / denom
result.push([ax + t * adx, az + t * adz]) 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.create).toHaveLength(0)
expect(plan.update).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', () => { describe('detectSpacesForLevel', () => {
@@ -188,4 +261,76 @@ describe('planAutoSlabsForLevel', () => {
expect(plan.create).toHaveLength(0) expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1) 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)
})
}) })
+101 -14
View File
@@ -67,6 +67,13 @@ const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08
// A wall endpoint within this distance of another wall's interior is treated as a // A wall endpoint within this distance of another wall's interior is treated as a
// T-junction and splits that wall (see `splitStraightWallAtVertices`). // T-junction and splits that wall (see `splitStraightWallAtVertices`).
const WALL_JUNCTION_TOLERANCE = 0.08 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 = { export type AutoCeilingPlanningContext = {
walls?: WallNode[] walls?: WallNode[]
@@ -200,6 +207,50 @@ function bboxOverlapArea(a: ReturnType<typeof bboxOf>, b: ReturnType<typeof bbox
return ix * iy 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[]) { function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
let minDistance = Number.POSITIVE_INFINITY let minDistance = Number.POSITIVE_INFINITY
for (let index = 0; index < polygon.length; index += 1) { for (let index = 0; index < polygon.length; index += 1) {
@@ -728,8 +779,9 @@ export function planAutoSlabsForLevel(
const manualSignatures = new Set( const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), 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) => ({ .map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple, pointFromTuple,
@@ -746,7 +798,10 @@ export function planAutoSlabsForLevel(
area: Math.abs(polygonArea(room.poly)), area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(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 existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls)
const existingAutoMeta = existingAuto.map((slab) => { const existingAutoMeta = existingAuto.map((slab) => {
@@ -815,18 +870,33 @@ export function planAutoSlabsForLevel(
updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple)) updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple))
} }
const slabsToDelete = existingAuto const detectedRoomPolygons = detectedAll.map((room) => room.poly)
.filter((slab) => !updatesById.has(slab.id)) const slabsToDelete: Array<SlabNodeType['id']> = []
.map((slab) => slab.id) const slabDemotions: AutoSlabSyncPlan['update'] = []
for (const slab of existingAuto) {
if (updatesById.has(slab.id)) continue
const slabsToUpdate = existingAuto 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 } })
}
}
const slabsToUpdate = [
...existingAuto
.filter((slab) => updatesById.has(slab.id)) .filter((slab) => updatesById.has(slab.id))
.flatMap((slab) => { .flatMap((slab) => {
const polygon = updatesById.get(slab.id) const polygon = updatesById.get(slab.id)
if (!polygon) return [] if (!polygon) return []
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }] return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }]
}) }),
...slabDemotions,
]
const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs]
const slabsToCreate: SlabNodeType[] = [] const slabsToCreate: SlabNodeType[] = []
@@ -912,8 +982,9 @@ export function planAutoCeilingsForLevel(
const manualSignatures = new Set( const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), 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) => ({ .map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple, pointFromTuple,
@@ -931,7 +1002,10 @@ export function planAutoCeilingsForLevel(
bbox: bboxOf(room.poly), bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context), 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 existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls)
const existingAutoMeta = existingAuto.map((ceiling) => { const existingAutoMeta = existingAuto.map((ceiling) => {
@@ -1006,11 +1080,22 @@ export function planAutoCeilingsForLevel(
}) })
} }
const ceilingsToDelete = existingAuto const detectedRoomPolygons = detectedAll.map((room) => room.poly)
.filter((ceiling) => !updatesById.has(ceiling.id)) const ceilingsToDelete: Array<CeilingNodeType['id']> = []
.map((ceiling) => ceiling.id) const ceilingDemotions: AutoCeilingSyncPlan['update'] = []
for (const ceiling of existingAuto) {
if (updatesById.has(ceiling.id)) continue
const ceilingsToUpdate = existingAuto 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 ceilingsToUpdate = [
...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id)) .filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => { .flatMap((ceiling) => {
const update = updatesById.get(ceiling.id) const update = updatesById.get(ceiling.id)
@@ -1028,7 +1113,9 @@ export function planAutoCeilingsForLevel(
} }
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
}) }),
...ceilingDemotions,
]
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
const ceilingsToCreate: CeilingNodeType[] = [] const ceilingsToCreate: CeilingNodeType[] = []
@@ -126,6 +126,12 @@ type ActiveDrag = {
session: FloorplanAffordanceSession session: FloorplanAffordanceSession
snapshots: NodeSnapshot[] snapshots: NodeSnapshot[]
historyPaused: boolean historyPaused: boolean
/**
* Last plan point handed to `session.apply` (the grab point until the first
* move). Lets the modifier-key listeners re-run the session immediately on
* an Alt/Shift flip instead of waiting for the next pointer move.
*/
lastPlanPoint: FloorplanPoint
/** /**
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
* the live angle wedge + degree readout — the 2D twin of the 3D rotate * the live angle wedge + degree readout — the 2D twin of the 3D rotate
@@ -922,6 +928,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
session, session,
snapshots, snapshots,
historyPaused: true, historyPaused: true,
lastPlanPoint: initialPlanPoint,
rotation, rotation,
reshapeScopeNodeId: reshapeScope ? nodeId : undefined, reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
} }
@@ -955,6 +962,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const planPoint = clientToPlan(event.clientX, event.clientY) const planPoint = clientToPlan(event.clientX, event.clientY)
if (!planPoint) return if (!planPoint) return
drag.lastPlanPoint = planPoint
drag.session.apply({ drag.session.apply({
planPoint, planPoint,
modifiers: { modifiers: {
@@ -1102,13 +1110,43 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setRotationOverlay(null) setRotationOverlay(null)
} }
// Re-run the active session the moment a modifier key flips so behaviors
// like the wall endpoint's Alt-detach / re-attach take effect immediately
// instead of waiting for the next pointer move. `event.altKey` & co
// already reflect the post-transition state on both keydown and keyup.
const onModifierKeyChange = (event: KeyboardEvent) => {
const drag = dragRef.current
if (!drag || event.repeat) return
if (
event.key !== 'Alt' &&
event.key !== 'Shift' &&
event.key !== 'Control' &&
event.key !== 'Meta'
) {
return
}
drag.session.apply({
planPoint: drag.lastPlanPoint,
modifiers: {
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
})
}
window.addEventListener('pointermove', onPointerMove) window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp) window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel) window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('keydown', onModifierKeyChange)
window.addEventListener('keyup', onModifierKeyChange)
return () => { return () => {
window.removeEventListener('pointermove', onPointerMove) window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('keydown', onModifierKeyChange)
window.removeEventListener('keyup', onModifierKeyChange)
// Component unmounted mid-drag — restore the baseline and unpause // Component unmounted mid-drag — restore the baseline and unpause
// history so we don't leak a paused store across mounts. Also // history so we don't leak a paused store across mounts. Also
// drop any live overrides the session published so the next // drop any live overrides the session published so the next
@@ -51,6 +51,7 @@ import {
WallNode as WallNodeSchema, WallNode as WallNodeSchema,
type WindowNode, type WindowNode,
WindowNode as WindowNodeSchema, WindowNode as WindowNodeSchema,
wallClosesRoom,
ZoneNode as ZoneNodeSchema, ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -173,6 +174,7 @@ import {
DEFAULT_STAIR_WIDTH, DEFAULT_STAIR_WIDTH,
} from '../tools/stair/stair-defaults' } from '../tools/stair/stair-defaults'
import { import {
chainEndJoinsExistingWall,
createWallOnCurrentLevel, createWallOnCurrentLevel,
isSegmentLongEnough, isSegmentLongEnough,
snapWallDraftPoint, snapWallDraftPoint,
@@ -5340,6 +5342,9 @@ export function FloorplanPanel({
// Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged. // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null) const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null) const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null)
// Walls committed by the current 2D-only chain — exclusion set for the
// T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
const wallChainWallIdsRef = useRef<string[]>([])
const setDraftEnd = useCallback( const setDraftEnd = useCallback(
(next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
const store = useFloorplanDraftPreview.getState() const store = useFloorplanDraftPreview.getState()
@@ -5922,7 +5927,12 @@ export function FloorplanPanel({
const holes = (slab.holes ?? []) const holes = (slab.holes ?? [])
.map((hole) => toFloorplanPolygon(hole)) .map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3) .filter((hole) => hole.length >= 3)
const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) const visualPolygon = toFloorplanPolygon(
getRenderableSlabPolygon(slab, {
walls: referenceWalls,
siblingSlabs: referenceSlabs.filter((other) => other.id !== slab.id),
}),
)
const visualHoles = holes const visualHoles = holes
return [ return [
@@ -7829,6 +7839,7 @@ export function FloorplanPanel({
const clearWallPlacementDraft = useCallback(() => { const clearWallPlacementDraft = useCallback(() => {
setDraftStart(null) setDraftStart(null)
setWallChainFirstVertex(null) setWallChainFirstVertex(null)
wallChainWallIdsRef.current = []
setDraftEnd(null) setDraftEnd(null)
useSegmentDraftChain.getState().clear('wall') useSegmentDraftChain.getState().clear('wall')
}, [setDraftEnd]) }, [setDraftEnd])
@@ -9673,8 +9684,11 @@ export function FloorplanPanel({
// `display:none`, so the tool never commits. Mirror the slab / // `display:none`, so the tool never commits. Mirror the slab /
// ceiling 2D-only committers: create locally here, gated on the // ceiling 2D-only committers: create locally here, gated on the
// view, so split / 3D keep their single-owner tool commit. // view, so split / 3D keep their single-owner tool commit.
const createdWall = const viewIs2DOnly = useEditor.getState().viewMode === '2d'
useEditor.getState().viewMode === '2d' ? createWallOnCurrentLevel(draftStart, point) : null const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null
if (createdWall) {
wallChainWallIdsRef.current.push(createdWall.id)
}
// Chain the next segment from the resolved commit endpoint (it may // Chain the next segment from the resolved commit endpoint (it may
// have corner-snapped or split-adjusted): the wall we just made in // have corner-snapped or split-adjusted): the wall we just made in
@@ -9694,11 +9708,48 @@ export function FloorplanPanel({
return return
} }
if (createdWall) {
// 2D-only committer: mirror the 3D tool's auto-close. Stop when the
// segment seals a room against the wall network, or when its resolved
// end tees into wall geometry outside the chain — continuing from a
// T-junction only drafts on top of existing walls.
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode => node?.type === 'wall' && node.parentId === levelId,
)
if (
chainEndJoinsExistingWall(
createdWall.end as WallPlanPoint,
levelWalls,
wallChainWallIdsRef.current,
) ||
wallClosesRoom(levelWalls, createdWall)
) {
clearWallPlacementDraft()
setCursorPoint(null)
return
}
} else if (!(viewIs2DOnly || publishedNextStart)) {
// Split view: the 3D tool owns both the commit and the continuation
// decision, and it clears the published chain start whenever it stops
// drafting (room close, T-junction, single). Mirror that here instead
// of chaining the 2D draft from a dead point.
clearWallPlacementDraft()
setCursorPoint(null)
return
}
setDraftStart(nextStart) setDraftStart(nextStart)
setDraftEnd(nextStart) setDraftEnd(nextStart)
setCursorPoint(nextStart) setCursorPoint(nextStart)
}, },
[clearWallPlacementDraft, draftStart, wallChainFirstVertex, setDraftEnd, setCursorPoint], [
clearWallPlacementDraft,
draftStart,
levelId,
wallChainFirstVertex,
setDraftEnd,
setCursorPoint,
],
) )
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons, ceilingPolygons: displayCeilingPolygons,
@@ -228,7 +228,7 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
() => () =>
new MeshBasicNodeMaterial({ new MeshBasicNodeMaterial({
color: new Color(EDGE_ARROW_COLOR), color: new Color(EDGE_ARROW_COLOR),
depthTest: true, depthTest: false,
depthWrite: true, depthWrite: true,
opacity: 1, opacity: 1,
side: DoubleSide, side: DoubleSide,
@@ -239,8 +239,7 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
} }
// One mesh per handle: lives on SCENE_LAYER with a node material so the // One mesh per handle: lives on SCENE_LAYER with a node material so the
// post-processing ink-edge pass outlines it. The visual material still // post-processing ink-edge pass outlines it.
// depth-tests, so walls/items in front can occlude it.
function OutlinedCylinderHandle({ function OutlinedCylinderHandle({
radius, radius,
height, height,
@@ -885,8 +884,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2 const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
// Interactive handles are SCENE_LAYER node-material meshes so the ink-edge // Interactive handles are SCENE_LAYER node-material meshes so the ink-edge
// pass outlines them while normal scene depth can hide them. The edge BAR and // pass outlines them. Edge arrows ignore scene depth so the active resize
// border line stay on EDITOR_LAYER, visual-only // affordance remains visible through walls and slabs. The edge BAR and border
// line stay on EDITOR_LAYER, visual-only
// (raycast disabled) so they never steal clicks from the vertex/midpoint // (raycast disabled) so they never steal clicks from the vertex/midpoint
// handles overlapping them — edge dragging starts from the chevron arrow // handles overlapping them — edge dragging starts from the chevron arrow
// outside the polygon edge. // outside the polygon edge.
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
DoorNode as DoorSchema,
runAsSingleSceneHistoryStep,
useScene, useScene,
type WallNode, type WallNode,
WallNode as WallSchema, WallNode as WallSchema,
@@ -9,9 +11,22 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope' import useInteractionScope from '../../../store/use-interaction-scope'
import { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting' import {
createWallOnCurrentLevel,
resolveEndpointWallSplit,
snapWallDraftPointDetailed,
} from './wall-drafting'
import type { WallPlanPoint } from './wall-snap-geometry' import type { WallPlanPoint } from './wall-snap-geometry'
// `updateNodes` batches its dirty-marking through requestAnimationFrame,
// which bun's test runtime doesn't provide.
if (typeof globalThis.requestAnimationFrame === 'undefined') {
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) =>
setTimeout(() => callback(0), 0)) as unknown as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = ((id: number) =>
clearTimeout(id)) as typeof cancelAnimationFrame
}
const LEVEL_ID = 'level_test' as AnyNodeId const LEVEL_ID = 'level_test' as AnyNodeId
function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode { function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode {
@@ -22,7 +37,7 @@ function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNod
} }
} }
function seedLevel(walls: WallNode[]) { function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
useScene.setState({ useScene.setState({
nodes: Object.fromEntries([ nodes: Object.fromEntries([
[ [
@@ -39,6 +54,7 @@ function seedLevel(walls: WallNode[]) {
} as AnyNode, } as AnyNode,
], ],
...walls.map((wall) => [wall.id, wall] as const), ...walls.map((wall) => [wall.id, wall] as const),
...extraNodes.map((node) => [node.id, node] as const),
]), ]),
rootNodeIds: [LEVEL_ID], rootNodeIds: [LEVEL_ID],
dirtyNodes: new Set(), dirtyNodes: new Set(),
@@ -62,10 +78,11 @@ describe('createWallOnCurrentLevel', () => {
selectedIds: [], selectedIds: [],
}, },
} as never) } as never)
// The commit-time corner-join / wall-split is a magnetic ('lines') snap, so // 'lines' keeps the generous commit-time join radius; the other modes
// these cases only apply in a magnetic context. A reshaping-endpoint scope // still resolve + split within the tight connect radius (covered by the
// resolves to the 'wall' context without needing the node registry (which // grid-mode cases below). A reshaping-endpoint scope resolves to the
// isn't loaded in this package's tests). // 'wall' context without needing the node registry (which isn't loaded in
// this package's tests).
useEditor.getState().setSnappingMode('wall', 'lines') useEditor.getState().setSnappingMode('wall', 'lines')
useInteractionScope useInteractionScope
.getState() .getState()
@@ -110,6 +127,186 @@ describe('createWallOnCurrentLevel', () => {
expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull() expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull()
expect(levelWalls()).toHaveLength(1) expect(levelWalls()).toHaveLength(1)
}) })
test('grid mode: endpoint resolved onto a wall body still splits the host', () => {
useEditor.getState().setSnappingMode('wall', 'grid')
const created = createWallOnCurrentLevel([2, 2], [2, 0])
expect(created?.end).toEqual([2, 0])
expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
expect(levelWalls()).toHaveLength(3)
})
test('grid mode: endpoint beyond the connect radius is left alone (no residual snap)', () => {
useEditor.getState().setSnappingMode('wall', 'grid')
const created = createWallOnCurrentLevel([2, 2], [2, 0.2])
expect(created?.end).toEqual([2, 0.2])
expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeDefined()
expect(levelWalls()).toHaveLength(2)
})
test('mid-span split migrates the host attachments to the covering half', () => {
const door = DoorSchema.parse({
position: [1, 1.05, 0],
parentId: 'wall_a',
wallId: 'wall_a',
})
seedLevel([{ ...makeWall([0, 0], [4, 0], 'wall_a'), children: [door.id] }], [door as AnyNode])
const created = createWallOnCurrentLevel([2, 2], [2, 0])
expect(created?.end).toEqual([2, 0])
const walls = levelWalls()
const firstHalf = walls.find((wall) => wall.start[0] === 0 && wall.end[0] === 2)
expect(firstHalf).toBeDefined()
const migratedDoor = useScene.getState().nodes[door.id as AnyNodeId]
expect(migratedDoor?.parentId).toBe(firstHalf?.id)
expect(firstHalf?.children).toContain(door.id)
})
test('a splitting commit lands as a single undo step', () => {
const before = useScene.temporal.getState().pastStates.length
const created = createWallOnCurrentLevel([2, 2], [2, 0])
expect(created).not.toBeNull()
expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
})
})
describe('resolveEndpointWallSplit', () => {
beforeEach(() => {
seedLevel([makeWall([0, 0], [4, 0], 'wall_host'), makeWall([2, 2], [2, 1], 'wall_moved')])
})
test('endpoint dropped mid-span splits the host and returns the projection', () => {
const resolved = resolveEndpointWallSplit({
point: [2, 0.02],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
expect(resolved).toEqual([2, 0])
expect(useScene.getState().nodes['wall_host' as AnyNodeId]).toBeUndefined()
const walls = levelWalls()
expect(walls).toHaveLength(3)
expect(
walls.some((wall) => wall.start[0] === 0 && wall.end[0] === 2 && wall.end[1] === 0),
).toBe(true)
expect(
walls.some((wall) => wall.start[0] === 2 && wall.start[1] === 0 && wall.end[0] === 4),
).toBe(true)
})
test('mid-span split migrates host attachments to the covering half', () => {
const door = DoorSchema.parse({
position: [1, 1.05, 0],
parentId: 'wall_host',
wallId: 'wall_host',
})
seedLevel(
[
{ ...makeWall([0, 0], [4, 0], 'wall_host'), children: [door.id] },
makeWall([2, 2], [2, 1], 'wall_moved'),
],
[door as AnyNode],
)
const resolved = resolveEndpointWallSplit({
point: [2, 0],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
expect(resolved).toEqual([2, 0])
const firstHalf = levelWalls().find((wall) => wall.start[0] === 0 && wall.end[0] === 2)
expect(firstHalf).toBeDefined()
const migratedDoor = useScene.getState().nodes[door.id as AnyNodeId]
expect(migratedDoor?.parentId).toBe(firstHalf?.id)
expect(firstHalf?.children).toContain(door.id)
})
test('a drop near an existing corner resolves to the corner without splitting', () => {
const resolved = resolveEndpointWallSplit({
point: [3.99, 0],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
expect(resolved).toEqual([4, 0])
expect(useScene.getState().nodes['wall_host' as AnyNodeId]).toBeDefined()
expect(levelWalls()).toHaveLength(2)
})
test('an opening straddling the drop point skips the split but still resolves the point', () => {
const door = DoorSchema.parse({
position: [2, 1.05, 0],
parentId: 'wall_host',
wallId: 'wall_host',
})
seedLevel(
[
{ ...makeWall([0, 0], [4, 0], 'wall_host'), children: [door.id] },
makeWall([2, 2], [2, 1], 'wall_moved'),
],
[door as AnyNode],
)
const resolved = resolveEndpointWallSplit({
point: [2, 0.02],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
expect(resolved).toEqual([2, 0])
expect(useScene.getState().nodes['wall_host' as AnyNodeId]).toBeDefined()
expect(levelWalls()).toHaveLength(2)
})
test('a drop beyond the connect radius resolves nothing and splits nothing', () => {
const resolved = resolveEndpointWallSplit({
point: [2, 0.2],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
expect(resolved).toBeNull()
expect(levelWalls()).toHaveLength(2)
})
test('ignored walls (the moved wall and its commit siblings) are never split', () => {
const resolved = resolveEndpointWallSplit({
point: [2, 0],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved', 'wall_host'],
})
expect(resolved).toBeNull()
expect(levelWalls()).toHaveLength(2)
})
test('split + endpoint write compose into a single history step', () => {
const before = useScene.temporal.getState().pastStates.length
runAsSingleSceneHistoryStep(useScene, () => {
const resolved = resolveEndpointWallSplit({
point: [2, 0],
levelId: LEVEL_ID,
ignoreWallIds: ['wall_moved'],
})
useScene
.getState()
.updateNodes([{ id: 'wall_moved' as AnyNodeId, data: { end: resolved ?? [2, 0] } }])
})
expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
expect(levelWalls()).toHaveLength(3)
const moved = useScene.getState().nodes['wall_moved' as AnyNodeId] as WallNode
expect(moved.end).toEqual([2, 0])
})
}) })
describe('snapWallDraftPointDetailed', () => { describe('snapWallDraftPointDetailed', () => {
@@ -5,6 +5,7 @@ import {
type DoorNode, type DoorNode,
getScaledDimensions, getScaledDimensions,
type ItemNode, type ItemNode,
runAsSingleSceneHistoryStep,
snapPointAlongAngleRay, snapPointAlongAngleRay,
useScene, useScene,
type WallNode, type WallNode,
@@ -30,6 +31,7 @@ import {
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
// existing importers (fence drafting, the editor barrel) keep their paths. // existing importers (fence drafting, the editor barrel) keep their paths.
export { export {
chainEndJoinsExistingWall,
findWallSnapTarget, findWallSnapTarget,
WALL_CONNECT_SNAP_RADIUS, WALL_CONNECT_SNAP_RADIUS,
WALL_JOIN_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS,
@@ -96,6 +98,7 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): bool
function findWallIntersection( function findWallIntersection(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
radius: number,
ignoreWallIds?: string[], ignoreWallIds?: string[],
): WallSplitIntersection | null { ): WallSplitIntersection | null {
const ignore = new Set(ignoreWallIds ?? []) const ignore = new Set(ignoreWallIds ?? [])
@@ -110,7 +113,7 @@ function findWallIntersection(
const candidateDistanceSquared = distanceSquared(point, projected) const candidateDistanceSquared = distanceSquared(point, projected)
if ( if (
candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS || candidateDistanceSquared > radius * radius ||
candidateDistanceSquared >= bestDistanceSquared candidateDistanceSquared >= bestDistanceSquared
) { ) {
continue continue
@@ -312,6 +315,43 @@ function splitWallIfNeeded(
} }
} }
/**
* Commit-time split resolution for an endpoint MOVE — the sibling of the
* inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
* dropped on another wall's interior, split that host exactly like the draw
* path (duplicate props, migrate attachments by span, skip the split when an
* opening straddles the point). Mutates the scene store (create halves /
* migrate attachments / delete host), so callers MUST run it inside the same
* `runAsSingleSceneHistoryStep` as their endpoint write.
*
* Returns the resolved endpoint (projection onto the host, or a nearby corner
* when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
* are not splits), or `null` when the point lands on no wall.
*/
export function resolveEndpointWallSplit(args: {
point: WallPlanPoint
/** Level the moved wall lives on — only its walls are split candidates. */
levelId: string | null
/** The moved wall + every wall receiving an endpoint update in the same commit. */
ignoreWallIds: string[]
/**
* Capture radius. The endpoint already snapped onto the wall body during
* the drag, so the tight connect radius (drop genuinely on the wall) is
* the default.
*/
radius?: number
}): WallPlanPoint | null {
const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState()
const walls = Object.values(nodes).filter(
(node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
)
const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
return split ? split.point : null
}
type SnapWallDraftArgs = { type SnapWallDraftArgs = {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
@@ -432,12 +472,19 @@ export function createWallOnCurrentLevel(
let resolvedStart = start let resolvedStart = start
let resolvedEnd = end let resolvedEnd = end
// The corner-join / wall-split snap on commit is a magnetic (line) snap, so // The corner-join / wall-split resolution follows the snapping mode like the
// it must be gated by the snapping mode like the draft preview is. Without // draft preview does: magnetic ('lines') keeps the generous join radius,
// this gate `'off'` (and `'angles'`) still snapped the committed endpoint to // every other mode uses the same tight connect radius the draft path already
// existing wall geometry — the residual snap the draft path no longer does. // sticks endpoints with. So an endpoint the user saw connect to a wall body
if (isMagneticSnapActive()) { // actually splits that wall (and redistributes its attachments) in every
const endIntersection = findWallIntersection(resolvedEnd, workingWalls) // mode, while `'off'` / `'angles'` gain no residual long-range snap.
const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
// One undo step for the whole commit: the split ops (create halves, migrate
// attachments, delete host) plus the new wall each push their own history
// entry, and a single Ctrl-Z must not strand a half-split wall network.
return runAsSingleSceneHistoryStep(useScene, () => {
const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
const splitEnd = splitWallIfNeeded( const splitEnd = splitWallIfNeeded(
endIntersection, endIntersection,
workingWalls, workingWalls,
@@ -451,7 +498,7 @@ export function createWallOnCurrentLevel(
resolvedEnd = splitEnd.point resolvedEnd = splitEnd.point
} }
const startIntersection = findWallIntersection(resolvedStart, workingWalls) const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
const splitStart = splitWallIfNeeded( const splitStart = splitWallIfNeeded(
startIntersection, startIntersection,
workingWalls, workingWalls,
@@ -464,9 +511,11 @@ export function createWallOnCurrentLevel(
workingWalls = splitStart.walls workingWalls = splitStart.walls
resolvedStart = splitStart.point resolvedStart = splitStart.point
} }
}
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { if (
!isSegmentLongEnough(resolvedStart, resolvedEnd) ||
pointsEqual(resolvedStart, resolvedEnd)
) {
return null return null
} }
@@ -495,4 +544,5 @@ export function createWallOnCurrentLevel(
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
return wall return wall
})
} }
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { WallNode } from '@pascal-app/core' import type { WallNode } from '@pascal-app/core'
import { import {
chainEndJoinsExistingWall,
findWallSnapTarget, findWallSnapTarget,
findWallSpecialPointSnap, findWallSpecialPointSnap,
type WallPlanPoint, type WallPlanPoint,
@@ -71,6 +72,36 @@ describe('findWallSpecialPointSnap', () => {
}) })
}) })
describe('chainEndJoinsExistingWall (chain termination)', () => {
test('true when the end lies on a non-chain wall interior (T junction)', () => {
const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([2, 2], [2, 0], 'chain_1')]
expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1'])).toBe(true)
})
test('true when the end lands on a non-chain wall endpoint', () => {
const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([6, 2], [4, 0], 'chain_1')]
expect(chainEndJoinsExistingWall([4, 0], walls, ['chain_1'])).toBe(true)
})
test('false when the end only touches the chain walls themselves', () => {
// Second segment doubles back onto the first one's midpoint — own-chain
// geometry is excluded, so the chain keeps going.
const walls = [makeWall([0, 0], [4, 0], 'chain_1'), makeWall([4, 0], [2, 0], 'chain_2')]
expect(chainEndJoinsExistingWall([2, 0], walls, ['chain_1', 'chain_2'])).toBe(false)
})
test('false for a dead end in free space', () => {
const walls = [makeWall([0, 0], [4, 0], 'host'), makeWall([0, 2], [2, 2], 'chain_1')]
expect(chainEndJoinsExistingWall([2, 2], walls, ['chain_1'])).toBe(false)
})
test('a near miss beyond the tolerance is not a join', () => {
const walls = [makeWall([0, 0], [4, 0], 'host')]
expect(chainEndJoinsExistingWall([2, 0.01], walls, [])).toBe(false)
expect(chainEndJoinsExistingWall([2, 0.0005], walls, [])).toBe(true)
})
})
describe('findWallSnapTarget (edge / along-wall)', () => { describe('findWallSnapTarget (edge / along-wall)', () => {
test('projects onto a wall body within range', () => { test('projects onto a wall body within range', () => {
const walls = [makeWall([0, 0], [4, 0])] const walls = [makeWall([0, 0], [4, 0])]
@@ -259,6 +259,53 @@ function nearestCandidate(
return best return best
} }
// Tolerance for "the committed endpoint actually lies on existing wall
// geometry". Commit-time resolution (corner join, connect snap, split) puts
// the endpoint exactly on the geometry, so this only needs to absorb float
// drift — it is NOT a snap radius.
export const WALL_CHAIN_JOIN_TOLERANCE = 1e-3
/**
* True when a committed chain segment's resolved `end` lies on wall geometry
* (an endpoint, or a straight wall's interior) of a wall outside the current
* draft chain. The wall tools stop chaining there: a segment that tees into
* the existing network is a termination — continuing would draft the next
* segment on top of existing walls. `chainWallIds` excludes the chain's own
* segments (including the just-committed one) so edge/midpoint snaps onto a
* previous own segment don't read as a join. Curved wall interiors are
* skipped (their endpoints still count) — resolving an end onto a curve body
* is rare and continuing there matches the previous behaviour.
*/
export function chainEndJoinsExistingWall(
end: WallPlanPoint,
walls: WallNode[],
chainWallIds: string[],
tolerance = WALL_CHAIN_JOIN_TOLERANCE,
): boolean {
const ignored = new Set(chainWallIds)
const toleranceSquared = tolerance * tolerance
for (const wall of walls) {
if (ignored.has(wall.id)) continue
if (
distanceSquared(end, wall.start) <= toleranceSquared ||
distanceSquared(end, wall.end) <= toleranceSquared
) {
return true
}
if (isCurvedWall(wall)) continue
const projected = projectPointOntoWall(end, wall)
if (projected && distanceSquared(end, projected) <= toleranceSquared) {
return true
}
}
return false
}
/** /**
* Discrete "special point" snap from the raw cursor, in priority order: * Discrete "special point" snap from the raw cursor, in priority order:
* 1. corners (endpoints) — strongest intent, largest radius * 1. corners (endpoints) — strongest intent, largest radius
+5
View File
@@ -149,9 +149,11 @@ export {
} from './components/tools/stair/stair-defaults' } from './components/tools/stair/stair-defaults'
export { ToolManager } from './components/tools/tool-manager' export { ToolManager } from './components/tools/tool-manager'
export { export {
chainEndJoinsExistingWall,
createWallOnCurrentLevel, createWallOnCurrentLevel,
getSegmentGridStep, getSegmentGridStep,
isSegmentLongEnough, isSegmentLongEnough,
resolveEndpointWallSplit,
snapPointToGrid, snapPointToGrid,
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
@@ -336,8 +338,11 @@ export { movementSfxStepKey } from './lib/sfx/movement-tick'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
export { export {
clearSlabSnapFeedback, clearSlabSnapFeedback,
resolveSlabEdgeBandSnap,
resolveSlabPlanPointSnap, resolveSlabPlanPointSnap,
SLAB_ALIGNMENT_THRESHOLD_M, SLAB_ALIGNMENT_THRESHOLD_M,
type SlabEdgeBandSnapInput,
type SlabEdgeBandSnapResult,
type SlabPlanSnapInput, type SlabPlanSnapInput,
type SlabPlanSnapResult, type SlabPlanSnapResult,
} from './lib/slab-plan-snap' } from './lib/slab-plan-snap'
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, WallNode } from '@pascal-app/core'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
import { resolveSlabEdgeBandSnap } from './slab-plan-snap'
function sceneWithWall(thickness = 0.1) {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness })
const nodes: Record<string, AnyNode> = { [wall.id]: wall }
return { wall, nodes }
}
describe('resolveSlabEdgeBandSnap', () => {
test('magnetic: an edge inside the wall band sticks to the centerline and shows the beacon', () => {
const { wall, nodes } = sceneWithWall()
useWallSnapIndicator.getState().clear()
const snap = resolveSlabEdgeBandSnap({
edge: [
[0.5, 0.08],
[3.5, 0.08],
],
nodes,
referencePoint: [1, 0.08],
magnetic: true,
})
expect(snap).not.toBeNull()
expect(snap!.wallId).toBe(wall.id)
expect(snap!.edge[0][1]).toBeCloseTo(0)
expect(snap!.edge[1][1]).toBeCloseTo(0)
const beacon = useWallSnapIndicator.getState().point
expect(beacon).not.toBeNull()
expect(beacon!.kind).toBe('wall')
expect(beacon!.wallIds).toEqual([wall.id])
// Beacon hugs the reference point projected onto the snapped edge.
expect(beacon!.x).toBeCloseTo(1)
expect(beacon!.z).toBeCloseTo(0)
})
test('non-magnetic: only the tight connect stick remains', () => {
const { nodes } = sceneWithWall()
// 8cm off the centerline: inside the band but beyond the connect radius.
expect(
resolveSlabEdgeBandSnap({
edge: [
[0.5, 0.08],
[3.5, 0.08],
],
nodes,
magnetic: false,
}),
).toBeNull()
expect(useWallSnapIndicator.getState().point).toBeNull()
// 4cm: genuinely dropped on the wall — still sticks.
expect(
resolveSlabEdgeBandSnap({
edge: [
[0.5, 0.04],
[3.5, 0.04],
],
nodes,
magnetic: false,
}),
).not.toBeNull()
})
test('clears the beacon when the edge leaves every band', () => {
const { nodes } = sceneWithWall()
resolveSlabEdgeBandSnap({
edge: [
[0.5, 0.05],
[3.5, 0.05],
],
nodes,
magnetic: true,
})
expect(useWallSnapIndicator.getState().point).not.toBeNull()
resolveSlabEdgeBandSnap({
edge: [
[0.5, 1.5],
[3.5, 1.5],
],
nodes,
magnetic: true,
})
expect(useWallSnapIndicator.getState().point).toBeNull()
})
})
+83
View File
@@ -1,5 +1,11 @@
import { type AnyNode, snapSlabEdgeToWallBand, useScene } from '@pascal-app/core'
import { WALL_CONNECT_SNAP_RADIUS } from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import { isMagneticSnapActive } from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
import { import {
clearSurfacePlanSnapFeedback, clearSurfacePlanSnapFeedback,
getLevelWalls,
resolveSurfacePlanPointSnap, resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M, SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput, type SurfacePlanSnapInput,
@@ -23,3 +29,80 @@ export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnap
movingId: input.movingId ?? SLAB_SNAP_MOVING_ID, movingId: input.movingId ?? SLAB_SNAP_MOVING_ID,
}) })
} }
export type SlabEdgeBandSnapInput = {
/** Candidate edge endpoints after the raw/grid perpendicular translation. */
edge: [[number, number], [number, number]]
levelId?: string | null
nodes?: Readonly<Record<string, AnyNode>>
/**
* Plan point (typically the cursor) the snap beacon should hug along
* the wall. Falls back to the snapped edge's midpoint.
*/
referencePoint?: [number, number]
/** Override the mode-driven magnetic gate (tests). */
magnetic?: boolean
}
export type SlabEdgeBandSnapResult = {
/** The candidate edge translated onto the wall centerline. */
edge: [[number, number], [number, number]]
wallId: string
}
/**
* Edge-level slab reshape snap against wall footprint bands. Unlike the
* cursor-based `resolveSlabPlanPointSnap`, this tests the DRAGGED EDGE
* itself (band adoption, span overlap — same permissiveness as the
* render rule) and sticks it onto the wall CENTERLINE, the canonical
* stored position; the render rule then places it flush with the face.
* The beacon, the live preview and the committed polygon therefore all
* agree. In non-magnetic modes only a tight connect-radius stick
* remains, mirroring the sanctioned wall connect snap. Publishes /
* clears the wall-snap beacon as a side effect.
*/
export function resolveSlabEdgeBandSnap(
input: SlabEdgeBandSnapInput,
): SlabEdgeBandSnapResult | null {
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId)
const magnetic = input.magnetic ?? isMagneticSnapActive()
const snap = snapSlabEdgeToWallBand(
input.edge[0],
input.edge[1],
walls,
magnetic ? undefined : { maxLateral: WALL_CONNECT_SNAP_RADIUS },
)
if (!snap) {
useWallSnapIndicator.getState().clear()
useAlignmentGuides.getState().clear()
return null
}
const [a, b] = snap.edge
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const lengthSquared = dx * dx + dz * dz
let beacon: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
if (input.referencePoint && lengthSquared > 1e-12) {
const t = Math.max(
0,
Math.min(
1,
((input.referencePoint[0] - a[0]) * dx + (input.referencePoint[1] - a[1]) * dz) /
lengthSquared,
),
)
beacon = [a[0] + dx * t, a[1] + dz * t]
}
useWallSnapIndicator.getState().set({
x: beacon[0],
z: beacon[1],
kind: 'wall',
wallIds: [snap.wallId],
})
useAlignmentGuides.getState().clear()
return { edge: snap.edge, wallId: snap.wallId }
}
+1 -1
View File
@@ -57,7 +57,7 @@ export type SurfacePlanSnapResult = {
wallIds: string[] wallIds: string[]
} }
function getLevelWalls( export function getLevelWalls(
nodes: Readonly<Record<string, AnyNode>>, nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined, levelId: string | null | undefined,
walls?: readonly WallNode[], walls?: readonly WallNode[],
@@ -60,8 +60,26 @@ export type PolygonAffordanceSnapContext<N extends PolygonShape & { id: AnyNodeI
mode: PolygonAffordanceMode mode: PolygonAffordanceMode
} }
export type PolygonEdgeSnapContext<N extends PolygonShape & { id: AnyNodeId }> = {
node: N
nodes: Record<AnyNodeId, AnyNode>
/** Candidate edge (after the perpendicular translation), in ring order. */
edge: [[number, number], [number, number]]
rawPoint: WallPlanPoint
modifiers: FloorplanAffordanceModifiers
holeIndex?: number
}
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = { type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
/**
* `move-edge` only: absolute edge snap. The point-based resolver runs
* on the CURSOR, so any grab offset between the pointer and the edge
* line gets baked into a point snap; an edge that must land exactly on
* a target line (wall centerline) snaps here instead — return the
* translated edge, or `null` to keep the candidate.
*/
snapEdge?: (context: PolygonEdgeSnapContext<N>) => [[number, number], [number, number]] | null
} }
type PolygonShape = { type PolygonShape = {
@@ -334,8 +352,33 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
holeIndex, holeIndex,
mode: 'move-edge', mode: 'move-edge',
}) })
const normalDistance = let normalDistance =
(snappedPoint[0] - startX) * normalX + (snappedPoint[1] - startY) * normalY (snappedPoint[0] - startX) * normalX + (snappedPoint[1] - startY) * normalY
if (options?.snapEdge) {
const candidate: [[number, number], [number, number]] = [
[
startVertex[0] + normalX * normalDistance,
startVertex[1] + normalY * normalDistance,
],
[endVertex[0] + normalX * normalDistance, endVertex[1] + normalY * normalDistance],
]
const snappedEdge = options.snapEdge({
node,
nodes,
edge: candidate,
rawPoint,
modifiers,
holeIndex,
})
if (snappedEdge) {
// Measure the final travel from the ORIGINAL edge so the
// stored edge lands exactly on the snapped line — grab
// offset and pointer position drop out entirely.
normalDistance =
(snappedEdge[0][0] - startVertex[0]) * normalX +
(snappedEdge[0][1] - startVertex[1]) * normalY
}
}
const nextRing: [number, number][] = originalRing.map((p, i) => { const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) { if (i === edgeStartIndex || i === edgeEndIndex) {
return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance] return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance]
@@ -0,0 +1,118 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
SlabNode,
type SlabNode as SlabNodeType,
useScene,
WallNode,
} from '@pascal-app/core'
import { slabMoveEdgeAffordance } from '../floorplan-affordances'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
/**
* Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom
* edge starts 0.5m away from the wall.
*/
function seedScene() {
const levelId = 'level_slab-move-edge' as AnyNodeId
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.1,
parentId: levelId,
})
const slab = SlabNode.parse({
polygon: [
[0, 0.5],
[4, 0.5],
[4, 3],
[0, 3],
],
autoFromWalls: false,
parentId: levelId,
})
const level = {
id: levelId,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
level: 0,
parentId: null,
children: [wall.id, slab.id],
} as unknown as AnyNode
useScene.setState({
nodes: { [levelId]: level, [wall.id]: wall, [slab.id]: slab } as never,
})
return { slab }
}
describe('slabMoveEdgeAffordance', () => {
test('commits the edge exactly on the wall centerline despite a grab offset', () => {
const { slab } = seedScene()
const nodes = useScene.getState().nodes
const session = slabMoveEdgeAffordance.start({
node: nodes[slab.id] as SlabNodeType,
payload: { edgeIndex: 0 },
nodes,
// Grabbed 0.15m off the stored edge line — inside the wide screen-px
// hit area. The old cursor-based snap baked this offset into the
// commit, leaving the edge short of the wall by exactly 0.15.
initialPlanPoint: [2, 0.65],
gridSnapStep: 0.1,
} as never)
// Drop the candidate edge just next to the centerline (z≈0.04 before
// any grid quantization) — inside the band / connect stick in every
// snapping mode.
session.apply({ planPoint: [2, 0.19], modifiers: MODIFIERS })
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
expect(updated.polygon[0]![1]).toBeCloseTo(0, 5)
expect(updated.polygon[1]![1]).toBeCloseTo(0, 5)
// Tangential coordinates are untouched by the perpendicular edge drag.
expect(updated.polygon[0]![0]).toBeCloseTo(0, 5)
expect(updated.polygon[1]![0]).toBeCloseTo(4, 5)
// The far edge never moves.
expect(updated.polygon[2]![1]).toBeCloseTo(3, 5)
expect(session.canCommit()).toBe(true)
})
test('a drag far from any wall keeps pure delta semantics', () => {
const { slab } = seedScene()
const nodes = useScene.getState().nodes
const session = slabMoveEdgeAffordance.start({
node: nodes[slab.id] as SlabNodeType,
payload: { edgeIndex: 0 },
nodes,
initialPlanPoint: [2, 0.5],
gridSnapStep: 0.1,
} as never)
// Move up 1m — nowhere near the wall band; the edge follows the
// pointer delta (possibly grid-quantized, which 1.0 is invariant to).
session.apply({ planPoint: [2, 1.5], modifiers: MODIFIERS })
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5)
expect(updated.polygon[1]![1]).toBeCloseTo(1.5, 5)
})
})
+49 -3
View File
@@ -4,9 +4,12 @@ import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@
import { import {
boundaryReshapeScope, boundaryReshapeScope,
clearSlabSnapFeedback, clearSlabSnapFeedback,
getSegmentGridStep,
PolygonEditor, PolygonEditor,
type PolygonEditorPlanPointSnapContext, type PolygonEditorPlanPointSnapContext,
resolveSlabEdgeBandSnap,
resolveSlabPlanPointSnap, resolveSlabPlanPointSnap,
snapScalarToGrid,
useInteractionScope, useInteractionScope,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -79,13 +82,56 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
) )
const resolvePolygonEditorPlanPoint = useCallback( const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) => (context: PolygonEditorPlanPointSnapContext) => {
resolveSlabPlanPointSnap({ // Edge drags: `PolygonEditor` translates the edge by the pointer
// DELTA from `initialPosition` — the cursor at grab time, which sits
// on the edge ARROW ~0.34m outside the edge. A cursor-based wall
// snap here therefore commits the edge short of the wall by exactly
// that offset while the beacon shows a snap ON the wall. Snap the
// CANDIDATE EDGE onto the wall band instead (2D parity: the slab
// `move-edge` affordance's `snapEdge`), and hand back a point whose
// normal projection encodes the final travel.
if (context.mode === 'edge' && context.edgeIndex !== undefined) {
const a = context.initialPolygon[context.edgeIndex]
const b = context.initialPolygon[(context.edgeIndex + 1) % context.initialPolygon.length]
if (a && b) {
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const length = Math.hypot(dx, dz)
if (length > 1e-6) {
// Same convention as PolygonEditor's getEdgeNormal.
const normalX = -dz / length
const normalZ = dx / length
const rawDelta =
(context.rawPoint[0] - context.initialPosition[0]) * normalX +
(context.rawPoint[1] - context.initialPosition[1]) * normalZ
const projection = snapScalarToGrid(rawDelta, getSegmentGridStep())
const candidate: [[number, number], [number, number]] = [
[a[0] + normalX * projection, a[1] + normalZ * projection],
[b[0] + normalX * projection, b[1] + normalZ * projection],
]
const snap = resolveSlabEdgeBandSnap({
edge: candidate,
levelId: slabLevelId,
referencePoint: context.rawPoint,
})
const distance = snap
? (snap.edge[0][0] - a[0]) * normalX + (snap.edge[0][1] - a[1]) * normalZ
: projection
return [
context.initialPosition[0] + normalX * distance,
context.initialPosition[1] + normalZ * distance,
] as [number, number]
}
}
}
return resolveSlabPlanPointSnap({
rawPoint: context.rawPoint, rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint, fallbackPoint: context.gridPoint,
levelId: slabLevelId, levelId: slabLevelId,
excludeId: slabId, excludeId: slabId,
}).point, }).point
},
[slabId, slabLevelId], [slabId, slabLevelId],
) )
+9
View File
@@ -188,6 +188,15 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
// Stage B: pure geometry function. // Stage B: pure geometry function.
geometry: buildSlabGeometry, geometry: buildSlabGeometry,
// Dependency tracker only — dirties level slabs when walls / sibling
// slabs change, since the renderable polygon derives from level context.
system: {
module: () => import('./system'),
priority: 4,
},
// The fill reads walls + sibling slabs via ctx (per-edge render offsets),
// so committed sibling edits must invalidate the cached floor-plan entry.
floorplanDependsOnSiblings: true,
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits // Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
// to [] when slab is registered (see floorplan-panel.tsx). // to [] when slab is registered (see floorplan-panel.tsx).
floorplan: buildSlabFloorplan, floorplan: buildSlabFloorplan,
@@ -1,10 +1,11 @@
import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core' import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
import { resolveSlabPlanPointSnap } from '@pascal-app/editor' import { resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap } from '@pascal-app/editor'
import { import {
createPolygonAddVertexAffordance, createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance, createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance, createPolygonVertexAffordance,
type PolygonAffordanceSnapContext, type PolygonAffordanceSnapContext,
type PolygonEdgeSnapContext,
} from '../shared/polygon-vertex-affordance' } from '../shared/polygon-vertex-affordance'
/** /**
@@ -27,7 +28,13 @@ const slabSnapOptions = {
nodes, nodes,
rawPoint, rawPoint,
fallbackPoint, fallbackPoint,
mode,
}: PolygonAffordanceSnapContext<SlabNode>) { }: PolygonAffordanceSnapContext<SlabNode>) {
// Edge drags snap the EDGE, not the cursor (`snapEdge` below): a
// cursor-based wall snap here would bake the pointer's grab offset
// from the edge line into the commit — the beacon shows a snap on
// the wall while the released edge stops short by that offset.
if (mode === 'move-edge') return fallbackPoint
const sceneNodes = nodes as Record<string, AnyNode> const sceneNodes = nodes as Record<string, AnyNode>
return resolveSlabPlanPointSnap({ return resolveSlabPlanPointSnap({
rawPoint, rawPoint,
@@ -39,6 +46,17 @@ const slabSnapOptions = {
// `lines` mode), so no Shift or Alt snap bypass. // `lines` mode), so no Shift or Alt snap bypass.
}).point }).point
}, },
snapEdge({ node, nodes, edge, rawPoint }: PolygonEdgeSnapContext<SlabNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return (
resolveSlabEdgeBandSnap({
edge,
levelId: resolveLevelId(node, sceneNodes),
nodes: sceneNodes,
referencePoint: rawPoint,
})?.edge ?? null
)
},
} }
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>( export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>(
+25 -3
View File
@@ -4,6 +4,7 @@ import {
type GeometryContext, type GeometryContext,
getRenderableSlabPolygon, getRenderableSlabPolygon,
type SlabNode, type SlabNode,
slabPolygonContextFromGeometry,
} from '@pascal-app/core' } from '@pascal-app/core'
/** /**
@@ -18,8 +19,8 @@ import {
* - Same three handle sets for every hole in `node.holes`, with the * - Same three handle sets for every hole in `node.holes`, with the
* `holeIndex` carried in each handle's payload. * `holeIndex` carried in each handle's payload.
* *
* Uses `getRenderableSlabPolygon` for the visible fill (auto-slabs * Uses `getRenderableSlabPolygon` for the visible fill (per-edge render
* generated from walls clip to wall footprints), but vertex / edge / * offsets against level walls + sibling slabs), but vertex / edge /
* midpoint handles live on the **raw** `node.polygon` — matches the * midpoint handles live on the **raw** `node.polygon` — matches the
* legacy slab boundary editor which always operates on raw data. * legacy slab boundary editor which always operates on raw data.
*/ */
@@ -27,7 +28,7 @@ export function buildSlabFloorplan(node: SlabNode, ctx: GeometryContext): Floorp
const polygon = node.polygon const polygon = node.polygon
if (!polygon || polygon.length < 3) return null if (!polygon || polygon.length < 3) return null
const visualPolygon = getRenderableSlabPolygon(node) const visualPolygon = getRenderableSlabPolygon(node, slabPolygonContextFromGeometry(ctx))
if (!visualPolygon || visualPolygon.length < 3) return null if (!visualPolygon || visualPolygon.length < 3) return null
const view = ctx.viewState const view = ctx.viewState
@@ -84,6 +85,27 @@ export function buildSlabFloorplan(node: SlabNode, ctx: GeometryContext): Floorp
// Boundary editor — visible only when the slab is the active selection. // Boundary editor — visible only when the slab is the active selection.
if (isSelected) { if (isSelected) {
// Handles operate on the STORED polygon while the fill shows the
// band-healed render polygon; when the two diverge (edges projected
// onto wall faces / interior centerline seams), a dashed skeleton of the
// stored boundary shows what the handles actually grab.
const rawDiffersFromVisual =
polygon.length !== visualPolygon.length ||
polygon.some((point, index) => {
const visual = visualPolygon[index]!
return Math.abs(point[0] - visual[0]) > 0.005 || Math.abs(point[1] - visual[1]) > 0.005
})
if (rawDiffersFromVisual) {
children.push({
kind: 'path',
d: ring(polygon.map(([x, z]) => [x, z] as FloorplanPoint)),
fill: 'none',
stroke: palette ? palette.selectedStroke : '#475569',
strokeWidth: 0.015,
strokeOpacity: 0.55,
strokeDasharray: '0.08 0.06',
})
}
appendRingEditor(children, polygon, undefined) appendRingEditor(children, polygon, undefined)
holes.forEach((hole, holeIndex) => { holes.forEach((hole, holeIndex) => {
if (hole.length >= 3) appendRingEditor(children, hole, holeIndex) if (hole.length >= 3) appendRingEditor(children, hole, holeIndex)
+7 -2
View File
@@ -1,4 +1,9 @@
import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' import {
type GeometryContext,
getMaterialPresetByRef,
type SlabNode,
slabPolygonContextFromGeometry,
} from '@pascal-app/core'
import { import {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
type ColorPreset, type ColorPreset,
@@ -177,7 +182,7 @@ export function buildSlabGeometry(
sceneTheme?: string, sceneTheme?: string,
): Group { ): Group {
const group = new Group() const group = new Group()
const merged = generateSlabGeometry(node) const merged = generateSlabGeometry(node, slabPolygonContextFromGeometry(ctx))
const { top, side } = splitSlabFacesByFacing(merged) const { top, side } = splitSlabFacesByFacing(merged)
merged.dispose() merged.dispose()
+80
View File
@@ -0,0 +1,80 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useEffect } from 'react'
/**
* Slab dependency tracker. The renderable slab polygon derives from level
* context — wall centerlines/thickness (exterior flush offsets) and sibling
* slab polygons (interior centerline seams) — none of which lives on the slab
* node itself. Store updates only dirty the node that changed, so a wall
* thickness edit or a neighbour slab add/remove/reshape would leave stale
* slab meshes. Watch a per-level signature of those inputs and dirty every
* slab on a level whose signature moved; `GeometrySystem` then rebuilds
* them through `def.geometry` as usual.
*/
function levelSlabContextSignatures(nodes: Record<string, AnyNode>): Map<string, string> {
const partsByLevel = new Map<string, string[]>()
const push = (levelId: string, part: string) => {
const parts = partsByLevel.get(levelId)
if (parts) parts.push(part)
else partsByLevel.set(levelId, [part])
}
for (const node of Object.values(nodes)) {
const levelId = node.parentId
if (!levelId) continue
if (node.type === 'wall') {
const wall = node as WallNode
push(
levelId,
`w|${wall.id}|${wall.start[0]},${wall.start[1]}|${wall.end[0]},${wall.end[1]}|${wall.thickness ?? ''}|${wall.curveOffset ?? ''}`,
)
} else if (node.type === 'slab') {
const slab = node as SlabNode
// Elevation is a seam input: an unequal-elevation seam projects to
// the lower side's wall face, so a height change reshapes siblings.
push(
levelId,
`s|${slab.id}|${slab.elevation ?? ''}|${slab.polygon.map(([x, z]) => `${x},${z}`).join(';')}`,
)
}
}
const signatures = new Map<string, string>()
for (const [levelId, parts] of partsByLevel.entries()) {
signatures.set(levelId, parts.sort().join('||'))
}
return signatures
}
const SlabSystems = () => {
useEffect(() => {
let previous = levelSlabContextSignatures(useScene.getState().nodes)
return useScene.subscribe((state) => {
const current = levelSlabContextSignatures(state.nodes)
for (const [levelId, signature] of current.entries()) {
if (previous.get(levelId) === signature) continue
for (const node of Object.values(state.nodes)) {
if (node.type === 'slab' && node.parentId === levelId) {
state.markDirty(node.id as AnyNodeId)
}
}
}
previous = current
})
}, [])
return null
}
export default SlabSystems
@@ -6,6 +6,7 @@ import {
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallChordFrame, getWallChordFrame,
normalizeWallCurveOffset, normalizeWallCurveOffset,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
type WallNode, type WallNode,
@@ -17,6 +18,7 @@ import {
isAngleSnapActive, isAngleSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
resolveEndpointWallSplit,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
@@ -244,6 +246,17 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const sceneState = useScene.getState() const sceneState = useScene.getState()
overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd }) overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd })
sceneState.markDirty(node.id as AnyNodeId) sceneState.markDirty(node.id as AnyNodeId)
if (modifiers.altKey) {
// Attach→detach transition: linked walls dragged on earlier attached
// ticks still carry overrides — drop them so their corners snap back
// to the scene originals (untouched during the drag).
for (const linked of linkedWalls) {
if (overrides.get(linked.id)) {
overrides.clear(linked.id)
sceneState.markDirty(linked.id)
}
}
}
for (const upd of linkedUpdates) { for (const upd of linkedUpdates) {
overrides.set(upd.id, { start: upd.start, end: upd.end }) overrides.set(upd.id, { start: upd.start, end: upd.end })
sceneState.markDirty(upd.id) sceneState.markDirty(upd.id)
@@ -261,14 +274,41 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
commit() { commit() {
// Atomic tracked write of the final endpoints, then drop the // Atomic tracked write of the final endpoints, then drop the
// overrides so the scene state is the single source of truth // overrides so the scene state is the single source of truth
// again. // again. Parity with the 3D move-endpoint tool: a drop on another
// wall's interior splits that host (create halves, migrate
// attachments, delete host) inside the same single history step as
// the endpoint write. Linked walls updated here share the drop point
// as an endpoint (a corner join, not a split) so they're excluded
// with the dragged wall; a zero-move drop skips the resolution
// entirely.
const movingPoint = endpoint === 'start' ? lastPrimaryStart : lastPrimaryEnd
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
runAsSingleSceneHistoryStep(useScene, () => {
const resolved = pointsEqual(movingPoint, originalMovingPoint)
? null
: resolveEndpointWallSplit({
point: movingPoint,
levelId: (node.parentId ?? null) as string | null,
ignoreWallIds: [node.id, ...lastLinkedUpdates.map((u) => String(u.id))],
})
const finalPoint = resolved ?? movingPoint
useScene.getState().updateNodes([ useScene.getState().updateNodes([
{ id: node.id, data: { start: lastPrimaryStart, end: lastPrimaryEnd } }, {
id: node.id,
data: {
start: endpoint === 'start' ? finalPoint : lastPrimaryStart,
end: endpoint === 'end' ? finalPoint : lastPrimaryEnd,
},
},
...lastLinkedUpdates.map((u) => ({ ...lastLinkedUpdates.map((u) => ({
id: u.id, id: u.id,
data: { start: u.start, end: u.end }, data: {
start: pointsEqual(u.start, movingPoint) ? finalPoint : u.start,
end: pointsEqual(u.end, movingPoint) ? finalPoint : u.end,
},
})), })),
]) ])
})
const overrides = useLiveNodeOverrides.getState() const overrides = useLiveNodeOverrides.getState()
overrides.clear(node.id as AnyNodeId) overrides.clear(node.id as AnyNodeId)
for (const upd of lastLinkedUpdates) overrides.clear(upd.id) for (const upd of lastLinkedUpdates) overrides.clear(upd.id)
+104 -18
View File
@@ -11,6 +11,7 @@ import {
pauseSceneHistory, pauseSceneHistory,
resolveAlignment, resolveAlignment,
resumeSceneHistory, resumeSceneHistory,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
type WallNode, type WallNode,
@@ -26,6 +27,7 @@ import {
isSegmentLongEnough, isSegmentLongEnough,
MeasurementPill, MeasurementPill,
markToolCancelConsumed, markToolCancelConsumed,
resolveEndpointWallSplit,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -207,6 +209,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const [altPressed, setAltPressed] = useState(false) const [altPressed, setAltPressed] = useState(false)
const unit = useViewer((s) => s.unit) const unit = useViewer((s) => s.unit)
// Alt-detach only affects walls sharing the moving endpoint; walls linked
// solely to the fixed endpoint never move, so the hint would be noise.
const movingOriginal =
target.endpoint === 'start' ? originalStartRef.current : originalEndRef.current
const canDetachCorner = linkedOriginalsRef.current.some(
(wall) => samePoint(wall.start, movingOriginal) || samePoint(wall.end, movingOriginal),
)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useInteractionScope useInteractionScope
.getState() .getState()
@@ -231,6 +241,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
// Last point handed to `applyPreview` — lets the Alt keydown/keyup
// handlers re-run the preview immediately on a modifier change instead of
// waiting for the next mousemove.
let lastMovedPoint: WallPlanPoint | null = null
// The first pointer-up is the *grab* of a click-to-move; later ones are
// drops. See the `!hasChanged` branch in `onPointerUp`.
let hasReleasedOnce = false
// Wall ids carrying a live position override during the drag. Mirrors the // Wall ids carrying a live position override during the drag. Mirrors the
// 3D/2D wall MOVE tools: preview via `useLiveNodeOverrides` (the wall // 3D/2D wall MOVE tools: preview via `useLiveNodeOverrides` (the wall
@@ -271,6 +288,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
} }
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
lastMovedPoint = movingPoint
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedWalls const linkedUpdates = detachLinkedWalls
@@ -282,6 +300,20 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
nextStart, nextStart,
nextEnd, nextEnd,
) )
if (detachLinkedWalls) {
// Attach→detach transition: `setMany` only writes the ids it is
// handed, so linked walls dragged on earlier attached ticks would keep
// their stale overrides. Drop them so their corners snap back to the
// scene originals (untouched during the drag).
const overrides = useLiveNodeOverrides.getState()
const sceneState = useScene.getState()
for (const linked of linkedOriginalsRef.current) {
if (touchedWallIds.delete(linked.id as AnyNodeId)) {
overrides.clear(linked.id)
sceneState.markDirty(linked.id as AnyNodeId)
}
}
}
previewRef.current = { start: nextStart, end: nextEnd } previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel( setAngleLabel(
@@ -395,7 +427,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
: null, : null,
) )
applyPreview(alignedPoint, event.nativeEvent.altKey) // The keydown listener can't observe an Alt press that predates the
// tool mounting; the pointer event can. Sync the shared ref (single Alt
// source for preview, HUD badge, and commit) before applying.
if (event.nativeEvent.altKey !== altPressedRef.current) {
altPressedRef.current = event.nativeEvent.altKey
setAltPressed(event.nativeEvent.altKey)
}
applyPreview(alignedPoint, altPressedRef.current)
} }
const onPointerUp = () => { const onPointerUp = () => {
@@ -413,14 +452,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd) samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
) )
// Endpoint still at its original spot: this release is the *grab* of a // Endpoint still at its original spot. The FIRST release is the *grab*
// click-to-move (a tap on the handle, or a press that never dragged). Stay // of a click-to-move (a tap on the handle, or a press that never
// armed so the endpoint keeps following the cursor — the next release after // dragged): stay armed so the endpoint keeps following the cursor — a
// an actual move commits. A press-drag and a click thus engage identically; // press-drag and a click thus engage identically. Any LATER release at
// previously the no-drag branch dismissed the tool, and whether it even ran // an unchanged position is a deliberate drop: end the interaction
// raced the window pointer-up listener mounting (hence "works once, then // cleanly (previews restored, scope ended, no history entry) instead of
// needs a long press"). // leaving the user stuck until they move the mouse.
if (!hasChanged) return if (!hasChanged) {
if (!hasReleasedOnce) {
hasReleasedOnce = true
return
}
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
return
}
if (isSegmentLongEnough(preview.start, preview.end)) { if (isSegmentLongEnough(preview.start, preview.end)) {
wasCommitted = true wasCommitted = true
@@ -438,20 +486,46 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// Drop the live overrides; the store write below is the source of truth. // Drop the live overrides; the store write below is the source of truth.
// The store sat at the pre-drag (original) values the whole drag — only // The store sat at the pre-drag (original) values the whole drag — only
// overrides moved — so one resume+write records original→final as a // overrides moved — so one resume+write records original→final as a
// single tracked change (one Ctrl-Z reverts to original). // single tracked change (one Ctrl-Z reverts to original). The split
// ops (create halves, migrate attachments, delete host) would each
// push their own entry, so the whole commit runs as one history step.
clearPreviewOverrides() clearPreviewOverrides()
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
runAsSingleSceneHistoryStep(useScene, () => {
// Dropping the endpoint on another wall's interior splits that host
// like the draw path does. Linked walls updated in this commit share
// the drop point as an endpoint (a corner join, not a split), so
// they're excluded along with the moved wall — in Alt-detach mode
// `linkedUpdates` is empty and a stationary former sibling can be
// split like any other host.
const movingPoint = target.endpoint === 'start' ? preview.start : preview.end
const resolved = resolveEndpointWallSplit({
point: movingPoint,
levelId: target.wall.parentId ?? null,
ignoreWallIds: [nodeId, ...linkedUpdates.map((u) => String(u.id))],
})
const finalPoint = resolved ?? movingPoint
useScene.getState().updateNodes([ useScene.getState().updateNodes([
{ id: nodeId as AnyNodeId, data: { start: preview.start, end: preview.end } }, {
id: nodeId as AnyNodeId,
data: {
start: target.endpoint === 'start' ? finalPoint : preview.start,
end: target.endpoint === 'end' ? finalPoint : preview.end,
},
},
...linkedUpdates.map((u) => ({ ...linkedUpdates.map((u) => ({
id: u.id as AnyNodeId, id: u.id as AnyNodeId,
data: { start: u.start, end: u.end }, data: {
start: samePoint(u.start, movingPoint) ? finalPoint : u.start,
end: samePoint(u.end, movingPoint) ? finalPoint : u.end,
},
})), })),
]) ])
useScene.getState().markDirty(nodeId as AnyNodeId) useScene.getState().markDirty(nodeId as AnyNodeId)
for (const u of linkedUpdates) { for (const u of linkedUpdates) {
useScene.getState().markDirty(u.id as AnyNodeId) useScene.getState().markDirty(u.id as AnyNodeId)
} }
})
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
} }
@@ -472,26 +546,36 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
exitMoveMode() exitMoveMode()
} }
// Single Alt writer for keyboard transitions. Re-running the preview on
// the flip keeps geometry and the HUD badge in lockstep — detach reverts
// the linked walls instantly, re-attach snaps them onto the dragged point
// — without waiting for the next mousemove.
const setAltState = (pressed: boolean) => {
if (altPressedRef.current === pressed) return
altPressedRef.current = pressed
setAltPressed(pressed)
if (lastMovedPoint) {
applyPreview(lastMovedPoint, pressed)
}
}
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return return
} }
if (event.key === 'Alt') { if (event.key === 'Alt') {
altPressedRef.current = true setAltState(true)
setAltPressed(true)
} }
} }
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Alt') { if (event.key === 'Alt') {
altPressedRef.current = false setAltState(false)
setAltPressed(false)
} }
} }
const onWindowBlur = () => { const onWindowBlur = () => {
altPressedRef.current = false setAltState(false)
setAltPressed(false)
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
@@ -550,6 +634,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
unit={unit} unit={unit}
/> />
</Html> </Html>
{canDetachCorner && (
<Html <Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]} position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }} style={{ pointerEvents: 'none', touchAction: 'none' }}
@@ -567,6 +652,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
</div> </div>
</div> </div>
</Html> </Html>
)}
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />} {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
</group> </group>
) )
+1 -1
View File
@@ -7,7 +7,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer'
* *
* - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs
* `calculateLevelMiters(levelWalls)`, rebuilds geometry via * `calculateLevelMiters(levelWalls)`, rebuilds geometry via
* `generateExtrudedWall(node, children, miterData, slabElevation)`, * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`,
* and cascades to adjacent walls that share a junction. This is the * and cascades to adjacent walls that share a junction. This is the
* bulk of the wall runtime (~820 lines in viewer). * bulk of the wall runtime (~820 lines in viewer).
* - **`WallCutout`** — cutaway-mode hide/show logic based on camera * - **`WallCutout`** — cutaway-mode hide/show logic based on camera
+17 -1
View File
@@ -16,6 +16,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
chainEndJoinsExistingWall,
createWallOnCurrentLevel, createWallOnCurrentLevel,
EDITOR_LAYER, EDITOR_LAYER,
formatAngleRadians, formatAngleRadians,
@@ -529,6 +530,10 @@ export const WallTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0))
const chainFirstVertex = useRef<Vector3 | null>(null) const chainFirstVertex = useRef<Vector3 | null>(null)
// Ids of the walls committed by the current chain — the exclusion set for
// the "segment tees into an existing wall" chain-termination test, so
// snapping onto the chain's own segments never reads as a join.
const chainWallIds = useRef<string[]>([])
const buildingState = useRef(0) const buildingState = useRef(0)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null) const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
@@ -588,6 +593,7 @@ export const WallTool: React.FC = () => {
const stopDrafting = () => { const stopDrafting = () => {
buildingState.current = 0 buildingState.current = 0
chainFirstVertex.current = null chainFirstVertex.current = null
chainWallIds.current = []
if (wallPreviewRef.current) { if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false wallPreviewRef.current.visible = false
} }
@@ -735,6 +741,7 @@ export const WallTool: React.FC = () => {
snappedEnd, snappedEnd,
) )
if (!createdWall) return if (!createdWall) return
chainWallIds.current.push(createdWall.id)
// The new segment is now a real node — make it an alignment target // The new segment is now a real node — make it an alignment target
// for the next segment, and drop the just-shown guide. // for the next segment, and drop the just-shown guide.
@@ -755,7 +762,16 @@ export const WallTool: React.FC = () => {
// existing wall network (e.g. a bay closed onto the middle of another // existing wall network (e.g. a bay closed onto the middle of another
// wall), not just when the chain loops back to its own start. Shares the // wall), not just when the chain loops back to its own start. Shares the
// room graph with auto slab/ceiling detection so the two never disagree. // room graph with auto slab/ceiling detection so the two never disagree.
if (closedToChainStart || wallClosesRoom(getCurrentLevelWalls(), createdWall)) { // A resolved end that tees into wall geometry outside the chain also
// terminates even without an enclosed room — nobody continues drawing
// from a T-junction into an existing wall; a dead end in free space
// keeps the chain going.
const levelWalls = getCurrentLevelWalls()
if (
closedToChainStart ||
chainEndJoinsExistingWall(createdWall.end, levelWalls, chainWallIds.current) ||
wallClosesRoom(levelWalls, createdWall)
) {
stopDrafting() stopDrafting()
return return
} }
+33 -6
View File
@@ -1,6 +1,6 @@
import type { Color, Material, Side, Texture } from 'three' import type { Color, Material, Side, Texture } from 'three'
import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl' import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu' import { MeshStandardNodeMaterial, type Node, type NodeBuilder } from 'three/webgpu'
/** /**
* Always-on wind for the plant kinds, done in TSL so it runs on the editor's * Always-on wind for the plant kinds, done in TSL so it runs on the editor's
@@ -53,6 +53,33 @@ const stemBend = Fn(() => {
}) })
const STEM_BEND = stemBend() const STEM_BEND = stemBend()
/**
* Standard node material whose wind displacement runs **before** the instance
* transform. The wind nodes read `positionLocal` expecting raw geometry-local
* coordinates — leaf phase from the card's position inside its own tree, stem
* height from the plant's own base — with the per-instance scale/rotation/
* translation applied on top. three r184 happened to emit `positionNode`
* statements in exactly that order; r185 fixed the emission order so
* `positionNode` now runs *after* instancing, which put the wind in level
* space: sway stopped scaling with the instance, leaf phase followed world
* placement, and STEM_BEND's height term read the floor elevation, so plants
* on upper levels slid around rigidly. Assigning `positionLocal` inside
* `setupPosition` before `super` applies instancing restores the r184
* (geometry-space) semantics on r185.
*/
class WindNodeMaterial extends MeshStandardNodeMaterial {
windNode: Node | null = null
setupPosition(builder: NodeBuilder): Node {
if (this.windNode !== null) positionLocal.assign(this.windNode)
return super.setupPosition(builder)
}
customProgramCacheKey(): string {
return `${super.customProgramCacheKey()}|wind:${this.windNode ? this.windNode.id : 'none'}`
}
}
/** The classic-material fields we carry over — enough to reproduce ez-tree's /** The classic-material fields we carry over — enough to reproduce ez-tree's
* bark/leaf look (textured, tinted, alpha-cut billboards). */ * bark/leaf look (textured, tinted, alpha-cut billboards). */
type ClassicMaterial = Material & { type ClassicMaterial = Material & {
@@ -76,7 +103,7 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
const cached = cache.get(material) const cached = cache.get(material)
if (cached) return cached if (cached) return cached
const src = material as ClassicMaterial const src = material as ClassicMaterial
const node = new MeshStandardNodeMaterial({ const node = new WindNodeMaterial({
map: src.map ?? null, map: src.map ?? null,
alphaMap: src.alphaMap ?? null, alphaMap: src.alphaMap ?? null,
color: src.color, color: src.color,
@@ -88,7 +115,7 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
roughness: 1, roughness: 1,
metalness: 0, metalness: 0,
}) })
if (material.name === 'leaves') node.positionNode = LEAF_FLUTTER if (material.name === 'leaves') node.windNode = LEAF_FLUTTER
cache.set(material, node) cache.set(material, node)
return node return node
} }
@@ -97,14 +124,14 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
export function windStandardMaterial( export function windStandardMaterial(
params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0], params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0],
): MeshStandardNodeMaterial { ): MeshStandardNodeMaterial {
const material = new MeshStandardNodeMaterial(params) const material = new WindNodeMaterial(params)
material.positionNode = STEM_BEND material.windNode = STEM_BEND
return material return material
} }
const staticCache = new WeakMap<Material, Material>() const staticCache = new WeakMap<Material, Material>()
/** Windless twin of a wind material — same look, no `positionNode`. The outline /** Windless twin of a wind material — same look, no wind node. The outline
* mask pass renders outlined meshes with a shared override material, so an * mask pass renders outlined meshes with a shared override material, so an
* outline can never follow the GPU sway; the selection proxy renders this twin * outline can never follow the GPU sway; the selection proxy renders this twin
* instead, so the outlined silhouette and the visible mesh match exactly (the * instead, so the outlined silhouette and the visible mesh match exactly (the
+4 -1
View File
@@ -6,7 +6,10 @@ import {
RGBAFormat, RGBAFormat,
UnsignedByteType, UnsignedByteType,
} from 'three' } from 'three'
import { KTX2Loader } from 'three/examples/jsm/Addons.js' // Deep path, NOT the `Addons.js` aggregate: the aggregate re-exports
// LottieLoader/TTFLoader, whose CDN URL imports (`lottie-web`, `opentype.js`)
// crash bun's test runtime for every consumer of the viewer barrel.
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js'
/** The private KTX2Loader surface this module relies on (stable across three /** The private KTX2Loader surface this module relies on (stable across three
* releases but not part of the public types). */ * releases but not part of the public types). */
@@ -1,10 +1,12 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time. // depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { SlabNode } from '@pascal-app/core' import { SlabNode, type SlabPolygonContext } from '@pascal-app/core'
import type * as THREE from 'three' import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system' import { generateSlabGeometry } from './slab-system'
const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] }
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position') const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) { for (let index = 0; index < positions.count; index += 1) {
@@ -35,7 +37,7 @@ describe('generateSlabGeometry', () => {
], ],
}) })
const geometry = generateSlabGeometry(slab) const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0) expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true) expect(hasVertexAt(geometry, 1, 1)).toBe(true)
@@ -61,7 +63,7 @@ describe('generateSlabGeometry', () => {
], ],
}) })
const geometry = generateSlabGeometry(slab) const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0) expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true) expect(hasVertexAt(geometry, 1, 1)).toBe(true)
@@ -1,4 +1,5 @@
import { import {
type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveNode, getEffectiveNode,
getRenderableSlabPolygon, getRenderableSlabPolygon,
@@ -6,8 +7,10 @@ import {
pointInPolygon2D, pointInPolygon2D,
polygonsIntersect, polygonsIntersect,
type SlabNode, type SlabNode,
type SlabPolygonContext,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react' import { useEffect } from 'react'
@@ -44,6 +47,7 @@ export const SlabSystem = () => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const contextByLevel = new Map<string | null, SlabPolygonContext>()
// Process dirty slabs // Process dirty slabs
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
@@ -52,7 +56,15 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) { if (mesh) {
updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh) const slab = node as SlabNode
const levelContext =
contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes)
contextByLevel.set(slab.parentId, levelContext)
updateSlabGeometry(
getEffectiveNode(slab),
excludeSlabFromContext(levelContext, slab.id),
mesh,
)
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
} }
// If mesh not found, keep it dirty for next frame // If mesh not found, keep it dirty for next frame
@@ -62,11 +74,32 @@ export const SlabSystem = () => {
return null return null
} }
function buildLevelSlabContext(
levelId: string | null,
nodes: Record<string, AnyNode>,
): SlabPolygonContext {
const walls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
if (node.parentId !== levelId) continue
if (node.type === 'wall') walls.push(node as WallNode)
else if (node.type === 'slab') siblingSlabs.push(node as SlabNode)
}
return { walls, siblingSlabs }
}
function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext {
return {
walls: context.walls,
siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId),
}
}
/** /**
* Updates the geometry for a single slab * Updates the geometry for a single slab
*/ */
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node) const newGeo = generateSlabGeometry(node, context)
ensureUv2Attribute(newGeo) ensureUv2Attribute(newGeo)
mesh.geometry.dispose() mesh.geometry.dispose()
@@ -92,11 +125,18 @@ function coplanarityEpsilon(id: string): number {
} }
/** /**
* Generates extruded slab geometry from polygon * Generates extruded slab geometry from polygon. `context` carries the
* slab's level neighbourhood (walls + sibling slabs) driving the per-edge
* render offsets — see `getRenderableSlabPolygon`.
*/ */
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { export function generateSlabGeometry(
slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const elevation = slabNode.elevation ?? 0.05 const elevation = slabNode.elevation ?? 0.05
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) return elevation < 0
? generatePoolGeometry(slabNode, context)
: generatePositiveSlabGeometry(slabNode, context)
} }
// Earcut normalizes cap triangulation regardless of input winding, but the side // Earcut normalizes cap triangulation regardless of input winding, but the side
@@ -159,8 +199,11 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][])
* thickness visible from any angle: the two coincident triangles never z-fight * thickness visible from any angle: the two coincident triangles never z-fight
* because exactly one faces the camera under FrontSide culling. * because exactly one faces the camera under FrontSide culling.
*/ */
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePositiveSlabGeometry(
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context))
const elevation = slabNode.elevation ?? 0.05 const elevation = slabNode.elevation ?? 0.05
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
@@ -257,8 +300,11 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool) * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool) * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/ */
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePoolGeometry(
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) slabNode: SlabNode,
context: SlabPolygonContext,
): THREE.BufferGeometry {
const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context))
const depth = Math.abs(slabNode.elevation ?? 0.05) const depth = Math.abs(slabNode.elevation ?? 0.05)
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
@@ -0,0 +1,54 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { calculateLevelMiters, WallNode } from '@pascal-app/core'
import { generateExtrudedWall } from './wall-system'
describe('wall support extension', () => {
test('preserves the raised wall origin and top while filling down to the lower support', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05)
geometry.computeBoundingBox()
expect(geometry.boundingBox?.min.y).toBeCloseTo(-0.55)
expect(geometry.boundingBox?.max.y).toBeCloseTo(2.5)
// With mesh.position.y = 0.6, the wall spans world Y=0.05..3.1.
expect((geometry.boundingBox?.min.y ?? 0) + 0.6).toBeCloseTo(0.05)
expect((geometry.boundingBox?.max.y ?? 0) + 0.6).toBeCloseTo(3.1)
geometry.dispose()
})
test('retains the existing negative-slab top constraint', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), -0.4, -0.4)
geometry.computeBoundingBox()
expect(geometry.boundingBox?.min.y).toBeCloseTo(0)
expect(geometry.boundingBox?.max.y).toBeCloseTo(2.9)
expect((geometry.boundingBox?.max.y ?? 0) - 0.4).toBeCloseTo(2.5)
geometry.dispose()
})
test('raises only the high-supported part of a mixed wall run', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 })
const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [
{ start: 0, end: 0.5, elevation: 0.6 },
{ start: 0.5, end: 1, elevation: 0.05 },
])
const position = geometry.getAttribute('position')
let highSpanMinY = Number.POSITIVE_INFINITY
let lowSpanMinY = Number.POSITIVE_INFINITY
for (let index = 0; index < position.count; index++) {
const x = position.getX(index)
const y = position.getY(index)
if (x < 1.9) highSpanMinY = Math.min(highSpanMinY, y)
if (x > 2.1) lowSpanMinY = Math.min(lowSpanMinY, y)
}
expect(highSpanMinY).toBeCloseTo(0)
expect(lowSpanMinY).toBeCloseTo(-0.55)
geometry.dispose()
})
})
@@ -25,6 +25,7 @@ import {
useScene, useScene,
type WallMiterData, type WallMiterData,
type WallNode, type WallNode,
type WallSlabSupportSegment,
type WallSurfaceSide, type WallSurfaceSide,
type WallSurfaceSlotId, type WallSurfaceSlotId,
type WindowNode, type WindowNode,
@@ -691,13 +692,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
if (!mesh) return if (!mesh) return
const levelId = resolveLevelId(node, nodes) const levelId = resolveLevelId(node, nodes)
const slabElevation = spatialGridManager.getSlabElevationForWall( const slabSupport = spatialGridManager.getSlabSupportForWall(
levelId, levelId,
node.start, node.start,
node.end, node.end,
node.curveOffset ?? 0, node.curveOffset ?? 0,
node.thickness, node.thickness,
) )
const slabElevation = slabSupport.elevation
const childrenIds = node.children || [] const childrenIds = node.children || []
// Merge live overrides into door / window children so cutouts track an // Merge live overrides into door / window children so cutouts track an
@@ -720,7 +722,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
return { ...effective, position: live.position } return { ...effective, position: live.position }
}) })
const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) const builtGeo = generateExtrudedWall(
node,
childrenNodes,
miterData,
slabElevation,
slabSupport.baseElevation,
slabSupport.baseSegments,
)
const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
// World transform the render mesh will apply (position + Y-rotation below). // World transform the render mesh will apply (position + Y-rotation below).
// Reproduce it here so the UVs can be projected in WORLD space — see // Reproduce it here so the UVs can be projected in WORLD space — see
@@ -737,7 +746,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
// Update collision mesh // Update collision mesh
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
if (collisionMesh) { if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation) const collisionGeo = generateExtrudedWall(
node,
[],
miterData,
slabElevation,
slabSupport.baseElevation,
slabSupport.baseSegments,
)
collisionMesh.geometry.dispose() collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo collisionMesh.geometry = collisionGeo
} }
@@ -823,13 +839,18 @@ export function generateExtrudedWall(
childrenNodes: AnyNode[], childrenNodes: AnyNode[],
miterData: WallMiterData, miterData: WallMiterData,
slabElevation = 0, slabElevation = 0,
baseElevation = slabElevation,
baseSegments: readonly WallSlabSupportSegment[] = [
{ start: 0, end: 1, elevation: baseElevation },
],
): THREE.BufferGeometry { ): THREE.BufferGeometry {
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Positive slab: shift the whole wall up (full height preserved)
// Negative slab: extend wall downward so top stays fixed at wallNode.height
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight
const effectiveBaseElevation = Math.min(baseElevation, slabElevation)
const localBottom = effectiveBaseElevation - slabElevation
const height = topElevation - effectiveBaseElevation
const thickness = getWallThickness(wallNode) const thickness = getWallThickness(wallNode)
@@ -888,12 +909,107 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y) // Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0)
geometry.computeVertexNormals() geometry.computeVertexNormals()
assignWallMaterialGroups(geometry, wallNode, boundaryEdges) assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
ensureRenderableGeometryAttributes(geometry) ensureRenderableGeometryAttributes(geometry)
// Apply CSG subtraction for cutouts (doors/windows) // Start with the lowest required wall prism, then remove the volume below
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) // each higher-supported run. This keeps the existing mitered footprint and
// opening CSG while giving one wall a stepped longitudinal base.
const baseProfileCutouts: Brush[] = []
for (const segment of baseSegments) {
const segmentElevation = Math.min(segment.elevation, slabElevation)
const cutHeight = segmentElevation - effectiveBaseElevation
if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7) continue
const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1)
const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1)
const cutHalfWidth = Math.max(thickness * 2, 0.2)
const worldCutoutPoints: Point2D[] = []
if (isCurvedWall(wallNode)) {
const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24))
const left: Point2D[] = []
const right: Point2D[] = []
for (let index = 0; index <= sampleCount; index++) {
const t = segmentStart + ((segmentEnd - segmentStart) * index) / sampleCount
const frame = getWallCurveFrameAt(wallNode, t)
const endpointExtension =
index === 0 && segmentStart <= 1e-7
? -cutHalfWidth
: index === sampleCount && segmentEnd >= 1 - 1e-7
? cutHalfWidth
: 0
const center = {
x: frame.point.x + frame.tangent.x * endpointExtension,
y: frame.point.y + frame.tangent.y * endpointExtension,
}
left.push({
x: center.x + frame.normal.x * cutHalfWidth,
y: center.y + frame.normal.y * cutHalfWidth,
})
right.push({
x: center.x - frame.normal.x * cutHalfWidth,
y: center.y - frame.normal.y * cutHalfWidth,
})
}
worldCutoutPoints.push(...left, ...right.reverse())
} else {
const tangentX = v.x / L
const tangentY = v.y / L
const normalX = -tangentY
const normalY = tangentX
const startExtension = segmentStart <= 1e-7 ? cutHalfWidth : 0
const endExtension = segmentEnd >= 1 - 1e-7 ? cutHalfWidth : 0
const startPoint = {
x: wallStart.x + tangentX * (segmentStart * L - startExtension),
y: wallStart.y + tangentY * (segmentStart * L - startExtension),
}
const endPoint = {
x: wallStart.x + tangentX * (segmentEnd * L + endExtension),
y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
}
worldCutoutPoints.push(
{
x: startPoint.x + normalX * cutHalfWidth,
y: startPoint.y + normalY * cutHalfWidth,
},
{ x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth },
{ x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth },
{
x: startPoint.x - normalX * cutHalfWidth,
y: startPoint.y - normalY * cutHalfWidth,
},
)
}
const localCutoutPoints = worldCutoutPoints.map(worldToLocal)
if (localCutoutPoints.length < 3) continue
const cutoutShape = new THREE.Shape()
cutoutShape.moveTo(localCutoutPoints[0]!.x, -localCutoutPoints[0]!.z)
for (let index = 1; index < localCutoutPoints.length; index++) {
cutoutShape.lineTo(localCutoutPoints[index]!.x, -localCutoutPoints[index]!.z)
}
cutoutShape.closePath()
const cutoutBottom = localBottom - 0.01
const cutoutTop = segmentElevation - slabElevation
const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
depth: cutoutTop - cutoutBottom,
bevelEnabled: false,
})
cutoutGeometry.rotateX(-Math.PI / 2)
cutoutGeometry.translate(0, cutoutBottom, 0)
computeGeometryBoundsTree(cutoutGeometry)
baseProfileCutouts.push(new Brush(cutoutGeometry))
}
// Apply base-profile and opening cutouts in one CSG pass.
const cutoutBrushes = [
...baseProfileCutouts,
...collectCutoutBrushes(wallNode, childrenNodes, thickness),
]
if (cutoutBrushes.length === 0) { if (cutoutBrushes.length === 0) {
const splitGeometry = splitGeometryAtHorizontalPlanes( const splitGeometry = splitGeometryAtHorizontalPlanes(
geometry, geometry,