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()])),
|
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||||
elevation: z.number().default(0.05), // Elevation in meters
|
elevation: z.number().default(0.05), // Elevation in meters
|
||||||
|
autoFromWalls: z.boolean().default(false),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Slab node - used to represent a slab/floor in the building
|
Slab node - used to represent a slab/floor in the building
|
||||||
- polygon: array of [x, z] points defining the slab boundary
|
- polygon: array of [x, z] points defining the slab boundary
|
||||||
- elevation: elevation in meters
|
- 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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
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_JOIN_SNAP_RADIUS = 0.35
|
||||||
export const WALL_MIN_LENGTH = 0.01
|
export const WALL_MIN_LENGTH = 0.01
|
||||||
|
|
||||||
|
type WallSplitIntersection = {
|
||||||
|
wallId: WallNode['id']
|
||||||
|
point: WallPlanPoint
|
||||||
|
}
|
||||||
|
|
||||||
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||||
const dx = a[0] - b[0]
|
const dx = a[0] - b[0]
|
||||||
const dz = a[1] - b[1]
|
const dz = a[1] - b[1]
|
||||||
@@ -53,6 +58,103 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
|
|||||||
return [x1 + dx * t, z1 + dz * t]
|
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(
|
export function findWallSnapTarget(
|
||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
@@ -120,17 +222,57 @@ export function createWallOnCurrentLevel(
|
|||||||
end: WallPlanPoint,
|
end: WallPlanPoint,
|
||||||
): WallNode | null {
|
): WallNode | null {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
const { createNode, nodes } = useScene.getState()
|
const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
|
||||||
|
|
||||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||||
return null
|
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 wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
||||||
const wall = WallSchema.parse({
|
const wall = WallSchema.parse({
|
||||||
name: `Wall ${wallCount + 1}`,
|
name: `Wall ${wallCount + 1}`,
|
||||||
start,
|
start: resolvedStart,
|
||||||
end,
|
end: resolvedEnd,
|
||||||
})
|
})
|
||||||
|
|
||||||
createNode(wall, currentLevelId)
|
createNode(wall, currentLevelId)
|
||||||
|
|||||||
Reference in New Issue
Block a user