From ae68d69f77272fd251b3d4270d2cfdaadeccbdff Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 16 Apr 2026 12:39:52 +0530 Subject: [PATCH] Simplify auto slab polygons for curved wall rooms --- packages/core/src/lib/polygon-geometry.ts | 134 ++++++++++++++++++ packages/core/src/lib/space-detection.ts | 72 +++++++++- .../core/src/systems/slab/slab-system.tsx | 35 +---- 3 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/lib/polygon-geometry.ts diff --git a/packages/core/src/lib/polygon-geometry.ts b/packages/core/src/lib/polygon-geometry.ts new file mode 100644 index 00000000..f8f639b5 --- /dev/null +++ b/packages/core/src/lib/polygon-geometry.ts @@ -0,0 +1,134 @@ +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], + end: [number, number], +) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + + if (lengthSquared < 1e-9) { + return Math.hypot(point[0] - start[0], point[1] - start[1]) + } + + const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx + return Math.abs(cross) / Math.sqrt(lengthSquared) +} + +function dedupePolygonPoints( + polygon: Array<[number, number]>, + tolerance = 1e-6, +): Array<[number, number]> { + const deduped: Array<[number, number]> = [] + + for (const point of polygon) { + const previous = deduped[deduped.length - 1] + if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) <= tolerance) { + continue + } + deduped.push(point) + } + + if ( + deduped.length > 2 && + Math.hypot( + deduped[0]![0] - deduped[deduped.length - 1]![0], + deduped[0]![1] - deduped[deduped.length - 1]![1], + ) <= tolerance + ) { + deduped.pop() + } + + return deduped +} + +function simplifyPolyline(points: Array<[number, number]>, tolerance: number): Array<[number, number]> { + if (points.length <= 2) { + return points.map(([x, z]) => [x, z] as [number, number]) + } + + let maxDistance = -1 + let splitIndex = -1 + + for (let index = 1; index < points.length - 1; index += 1) { + const distance = pointLineDistance(points[index]!, points[0]!, points[points.length - 1]!) + if (distance > maxDistance) { + maxDistance = distance + splitIndex = index + } + } + + if (maxDistance <= tolerance || splitIndex === -1) { + return [points[0]!, points[points.length - 1]!] + } + + const left = simplifyPolyline(points.slice(0, splitIndex + 1), tolerance) + const right = simplifyPolyline(points.slice(splitIndex), tolerance) + return [...left.slice(0, -1), ...right] +} + +export function simplifyClosedPolygon( + polygon: Array<[number, number]>, + tolerance: number, +): Array<[number, number]> { + const cleanPolygon = dedupePolygonPoints(polygon) + if (cleanPolygon.length <= 3 || tolerance <= 0) { + return cleanPolygon + } + + let anchorA = 0 + let anchorB = Math.floor(cleanPolygon.length / 2) + let maxDistanceSquared = -1 + + for (let i = 0; i < cleanPolygon.length; i += 1) { + for (let j = i + 1; j < cleanPolygon.length; j += 1) { + const dx = cleanPolygon[j]![0] - cleanPolygon[i]![0] + const dz = cleanPolygon[j]![1] - cleanPolygon[i]![1] + const distanceSquared = dx * dx + dz * dz + if (distanceSquared > maxDistanceSquared) { + maxDistanceSquared = distanceSquared + anchorA = i + anchorB = j + } + } + } + + const forward = cleanPolygon.slice(anchorA, anchorB + 1) + const wrapped = [...cleanPolygon.slice(anchorB), ...cleanPolygon.slice(0, anchorA + 1)] + const simplifiedForward = simplifyPolyline(forward, tolerance) + const simplifiedWrapped = simplifyPolyline(wrapped, tolerance) + const simplified = dedupePolygonPoints( + [...simplifiedForward.slice(0, -1), ...simplifiedWrapped.slice(0, -1)], + tolerance * 0.25, + ) + + return simplified.length >= 3 ? simplified : cleanPolygon +} diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 83402af5..beed6a99 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1,4 +1,9 @@ -import { getClampedWallCurveOffset, sampleWallCenterline } from '../systems/wall/wall-curve' +import { + getClampedWallCurveOffset, + getWallCurveFrameAt, + isCurvedWall, +} from '../systems/wall/wall-curve' +import { simplifyClosedPolygon } from './polygon-geometry' import { SlabNode, type SlabNode as SlabNodeType, type WallNode } from '../schema' type Point2D = { x: number; y: number } @@ -26,6 +31,9 @@ type DetectedRoom = { } const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 +const ROOM_CURVE_TOLERANCE = 0.04 +const MAX_CURVE_SUBDIVISION_DEPTH = 6 +const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 function pointFromTuple(point: [number, number]): Point2D { return { x: point[0], y: point[1] } @@ -178,8 +186,49 @@ function getWallDirection(wall: Pick) { } } +function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared < 1e-9) { + return Math.hypot(point.x - start.x, point.y - start.y) + } + + const cross = (point.x - start.x) * dy - (point.y - start.y) * dx + return Math.abs(cross) / Math.sqrt(lengthSquared) +} + +function sampleWallPointsForRoomDetection( + wall: Pick, + tolerance = ROOM_CURVE_TOLERANCE, +) { + const start = { x: wall.start[0], y: wall.start[1] } + const end = { x: wall.end[0], y: wall.end[1] } + + if (!isCurvedWall(wall)) { + return [start, end] + } + + const subdivide = (t0: number, p0: Point2D, t1: number, p1: Point2D, depth: number): Point2D[] => { + const midT = (t0 + t1) / 2 + const midPoint = getWallCurveFrameAt(wall, midT).point + const deviation = pointLineDistance(midPoint, p0, p1) + + if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) { + return [p0, p1] + } + + const left = subdivide(t0, p0, midT, midPoint, depth + 1) + const right = subdivide(midT, midPoint, t1, p1, depth + 1) + return [...left.slice(0, -1), ...right] + } + + return subdivide(0, start, 1, end, 0) +} + function getDirectedWallBoundaryPoints(wall: WallNode, forward: boolean) { - const points = sampleWallCenterline(wall).map((point) => ({ x: point.x, y: point.y })) + const points = sampleWallPointsForRoomDetection(wall) return forward ? points : [...points].reverse() } @@ -426,11 +475,20 @@ function syncAutoSlabsForLevel( const detected: DetectedRoom[] = roomPolygons .map((poly) => ({ - poly, - sig: polygonSignature(poly), - centroid: polygonCentroid(poly), - area: Math.abs(polygonArea(poly)), - bbox: bboxOf(poly), + poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( + pointFromTuple, + ), + sig: '', + centroid: { x: 0, y: 0 }, + area: 0, + bbox: bboxOf([]), + })) + .map((room) => ({ + ...room, + sig: polygonSignature(room.poly), + centroid: polygonCentroid(room.poly), + area: Math.abs(polygonArea(room.poly)), + bbox: bboxOf(room.poly), })) .filter(({ sig }) => !manualSignatures.has(sig)) diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index bc2ed887..10e75627 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -1,6 +1,7 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import { insetPolygonFromCentroid, simplifyClosedPolygon } from '../../lib/polygon-geometry' import type { AnyNodeId, SlabNode } from '../../schema' import useScene from '../../store/use-scene' @@ -60,38 +61,14 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { /** Half of default wall thickness — used to extend slab geometry under walls */ const SLAB_OUTSET = 0.05 const AUTO_SLAB_INSET = 0.02 - -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] - }) -} +const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { return slabNode.autoFromWalls - ? insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET) + ? simplifyClosedPolygon( + insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), + AUTO_SLAB_SIMPLIFY_TOLERANCE, + ) : outsetPolygon(slabNode.polygon, SLAB_OUTSET) }