diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index e16087c1..81c322e1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,12 +1,9 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import useScene from '../../store/use-scene' -import { - getWallCurveFrameAt, - isCurvedWall, - sampleWallCenterline, -} from '../../systems/wall/wall-curve' +import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' @@ -332,23 +329,70 @@ function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, return false } +/** Sub-interval along a segment or polyline: [start, end] in length units. */ +type LengthInterval = [number, number] + +function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { + if (intervals.length <= 1) return intervals + const sorted = [...intervals].sort((a, b) => a[0] - b[0]) + const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] + for (let i = 1; i < sorted.length; i++) { + const [intervalStart, intervalEnd] = sorted[i]! + const last = merged[merged.length - 1]! + if (intervalStart <= last[1] + 1e-9) { + last[1] = Math.max(last[1], intervalEnd) + } else { + merged.push([intervalStart, intervalEnd]) + } + } + return merged +} + +/** Total length of a merged (sorted, disjoint) interval list. */ +function intervalsLength(intervals: readonly LengthInterval[]): number { + let total = 0 + for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart + return total +} + +/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ +function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { + if (base.length === 0 || cut.length === 0) return mergeIntervals(base) + const cuts = mergeIntervals(cut) + const result: LengthInterval[] = [] + for (const [baseStart, baseEnd] of mergeIntervals(base)) { + let cursor = baseStart + for (const [cutStart, cutEnd] of cuts) { + if (cutEnd <= cursor) continue + if (cutStart >= baseEnd) break + if (cutStart > cursor) result.push([cursor, cutStart]) + cursor = cutEnd + if (cursor >= baseEnd) break + } + if (cursor < baseEnd) result.push([cursor, baseEnd]) + } + return result +} + /** - * Length of the sub-intervals of segment (ax,az)→(bx,bz) that lie inside the - * polygon or on its boundary. The segment is split at every crossing with a - * polygon edge and each sub-interval is classified by its midpoint, so no - * test point ever sits on a crossing. + * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, + * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the + * segment. The segment is split at every crossing with a polygon edge and + * each sub-interval is classified by its midpoint, so no test point ever + * sits on a crossing. */ -function segmentInsideLength( +function segmentInsideIntervals( ax: number, az: number, bx: number, bz: number, polygon: Array<[number, number]>, -): number { + includeBoundary: boolean, +): LengthInterval[] { const dx = bx - ax const dz = bz - az const length = Math.hypot(dx, dz) - if (length < 1e-9) return 0 + if (length < 1e-9) return [] const ts = [0, 1] const n = polygon.length @@ -365,7 +409,7 @@ function segmentInsideLength( } ts.sort((a, b) => a - b) - let inside = 0 + const inside: LengthInterval[] = [] for (let i = 1; i < ts.length; i++) { const t0 = ts[i - 1]! const t1 = ts[i]! @@ -373,27 +417,58 @@ function segmentInsideLength( const tm = (t0 + t1) / 2 const mx = ax + dx * tm const mz = az + dz * tm - if (pointOnPolygonBoundary(mx, mz, polygon) || pointInPolygon(mx, mz, polygon)) { - inside += (t1 - t0) * length - } + const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) + ? includeBoundary + : pointInPolygon(mx, mz, polygon) + if (midpointInside) inside.push([t0, t1]) } return inside } +function polylineLength(points: Array<{ x: number; y: number }>): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) + } + return total +} + +/** + * Inside sub-intervals of a polyline against a polygon, in cumulative + * arc-length units from the polyline start (merged, disjoint). Boundary + * contact counts as inside for slab support (walls sit exactly on slab + * edges — see ON_BOUNDARY_EPSILON above); hole callers pass + * `includeBoundary: false` so a wall running along a stairwell hole's + * rim keeps the rim's support. + */ +function polylineInsideIntervals( + points: Array<{ x: number; y: number }>, + polygon: Array<[number, number]>, + includeBoundary = true, +): LengthInterval[] { + const intervals: LengthInterval[] = [] + let offset = 0 + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]! + const b = points[i]! + const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) + if (segmentLength < 1e-9) continue + for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { + intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) + } + offset += segmentLength + } + return mergeIntervals(intervals) +} + function polylineInsideLength( points: Array<{ x: number; y: number }>, polygon: Array<[number, number]>, ): number { - let total = 0 - for (let i = 1; i < points.length; i++) { - const a = points[i - 1]! - const b = points[i]! - total += segmentInsideLength(a.x, a.y, b.x, b.y, polygon) - } - return total + return intervalsLength(polylineInsideIntervals(points, polygon)) } -type WallOverlapInput = { +export type WallOverlapInput = { start: [number, number] end: [number, number] curveOffset?: number @@ -503,11 +578,7 @@ export function wallOverlapsPolygon( const halfThickness = Math.max(thickness / 2, 0) const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const center = polylines[0]! - let centerLength = 0 - for (let i = 1; i < center.length; i++) { - centerLength += Math.hypot(center[i]!.x - center[i - 1]!.x, center[i]!.y - center[i - 1]!.y) - } + const centerLength = polylineLength(polylines[0]!) if (centerLength < 1e-9) return false let overlap = 0 @@ -518,6 +589,220 @@ export function wallOverlapsPolygon( return overlap >= threshold } +// A slab elevation must support at least this fraction of the wall's +// length before it can dictate the wall's base. Below majority, a raised +// slab reaching one endpoint would hoist the whole wall off the floor +// that actually carries it. +const WALL_SLAB_SUPPORT_MAJORITY = 0.5 + +// Slabs whose elevations differ by less than this pool their support: +// a wall shared between two rooms' slabs is covered roughly half by +// each, and must still follow their common elevation. +const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 + +/** + * Base elevation for a wall, decided by which slabs actually SUPPORT it. + * + * Support is measured as covered length: the wall's centerline and face + * lines are clipped against each slab's RENDERED footprint + * (`getRenderableSlabPolygon` with the level walls + siblings, not the + * stored polygon — legacy polygons stored at wall faces or with old + * baked offsets fall short of the wall body, but their band-adopted + * rendered edge reaches the wall's outer face) minus the slab's stored + * holes (holes are data, never render-offset). A slab supporting less + * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point + * contact, endpoint grazes). + * + * Same-elevation slabs pool their coverage. `elevation` preserves the + * existing wall-relative origin: the highest elevation covering at + * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered + * elevation when none reaches majority. `baseElevation` only fills down + * where a lower support remains exposed on a wall face after higher, + * overlapping support is accounted for. Coincident floor/platform slabs + * therefore keep the wall on the platform, while slabs on opposite wall + * sides bridge correctly. A slab touching only one endpoint never enters + * either result. Pure; + * exported for tests. + */ +export type WallSlabSupport = { + /** Existing wall-relative floor elevation used by hosted children and wall height. */ + elevation: number + /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ + baseElevation: number + /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ + baseSegments: WallSlabSupportSegment[] +} + +export type WallSlabSupportSegment = { + start: number + end: number + elevation: number +} + +export function computeWallSlabSupport( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): WallSlabSupport { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const polylineLengths = polylines.map(polylineLength) + const wallLength = polylineLengths[0]! + if (wallLength < 1e-9) { + return { elevation: 0, baseElevation: 0, baseSegments: [] } + } + + const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) + + type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] } + const groups: ElevationGroup[] = [] + + for (const slab of slabs) { + if (slab.polygon.length < 3) continue + const renderedPolygon = getRenderableSlabPolygon(slab, { + walls: levelWalls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + + let supported = 0 + const perPolyline = polylines.map((line) => { + let intervals = polylineInsideIntervals(line, renderedPolygon) + for (const hole of slab.holes || []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + supported = Math.max(supported, intervalsLength(intervals)) + return intervals + }) + if (supported < minSupport) continue + + const elevation = slab.elevation ?? 0.05 + let group = groups.find( + (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!group) { + group = { elevation, perPolyline: polylines.map(() => []) } + groups.push(group) + } + for (let i = 0; i < perPolyline.length; i++) { + group.perPolyline[i]!.push(...perPolyline[i]!) + } + } + + type EvaluatedGroup = ElevationGroup & { + coverage: number + mergedPerPolyline: LengthInterval[][] + } + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { + let coverage = 0 + const mergedPerPolyline = group.perPolyline.map(mergeIntervals) + for (let i = 0; i < group.perPolyline.length; i++) { + const lineLength = polylineLengths[i]! + if (lineLength < 1e-9) continue + coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) + } + return { ...group, coverage, mergedPerPolyline } + }) + + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const group of evaluatedGroups) { + if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, group.elevation) + } + if ( + group.coverage > bestCoverage + 1e-6 || + (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) + ) { + bestCoverage = group.coverage + bestElevation = group.elevation + } + } + + const elevation = + majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { + const lineLength = polylineLengths[polylineIndex]! + if (lineLength < 1e-9) return [] + return group.mergedPerPolyline[polylineIndex]!.map( + ([intervalStart, intervalEnd]) => + [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, + ) + } + + const normalizedByGroup = evaluatedGroups.map((group) => ({ + elevation: group.elevation, + perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), + })) + const breakpoints = [0, 1] + for (const group of normalizedByGroup) { + for (const intervals of group.perPolyline) { + for (const [intervalStart, intervalEnd] of intervals) { + breakpoints.push(intervalStart, intervalEnd) + } + } + } + breakpoints.sort((left, right) => left - right) + const uniqueBreakpoints = breakpoints.filter( + (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, + ) + + const highestAt = (polylineIndex: number, t: number) => { + let highest = Number.NEGATIVE_INFINITY + for (const group of normalizedByGroup) { + if ( + group.perPolyline[polylineIndex]?.some( + ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, + ) + ) { + highest = Math.max(highest, group.elevation) + } + } + return highest + } + + const baseSegments: WallSlabSupportSegment[] = [] + for (let index = 1; index < uniqueBreakpoints.length; index++) { + const start = uniqueBreakpoints[index - 1]! + const end = uniqueBreakpoints[index]! + if (end - start < 1e-7) continue + const midpoint = (start + end) / 2 + const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) + const segmentElevation = + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + const previous = baseSegments[baseSegments.length - 1] + if ( + previous && + Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON + ) { + previous.end = end + } else { + baseSegments.push({ start, end, elevation: segmentElevation }) + } + } + + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) + const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) + return { elevation, baseElevation, baseSegments } +} + +export function computeWallSlabElevation( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): number { + return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +} + export class SpatialGridManager { private readonly floorGrids = new Map() // levelId -> grid private readonly wallGrids = new Map() // levelId -> wall grid @@ -959,7 +1244,6 @@ export class SpatialGridManager { /** * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). - * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments). * Returns the highest slab elevation found, or 0 if none. * * Accepts an optional `curveOffset` so curved walls evaluate overlap @@ -972,55 +1256,66 @@ export class SpatialGridManager { curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, ): number { + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation + } + + getSlabSupportForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + ): WallSlabSupport { const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 - - const wallLike: WallOverlapInput = { start, end, curveOffset, thickness } - const isCurved = curveOffset !== 0 && isCurvedWall(wallLike) - const holeSamplePoints: Array<{ x: number; y: number }> = isCurved - ? sampleWallCenterline(wallLike, 8) - : [0, 0.25, 0.5, 0.75, 1].map((t) => ({ - x: start[0] + (end[0] - start[0]) * t, - y: start[1] + (end[1] - start[1]) * t, - })) - - let maxElevation = Number.NEGATIVE_INFINITY - for (const slab of slabMap.values()) { - if (slab.polygon.length < 3) continue - if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue - - const holes = slab.holes || [] - if (holes.length === 0) { - // No holes: wall is on this slab - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) maxElevation = elevation - continue - } - - // Sample multiple points along the wall to check whether any portion lies on - // solid slab (not inside any hole). Checking only the midpoint fails when the - // midpoint falls in a staircase hole but the wall's endpoints are on solid slab. - let hasValidPoint = false - for (const sample of holeSamplePoints) { - let inHole = false - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) { - inHole = true - break - } - } - if (!inHole) { - hasValidPoint = true - break - } - } - - if (hasValidPoint) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) maxElevation = elevation + if (!slabMap) { + return { + elevation: 0, + baseElevation: 0, + baseSegments: [{ start: 0, end: 1, elevation: 0 }], } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + + return computeWallSlabSupport( + { start, end, curveOffset, thickness }, + [...slabMap.values()], + this.getLevelWallNodes(levelId), + ) + } + + /** + * Walls on a level, resolved fresh from the scene store (the manager's + * own wall map is only maintained on create/delete, not on updates). + * Cached per scene `nodes` record so per-pointer-tick callers + * (door/window move) don't rescan the node map. + */ + private readonly levelWallsCache = new WeakMap>() + + private getLevelWallNodes(levelId: string): WallNode[] { + const nodes = useScene.getState().nodes + let byLevel = this.levelWallsCache.get(nodes) + if (!byLevel) { + byLevel = new Map() + this.levelWallsCache.set(nodes, byLevel) + } + const cached = byLevel.get(levelId) + if (cached) return cached + + const walls: WallNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + // Walk the parent chain to the owning level (guarded against cycles). + let current: AnyNode | undefined = node + let guard = 0 + while (current && current.type !== 'level' && guard < 16) { + current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined + guard += 1 + } + if (current?.type === 'level' && current.id === levelId) { + walls.push(node as WallNode) + } + } + byLevel.set(levelId, walls) + return walls } /** diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index ff85ff5a..8f37156e 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,3 +1,4 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import useScene from '../../store/use-scene' @@ -189,6 +190,25 @@ function markNodesOverlappingSlab( if (slab.polygon.length < 3) return const slabLevelId = resolveLevelId(slab, nodes) + // Walls follow the slab's RENDERED footprint (band-adopted edges reach + // the wall's outer face), so the dirty gate must test the same polygon + // `getSlabElevationForWall` will re-evaluate — a stored polygon that + // stops short of the wall body would otherwise never re-elevate it. + const levelWalls: WallNode[] = [] + const siblingSlabs: SlabNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) { + levelWalls.push(node as WallNode) + } else if ( + node.type === 'slab' && + node.id !== slab.id && + resolveLevelId(node, nodes) === slabLevelId + ) { + siblingSlabs.push(node as SlabNode) + } + } + const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + for (const node of Object.values(nodes)) { if (node.type === 'wall') { const wall = node as WallNode @@ -201,7 +221,7 @@ function markNodesOverlappingSlab( curveOffset: wall.curveOffset ?? 0, thickness: wall.thickness, }, - slab.polygon, + renderedPolygon, ) ) { markDirty(node.id) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index 5abbeaaf..b1da3b33 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'bun:test' -import { wallOverlapsPolygon } from './spatial-grid-manager' +import { SlabNode, WallNode } from '../../schema' +import { + computeWallSlabElevation, + computeWallSlabSupport, + wallOverlapsPolygon, +} from './spatial-grid-manager' // 4×4 square slab, like an auto-slab derived from a room's wall centerlines. const SLAB: Array<[number, number]> = [ @@ -73,3 +78,402 @@ describe('wallOverlapsPolygon', () => { expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false) }) }) + +describe('computeWallSlabElevation', () => { + const parseWall = (start: [number, number], end: [number, number], thickness = 0.1) => + WallNode.parse({ start, end, thickness }) + + it('lifts a wall standing on an auto slab stored at the centerlines', () => { + const walls = [ + parseWall([0, 0], [4, 0]), + parseWall([4, 0], [4, 4]), + parseWall([4, 4], [0, 4]), + parseWall([0, 4], [0, 0]), + ] + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) + + const bottom = walls[0]! + expect( + computeWallSlabElevation( + { start: bottom.start, end: bottom.end, thickness: bottom.thickness }, + [slab], + walls, + ), + ).toBeCloseTo(0.1) + }) + + it('lifts a wall whose body a legacy stored polygon falls short of', () => { + // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — + // 1cm short of even the inner faces, so the STORED polygon never + // touches the wall body and the old stored-polygon test returned 0. + // The rendered footprint band-adopts the edges out to the outer + // faces, so the wall stands on the slab. + const walls = [ + parseWall([0, 0], [4, 0]), + parseWall([4, 0], [4, 4]), + parseWall([4, 4], [0, 4]), + parseWall([0, 4], [0, 0]), + ] + const slab = SlabNode.parse({ + polygon: [ + [0.06, 0.06], + [3.94, 0.06], + [3.94, 3.94], + [0.06, 3.94], + ], + elevation: 0.1, + }) + + const bottom = walls[0]! + expect( + computeWallSlabElevation( + { start: bottom.start, end: bottom.end, thickness: bottom.thickness }, + [slab], + walls, + ), + ).toBeCloseTo(0.1) + }) + + it('does not lift a wall clearly off the slab', () => { + const walls = [parseWall([0, 0], [4, 0])] + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) + + expect( + computeWallSlabElevation({ start: [0, -1], end: [4, -1], thickness: 0.1 }, [slab], walls), + ).toBe(0) + }) + + it('ignores a slab when the wall runs entirely inside a hole', () => { + const walls = [parseWall([1, 2], [3, 2])] + const slab = SlabNode.parse({ + polygon: SLAB, + elevation: 0.1, + holes: [ + [ + [0.5, 0.5], + [3.5, 0.5], + [3.5, 3.5], + [0.5, 3.5], + ], + ], + }) + + expect( + computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [slab], walls), + ).toBe(0) + }) + + it('keeps a wall on the lower slab when a higher slab only reaches one endpoint', () => { + // The floating-wall bug: a wall standing on the low room whose far + // endpoint pokes 0.2m onto a raised platform must NOT lift wholesale. + const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + const high = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 4], + [4, 4], + ], + elevation: 0.6, + }) + + expect( + computeWallSlabElevation({ start: [0.5, 2], end: [4.2, 2], thickness: 0.1 }, [low, high], []), + ).toBeCloseTo(0.05) + }) + + it('keeps a curved wall on the lower slab when a higher slab only reaches its end', () => { + const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + const high = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 4], + [4, 4], + ], + elevation: 0.6, + }) + + expect( + computeWallSlabElevation( + { start: [0.5, 2], end: [4.2, 2], curveOffset: 0.5, thickness: 0.1 }, + [low, high], + [], + ), + ).toBeCloseTo(0.05) + }) + + it('lifts a wall standing fully on a raised platform', () => { + const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 }) + + expect( + computeWallSlabElevation({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [platform], []), + ).toBeCloseTo(0.6) + }) + + it('lifts a wall half on a raised platform, half in the air, onto the platform', () => { + // No elevation reaches majority (only 39% supported), so the + // best-covered slab wins — the only alternative would bury the + // supported half inside the platform. + const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 }) + + expect( + computeWallSlabElevation({ start: [0.1, 2], end: [10.1, 2], thickness: 0.1 }, [platform], []), + ).toBeCloseTo(0.6) + }) + + it('pools same-elevation slabs so a shared wall follows their common level', () => { + // Rooms A and B at the same elevation each cover exactly half the + // wall (interior edges seam at the x=2 midline); a raised slab covers + // just under half. Pooled, the common level covers 100% and must + // win — without pooling the raised slab's 0.4975 would beat either + // half alone. + const roomA = SlabNode.parse({ + polygon: [ + [0, 0], + [2, 0], + [2, 4], + [0, 4], + ], + elevation: 0.1, + }) + const roomB = SlabNode.parse({ + polygon: [ + [2, 0], + [4, 0], + [4, 4], + [2, 4], + ], + elevation: 0.1, + }) + const raised = SlabNode.parse({ + polygon: [ + [1.0, 1], + [2.99, 1], + [2.99, 3], + [1.0, 3], + ], + elevation: 0.6, + }) + + expect( + computeWallSlabElevation( + { start: [0, 2], end: [4, 2], thickness: 0.1 }, + [roomA, roomB, raised], + [], + ), + ).toBeCloseTo(0.1) + }) + + it('prefers the higher of two majority-supporting slabs (platform stacked on a floor)', () => { + const floor = SlabNode.parse({ + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + elevation: 0.05, + }) + const platform = SlabNode.parse({ + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + elevation: 0.6, + }) + + // Wall 6m long: floor covers all of it, platform covers ~2/3 — both + // majorities, and the wall physically rests on the platform. + expect( + computeWallSlabElevation( + { start: [1, 2], end: [7, 2], thickness: 0.1 }, + [floor, platform], + [], + ), + ).toBeCloseTo(0.6) + }) + + it('keeps a wall pushed up on a raised platform above a coincident floor', () => { + const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + const platform = SlabNode.parse({ polygon: SLAB, elevation: 0.6 }) + + expect( + computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []), + ).toEqual({ + elevation: 0.6, + baseElevation: 0.6, + baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], + }) + }) + + it('fills down only when a lower support is exposed beyond a partial platform', () => { + const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + const platform = SlabNode.parse({ + polygon: [ + [0, 0], + [2.5, 0], + [2.5, 4], + [0, 4], + ], + elevation: 0.6, + }) + + expect( + computeWallSlabSupport( + { start: [0.5, 2], end: [3.5, 2], thickness: 0.1 }, + [floor, platform], + [], + ), + ).toEqual({ + elevation: 0.6, + baseElevation: 0.05, + baseSegments: [ + { start: 0, end: 2 / 3, elevation: 0.6 }, + { start: 2 / 3, end: 1, elevation: 0.05 }, + ], + }) + }) + + it('keeps a shared wall on the higher slab that carries the full wall band', () => { + const sharedWall = parseWall([4, 0], [4, 4]) + const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + const high = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 4], + [4, 4], + ], + elevation: 0.6, + }) + + expect( + computeWallSlabSupport( + { start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness }, + [low, high], + [sharedWall], + ), + ).toEqual({ + elevation: 0.6, + baseElevation: 0.6, + baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], + }) + }) + + it('profiles an offset-room wall as high-only, shared, then low-only', () => { + const sharedWall = parseWall([4, 0], [4, 4.5]) + const walls = [ + parseWall([0, 0], [4, 0]), + parseWall([0, 3], [0, 0]), + parseWall([0, 3], [4, 3]), + sharedWall, + parseWall([4, 1.5], [8, 1.5]), + parseWall([8, 1.5], [8, 4.5]), + parseWall([8, 4.5], [4, 4.5]), + ] + const high = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + elevation: 0.6, + }) + const low = SlabNode.parse({ + polygon: [ + [4, 1.5], + [8, 1.5], + [8, 4.5], + [4, 4.5], + ], + elevation: 0.05, + }) + + expect( + computeWallSlabSupport( + { start: sharedWall.start, end: sharedWall.end, thickness: sharedWall.thickness }, + [high, low], + walls, + ), + ).toEqual({ + elevation: 0.6, + baseElevation: 0.05, + baseSegments: [ + { start: 0, end: 3.05 / 4.5, elevation: 0.6 }, + { start: 3.05 / 4.5, end: 1, elevation: 0.05 }, + ], + }) + }) + + it('lifts a tiny stub wall standing fully on a slab', () => { + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.3 }) + + expect( + computeWallSlabElevation({ start: [2, 2], end: [2.08, 2], thickness: 0.1 }, [slab], []), + ).toBeCloseTo(0.3) + }) + + it('does not lift a wall whose run over a higher slab is mostly inside a hole', () => { + const low = SlabNode.parse({ + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + elevation: 0.05, + }) + const high = SlabNode.parse({ + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + elevation: 0.6, + holes: [ + [ + [0.5, 0], + [8, 0], + [8, 4], + [0.5, 4], + ], + ], + }) + + // Net high support is only x ∈ [0, 0.5]; the low slab carries the wall. + expect( + computeWallSlabElevation({ start: [0, 2], end: [8, 2], thickness: 0.1 }, [low, high], []), + ).toBeCloseTo(0.05) + }) + + it('keeps support for a wall running along a hole rim', () => { + // Hole boundaries count as solid: the wall ringing a stairwell sits + // on the rim, its outer face on solid slab. + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [6, 0], + [6, 6], + [0, 6], + ], + elevation: 0.4, + holes: [ + [ + [2, 2], + [4, 2], + [4, 4], + [2, 4], + ], + ], + }) + + expect( + computeWallSlabElevation({ start: [2, 2], end: [4, 2], thickness: 0.1 }, [slab], []), + ).toBeCloseTo(0.4) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0857b4c..e51840b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,7 +48,11 @@ export { getFloorPlacedFootprints, getFloorStackedPosition, } from './hooks/spatial-grid/floor-placed-elevation' -export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' +export { + pointInPolygon, + spatialGridManager, + type WallSlabSupportSegment, +} from './hooks/spatial-grid/spatial-grid-manager' export { findLevelAncestorId, initSpatialGridSync, @@ -75,7 +79,13 @@ export { segmentsIntersect, } from './lib/polygon-relations' export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' -export { getRenderableSlabPolygon } from './lib/slab-polygon' +export { + getRenderableSlabPolygon, + type SlabEdgeWallBandSnap, + type SlabPolygonContext, + slabPolygonContextFromGeometry, + snapSlabEdgeToWallBand, +} from './lib/slab-polygon' export { deriveSlotId, isSlotMaterialName, diff --git a/packages/core/src/lib/polygon-geometry.ts b/packages/core/src/lib/polygon-geometry.ts index b2689b84..9ac9da45 100644 --- a/packages/core/src/lib/polygon-geometry.ts +++ b/packages/core/src/lib/polygon-geometry.ts @@ -1,28 +1,3 @@ -export function insetPolygonFromCentroid( - polygon: Array<[number, number]>, - inset: number, -): Array<[number, number]> { - if (inset <= 0) { - return polygon.map(([x, z]) => [x, z] as [number, number]) - } - - const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 }) - centroid.x /= Math.max(polygon.length, 1) - centroid.z /= Math.max(polygon.length, 1) - - return polygon.map(([x, z]) => { - const dx = x - centroid.x - const dz = z - centroid.z - const length = Math.hypot(dx, dz) - if (length <= inset + 1e-6) { - return [x, z] as [number, number] - } - - const scale = (length - inset) / length - return [centroid.x + dx * scale, centroid.z + dz * scale] as [number, number] - }) -} - function pointLineDistance( point: [number, number], start: [number, number], diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts new file mode 100644 index 00000000..d86006c6 --- /dev/null +++ b/packages/core/src/lib/slab-polygon.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index 4dd2f4bf..23a196e2 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -1,27 +1,700 @@ -import type { SlabNode } from '../schema' -import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry' +import type { GeometryContext } from '../registry/types' +import type { AnyNodeId, SlabNode, WallNode } from '../schema' +import { isCurvedWall, sampleWallCenterline } from '../systems/wall/wall-curve' +import { getWallThickness } from '../systems/wall/wall-footprint' -/** Half of default wall thickness — used to extend slab geometry under walls */ -const SLAB_OUTSET = 0.05 -const AUTO_SLAB_INSET = 0.02 -const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 +/** + * Render-time slab polygon rules. + * + * Slab nodes store the wall-centerline polygon (auto slabs) or the drawn + * polygon (manual slabs) — render offsets are NEVER stored in node data. + * At geometry build time each polygon edge is SPLIT at the clipped span + * boundaries of every overlapping candidate (sibling slab edges and wall + * centerlines in the adoption band) and each sub-edge is classified + * independently — a single stored edge can be backed differently along + * its span (two rooms offset diagonally share a wall only where they + * overlap, so one room's edge is interior beside the sibling and its own + * facade elsewhere). Each sub-edge is PROJECTED onto an absolute target + * line: + * + * - INTERIOR — a sibling slab has a collinear, overlapping edge across + * it (directly, or across the same wall's footprint band). Projected + * EXACTLY onto a shared line so both neighbours emit the same seam + * and tile with no gap or overlap. Equal-height rooms partition the + * real wall band at its centerline. At an unequal-height boundary, the + * higher slab carries the full band to the lower room's wall face and + * the lower slab terminates at that same plane. This closes the step + * below the raised wall without lowering the wall or exposing a pocket. + * Without a wall, the target is the midline between the stored edges. + * The coincident vertical seam faces carry opposite outward + * normals and every slab material is front-side, so at most one face + * renders per view — no z-fighting. + * - WALL-BACKED — no slab neighbour across the edge, but the sub-edge + * lies inside a wall's footprint band (lateral distance from the + * centerline within half-thickness + adoption tolerance). Projected + * to the wall's OUTER face line so the slab reaches flush with the + * facade regardless of where the stored edge sits inside the band — + * this self-heals legacy data stored at wall faces or with old baked + * render offsets. + * - FREE — no neighbour, no wall. Rendered exactly as drawn. + * + * Sub-edges of one edge with different projections are joined by a + * perpendicular STEP connector at the breakpoint. Breakpoints sit on + * candidate span boundaries — wall junctions — so the step's vertical + * face lands inside the crossing wall's footprint and stays hidden + * under the wall body. + */ -export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { - return slabNode.autoFromWalls - ? simplifyClosedPolygon( - insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), - AUTO_SLAB_SIMPLIFY_TOLERANCE, - ) - : outsetPolygon(slabNode.polygon, SLAB_OUTSET) +/** Lateral distance within which a sibling slab edge counts as directly "across" an edge. */ +const SLAB_NEIGHBOR_LATERAL_TOLERANCE = 0.05 +/** + * Extra lateral tolerance beyond a wall's half-thickness for band + * adoption. 0.06 keeps every previously-adopted edge adopted (the old + * fixed 0.1 tolerance equals half-thickness + 0.05 for the thinnest + * 0.1 walls, and auto slab polygons simplified with a 0.08 tolerance on + * curved walls stay inside half + 0.06), while giving hand-adjusted + * edges ~6cm of slack on either side of the wall faces. + */ +const WALL_ADOPTION_TOLERANCE = 0.06 +/** Minimum collinear overlap (m) before a candidate drives the classification. */ +const MIN_CLASSIFYING_OVERLAP = 0.05 +/** + * Minimum sub-edge length (m) when splitting an edge at candidate span + * boundaries. Breakpoints closer than this to the previous one or to an + * edge endpoint are dropped, so slivers never survive into the ring. + */ +const MIN_SUBEDGE_LENGTH = 0.05 +/** + * Two candidate walls whose centerlines sit within this lateral + * difference count as equally near; the larger span overlap wins + * (collinear runs of different-thickness walls along one edge). + * Otherwise the nearest centerline wins (parallel close walls). + */ +const WALL_LATERAL_TIE_EPSILON = 0.02 +const CURVED_WALL_SAMPLE_SEGMENTS = 32 +const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 +const DEFAULT_SLAB_ELEVATION = 0.05 + +export type SlabPolygonContext = { + /** Walls on the slab's level. */ + walls: WallNode[] + /** Other slabs on the same level — the slab itself must be excluded. */ + siblingSlabs: SlabNode[] +} + +/** [ax, az, bx, bz] segment in plan space. */ +type Segment = [number, number, number, number] + +/** A sibling slab edge and the direction of its polygon interior. */ +type NeighborSegment = { + segment: Segment + elevation: number + /** Unit normal pointing into the sibling polygon at this edge. */ + inwardX: number + inwardZ: number +} + +function polygonWindingSign(polygon: Array<[number, number]>): 1 | -1 { + let area2 = 0 + for (let index = 0; index < polygon.length; index += 1) { + const next = (index + 1) % polygon.length + area2 += polygon[index]![0] * polygon[next]![1] - polygon[next]![0] * polygon[index]![1] + } + return area2 >= 0 ? 1 : -1 } /** - * Expand a polygon outward by a uniform distance. - * Offsets each edge outward then intersects consecutive offset edges. + * Derive a {@link SlabPolygonContext} from a registry `GeometryContext`: + * sibling slabs come from `ctx.siblings` (same kind, same parent, self + * excluded) and walls from the parent level's children. */ -function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> { +export function slabPolygonContextFromGeometry( + ctx: GeometryContext | undefined, +): SlabPolygonContext { + if (!ctx) return { walls: [], siblingSlabs: [] } + + const siblingSlabs = ctx.siblings.filter( + (node): node is SlabNode => node.type === 'slab', + ) as SlabNode[] + + const walls: WallNode[] = [] + const parentChildIds = (ctx.parent as { children?: AnyNodeId[] } | null)?.children + if (Array.isArray(parentChildIds)) { + for (const childId of parentChildIds) { + const child = ctx.resolve(childId) + if ((child as { type?: string } | undefined)?.type === 'wall') { + walls.push(child as WallNode) + } + } + } + + return { walls, siblingSlabs } +} + +export function getRenderableSlabPolygon( + slabNode: SlabNode, + context: SlabPolygonContext, +): Array<[number, number]> { + const polygon = slabNode.polygon + if (polygon.length < 3) { + return polygon.map(([x, z]) => [x, z] as [number, number]) + } + + const subSpans = computeEdgeSubSpans( + polygon, + slabNode.elevation ?? DEFAULT_SLAB_ELEVATION, + context, + ) + if (subSpans.every((spans) => spans.length === 1 && spans[0]!.offset === 0)) { + return polygon.map(([x, z]) => [x, z] as [number, number]) + } + + return offsetPolygonPerEdge(polygon, subSpans) +} + +type SegmentMatch = { + /** Overlap (m) between the candidate and the edge span, along the edge axis. */ + overlap: number + /** + * Signed lateral distance of the candidate from the edge's infinite + * line, measured at the middle of the clipped span, along the edge's + * left normal `n = (dirZ, -dirX)`. + */ + lateral: number + /** Clipped span start along the edge axis (param from the edge start). */ + start: number + /** Clipped span end along the edge axis (param from the edge start). */ + end: number +} + +/** + * Clip the candidate segment `(px, pz) → (qx, qz)` against the span of + * the edge starting at `(ax, az)` with normalized direction + * `(dirX, dirZ)`, counting only when the candidate is collinear with + * the edge within `lateralTolerance` (both candidate endpoints within + * that distance of the edge's infinite line — testing against the + * infinite line, not the edge span, is what lets a T-junction + * sub-segment match its longer host edge). + */ +function clipCollinearSegment( + ax: number, + az: number, + dirX: number, + dirZ: number, + edgeLength: number, + px: number, + pz: number, + qx: number, + qz: number, + lateralTolerance: number, +): SegmentMatch | null { + const relPX = px - ax + const relPZ = pz - az + const latP = relPX * dirZ - relPZ * dirX + if (Math.abs(latP) > lateralTolerance) return null + const relQX = qx - ax + const relQZ = qz - az + const latQ = relQX * dirZ - relQZ * dirX + if (Math.abs(latQ) > lateralTolerance) return null + + const t0 = relPX * dirX + relPZ * dirZ + const t1 = relQX * dirX + relQZ * dirZ + const low = Math.max(Math.min(t0, t1), 0) + const high = Math.min(Math.max(t0, t1), edgeLength) + const overlap = high - low + if (overlap <= 0) return null + + const tMid = (low + high) / 2 + const u = Math.abs(t1 - t0) < 1e-12 ? 0.5 : (tMid - t0) / (t1 - t0) + return { overlap, lateral: latP + (latQ - latP) * u, start: low, end: high } +} + +function wallCenterlineSegments(wall: WallNode): Segment[] { + if (!isCurvedWall(wall)) { + return [[wall.start[0], wall.start[1], wall.end[0], wall.end[1]]] + } + + const points = sampleWallCenterline(wall, CURVED_WALL_SAMPLE_SEGMENTS) + const segments: Segment[] = [] + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]! + const to = points[index + 1]! + segments.push([from.x, from.y, to.x, to.y]) + } + return segments +} + +type WallCandidate = { + wall: WallNode + segments: Segment[] + halfThickness: number +} + +type WallBandMatch = { + wall: WallNode + halfThickness: number + /** Total collinear overlap (m) of the wall centerline with the edge span. */ + overlap: number + /** + * Overlap-weighted mean signed lateral distance of the wall + * centerline from the edge line, along `n = (dirZ, -dirX)`. + */ + lateral: number +} + +/** + * Best wall whose footprint band contains the edge: the centerline is + * collinear within half-thickness + {@link WALL_ADOPTION_TOLERANCE} and + * span-overlaps the edge by at least `requiredOverlap`. Nearest + * centerline wins; near-ties fall back to the larger overlap. + */ +function matchEdgeWallBand( + ax: number, + az: number, + dirX: number, + dirZ: number, + edgeLength: number, + requiredOverlap: number, + candidates: readonly WallCandidate[], +): WallBandMatch | null { + let best: WallBandMatch | null = null + for (const candidate of candidates) { + const tolerance = candidate.halfThickness + WALL_ADOPTION_TOLERANCE + let overlap = 0 + let weightedLateral = 0 + for (const [px, pz, qx, qz] of candidate.segments) { + const match = clipCollinearSegment(ax, az, dirX, dirZ, edgeLength, px, pz, qx, qz, tolerance) + if (!match) continue + overlap += match.overlap + weightedLateral += match.lateral * match.overlap + } + if (overlap < requiredOverlap) continue + + const lateral = weightedLateral / overlap + if (!best) { + best = { wall: candidate.wall, halfThickness: candidate.halfThickness, overlap, lateral } + continue + } + const bestAbs = Math.abs(best.lateral) + const thisAbs = Math.abs(lateral) + const nearTie = Math.abs(thisAbs - bestAbs) <= WALL_LATERAL_TIE_EPSILON + if (nearTie ? overlap > best.overlap : thisAbs < bestAbs) { + best = { wall: candidate.wall, halfThickness: candidate.halfThickness, overlap, lateral } + } + } + return best +} + +export type SlabEdgeWallBandSnap = { + wallId: WallNode['id'] + /** The candidate edge translated perpendicular onto the wall centerline. */ + edge: [[number, number], [number, number]] +} + +/** + * Reshape-snap counterpart of the render band rule: when the edge + * `a → b` lies inside a wall's footprint band, return the edge + * translated onto that wall's CENTERLINE — the canonical stored + * position (matching what auto slabs store); the render rule then + * places it at the face. `maxLateral` optionally tightens the stick + * distance (non-magnetic modes keep only a connect-radius stick). + */ +export function snapSlabEdgeToWallBand( + a: [number, number], + b: [number, number], + walls: readonly WallNode[], + options?: { maxLateral?: number }, +): SlabEdgeWallBandSnap | null { + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const edgeLength = Math.hypot(dx, dz) + if (edgeLength < 1e-9) return null + const dirX = dx / edgeLength + const dirZ = dz / edgeLength + const requiredOverlap = Math.min(MIN_CLASSIFYING_OVERLAP, edgeLength * 0.5) + + const candidates: WallCandidate[] = walls.map((wall) => ({ + wall, + segments: wallCenterlineSegments(wall), + halfThickness: getWallThickness(wall) / 2, + })) + + const match = matchEdgeWallBand(a[0], a[1], dirX, dirZ, edgeLength, requiredOverlap, candidates) + if (!match) return null + if (options?.maxLateral !== undefined && Math.abs(match.lateral) > options.maxLateral) return null + + const nx = dirZ * match.lateral + const nz = -dirX * match.lateral + return { + wallId: match.wall.id, + edge: [ + [a[0] + nx, a[1] + nz], + [b[0] + nx, b[1] + nz], + ], + } +} + +/** + * A contiguous run of one polygon edge sharing a single classification. + * `start`/`end` are arc-length params from the edge start; `offset` is + * along the edge's outward normal (negative insets). `key` names the + * classification + target so same-target neighbours re-fuse. + */ +type EdgeSubSpan = { + start: number + end: number + offset: number + key: string +} + +/** + * Split every polygon edge at candidate span boundaries and classify + * each sub-span independently. Returns one non-empty span list per + * edge, covering [0, edgeLength] without gaps. + */ +function computeEdgeSubSpans( + polygon: Array<[number, number]>, + selfElevation: number, + context: SlabPolygonContext, +): EdgeSubSpan[][] { const n = polygon.length - if (n < 3) return polygon + + // Winding sign: the outward normal of an edge with direction `dir` is + // `s * (dirZ, -dirX)`, so a target lateral `L` measured along + // `(dirZ, -dirX)` is `s * L` along the outward normal. + const s = polygonWindingSign(polygon) + + const neighborSegments: NeighborSegment[] = [] + for (const sibling of context.siblingSlabs) { + const siblingPolygon = sibling.polygon + if (siblingPolygon.length < 2) continue + const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION + const siblingWinding = polygonWindingSign(siblingPolygon) + for (let index = 0; index < siblingPolygon.length; index += 1) { + const from = siblingPolygon[index]! + const to = siblingPolygon[(index + 1) % siblingPolygon.length]! + const dx = to[0] - from[0] + const dz = to[1] - from[1] + const length = Math.hypot(dx, dz) + if (length < 1e-9) continue + neighborSegments.push({ + segment: [from[0], from[1], to[0], to[1]], + elevation, + inwardX: (-siblingWinding * dz) / length, + inwardZ: (siblingWinding * dx) / length, + }) + } + } + + const wallCandidates: WallCandidate[] = context.walls.map((wall) => ({ + wall, + segments: wallCenterlineSegments(wall), + halfThickness: getWallThickness(wall) / 2, + })) + + // Sibling breakpoints also matter for legacy face-aligned polygons up + // to a full wall band away from the edge (the band-sibling interior + // rule in classifySpan), so clip them with the widest band reach any + // level wall allows. Over-collection is harmless — same-target + // neighbours re-fuse after classification. + let siblingBreakTolerance = SLAB_NEIGHBOR_LATERAL_TOLERANCE + for (const candidate of wallCandidates) { + siblingBreakTolerance = Math.max( + siblingBreakTolerance, + 2 * (candidate.halfThickness + WALL_ADOPTION_TOLERANCE), + ) + } + + const subSpans: EdgeSubSpan[][] = [] + for (let index = 0; index < n; index += 1) { + const a = polygon[index]! + const b = polygon[(index + 1) % n]! + const dx = b[0] - a[0] + const dz = b[1] - a[1] + const edgeLength = Math.hypot(dx, dz) + if (edgeLength < 1e-9) { + subSpans.push([{ start: 0, end: edgeLength, offset: 0, key: 'free' }]) + continue + } + const dirX = dx / edgeLength + const dirZ = dz / edgeLength + + const rawBreakpoints: number[] = [] + for (const candidate of wallCandidates) { + const tolerance = candidate.halfThickness + WALL_ADOPTION_TOLERANCE + for (const [px, pz, qx, qz] of candidate.segments) { + const match = clipCollinearSegment( + a[0], + a[1], + dirX, + dirZ, + edgeLength, + px, + pz, + qx, + qz, + tolerance, + ) + if (match) rawBreakpoints.push(match.start, match.end) + } + } + const inwardX = -s * dirZ + const inwardZ = s * dirX + for (const { + segment: [px, pz, qx, qz], + inwardX: siblingInwardX, + inwardZ: siblingInwardZ, + } of neighborSegments) { + // Coincident/stacked slabs have their interiors on the same side of + // the edge. Only an edge whose sibling interior is across this edge + // can form a room-to-room seam. + if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue + const match = clipCollinearSegment( + a[0], + a[1], + dirX, + dirZ, + edgeLength, + px, + pz, + qx, + qz, + siblingBreakTolerance, + ) + if (match) rawBreakpoints.push(match.start, match.end) + } + rawBreakpoints.sort((left, right) => left - right) + + const breakpoints: number[] = [] + let previous = 0 + for (const t of rawBreakpoints) { + if (t - previous < MIN_SUBEDGE_LENGTH) continue + // Sorted ascending — every later breakpoint is even closer to the end. + if (edgeLength - t < MIN_SUBEDGE_LENGTH) break + breakpoints.push(t) + previous = t + } + + const bounds = [0, ...breakpoints, edgeLength] + const spans: EdgeSubSpan[] = [] + for (let k = 0; k + 1 < bounds.length; k += 1) { + spans.push( + classifySpan( + a, + dirX, + dirZ, + bounds[k]!, + bounds[k + 1]!, + s, + selfElevation, + wallCandidates, + neighborSegments, + ), + ) + } + + // Re-fuse same-target neighbours, reclassifying over the fused span: + // curved-wall sampling collapses back to one span per contiguous + // band, and an edge fully backed by one target reproduces the + // whole-edge projection exactly. + let fused = true + while (fused && spans.length > 1) { + fused = false + for (let k = 0; k + 1 < spans.length; k += 1) { + if (spans[k]!.key !== spans[k + 1]!.key) continue + const merged = classifySpan( + a, + dirX, + dirZ, + spans[k]!.start, + spans[k + 1]!.end, + s, + selfElevation, + wallCandidates, + neighborSegments, + ) + spans.splice(k, 2, merged) + fused = true + break + } + } + + subSpans.push(spans) + } + + return subSpans +} + +/** + * Classify one span of the edge `a + t·dir`, `t ∈ [start, end]`, with + * the whole-edge rules: direct sibling wins, sibling across the same + * wall band forces interior, otherwise the matched wall's outer face, + * otherwise free. Interior spans backed by a wall project onto the + * wall centerline at equal heights. At unequal heights both slabs target + * the lower room's wall face, giving the higher slab the full band. + */ +function classifySpan( + a: [number, number], + dirX: number, + dirZ: number, + start: number, + end: number, + s: number, + selfElevation: number, + wallCandidates: readonly WallCandidate[], + neighborSegments: readonly NeighborSegment[], +): EdgeSubSpan { + const sx = a[0] + dirX * start + const sz = a[1] + dirZ * start + const spanLength = end - start + // Short spans still need classification — require at most half their span. + const requiredOverlap = Math.min(MIN_CLASSIFYING_OVERLAP, spanLength * 0.5) + const inwardX = -s * dirZ + const inwardZ = s * dirX + + const wallMatch = matchEdgeWallBand( + sx, + sz, + dirX, + dirZ, + spanLength, + requiredOverlap, + wallCandidates, + ) + + // Direct neighbour: a sibling edge collinear within the tight + // tolerance regardless of any wall (slabs butted against each other). + let directOverlap = 0 + let directWeightedLateral = 0 + let directSiblingElevation: number | null = null + for (const { + segment: [px, pz, qx, qz], + elevation, + inwardX: siblingInwardX, + inwardZ: siblingInwardZ, + } of neighborSegments) { + if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue + const match = clipCollinearSegment( + sx, + sz, + dirX, + dirZ, + spanLength, + px, + pz, + qx, + qz, + SLAB_NEIGHBOR_LATERAL_TOLERANCE, + ) + if (!match) continue + directOverlap += match.overlap + directWeightedLateral += match.lateral * match.overlap + directSiblingElevation = + directSiblingElevation === null ? elevation : Math.max(directSiblingElevation, elevation) + } + + let interiorLateral: number | null = null + let siblingElevation: number | null = null + if (directOverlap >= requiredOverlap) { + // No-wall fallback: half the mean sibling separation — the midline + // between the two stored edges. Symmetric: the sibling measures the + // same separation with opposite sign from its own line, so both + // project onto the same line and the seam stays gapless. + interiorLateral = wallMatch ? wallMatch.lateral : directWeightedLateral / directOverlap / 2 + siblingElevation = directSiblingElevation + } else if (wallMatch) { + // Rooms across a shared wall: legacy face-aligned polygons sit a + // full thickness apart — far beyond the direct tolerance — but + // both edges live inside the same wall band. Both sides must + // classify interior, otherwise each would project to the opposite + // outer face and overlap under the wall. + const bandTolerance = wallMatch.halfThickness + WALL_ADOPTION_TOLERANCE + const looseTolerance = Math.abs(wallMatch.lateral) + bandTolerance + let bandOverlap = 0 + for (const { + segment: [px, pz, qx, qz], + elevation, + inwardX: siblingInwardX, + inwardZ: siblingInwardZ, + } of neighborSegments) { + if (inwardX * siblingInwardX + inwardZ * siblingInwardZ >= -0.5) continue + const match = clipCollinearSegment( + sx, + sz, + dirX, + dirZ, + spanLength, + px, + pz, + qx, + qz, + looseTolerance, + ) + if (!match) continue + if (Math.abs(match.lateral - wallMatch.lateral) > bandTolerance) continue + bandOverlap += match.overlap + siblingElevation = + siblingElevation === null ? elevation : Math.max(siblingElevation, elevation) + } + if (bandOverlap >= requiredOverlap) { + interiorLateral = wallMatch.lateral + } else { + siblingElevation = null + } + } + + if (interiorLateral !== null) { + if (wallMatch && siblingElevation !== null) { + const elevationDelta = selfElevation - siblingElevation + if (elevationDelta > SLAB_SEAM_ELEVATION_EPSILON) { + return { + start, + end, + offset: s * wallMatch.lateral + wallMatch.halfThickness, + key: `interior|${wallMatch.wall.id}|higher`, + } + } + if (elevationDelta < -SLAB_SEAM_ELEVATION_EPSILON) { + return { + start, + end, + offset: s * wallMatch.lateral - wallMatch.halfThickness, + key: `interior|${wallMatch.wall.id}|lower`, + } + } + } + return { + start, + end, + offset: s * interiorLateral, + key: `interior|${wallMatch ? wallMatch.wall.id : '~'}`, + } + } + if (wallMatch) { + return { + start, + end, + offset: s * wallMatch.lateral + wallMatch.halfThickness, + key: `wall|${wallMatch.wall.id}`, + } + } + return { start, end, offset: 0, key: 'free' } +} + +/** + * Offset each edge's sub-spans along the edge's outward normal by their + * own amounts (negative insets), then rebuild the ring: consecutive + * sub-spans of one edge with different offsets are joined by a + * perpendicular step connector at the breakpoint (both boundary points + * share the breakpoint param), and corners between different edges + * intersect the offset lines of the adjoining sub-spans. + */ +function offsetPolygonPerEdge( + polygon: Array<[number, number]>, + subSpans: EdgeSubSpan[][], +): Array<[number, number]> { + const n = polygon.length + if (n < 3) return polygon.map(([x, z]) => [x, z] as [number, number]) // Determine winding via signed area let area2 = 0 @@ -31,35 +704,71 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array< } const s = area2 >= 0 ? 1 : -1 - // Offset each edge outward by amount - const offEdges: Array<[number, number, number, number]> = [] + type EdgeFrame = { ax: number; az: number; dx: number; dz: number; dirX: number; dirZ: number } + const frames: EdgeFrame[] = [] for (let i = 0; i < n; i++) { const j = (i + 1) % n const dx = polygon[j]![0] - polygon[i]![0] const dz = polygon[j]![1] - polygon[i]![1] - const len = Math.sqrt(dx * dx + dz * dz) - if (len < 1e-9) { - offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) - continue - } - const nx = ((s * dz) / len) * amount - const nz = ((s * -dx) / len) * amount - offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) + const length = Math.hypot(dx, dz) + const dirX = length < 1e-9 ? 0 : dx / length + const dirZ = length < 1e-9 ? 0 : dz / length + frames.push({ ax: polygon[i]![0], az: polygon[i]![1], dx, dz, dirX, dirZ }) + } + + const pointAt = (edge: number, t: number, offset: number): [number, number] => { + const frame = frames[edge]! + return [ + frame.ax + frame.dirX * t + s * frame.dirZ * offset, + frame.az + frame.dirZ * t - s * frame.dirX * offset, + ] } - // Intersect consecutive offset edges to get new vertices const result: Array<[number, number]> = [] + const push = (point: [number, number]) => { + const previous = result[result.length - 1] + if (previous && Math.hypot(previous[0] - point[0], previous[1] - point[1]) < 1e-9) return + result.push(point) + } + for (let i = 0; i < n; i++) { + const spans = subSpans[i]! + + // Step connectors between differently-projected sub-spans of edge i. + // Equal offsets (different walls sharing a face line) collapse to a + // single collinear vertex via the coincident-point guard in `push`. + for (let k = 1; k < spans.length; k += 1) { + push(pointAt(i, spans[k - 1]!.end, spans[k - 1]!.offset)) + push(pointAt(i, spans[k]!.start, spans[k]!.offset)) + } + + // Corner between edge i and edge j: intersect the offset lines of + // edge i's last sub-span and edge j's first sub-span. const j = (i + 1) % n - const [ax, az, adx, adz] = offEdges[i]! - const [bx, bz, bdx, bdz] = offEdges[j]! - const denom = adx * bdz - adz * bdx + const last = spans[spans.length - 1]! + const first = subSpans[j]![0]! + const [ax, az] = pointAt(i, last.start, last.offset) + const [bx, bz] = pointAt(j, first.start, first.offset) + const frameI = frames[i]! + const frameJ = frames[j]! + const denom = frameI.dx * frameJ.dz - frameI.dz * frameJ.dx if (Math.abs(denom) < 1e-9) { - // Parallel edges — use offset endpoint - result.push([ax + adx, az + adz]) + // Parallel edges have no unique intersection. Emit both offset + // endpoints — collinear edges with different offsets need the step + // between them. + push(pointAt(i, last.end, last.offset)) + push([bx, bz]) } else { - const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom - result.push([ax + t * adx, az + t * adz]) + const t = ((bx - ax) * frameJ.dz - (bz - az) * frameJ.dx) / denom + push([ax + t * frameI.dx, az + t * frameI.dz]) + } + } + + if (result.length > 1) { + const firstPoint = result[0]! + const lastPoint = result[result.length - 1]! + if (Math.hypot(firstPoint[0] - lastPoint[0], firstPoint[1] - lastPoint[1]) < 1e-9) { + result.pop() } } diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 01f321d5..57e7eff3 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -94,6 +94,79 @@ describe('planAutoCeilingsForLevel', () => { expect(plan.create).toHaveLength(0) expect(plan.update).toHaveLength(0) }) + + test('demotes an orphaned auto ceiling to manual with its polygon untouched', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([], [ceiling]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(ceiling.id) + // Ceilings render the stored polygon in both modes, so no polygon bake. + expect(plan.update[0]?.data).toEqual({ autoFromWalls: false }) + }) + + test('deletes an unmatched auto ceiling absorbed by a room merge', () => { + const leftCeiling = CeilingNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + }) + const rightCeiling = CeilingNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoCeilingsForLevel([mergedRoom], [leftCeiling, rightCeiling]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(1) + const survivorId = plan.update[0]?.id + expect([leftCeiling.id, rightCeiling.id]).toContain(plan.delete[0]!) + expect(plan.delete[0]).not.toBe(survivorId) + }) + + test('a demoted ceiling suppresses re-creating an auto ceiling when the room re-forms', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const demotion = planAutoCeilingsForLevel([], [ceiling]).update[0] + const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data }) + expect(demoted.autoFromWalls).toBe(false) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], { + walls: squareWalls(), + slabs: [slab(0.05)], + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + }) }) describe('detectSpacesForLevel', () => { @@ -188,4 +261,76 @@ describe('planAutoSlabsForLevel', () => { expect(plan.create).toHaveLength(0) expect(plan.delete).toHaveLength(1) }) + + test('demotes an orphaned auto slab to manual when its room disappears', () => { + const painted = SlabNode.parse({ + polygon: square, + elevation: 0.4, + autoFromWalls: true, + }) + + const plan = planAutoSlabsForLevel([], [painted]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + expect(plan.update).toHaveLength(1) + + const update = plan.update[0] + expect(update?.id).toBe(painted.id) + // Demotion flips only the flag — the stored polygon stays untouched + // (render offsets derive from level context at geometry build time). + expect(update?.data).toEqual({ autoFromWalls: false }) + }) + + test('deletes an unmatched auto slab whose area was absorbed by a room merge', () => { + const leftSlab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + }) + const rightSlab = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoSlabsForLevel([mergedRoom], [leftSlab, rightSlab]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(1) + expect(plan.update).toHaveLength(1) + const survivorId = plan.update[0]?.id + expect([leftSlab.id, rightSlab.id]).toContain(plan.delete[0]!) + expect(plan.delete[0]).not.toBe(survivorId) + // The survivor stays auto — updated to the merged polygon, not demoted. + expect(plan.update[0]?.data.autoFromWalls).toBeUndefined() + }) + + test('a demoted slab suppresses re-creating an auto slab when the room re-forms', () => { + const auto = slab(0.05) + + const demotion = planAutoSlabsForLevel([], [auto]).update[0] + const demoted = SlabNode.parse({ ...auto, ...demotion?.data }) + expect(demoted.autoFromWalls).toBe(false) + + const plan = planAutoSlabsForLevel([roomPolygon()], [demoted]) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + }) }) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3e28fb3a..37679f56 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -67,6 +67,13 @@ const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 // A wall endpoint within this distance of another wall's interior is treated as a // T-junction and splits that wall (see `splitStraightWallAtVertices`). const WALL_JUNCTION_TOLERANCE = 0.08 +// An unmatched auto slab/ceiling whose polygon is still substantially covered +// by a detected room was absorbed by a room merge — the surviving auto surface +// owns that area, so keeping it would z-fight and it is deleted. Below this +// coverage the room genuinely ceased to exist (e.g. an enclosing wall was +// deleted) and the node is demoted to manual so user data survives. +const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 +const COVERAGE_SAMPLE_STEPS = 12 export type AutoCeilingPlanningContext = { walls?: WallNode[] @@ -200,6 +207,50 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType + polygonCoverageRatio(roomPolygon, [manual]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD && + polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD, + ) +} + function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { let minDistance = Number.POSITIVE_INFINITY for (let index = 0; index < polygon.length; index += 1) { @@ -728,8 +779,9 @@ export function planAutoSlabsForLevel( const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), ) + const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple)) - const detected: DetectedRoom[] = roomPolygons + const detectedAll: DetectedRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -746,7 +798,10 @@ export function planAutoSlabsForLevel( area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), })) - .filter(({ sig }) => !manualSignatures.has(sig)) + + const detected = detectedAll.filter( + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ) const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls) const existingAutoMeta = existingAuto.map((slab) => { @@ -815,18 +870,33 @@ export function planAutoSlabsForLevel( updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple)) } - const slabsToDelete = existingAuto - .filter((slab) => !updatesById.has(slab.id)) - .map((slab) => slab.id) + const detectedRoomPolygons = detectedAll.map((room) => room.poly) + const slabsToDelete: Array = [] + const slabDemotions: AutoSlabSyncPlan['update'] = [] + for (const slab of existingAuto) { + if (updatesById.has(slab.id)) continue - const slabsToUpdate = existingAuto - .filter((slab) => updatesById.has(slab.id)) - .flatMap((slab) => { - const polygon = updatesById.get(slab.id) - if (!polygon) return [] + const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons) + if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { + slabsToDelete.push(slab.id) + } else { + // Render offsets derive from level context at geometry build time, so + // demotion leaves the stored polygon untouched (same as ceilings). + slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } }) + } + } - return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }] - }) + const slabsToUpdate = [ + ...existingAuto + .filter((slab) => updatesById.has(slab.id)) + .flatMap((slab) => { + const polygon = updatesById.get(slab.id) + if (!polygon) return [] + + return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }] + }), + ...slabDemotions, + ] const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] const slabsToCreate: SlabNodeType[] = [] @@ -912,8 +982,9 @@ export function planAutoCeilingsForLevel( const manualSignatures = new Set( manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), ) + const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple)) - const detected: DetectedCeilingRoom[] = roomPolygons + const detectedAll: DetectedCeilingRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -931,7 +1002,10 @@ export function planAutoCeilingsForLevel( bbox: bboxOf(room.poly), ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) - .filter(({ sig }) => !manualSignatures.has(sig)) + + const detected = detectedAll.filter( + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ) const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls) const existingAutoMeta = existingAuto.map((ceiling) => { @@ -1006,29 +1080,42 @@ export function planAutoCeilingsForLevel( }) } - const ceilingsToDelete = existingAuto - .filter((ceiling) => !updatesById.has(ceiling.id)) - .map((ceiling) => ceiling.id) + const detectedRoomPolygons = detectedAll.map((room) => room.poly) + const ceilingsToDelete: Array = [] + const ceilingDemotions: AutoCeilingSyncPlan['update'] = [] + for (const ceiling of existingAuto) { + if (updatesById.has(ceiling.id)) continue - const ceilingsToUpdate = existingAuto - .filter((ceiling) => updatesById.has(ceiling.id)) - .flatMap((ceiling) => { - const update = updatesById.get(ceiling.id) - if (!update) return [] + const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons) + if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { + ceilingsToDelete.push(ceiling.id) + } else { + ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } }) + } + } - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { - data.polygon = update.polygon - } - if ( - Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > - CEILING_HEIGHT_EPSILON - ) { - data.height = update.height - } + const ceilingsToUpdate = [ + ...existingAuto + .filter((ceiling) => updatesById.has(ceiling.id)) + .flatMap((ceiling) => { + const update = updatesById.get(ceiling.id) + if (!update) return [] - return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] - }) + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { + data.polygon = update.polygon + } + if ( + Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > + CEILING_HEIGHT_EPSILON + ) { + data.height = update.height + } + + return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] + }), + ...ceilingDemotions, + ] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const ceilingsToCreate: CeilingNodeType[] = [] diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 10dac6ad..65ce7280 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -126,6 +126,12 @@ type ActiveDrag = { session: FloorplanAffordanceSession snapshots: NodeSnapshot[] 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 * the live angle wedge + degree readout — the 2D twin of the 3D rotate @@ -922,6 +928,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { session, snapshots, historyPaused: true, + lastPlanPoint: initialPlanPoint, rotation, reshapeScopeNodeId: reshapeScope ? nodeId : undefined, } @@ -955,6 +962,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const planPoint = clientToPlan(event.clientX, event.clientY) if (!planPoint) return + drag.lastPlanPoint = planPoint drag.session.apply({ planPoint, modifiers: { @@ -1102,13 +1110,43 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { 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('pointerup', onPointerUp) window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onModifierKeyChange) + window.addEventListener('keyup', onModifierKeyChange) return () => { window.removeEventListener('pointermove', onPointerMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onModifierKeyChange) + window.removeEventListener('keyup', onModifierKeyChange) // Component unmounted mid-drag — restore the baseline and unpause // history so we don't leak a paused store across mounts. Also // drop any live overrides the session published so the next diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index fede6c6e..3dbd33d5 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -51,6 +51,7 @@ import { WallNode as WallNodeSchema, type WindowNode, WindowNode as WindowNodeSchema, + wallClosesRoom, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -173,6 +174,7 @@ import { DEFAULT_STAIR_WIDTH, } from '../tools/stair/stair-defaults' import { + chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, snapWallDraftPoint, @@ -5340,6 +5342,9 @@ export function FloorplanPanel({ // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged. const [draftStart, setDraftStart] = useState(null) const [wallChainFirstVertex, setWallChainFirstVertex] = useState(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([]) const setDraftEnd = useCallback( (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { const store = useFloorplanDraftPreview.getState() @@ -5922,7 +5927,12 @@ export function FloorplanPanel({ const holes = (slab.holes ?? []) .map((hole) => toFloorplanPolygon(hole)) .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 return [ @@ -7829,6 +7839,7 @@ export function FloorplanPanel({ const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) + wallChainWallIdsRef.current = [] setDraftEnd(null) useSegmentDraftChain.getState().clear('wall') }, [setDraftEnd]) @@ -9673,8 +9684,11 @@ export function FloorplanPanel({ // `display:none`, so the tool never commits. Mirror the slab / // ceiling 2D-only committers: create locally here, gated on the // view, so split / 3D keep their single-owner tool commit. - const createdWall = - useEditor.getState().viewMode === '2d' ? createWallOnCurrentLevel(draftStart, point) : null + const viewIs2DOnly = useEditor.getState().viewMode === '2d' + 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 // have corner-snapped or split-adjusted): the wall we just made in @@ -9694,11 +9708,48 @@ export function FloorplanPanel({ 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) setDraftEnd(nextStart) setCursorPoint(nextStart) }, - [clearWallPlacementDraft, draftStart, wallChainFirstVertex, setDraftEnd, setCursorPoint], + [ + clearWallPlacementDraft, + draftStart, + levelId, + wallChainFirstVertex, + setDraftEnd, + setCursorPoint, + ], ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ ceilingPolygons: displayCeilingPolygons, diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 6e9baf46..506cb707 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -228,7 +228,7 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial { () => new MeshBasicNodeMaterial({ color: new Color(EDGE_ARROW_COLOR), - depthTest: true, + depthTest: false, depthWrite: true, opacity: 1, side: DoubleSide, @@ -239,8 +239,7 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial { } // 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 -// depth-tests, so walls/items in front can occlude it. +// post-processing ink-edge pass outlines it. function OutlinedCylinderHandle({ radius, height, @@ -885,8 +884,9 @@ export const PolygonEditor: React.FC = ({ const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2 // 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 - // border line stay on EDITOR_LAYER, visual-only + // pass outlines them. Edge arrows ignore scene depth so the active resize + // 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 // handles overlapping them — edge dragging starts from the chevron arrow // outside the polygon edge. diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 9726be7e..9a1de451 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, + DoorNode as DoorSchema, + runAsSingleSceneHistoryStep, useScene, type WallNode, WallNode as WallSchema, @@ -9,9 +11,22 @@ import { import { useViewer } from '@pascal-app/viewer' import useEditor from '../../../store/use-editor' 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' +// `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 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({ nodes: Object.fromEntries([ [ @@ -39,6 +54,7 @@ function seedLevel(walls: WallNode[]) { } as AnyNode, ], ...walls.map((wall) => [wall.id, wall] as const), + ...extraNodes.map((node) => [node.id, node] as const), ]), rootNodeIds: [LEVEL_ID], dirtyNodes: new Set(), @@ -62,10 +78,11 @@ describe('createWallOnCurrentLevel', () => { selectedIds: [], }, } as never) - // The commit-time corner-join / wall-split is a magnetic ('lines') snap, so - // these cases only apply in a magnetic context. A reshaping-endpoint scope - // resolves to the 'wall' context without needing the node registry (which - // isn't loaded in this package's tests). + // 'lines' keeps the generous commit-time join radius; the other modes + // still resolve + split within the tight connect radius (covered by the + // grid-mode cases below). A reshaping-endpoint scope resolves to the + // 'wall' context without needing the node registry (which isn't loaded in + // this package's tests). useEditor.getState().setSnappingMode('wall', 'lines') useInteractionScope .getState() @@ -110,6 +127,186 @@ describe('createWallOnCurrentLevel', () => { expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull() 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', () => { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 901cd9c2..20edaeed 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -5,6 +5,7 @@ import { type DoorNode, getScaledDimensions, type ItemNode, + runAsSingleSceneHistoryStep, snapPointAlongAngleRay, useScene, type WallNode, @@ -30,6 +31,7 @@ import { // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so // existing importers (fence drafting, the editor barrel) keep their paths. export { + chainEndJoinsExistingWall, findWallSnapTarget, WALL_CONNECT_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS, @@ -96,6 +98,7 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): bool function findWallIntersection( point: WallPlanPoint, walls: WallNode[], + radius: number, ignoreWallIds?: string[], ): WallSplitIntersection | null { const ignore = new Set(ignoreWallIds ?? []) @@ -110,7 +113,7 @@ function findWallIntersection( const candidateDistanceSquared = distanceSquared(point, projected) if ( - candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS || + candidateDistanceSquared > radius * radius || candidateDistanceSquared >= bestDistanceSquared ) { 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 = { point: WallPlanPoint walls: WallNode[] @@ -432,12 +472,19 @@ export function createWallOnCurrentLevel( let resolvedStart = start let resolvedEnd = end - // The corner-join / wall-split snap on commit is a magnetic (line) snap, so - // it must be gated by the snapping mode like the draft preview is. Without - // this gate `'off'` (and `'angles'`) still snapped the committed endpoint to - // existing wall geometry — the residual snap the draft path no longer does. - if (isMagneticSnapActive()) { - const endIntersection = findWallIntersection(resolvedEnd, workingWalls) + // The corner-join / wall-split resolution follows the snapping mode like the + // draft preview does: magnetic ('lines') keeps the generous join radius, + // every other mode uses the same tight connect radius the draft path already + // sticks endpoints with. So an endpoint the user saw connect to a wall body + // actually splits that wall (and redistributes its attachments) in every + // 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( endIntersection, workingWalls, @@ -451,7 +498,7 @@ export function createWallOnCurrentLevel( resolvedEnd = splitEnd.point } - const startIntersection = findWallIntersection(resolvedStart, workingWalls) + const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius) const splitStart = splitWallIfNeeded( startIntersection, workingWalls, @@ -464,35 +511,38 @@ export function createWallOnCurrentLevel( workingWalls = splitStart.walls resolvedStart = splitStart.point } - } - if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { - return null - } + if ( + !isSegmentLongEnough(resolvedStart, resolvedEnd) || + pointsEqual(resolvedStart, resolvedEnd) + ) { + return null + } - const duplicateWall = workingWalls.some( - (wall) => - (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) || - (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)), - ) - if (duplicateWall) { - return null - } + const duplicateWall = workingWalls.some( + (wall) => + (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) || + (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)), + ) + if (duplicateWall) { + return null + } - const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length - // A placed wall preset seeds `toolDefaults.wall` (thickness, height, - // materials, sides) before the tool activates; merge those first so the - // drawn wall reproduces the preset. Identity + endpoints always win. - const defaults = useEditor.getState().toolDefaults.wall ?? {} - const wall = WallSchema.parse({ - ...defaults, - name: `Wall ${wallCount + 1}`, - start: resolvedStart, - end: resolvedEnd, + const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length + // A placed wall preset seeds `toolDefaults.wall` (thickness, height, + // materials, sides) before the tool activates; merge those first so the + // drawn wall reproduces the preset. Identity + endpoints always win. + const defaults = useEditor.getState().toolDefaults.wall ?? {} + const wall = WallSchema.parse({ + ...defaults, + name: `Wall ${wallCount + 1}`, + start: resolvedStart, + end: resolvedEnd, + }) + + createNode(wall, currentLevelId) + sfxEmitter.emit('sfx:structure-build') + + return wall }) - - createNode(wall, currentLevelId) - sfxEmitter.emit('sfx:structure-build') - - return wall } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts index 35829dcd..bf2526fd 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { WallNode } from '@pascal-app/core' import { + chainEndJoinsExistingWall, findWallSnapTarget, findWallSpecialPointSnap, 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)', () => { test('projects onto a wall body within range', () => { const walls = [makeWall([0, 0], [4, 0])] diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 4df694fa..5a6ae7cb 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -259,6 +259,53 @@ function nearestCandidate( 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: * 1. corners (endpoints) — strongest intent, largest radius diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 358b92f9..8c3e248f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -149,9 +149,11 @@ export { } from './components/tools/stair/stair-defaults' export { ToolManager } from './components/tools/tool-manager' export { + chainEndJoinsExistingWall, createWallOnCurrentLevel, getSegmentGridStep, isSegmentLongEnough, + resolveEndpointWallSplit, snapPointToGrid, snapScalarToGrid, snapWallDraftPoint, @@ -336,8 +338,11 @@ export { movementSfxStepKey } from './lib/sfx/movement-tick' export { triggerSFX } from './lib/sfx-bus' export { clearSlabSnapFeedback, + resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap, SLAB_ALIGNMENT_THRESHOLD_M, + type SlabEdgeBandSnapInput, + type SlabEdgeBandSnapResult, type SlabPlanSnapInput, type SlabPlanSnapResult, } from './lib/slab-plan-snap' diff --git a/packages/editor/src/lib/slab-plan-snap.test.ts b/packages/editor/src/lib/slab-plan-snap.test.ts new file mode 100644 index 00000000..dd8dcaed --- /dev/null +++ b/packages/editor/src/lib/slab-plan-snap.test.ts @@ -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 = { [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() + }) +}) diff --git a/packages/editor/src/lib/slab-plan-snap.ts b/packages/editor/src/lib/slab-plan-snap.ts index 3902e324..2d7b8250 100644 --- a/packages/editor/src/lib/slab-plan-snap.ts +++ b/packages/editor/src/lib/slab-plan-snap.ts @@ -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 { clearSurfacePlanSnapFeedback, + getLevelWalls, resolveSurfacePlanPointSnap, SURFACE_ALIGNMENT_THRESHOLD_M, type SurfacePlanSnapInput, @@ -23,3 +29,80 @@ export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnap 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> + /** + * 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 } +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index d9e560b1..7fa8046a 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -57,7 +57,7 @@ export type SurfacePlanSnapResult = { wallIds: string[] } -function getLevelWalls( +export function getLevelWalls( nodes: Readonly>, levelId: string | null | undefined, walls?: readonly WallNode[], diff --git a/packages/nodes/src/shared/polygon-vertex-affordance.ts b/packages/nodes/src/shared/polygon-vertex-affordance.ts index 4af2ce81..73c851d8 100644 --- a/packages/nodes/src/shared/polygon-vertex-affordance.ts +++ b/packages/nodes/src/shared/polygon-vertex-affordance.ts @@ -60,8 +60,26 @@ export type PolygonAffordanceSnapContext = { + node: N + nodes: Record + /** Candidate edge (after the perpendicular translation), in ring order. */ + edge: [[number, number], [number, number]] + rawPoint: WallPlanPoint + modifiers: FloorplanAffordanceModifiers + holeIndex?: number +} + type PolygonAffordanceOptions = { resolvePlanPoint?: (context: PolygonAffordanceSnapContext) => 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) => [[number, number], [number, number]] | null } type PolygonShape = { @@ -334,8 +352,33 @@ export function createPolygonMoveEdgeAffordance { if (i === edgeStartIndex || i === edgeEndIndex) { return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance] diff --git a/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts b/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts new file mode 100644 index 00000000..97302809 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts @@ -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) + }) +}) diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index 0d0a1748..78108b66 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -4,9 +4,12 @@ import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@ import { boundaryReshapeScope, clearSlabSnapFeedback, + getSegmentGridStep, PolygonEditor, type PolygonEditorPlanPointSnapContext, + resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap, + snapScalarToGrid, useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -79,13 +82,56 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI ) const resolvePolygonEditorPlanPoint = useCallback( - (context: PolygonEditorPlanPointSnapContext) => - resolveSlabPlanPointSnap({ + (context: PolygonEditorPlanPointSnapContext) => { + // 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, fallbackPoint: context.gridPoint, levelId: slabLevelId, excludeId: slabId, - }).point, + }).point + }, [slabId, slabLevelId], ) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 181d918e..1a18bb33 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -188,6 +188,15 @@ export const slabDefinition: NodeDefinition = { // Stage B: pure geometry function. 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 // to [] when slab is registered (see floorplan-panel.tsx). floorplan: buildSlabFloorplan, diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 5054294a..7f43b699 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -1,10 +1,11 @@ import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core' -import { resolveSlabPlanPointSnap } from '@pascal-app/editor' +import { resolveSlabEdgeBandSnap, resolveSlabPlanPointSnap } from '@pascal-app/editor' import { createPolygonAddVertexAffordance, createPolygonMoveEdgeAffordance, createPolygonVertexAffordance, type PolygonAffordanceSnapContext, + type PolygonEdgeSnapContext, } from '../shared/polygon-vertex-affordance' /** @@ -27,7 +28,13 @@ const slabSnapOptions = { nodes, rawPoint, fallbackPoint, + mode, }: PolygonAffordanceSnapContext) { + // 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 return resolveSlabPlanPointSnap({ rawPoint, @@ -39,6 +46,17 @@ const slabSnapOptions = { // `lines` mode), so no Shift or Alt snap bypass. }).point }, + snapEdge({ node, nodes, edge, rawPoint }: PolygonEdgeSnapContext) { + const sceneNodes = nodes as Record + return ( + resolveSlabEdgeBandSnap({ + edge, + levelId: resolveLevelId(node, sceneNodes), + nodes: sceneNodes, + referencePoint: rawPoint, + })?.edge ?? null + ) + }, } export const slabMoveVertexAffordance = createPolygonVertexAffordance( diff --git a/packages/nodes/src/slab/floorplan.ts b/packages/nodes/src/slab/floorplan.ts index 93e0a971..84a56002 100644 --- a/packages/nodes/src/slab/floorplan.ts +++ b/packages/nodes/src/slab/floorplan.ts @@ -4,6 +4,7 @@ import { type GeometryContext, getRenderableSlabPolygon, type SlabNode, + slabPolygonContextFromGeometry, } from '@pascal-app/core' /** @@ -18,8 +19,8 @@ import { * - Same three handle sets for every hole in `node.holes`, with the * `holeIndex` carried in each handle's payload. * - * Uses `getRenderableSlabPolygon` for the visible fill (auto-slabs - * generated from walls clip to wall footprints), but vertex / edge / + * Uses `getRenderableSlabPolygon` for the visible fill (per-edge render + * offsets against level walls + sibling slabs), but vertex / edge / * midpoint handles live on the **raw** `node.polygon` — matches the * 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 if (!polygon || polygon.length < 3) return null - const visualPolygon = getRenderableSlabPolygon(node) + const visualPolygon = getRenderableSlabPolygon(node, slabPolygonContextFromGeometry(ctx)) if (!visualPolygon || visualPolygon.length < 3) return null 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. 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) holes.forEach((hole, holeIndex) => { if (hole.length >= 3) appendRingEditor(children, hole, holeIndex) diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 6b3c241d..38f248c6 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -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 { applyMaterialPresetToMaterials, type ColorPreset, @@ -177,7 +182,7 @@ export function buildSlabGeometry( sceneTheme?: string, ): Group { const group = new Group() - const merged = generateSlabGeometry(node) + const merged = generateSlabGeometry(node, slabPolygonContextFromGeometry(ctx)) const { top, side } = splitSlabFacesByFacing(merged) merged.dispose() diff --git a/packages/nodes/src/slab/system.tsx b/packages/nodes/src/slab/system.tsx new file mode 100644 index 00000000..b60fff7c --- /dev/null +++ b/packages/nodes/src/slab/system.tsx @@ -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): Map { + const partsByLevel = new Map() + + 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() + 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 diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 20382e7a..4bb0c18d 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -6,6 +6,7 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + runAsSingleSceneHistoryStep, useLiveNodeOverrides, useScene, type WallNode, @@ -17,6 +18,7 @@ import { isAngleSnapActive, isMagneticSnapActive, isSegmentLongEnough, + resolveEndpointWallSplit, snapBuildingLocalToWorldGrid, snapScalarToGrid, snapWallDraftPoint, @@ -244,6 +246,17 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const sceneState = useScene.getState() overrides.set(node.id as AnyNodeId, { start: primaryStart, end: primaryEnd }) 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) { overrides.set(upd.id, { start: upd.start, end: upd.end }) sceneState.markDirty(upd.id) @@ -261,14 +274,41 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { commit() { // Atomic tracked write of the final endpoints, then drop the // overrides so the scene state is the single source of truth - // again. - useScene.getState().updateNodes([ - { id: node.id, data: { start: lastPrimaryStart, end: lastPrimaryEnd } }, - ...lastLinkedUpdates.map((u) => ({ - id: u.id, - data: { start: u.start, end: u.end }, - })), - ]) + // 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([ + { + id: node.id, + data: { + start: endpoint === 'start' ? finalPoint : lastPrimaryStart, + end: endpoint === 'end' ? finalPoint : lastPrimaryEnd, + }, + }, + ...lastLinkedUpdates.map((u) => ({ + id: u.id, + data: { + start: pointsEqual(u.start, movingPoint) ? finalPoint : u.start, + end: pointsEqual(u.end, movingPoint) ? finalPoint : u.end, + }, + })), + ]) + }) const overrides = useLiveNodeOverrides.getState() overrides.clear(node.id as AnyNodeId) for (const upd of lastLinkedUpdates) overrides.clear(upd.id) diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index a2aaa2b8..fcb7bfd4 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -11,6 +11,7 @@ import { pauseSceneHistory, resolveAlignment, resumeSceneHistory, + runAsSingleSceneHistoryStep, useLiveNodeOverrides, useScene, type WallNode, @@ -26,6 +27,7 @@ import { isSegmentLongEnough, MeasurementPill, markToolCancelConsumed, + resolveEndpointWallSplit, snapWallDraftPointDetailed, triggerSFX, useAlignmentGuides, @@ -207,6 +209,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const [altPressed, setAltPressed] = useState(false) 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(() => { useInteractionScope .getState() @@ -231,6 +241,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ pauseSceneHistory(useScene) 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 // 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) => { + lastMovedPoint = movingPoint const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const linkedUpdates = detachLinkedWalls @@ -282,6 +300,20 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ nextStart, 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 } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setAngleLabel( @@ -395,7 +427,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ : 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 = () => { @@ -413,14 +452,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd) ) - // Endpoint still at its original spot: this release is the *grab* of a - // click-to-move (a tap on the handle, or a press that never dragged). Stay - // armed so the endpoint keeps following the cursor — the next release after - // an actual move commits. A press-drag and a click thus engage identically; - // previously the no-drag branch dismissed the tool, and whether it even ran - // raced the window pointer-up listener mounting (hence "works once, then - // needs a long press"). - if (!hasChanged) return + // Endpoint still at its original spot. The FIRST release is the *grab* + // of a click-to-move (a tap on the handle, or a press that never + // dragged): stay armed so the endpoint keeps following the cursor — a + // press-drag and a click thus engage identically. Any LATER release at + // an unchanged position is a deliberate drop: end the interaction + // cleanly (previews restored, scope ended, no history entry) instead of + // leaving the user stuck until they move the mouse. + if (!hasChanged) { + if (!hasReleasedOnce) { + hasReleasedOnce = true + return + } + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitMoveMode() + return + } if (isSegmentLongEnough(preview.start, preview.end)) { 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. // The store sat at the pre-drag (original) values the whole drag — only // 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() resumeSceneHistory(useScene) - useScene.getState().updateNodes([ - { id: nodeId as AnyNodeId, data: { start: preview.start, end: preview.end } }, - ...linkedUpdates.map((u) => ({ - id: u.id as AnyNodeId, - data: { start: u.start, end: u.end }, - })), - ]) - useScene.getState().markDirty(nodeId as AnyNodeId) - for (const u of linkedUpdates) { - useScene.getState().markDirty(u.id as AnyNodeId) - } + 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([ + { + id: nodeId as AnyNodeId, + data: { + start: target.endpoint === 'start' ? finalPoint : preview.start, + end: target.endpoint === 'end' ? finalPoint : preview.end, + }, + }, + ...linkedUpdates.map((u) => ({ + id: u.id as AnyNodeId, + data: { + start: samePoint(u.start, movingPoint) ? finalPoint : u.start, + end: samePoint(u.end, movingPoint) ? finalPoint : u.end, + }, + })), + ]) + useScene.getState().markDirty(nodeId as AnyNodeId) + for (const u of linkedUpdates) { + useScene.getState().markDirty(u.id as AnyNodeId) + } + }) pauseSceneHistory(useScene) triggerSFX('sfx:item-place') } @@ -472,26 +546,36 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ 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) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return } if (event.key === 'Alt') { - altPressedRef.current = true - setAltPressed(true) + setAltState(true) } } const onKeyUp = (event: KeyboardEvent) => { if (event.key === 'Alt') { - altPressedRef.current = false - setAltPressed(false) + setAltState(false) } } const onWindowBlur = () => { - altPressedRef.current = false - setAltPressed(false) + setAltState(false) } emitter.on('grid:move', onGridMove) @@ -550,23 +634,25 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ unit={unit} /> - -
-
- {altPressed ? 'Detaching corner' : 'Alt to detach'} + {canDetachCorner && ( + +
+
+ {altPressed ? 'Detaching corner' : 'Alt to detach'} +
-
- + + )} {angleLabel && } ) diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index 0bddb2c2..084e6ecc 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -7,7 +7,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer' * * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs * `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 * bulk of the wall runtime (~820 lines in viewer). * - **`WallCutout`** — cutaway-mode hide/show logic based on camera diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 7ff36ee5..fb5ba5b6 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -16,6 +16,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + chainEndJoinsExistingWall, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, @@ -529,6 +530,10 @@ export const WallTool: React.FC = () => { const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const chainFirstVertex = useRef(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([]) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -588,6 +593,7 @@ export const WallTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 chainFirstVertex.current = null + chainWallIds.current = [] if (wallPreviewRef.current) { wallPreviewRef.current.visible = false } @@ -735,6 +741,7 @@ export const WallTool: React.FC = () => { snappedEnd, ) if (!createdWall) return + chainWallIds.current.push(createdWall.id) // The new segment is now a real node — make it an alignment target // 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 // 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. - 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() return } diff --git a/packages/plugin-trees/src/wind-node.ts b/packages/plugin-trees/src/wind-node.ts index 4b4a0fb1..9fa0a56a 100644 --- a/packages/plugin-trees/src/wind-node.ts +++ b/packages/plugin-trees/src/wind-node.ts @@ -1,6 +1,6 @@ import type { Color, Material, Side, Texture } from 'three' 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 @@ -53,6 +53,33 @@ const stemBend = Fn(() => { }) 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 * bark/leaf look (textured, tinted, alpha-cut billboards). */ type ClassicMaterial = Material & { @@ -76,7 +103,7 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial { const cached = cache.get(material) if (cached) return cached const src = material as ClassicMaterial - const node = new MeshStandardNodeMaterial({ + const node = new WindNodeMaterial({ map: src.map ?? null, alphaMap: src.alphaMap ?? null, color: src.color, @@ -88,7 +115,7 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial { roughness: 1, metalness: 0, }) - if (material.name === 'leaves') node.positionNode = LEAF_FLUTTER + if (material.name === 'leaves') node.windNode = LEAF_FLUTTER cache.set(material, node) return node } @@ -97,14 +124,14 @@ export function toWindMaterial(material: Material): MeshStandardNodeMaterial { export function windStandardMaterial( params: ConstructorParameters[0], ): MeshStandardNodeMaterial { - const material = new MeshStandardNodeMaterial(params) - material.positionNode = STEM_BEND + const material = new WindNodeMaterial(params) + material.windNode = STEM_BEND return material } const staticCache = new WeakMap() -/** 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 * 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 diff --git a/packages/viewer/src/lib/ktx2-loader.ts b/packages/viewer/src/lib/ktx2-loader.ts index f9dd557b..0ef7d792 100644 --- a/packages/viewer/src/lib/ktx2-loader.ts +++ b/packages/viewer/src/lib/ktx2-loader.ts @@ -6,7 +6,10 @@ import { RGBAFormat, UnsignedByteType, } from 'three' -import { KTX2Loader } from 'three/examples/jsm/Addons.js' +// Deep path, NOT the `Addons.js` aggregate: the aggregate re-exports +// LottieLoader/TTFLoader, whose CDN URL imports (`lottie-web`, `opentype.js`) +// crash bun's test runtime for every consumer of the viewer barrel. +import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js' /** The private KTX2Loader surface this module relies on (stable across three * releases but not part of the public types). */ diff --git a/packages/viewer/src/systems/slab/slab-system.test.ts b/packages/viewer/src/systems/slab/slab-system.test.ts index 3f0b0d14..a6e58aa2 100644 --- a/packages/viewer/src/systems/slab/slab-system.test.ts +++ b/packages/viewer/src/systems/slab/slab-system.test.ts @@ -1,10 +1,12 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { SlabNode } from '@pascal-app/core' +import { SlabNode, type SlabPolygonContext } from '@pascal-app/core' import type * as THREE from 'three' import { generateSlabGeometry } from './slab-system' +const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } + function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { const positions = geometry.getAttribute('position') for (let index = 0; index < positions.count; index += 1) { @@ -35,7 +37,7 @@ describe('generateSlabGeometry', () => { ], }) - const geometry = generateSlabGeometry(slab) + const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT) expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0) expect(hasVertexAt(geometry, 1, 1)).toBe(true) @@ -61,7 +63,7 @@ describe('generateSlabGeometry', () => { ], }) - const geometry = generateSlabGeometry(slab) + const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT) expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0) expect(hasVertexAt(geometry, 1, 1)).toBe(true) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 8ee82304..60bd6984 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,4 +1,5 @@ import { + type AnyNode, type AnyNodeId, getEffectiveNode, getRenderableSlabPolygon, @@ -6,8 +7,10 @@ import { pointInPolygon2D, polygonsIntersect, type SlabNode, + type SlabPolygonContext, sceneRegistry, useScene, + type WallNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect } from 'react' @@ -44,6 +47,7 @@ export const SlabSystem = () => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes + const contextByLevel = new Map() // Process dirty slabs dirtyNodes.forEach((id) => { @@ -52,7 +56,15 @@ export const SlabSystem = () => { const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { - updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh) + const slab = node as SlabNode + const levelContext = + contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes) + contextByLevel.set(slab.parentId, levelContext) + updateSlabGeometry( + getEffectiveNode(slab), + excludeSlabFromContext(levelContext, slab.id), + mesh, + ) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -62,11 +74,32 @@ export const SlabSystem = () => { return null } +function buildLevelSlabContext( + levelId: string | null, + nodes: Record, +): SlabPolygonContext { + const walls: WallNode[] = [] + const siblingSlabs: SlabNode[] = [] + for (const node of Object.values(nodes)) { + if (node.parentId !== levelId) continue + if (node.type === 'wall') walls.push(node as WallNode) + else if (node.type === 'slab') siblingSlabs.push(node as SlabNode) + } + return { walls, siblingSlabs } +} + +function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext { + return { + walls: context.walls, + siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId), + } +} + /** * Updates the geometry for a single slab */ -function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { - const newGeo = generateSlabGeometry(node) +function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) { + const newGeo = generateSlabGeometry(node, context) ensureUv2Attribute(newGeo) mesh.geometry.dispose() @@ -92,11 +125,18 @@ function coplanarityEpsilon(id: string): number { } /** - * Generates extruded slab geometry from polygon + * Generates extruded slab geometry from polygon. `context` carries the + * slab's level neighbourhood (walls + sibling slabs) driving the per-edge + * render offsets — see `getRenderableSlabPolygon`. */ -export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { +export function generateSlabGeometry( + slabNode: SlabNode, + context: SlabPolygonContext, +): THREE.BufferGeometry { const elevation = slabNode.elevation ?? 0.05 - return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) + return elevation < 0 + ? generatePoolGeometry(slabNode, context) + : generatePositiveSlabGeometry(slabNode, context) } // Earcut normalizes cap triangulation regardless of input winding, but the side @@ -159,8 +199,11 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) * thickness visible from any angle: the two coincident triangles never z-fight * because exactly one faces the camera under FrontSide culling. */ -function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { - const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) +function generatePositiveSlabGeometry( + slabNode: SlabNode, + context: SlabPolygonContext, +): THREE.BufferGeometry { + const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) const elevation = slabNode.elevation ?? 0.05 const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) @@ -257,8 +300,11 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool) * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool) */ -function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { - const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) +function generatePoolGeometry( + slabNode: SlabNode, + context: SlabPolygonContext, +): THREE.BufferGeometry { + const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) const depth = Math.abs(slabNode.elevation ?? 0.05) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) diff --git a/packages/viewer/src/systems/wall/wall-support-extension.test.ts b/packages/viewer/src/systems/wall/wall-support-extension.test.ts new file mode 100644 index 00000000..881fb29e --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-support-extension.test.ts @@ -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() + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index a0263448..fe9b026a 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -25,6 +25,7 @@ import { useScene, type WallMiterData, type WallNode, + type WallSlabSupportSegment, type WallSurfaceSide, type WallSurfaceSlotId, type WindowNode, @@ -691,13 +692,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) - const slabElevation = spatialGridManager.getSlabElevationForWall( + const slabSupport = spatialGridManager.getSlabSupportForWall( levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, ) + const slabElevation = slabSupport.elevation const childrenIds = node.children || [] // Merge live overrides into door / window children so cutouts track an @@ -720,7 +722,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { return { ...effective, position: live.position } }) - const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const builtGeo = generateExtrudedWall( + node, + childrenNodes, + miterData, + slabElevation, + slabSupport.baseElevation, + slabSupport.baseSegments, + ) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). // Reproduce it here so the UVs can be projected in WORLD space — see @@ -737,7 +746,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { - const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation) + const collisionGeo = generateExtrudedWall( + node, + [], + miterData, + slabElevation, + slabSupport.baseElevation, + slabSupport.baseSegments, + ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo } @@ -823,13 +839,18 @@ export function generateExtrudedWall( childrenNodes: AnyNode[], miterData: WallMiterData, slabElevation = 0, + baseElevation = slabElevation, + baseSegments: readonly WallSlabSupportSegment[] = [ + { start: 0, end: 1, elevation: baseElevation }, + ], ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } - // Positive slab: shift the whole wall up (full height preserved) - // Negative slab: extend wall downward so top stays fixed at wallNode.height const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT - const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation + const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight + const effectiveBaseElevation = Math.min(baseElevation, slabElevation) + const localBottom = effectiveBaseElevation - slabElevation + const height = topElevation - effectiveBaseElevation const thickness = getWallThickness(wallNode) @@ -888,12 +909,107 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) + if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges) ensureRenderableGeometryAttributes(geometry) - // Apply CSG subtraction for cutouts (doors/windows) - const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) + // Start with the lowest required wall prism, then remove the volume below + // each higher-supported run. This keeps the existing mitered footprint and + // opening CSG while giving one wall a stepped longitudinal base. + const baseProfileCutouts: Brush[] = [] + for (const segment of baseSegments) { + const segmentElevation = Math.min(segment.elevation, slabElevation) + const cutHeight = segmentElevation - effectiveBaseElevation + if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7) continue + + const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1) + const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1) + const cutHalfWidth = Math.max(thickness * 2, 0.2) + const worldCutoutPoints: Point2D[] = [] + + if (isCurvedWall(wallNode)) { + const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24)) + const left: Point2D[] = [] + const right: Point2D[] = [] + for (let index = 0; index <= sampleCount; index++) { + const t = segmentStart + ((segmentEnd - segmentStart) * index) / sampleCount + const frame = getWallCurveFrameAt(wallNode, t) + const endpointExtension = + index === 0 && segmentStart <= 1e-7 + ? -cutHalfWidth + : index === sampleCount && segmentEnd >= 1 - 1e-7 + ? cutHalfWidth + : 0 + const center = { + x: frame.point.x + frame.tangent.x * endpointExtension, + y: frame.point.y + frame.tangent.y * endpointExtension, + } + left.push({ + x: center.x + frame.normal.x * cutHalfWidth, + y: center.y + frame.normal.y * cutHalfWidth, + }) + right.push({ + x: center.x - frame.normal.x * cutHalfWidth, + y: center.y - frame.normal.y * cutHalfWidth, + }) + } + worldCutoutPoints.push(...left, ...right.reverse()) + } else { + const tangentX = v.x / L + const tangentY = v.y / L + const normalX = -tangentY + const normalY = tangentX + const startExtension = segmentStart <= 1e-7 ? cutHalfWidth : 0 + const endExtension = segmentEnd >= 1 - 1e-7 ? cutHalfWidth : 0 + const startPoint = { + x: wallStart.x + tangentX * (segmentStart * L - startExtension), + y: wallStart.y + tangentY * (segmentStart * L - startExtension), + } + const endPoint = { + x: wallStart.x + tangentX * (segmentEnd * L + endExtension), + y: wallStart.y + tangentY * (segmentEnd * L + endExtension), + } + worldCutoutPoints.push( + { + x: startPoint.x + normalX * cutHalfWidth, + y: startPoint.y + normalY * cutHalfWidth, + }, + { x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth }, + { x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth }, + { + x: startPoint.x - normalX * cutHalfWidth, + y: startPoint.y - normalY * cutHalfWidth, + }, + ) + } + + const localCutoutPoints = worldCutoutPoints.map(worldToLocal) + if (localCutoutPoints.length < 3) continue + const cutoutShape = new THREE.Shape() + cutoutShape.moveTo(localCutoutPoints[0]!.x, -localCutoutPoints[0]!.z) + for (let index = 1; index < localCutoutPoints.length; index++) { + cutoutShape.lineTo(localCutoutPoints[index]!.x, -localCutoutPoints[index]!.z) + } + cutoutShape.closePath() + + const cutoutBottom = localBottom - 0.01 + const cutoutTop = segmentElevation - slabElevation + const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, { + depth: cutoutTop - cutoutBottom, + bevelEnabled: false, + }) + cutoutGeometry.rotateX(-Math.PI / 2) + cutoutGeometry.translate(0, cutoutBottom, 0) + computeGeometryBoundsTree(cutoutGeometry) + baseProfileCutouts.push(new Brush(cutoutGeometry)) + } + + // Apply base-profile and opening cutouts in one CSG pass. + const cutoutBrushes = [ + ...baseProfileCutouts, + ...collectCutoutBrushes(wallNode, childrenNodes, thickness), + ] if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry,