feat: implement automatic wall-splitting and slab generation from closed wall loops

This commit is contained in:
sudhir
2026-04-15 14:18:09 +05:30
parent 3ac26bf635
commit 6190ba4bdc
3 changed files with 805 additions and 571 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,11 +10,13 @@ export const SlabNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
elevation: z.number().default(0.05), // Elevation in meters
autoFromWalls: z.boolean().default(false),
}).describe(
dedent`
Slab node - used to represent a slab/floor in the building
- polygon: array of [x, z] points defining the slab boundary
- elevation: elevation in meters
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
`,
)
@@ -1,4 +1,4 @@
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
import { type AnyNodeId, useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -8,6 +8,11 @@ export const WALL_GRID_STEP = 0.5
export const WALL_JOIN_SNAP_RADIUS = 0.35
export const WALL_MIN_LENGTH = 0.01
type WallSplitIntersection = {
wallId: WallNode['id']
point: WallPlanPoint
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
@@ -53,6 +58,103 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
return [x1 + dx * t, z1 + dz * t]
}
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
const { id: _id, parentId: _parentId, children, ...rest } = wall
const first = WallSchema.parse({
...rest,
start: wall.start,
end: splitPoint,
children: children ?? [],
})
const second = WallSchema.parse({
...rest,
start: splitPoint,
end: wall.end,
children: children ?? [],
})
return [first, second]
}
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
return distanceSquared(a, b) <= tolerance * tolerance
}
function findWallIntersection(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
): WallSplitIntersection | null {
const ignore = new Set(ignoreWallIds ?? [])
let best: WallSplitIntersection | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY
for (const wall of walls) {
if (ignore.has(wall.id)) continue
const projected = projectPointOntoWall(point, wall)
if (!projected) continue
const candidateDistanceSquared = distanceSquared(point, projected)
if (
candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
candidateDistanceSquared >= bestDistanceSquared
) {
continue
}
best = { wallId: wall.id, point: projected }
bestDistanceSquared = candidateDistanceSquared
}
return best
}
function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
if ((wall.children?.length ?? 0) > 0) {
return true
}
return Object.values(nodes).some((node) => {
if (!node) return false
if ('parentId' in node && node.parentId === wall.id) return true
if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
return false
})
}
function splitWallIfNeeded(
intersection: WallSplitIntersection | null,
walls: WallNode[],
nodes: ReturnType<typeof useScene.getState>['nodes'],
createNodes: ReturnType<typeof useScene.getState>['createNodes'],
deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
): { walls: WallNode[]; point: WallPlanPoint } | null {
if (!intersection) return null
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
if (!wallToSplit) {
return { walls, point: intersection.point }
}
if (wallHasAttachments(wallToSplit, nodes)) {
return { walls, point: intersection.point }
}
const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
createNodes([
{ node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
{ node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
])
deleteNode(wallToSplit.id as AnyNodeId)
return {
walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
point: intersection.point,
}
}
export function findWallSnapTarget(
point: WallPlanPoint,
walls: WallNode[],
@@ -120,17 +222,57 @@ export function createWallOnCurrentLevel(
end: WallPlanPoint,
): WallNode | null {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) {
return null
}
let workingWalls = Object.values(nodes).filter(
(node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
)
let resolvedStart = start
let resolvedEnd = end
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded(endIntersection, workingWalls, nodes, createNodes, deleteNode)
if (splitEnd) {
workingWalls = splitEnd.walls
resolvedEnd = splitEnd.point
}
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
const splitStart = splitWallIfNeeded(
startIntersection,
workingWalls,
nodes,
createNodes,
deleteNode,
)
if (splitStart) {
workingWalls = splitStart.walls
resolvedStart = splitStart.point
}
if (!isWallLongEnough(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 wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
const wall = WallSchema.parse({
name: `Wall ${wallCount + 1}`,
start,
end,
start: resolvedStart,
end: resolvedEnd,
})
createNode(wall, currentLevelId)