feat: implement automatic wall-splitting and slab generation from closed wall loops
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user