ai-chatbot/packages/core/src/lib/space-detection.ts

986 lines
27 KiB
TypeScript

import {
CeilingNode,
type CeilingNode as CeilingNodeType,
SlabNode,
type SlabNode as SlabNodeType,
type WallNode,
} from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number }
export type Space = {
id: string
levelId: string
polygon: Array<[number, number]>
wallIds: string[]
isExterior: boolean
}
type WallSideUpdate = {
wallId: string
frontSide: 'interior' | 'exterior' | 'unknown'
backSide: 'interior' | 'exterior' | 'unknown'
}
type DetectedRoom = {
poly: Point2D[]
sig: string
centroid: Point2D
area: number
bbox: ReturnType<typeof bboxOf>
}
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
delete: Array<SlabNodeType['id']>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
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] }
}
function pointToTuple(point: Point2D): [number, number] {
return [point.x, point.y]
}
function pointKey(point: Point2D) {
return `${point.x.toFixed(3)},${point.y.toFixed(3)}`
}
function polygonArea(points: Point2D[]) {
let area = 0
for (let i = 0; i < points.length; i++) {
const a = points[i]
const b = points[(i + 1) % points.length]
if (!(a && b)) continue
area += a.x * b.y - b.x * a.y
}
return area / 2
}
function minRotationSignature(keys: string[]) {
if (keys.length === 0) return ''
let best = ''
for (let i = 0; i < keys.length; i++) {
const rotated = [...keys.slice(i), ...keys.slice(0, i)]
const value = rotated.join('|')
if (!best || value < best) best = value
}
return best
}
function polygonSignature(points: Point2D[]) {
const keys = points.map(pointKey)
const forward = minRotationSignature(keys)
const reversed = minRotationSignature([...keys].reverse())
return forward < reversed ? forward : reversed
}
function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) {
return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance
}
function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) {
const deduped: Point2D[] = []
for (const point of points) {
const previous = deduped[deduped.length - 1]
if (previous && samePointWithinTolerance(previous, point, tolerance)) {
continue
}
deduped.push(point)
}
const firstPoint = deduped[0]
const lastPoint = deduped[deduped.length - 1]
if (
deduped.length > 2 &&
firstPoint &&
lastPoint &&
samePointWithinTolerance(firstPoint, lastPoint, tolerance)
) {
deduped.pop()
}
return deduped
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
if (polygon.length < 3) return false
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i]?.x ?? 0
const yi = polygon[i]?.y ?? 0
const xj = polygon[j]?.x ?? 0
const yj = polygon[j]?.y ?? 0
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi
if (intersect) inside = !inside
}
return inside
}
function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) {
return polygons.some((polygon) => pointInPolygon(point, polygon))
}
function polygonCentroid(points: Point2D[]) {
const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), {
x: 0,
y: 0,
})
return {
x: sum.x / Math.max(points.length, 1),
y: sum.y / Math.max(points.length, 1),
}
}
function bboxOf(points: Point2D[]) {
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const point of points) {
minX = Math.min(minX, point.x)
minY = Math.min(minY, point.y)
maxX = Math.max(maxX, point.x)
maxY = Math.max(maxY, point.y)
}
return { minX, minY, maxX, maxY }
}
function bboxOverlapArea(a: ReturnType<typeof bboxOf>, b: ReturnType<typeof bboxOf>) {
const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX))
const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY))
return ix * iy
}
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dy)
if (length < 1e-9) {
return {
point: pointFromTuple(wall.start),
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
const tangent = { x: dx / length, y: dy / length }
return {
point: {
x: (wall.start[0] + wall.end[0]) / 2,
y: (wall.start[1] + wall.end[1]) / 2,
},
tangent,
normal: { x: -tangent.y, y: tangent.x },
}
}
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<WallNode, 'start' | 'end' | 'curveOffset'>,
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 = sampleWallPointsForRoomDetection(wall)
return forward ? points : [...points].reverse()
}
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
if (walls.length < 3) return []
type HalfEdge = {
id: string
reverseId: string
fromKey: string
toKey: string
angle: number
points: Point2D[]
}
type Node = { point: Point2D; outgoing: string[] }
const graph = new Map<string, Node>()
const halfEdges = new Map<string, HalfEdge>()
const upsertNode = (point: Point2D) => {
const key = pointKey(point)
if (!graph.has(key)) {
graph.set(key, { point: { ...point }, outgoing: [] })
}
return key
}
for (const wall of walls) {
const start = pointFromTuple(wall.start)
const end = pointFromTuple(wall.end)
const startKey = upsertNode(start)
const endKey = upsertNode(end)
if (startKey === endKey) continue
const forwardDirection = getWallDirection(wall)
const reverseDirection = getWallDirection({ start: wall.end, end: wall.start })
const forwardId = `${wall.id}:f`
const reverseId = `${wall.id}:r`
halfEdges.set(forwardId, {
id: forwardId,
reverseId,
fromKey: startKey,
toKey: endKey,
angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
points: getDirectedWallBoundaryPoints(wall, true),
})
halfEdges.set(reverseId, {
id: reverseId,
reverseId: forwardId,
fromKey: endKey,
toKey: startKey,
angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
points: getDirectedWallBoundaryPoints(wall, false),
})
graph.get(startKey)?.outgoing.push(forwardId)
graph.get(endKey)?.outgoing.push(reverseId)
}
const sortedOutgoing = new Map<string, string[]>()
for (const [key, node] of graph.entries()) {
const outgoing = [...node.outgoing]
outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0))
sortedOutgoing.set(key, outgoing)
}
const nextEdge = (edgeId: string) => {
const edge = halfEdges.get(edgeId)
if (!edge) return null
const outgoing = sortedOutgoing.get(edge.toKey)
if (!outgoing || outgoing.length === 0) return null
const idx = outgoing.indexOf(edge.reverseId)
if (idx === -1) return null
const nextIdx = (idx - 1 + outgoing.length) % outgoing.length
return outgoing[nextIdx] ?? null
}
const visitedDirected = new Set<string>()
const faces: Point2D[][] = []
const maxSteps = Math.min(500, walls.length * 8 + 20)
for (const edgeId of halfEdges.keys()) {
if (visitedDirected.has(edgeId)) continue
const cycleEdgeIds: string[] = []
let currentEdgeId = edgeId
let valid = true
for (let step = 0; step < maxSteps; step += 1) {
const currentEdge = halfEdges.get(currentEdgeId)
if (!currentEdge) {
valid = false
break
}
visitedDirected.add(currentEdgeId)
cycleEdgeIds.push(currentEdgeId)
const next = nextEdge(currentEdgeId)
if (!next) {
valid = false
break
}
currentEdgeId = next
if (currentEdgeId === edgeId) break
}
if (!valid || cycleEdgeIds.length < 3) continue
const polygon = dedupeSequentialPoints(
cycleEdgeIds.flatMap((id, index) => {
const points = halfEdges.get(id)?.points ?? []
return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1)
}),
)
if (polygon.length < 3) continue
const signedArea = polygonArea(polygon)
if (signedArea <= 0) continue
if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon)
if (faces.some((face) => polygonSignature(face) === signature)) continue
faces.push(polygon)
}
faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)))
return faces
}
export function resolveWallSurfaceSides(
wall: Pick<WallNode, 'start' | 'end' | 'thickness' | 'frontSide' | 'backSide'>,
roomPolygons: Point2D[][],
): Pick<WallSideUpdate, 'frontSide' | 'backSide'> {
if (roomPolygons.length === 0) {
return {
frontSide: 'unknown' as const,
backSide: 'unknown' as const,
}
}
const frame = getWallDirection(wall)
const normalLength = Math.hypot(frame.normal.x, frame.normal.y)
if (normalLength < 1e-9) {
return {
frontSide: wall.frontSide,
backSide: wall.backSide,
}
}
const normalX = frame.normal.x / normalLength
const normalY = frame.normal.y / normalLength
const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16)
const frontPoint = {
x: frame.point.x + normalX * sampleDistance,
y: frame.point.y + normalY * sampleDistance,
}
const backPoint = {
x: frame.point.x - normalX * sampleDistance,
y: frame.point.y - normalY * sampleDistance,
}
const frontInside = pointInAnyPolygon(frontPoint, roomPolygons)
const backInside = pointInAnyPolygon(backPoint, roomPolygons)
if (frontInside === backInside) {
return {
frontSide: wall.frontSide,
backSide: wall.backSide,
}
}
return {
frontSide: frontInside ? 'interior' : 'exterior',
backSide: backInside ? 'interior' : 'exterior',
}
}
function nextAutoRoomName(
nodes: Array<{
name?: string
}>,
suffix: 'Slab' | 'Ceiling',
) {
let maxIndex = 0
for (const node of nodes) {
const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim())
if (!match) continue
const index = Number(match[1])
if (Number.isFinite(index)) {
maxIndex = Math.max(maxIndex, index)
}
}
return `Room ${maxIndex + 1} ${suffix}`
}
function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
return (
current.length === next.length &&
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
)
}
function wallGeometrySignature(wall: WallNode) {
return [
wall.id,
wall.start[0].toFixed(4),
wall.start[1].toFixed(4),
wall.end[0].toFixed(4),
wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4),
].join('|')
}
function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||')
}
function buildSpace(levelId: string, polygon: Point2D[]): Space {
const signature = polygonSignature(polygon)
return {
id: `space-${levelId}-${signature.slice(0, 12)}`,
levelId,
polygon: polygon.map(pointToTuple),
wallIds: [],
isExterior: false,
}
}
export function planAutoSlabsForLevel(
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
): AutoSlabSyncPlan {
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
)
const detected: DetectedRoom[] = roomPolygons
.map((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))
const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls)
const existingAutoMeta = existingAuto.map((slab) => {
const poly = slab.polygon.map(pointFromTuple)
return {
slab,
sig: polygonSignature(poly),
centroid: polygonCentroid(poly),
area: Math.abs(polygonArea(poly)),
bbox: bboxOf(poly),
}
})
const matchedSlabIds = new Set<string>()
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
if (!existing) return
matchedDetectedIdx.add(index)
matchedSlabIds.add(existing.slab.id)
updatesById.set(existing.slab.id, room.poly.map(pointToTuple))
})
const remainingDetected = detected
.map((room, index) => ({ room, index }))
.filter(({ index }) => !matchedDetectedIdx.has(index))
.sort((a, b) => b.room.area - a.room.area)
const remainingAuto = existingAutoMeta.filter((entry) => !matchedSlabIds.has(entry.slab.id))
for (const { room, index } of remainingDetected) {
let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null
for (const entry of remainingAuto) {
if (matchedSlabIds.has(entry.slab.id)) continue
const dx = room.centroid.x - entry.centroid.x
const dy = room.centroid.y - entry.centroid.y
const dist = Math.hypot(dx, dy)
const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999
const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)))
const overlap = bboxOverlapArea(room.bbox, entry.bbox)
if (overlap <= 0.0001 && dist > 1.5) continue
const score = dist + areaPenalty * 0.35
if (!bestMatch || score < bestMatch.score) {
bestMatch = { entry, score }
}
}
if (!bestMatch) continue
matchedDetectedIdx.add(index)
matchedSlabIds.add(bestMatch.entry.slab.id)
updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple))
}
const slabsToDelete = existingAuto
.filter((slab) => !updatesById.has(slab.id))
.map((slab) => slab.id)
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 } }]
})
const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs]
const slabsToCreate: SlabNodeType[] = []
for (let index = 0; index < detected.length; index += 1) {
if (matchedDetectedIdx.has(index)) continue
const room = detected[index]
if (!room) continue
const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab')
plannedSlabsForNaming.push({ name })
slabsToCreate.push(
SlabNode.parse({
name,
polygon: room.poly.map(pointToTuple),
holes: [],
elevation: DEFAULT_AUTO_SLAB_ELEVATION,
autoFromWalls: true,
}),
)
}
return {
create: slabsToCreate,
update: slabsToUpdate,
delete: slabsToDelete,
}
}
function syncAutoSlabsForLevel(
levelId: string,
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
) {
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
}
if (plan.update.length > 0) {
sceneStore.getState().updateNodes(plan.update)
}
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
}
}
function syncAutoCeilingsForLevel(
levelId: string,
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
sceneStore: any,
) {
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
)
const detected: DetectedRoom[] = roomPolygons
.map((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))
const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls)
const existingAutoMeta = existingAuto.map((ceiling) => {
const poly = ceiling.polygon.map(pointFromTuple)
return {
ceiling,
sig: polygonSignature(poly),
centroid: polygonCentroid(poly),
area: Math.abs(polygonArea(poly)),
bbox: bboxOf(poly),
}
})
const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
if (!existing) return
matchedDetectedIdx.add(index)
matchedCeilingIds.add(existing.ceiling.id)
updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple))
})
const remainingDetected = detected
.map((room, index) => ({ room, index }))
.filter(({ index }) => !matchedDetectedIdx.has(index))
.sort((a, b) => b.room.area - a.room.area)
const remainingAuto = existingAutoMeta.filter((entry) => !matchedCeilingIds.has(entry.ceiling.id))
for (const { room, index } of remainingDetected) {
let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null
for (const entry of remainingAuto) {
if (matchedCeilingIds.has(entry.ceiling.id)) continue
const dx = room.centroid.x - entry.centroid.x
const dy = room.centroid.y - entry.centroid.y
const dist = Math.hypot(dx, dy)
const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999
const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)))
const overlap = bboxOverlapArea(room.bbox, entry.bbox)
if (overlap <= 0.0001 && dist > 1.5) continue
const score = dist + areaPenalty * 0.35
if (!bestMatch || score < bestMatch.score) {
bestMatch = { entry, score }
}
}
if (!bestMatch) continue
matchedDetectedIdx.add(index)
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple))
}
const ceilingsToDelete = existingAuto
.filter((ceiling) => !updatesById.has(ceiling.id))
.map((ceiling) => ceiling.id)
const ceilingsToUpdate = existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const polygon = updatesById.get(ceiling.id)
if (!polygon) return []
return sameTuplePolygon(ceiling.polygon, polygon)
? []
: [{ id: ceiling.id, data: { polygon } }]
})
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
const ceilingsToCreate: CeilingNodeType[] = []
for (let index = 0; index < detected.length; index += 1) {
if (matchedDetectedIdx.has(index)) continue
const room = detected[index]
if (!room) continue
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
plannedCeilingsForNaming.push({ name })
ceilingsToCreate.push(
CeilingNode.parse({
name,
polygon: room.poly.map(pointToTuple),
holes: [],
height: DEFAULT_AUTO_CEILING_HEIGHT,
autoFromWalls: true,
}),
)
}
if (ceilingsToDelete.length > 0) {
sceneStore.getState().deleteNodes(ceilingsToDelete)
}
if (ceilingsToUpdate.length > 0) {
sceneStore.getState().updateNodes(ceilingsToUpdate)
}
if (ceilingsToCreate.length > 0) {
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })))
}
}
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
const roomPolygons = extractRoomPolygons(walls)
const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
wallId: wall.id,
...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick<
WallSideUpdate,
'frontSide' | 'backSide'
>),
}))
return {
roomPolygons,
spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
wallUpdates,
}
}
export function detectSpacesForLevel(levelId: string, walls: WallNode[]) {
return detectSpacesFromWalls(levelId, walls)
}
function runSpaceDetection(
levelIds: string[],
sceneStore: any,
editorStore: any,
nodes: any,
): void {
const { updateNodes } = sceneStore.getState()
const existingSpaces = editorStore.getState().spaces as Record<string, Space>
const nextSpaces: Record<string, Space> = {}
for (const [spaceId, space] of Object.entries(existingSpaces)) {
if (!levelIds.includes(space.levelId)) {
nextSpaces[spaceId] = space
}
}
for (const levelId of levelIds) {
const walls = Object.values(nodes).filter(
(node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId,
)
const slabs = Object.values(nodes).filter(
(node: any) => node?.type === 'slab' && node.parentId === levelId,
)
const ceilings = Object.values(nodes).filter(
(node: any) => node?.type === 'ceiling' && node.parentId === levelId,
)
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls)
const changedWallUpdates = wallUpdates.filter((update) => {
const wall = nodes[update.wallId]
return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide)
})
if (changedWallUpdates.length > 0) {
updateNodes(
changedWallUpdates.map((update) => ({
id: update.wallId,
data: {
frontSide: update.frontSide,
backSide: update.backSide,
},
})),
)
}
syncAutoSlabsForLevel(
levelId,
roomPolygons,
slabs.map((slab: any) => SlabNode.parse(slab)),
sceneStore,
)
syncAutoCeilingsForLevel(
levelId,
roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore,
)
for (const space of spaces) {
nextSpaces[space.id] = space
}
}
editorStore.getState().setSpaces(nextSpaces)
}
export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void {
const previousSnapshots = new Map<string, string>()
let isProcessing = false
const unsubscribe = sceneStore.subscribe((state: any) => {
if (isProcessing) return
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (node && (node as any).type === 'wall' && (node as any).parentId) {
const levelId = (node as any).parentId as string
const levelWalls = wallsByLevel.get(levelId) ?? []
levelWalls.push(node as WallNode)
wallsByLevel.set(levelId, levelWalls)
}
}
const currentSnapshots = new Map<string, string>()
for (const [levelId, walls] of wallsByLevel.entries()) {
currentSnapshots.set(levelId, levelWallSnapshot(walls))
}
const levelsToUpdate = new Set<string>()
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {
levelsToUpdate.add(levelId)
}
}
if (levelsToUpdate.size === 0) {
previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot)
}
return
}
isProcessing = true
pauseSceneHistory(sceneStore)
try {
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
} finally {
resumeSceneHistory(sceneStore)
previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot)
}
isProcessing = false
}
})
return unsubscribe
}
export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean {
const threshold = 0.1
for (const other of otherWalls) {
if (other.id === wall.id) continue
if (
distanceToSegment(wall.start, other.start, other.end) < threshold ||
distanceToSegment(wall.end, other.start, other.end) < threshold ||
distanceToSegment(other.start, wall.start, wall.end) < threshold ||
distanceToSegment(other.end, wall.start, wall.end) < threshold
) {
return true
}
}
return false
}
function distanceToSegment(
point: [number, number],
segStart: [number, number],
segEnd: [number, number],
) {
const [px, py] = point
const [x1, y1] = segStart
const [x2, y2] = segEnd
const dx = x2 - x1
const dy = y2 - y1
const lenSq = dx * dx + dy * dy
if (lenSq < 0.0001) {
return Math.hypot(px - x1, py - y1)
}
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq))
const projX = x1 + t * dx
const projY = y1 + t * dy
return Math.hypot(px - projX, py - projY)
}