Fix room surfaces, wall openings, and paint scope (#498)
* chore(core): point material catalog at KTX2 tiers for wood/flooring/roofing finishes All 48 remaining webp/jpg/png finish entries now reference _512.ktx2 maps and 256px _thumb.webp previews, matching the fabric/leather/concrete/metal convention. flipY set to false on the converted entries — compressed textures can't be flipped at upload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: stabilize room surfaces and wall openings * style(core): format KTX2 material catalog --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4fca38a3ef
commit
a524da1574
@@ -105,6 +105,7 @@ export {
|
||||
projectAutoSlabsForPlan,
|
||||
resumeSpaceDetection,
|
||||
type Space,
|
||||
type SpaceBoundaryFace,
|
||||
wallClosesRoom,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
|
||||
@@ -181,8 +181,14 @@ describe('detectSpacesForLevel', () => {
|
||||
}
|
||||
|
||||
test('detects an isolated four-wall room', () => {
|
||||
const { roomPolygons } = detectSpacesForLevel('level-1', squareWalls())
|
||||
const walls = squareWalls()
|
||||
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
|
||||
expect(roomPolygons).toHaveLength(1)
|
||||
expect(spaces[0]?.wallIds.sort()).toEqual(walls.map((wall) => wall.id).sort())
|
||||
expect(spaces[0]?.boundaryFaces).toHaveLength(4)
|
||||
expect(
|
||||
spaces[0]?.boundaryFaces.map((boundary) => [boundary.wallId, boundary.face]).sort(),
|
||||
).toEqual(walls.map((wall) => [wall.id, 'front']).sort())
|
||||
})
|
||||
|
||||
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
|
||||
@@ -200,12 +206,24 @@ describe('detectSpacesForLevel', () => {
|
||||
WallNode.parse({ start: [3, -2], end: [3, 0] }),
|
||||
]
|
||||
|
||||
const { roomPolygons } = detectSpacesForLevel('level-1', walls)
|
||||
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
|
||||
const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b)
|
||||
|
||||
expect(roomPolygons).toHaveLength(2)
|
||||
expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2
|
||||
expect(areas[1]).toBeCloseTo(30, 1) // big room: 6×5
|
||||
|
||||
const longWallId = walls[0]!.id
|
||||
const longWallBoundaries = spaces.flatMap((space) =>
|
||||
space.boundaryFaces.filter((boundary) => boundary.wallId === longWallId),
|
||||
)
|
||||
expect(longWallBoundaries).toHaveLength(4)
|
||||
expect(longWallBoundaries.filter((boundary) => boundary.face === 'back')).toHaveLength(1)
|
||||
expect(longWallBoundaries.filter((boundary) => boundary.face === 'front')).toHaveLength(3)
|
||||
expect(longWallBoundaries.map((boundary) => boundary.points)).toContainEqual([
|
||||
[1, 0],
|
||||
[3, 0],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -333,4 +351,31 @@ describe('planAutoSlabsForLevel', () => {
|
||||
expect(plan.update).toHaveLength(0)
|
||||
expect(plan.delete).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('manual slabs that split one room suppress a replacement full-room slab', () => {
|
||||
const left = SlabNode.parse({
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 3],
|
||||
[0, 3],
|
||||
],
|
||||
autoFromWalls: false,
|
||||
})
|
||||
const right = SlabNode.parse({
|
||||
polygon: [
|
||||
[2, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[2, 3],
|
||||
],
|
||||
autoFromWalls: false,
|
||||
})
|
||||
|
||||
const plan = planAutoSlabsForLevel([roomPolygon()], [left, right])
|
||||
|
||||
expect(plan.create).toHaveLength(0)
|
||||
expect(plan.update).toHaveLength(0)
|
||||
expect(plan.delete).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,14 +19,26 @@ import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
|
||||
export type SpaceBoundaryFace = {
|
||||
wallId: string
|
||||
face: 'front' | 'back'
|
||||
points: Array<[number, number]>
|
||||
}
|
||||
|
||||
export type Space = {
|
||||
id: string
|
||||
levelId: string
|
||||
polygon: Array<[number, number]>
|
||||
wallIds: string[]
|
||||
boundaryFaces: SpaceBoundaryFace[]
|
||||
isExterior: boolean
|
||||
}
|
||||
|
||||
type ExtractedRoom = {
|
||||
polygon: Point2D[]
|
||||
boundaryFaces: SpaceBoundaryFace[]
|
||||
}
|
||||
|
||||
type WallSideUpdate = {
|
||||
wallId: string
|
||||
frontSide: 'interior' | 'exterior' | 'unknown'
|
||||
@@ -240,15 +252,15 @@ function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) {
|
||||
}
|
||||
|
||||
// Demoted auto surfaces keep their polygon untouched, so a re-closed room
|
||||
// usually hits the exact-signature manual check. Mutual footprint coverage
|
||||
// still guards the case where the user edited the demoted surface's polygon
|
||||
// afterwards — a fresh auto surface must not stack on top of it.
|
||||
// usually hits the exact-signature manual check. Coverage also handles a room
|
||||
// deliberately split across multiple manual surfaces: their union suppresses
|
||||
// a replacement auto surface as long as the pieces substantially belong to
|
||||
// and cover the room.
|
||||
function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
|
||||
return manualPolygons.some(
|
||||
(manual) =>
|
||||
polygonCoverageRatio(roomPolygon, [manual]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD &&
|
||||
polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
|
||||
const roomManualPolygons = manualPolygons.filter(
|
||||
(manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
|
||||
)
|
||||
return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
|
||||
}
|
||||
|
||||
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
|
||||
@@ -460,7 +472,7 @@ function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Poi
|
||||
return ordered
|
||||
}
|
||||
|
||||
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
function extractRooms(walls: WallNode[]): ExtractedRoom[] {
|
||||
if (walls.length < 3) return []
|
||||
|
||||
type HalfEdge = {
|
||||
@@ -470,6 +482,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
toKey: string
|
||||
angle: number
|
||||
points: Point2D[]
|
||||
wallId: string
|
||||
face: 'front' | 'back'
|
||||
}
|
||||
type Node = { point: Point2D; outgoing: string[] }
|
||||
|
||||
@@ -535,6 +549,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
toKey,
|
||||
angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x),
|
||||
points,
|
||||
wallId: wall.id,
|
||||
face: 'front',
|
||||
})
|
||||
halfEdges.set(reverseId, {
|
||||
id: reverseId,
|
||||
@@ -543,6 +559,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
toKey: fromKey,
|
||||
angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x),
|
||||
points: reversePoints,
|
||||
wallId: wall.id,
|
||||
face: 'back',
|
||||
})
|
||||
|
||||
graph.get(fromKey)?.outgoing.push(forwardId)
|
||||
@@ -572,7 +590,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
}
|
||||
|
||||
const visitedDirected = new Set<string>()
|
||||
const faces: Point2D[][] = []
|
||||
const rooms: ExtractedRoom[] = []
|
||||
// A single face cannot revisit a half-edge, so the half-edge count bounds the
|
||||
// longest possible cycle. Splitting at junctions can multiply edges per wall.
|
||||
const maxSteps = Math.min(2000, halfEdges.size + 10)
|
||||
@@ -620,13 +638,30 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
if (signedArea < 0.5 || signedArea > 10_000) continue
|
||||
|
||||
const signature = polygonSignature(polygon)
|
||||
if (faces.some((face) => polygonSignature(face) === signature)) continue
|
||||
if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue
|
||||
|
||||
faces.push(polygon)
|
||||
rooms.push({
|
||||
polygon,
|
||||
boundaryFaces: cycleEdgeIds.flatMap((id) => {
|
||||
const edge = halfEdges.get(id)
|
||||
if (!edge) return []
|
||||
return [
|
||||
{
|
||||
wallId: edge.wallId,
|
||||
face: edge.face,
|
||||
points: edge.points.map(pointToTuple),
|
||||
},
|
||||
]
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)))
|
||||
return faces
|
||||
rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)))
|
||||
return rooms
|
||||
}
|
||||
|
||||
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
return extractRooms(walls).map((room) => room.polygon)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -760,13 +795,14 @@ function levelStructureSnapshots(nodes: Record<string, any>) {
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function buildSpace(levelId: string, polygon: Point2D[]): Space {
|
||||
const signature = polygonSignature(polygon)
|
||||
function buildSpace(levelId: string, room: ExtractedRoom): Space {
|
||||
const signature = polygonSignature(room.polygon)
|
||||
return {
|
||||
id: `space-${levelId}-${signature.slice(0, 12)}`,
|
||||
levelId,
|
||||
polygon: polygon.map(pointToTuple),
|
||||
wallIds: [],
|
||||
polygon: room.polygon.map(pointToTuple),
|
||||
wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
|
||||
boundaryFaces: room.boundaryFaces,
|
||||
isExterior: false,
|
||||
}
|
||||
}
|
||||
@@ -1169,7 +1205,8 @@ function syncAutoCeilingsForLevel(
|
||||
}
|
||||
|
||||
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
|
||||
const roomPolygons = extractRoomPolygons(walls)
|
||||
const rooms = extractRooms(walls)
|
||||
const roomPolygons = rooms.map((room) => room.polygon)
|
||||
const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
|
||||
wallId: wall.id,
|
||||
...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick<
|
||||
@@ -1180,7 +1217,7 @@ function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
|
||||
|
||||
return {
|
||||
roomPolygons,
|
||||
spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
|
||||
spaces: rooms.map((room) => buildSpace(levelId, room)),
|
||||
wallUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ import {
|
||||
type PaintHoverInfo,
|
||||
resolvePaintScopeTargets,
|
||||
slotDisplayLabel,
|
||||
type WallPaintHit,
|
||||
} from '../../lib/paint-scope'
|
||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||
import {
|
||||
@@ -288,6 +289,29 @@ function meshSlotRoles(node: AnyNode): string[] {
|
||||
}
|
||||
|
||||
const roofSelectionWorldPoint = new Vector3()
|
||||
const wallPaintWorldPoint = new Vector3()
|
||||
|
||||
function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
|
||||
const wall = event.node
|
||||
if (wall.type !== 'wall') return undefined
|
||||
const root = getRegisteredNodeObject(wall.id)
|
||||
if (!root) return undefined
|
||||
|
||||
root.updateWorldMatrix(true, false)
|
||||
wallPaintWorldPoint.set(...event.position)
|
||||
const local = root.worldToLocal(wallPaintWorldPoint)
|
||||
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
|
||||
return {
|
||||
face: local.z >= 0 ? 'front' : 'back',
|
||||
point: [
|
||||
wall.start[0] + local.x * cos - local.z * sin,
|
||||
wall.start[1] + local.x * sin + local.z * cos,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
|
||||
const roof = event.node
|
||||
@@ -841,6 +865,7 @@ export const SelectionManager = () => {
|
||||
// (Shift) re-keys the interaction → the preview re-applies for the new
|
||||
// spread instead of being deduped to the single-surface preview.
|
||||
const scope = useEditor.getState().paintScope
|
||||
const wallHit = resolveWallPaintHit(event)
|
||||
const scopeTargets =
|
||||
compatible && role
|
||||
? resolvePaintScopeTargets({
|
||||
@@ -850,10 +875,15 @@ export const SelectionManager = () => {
|
||||
nodes: useScene.getState().nodes,
|
||||
spaces: useEditor.getState().spaces,
|
||||
slotRolesOf: () => slotRoles,
|
||||
wallHit,
|
||||
})
|
||||
: []
|
||||
const scopeTargetKey = scopeTargets
|
||||
.map((target) => `${target.nodeId}:${target.role}`)
|
||||
.sort()
|
||||
.join(',')
|
||||
return {
|
||||
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}`,
|
||||
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`,
|
||||
hoveredId: node.id as AnyNodeId,
|
||||
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
||||
paintHover:
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { AnyNode, ItemNode, SlabNode, Space } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
detectSpacesForLevel,
|
||||
type ItemNode,
|
||||
type SlabNode,
|
||||
type Space,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
availablePaintScopes,
|
||||
cyclePaintScope,
|
||||
@@ -7,6 +14,7 @@ import {
|
||||
type PaintScope,
|
||||
paintScopeLabel,
|
||||
resolvePaintScopeTargets,
|
||||
type WallPaintHit,
|
||||
} from './paint-scope'
|
||||
|
||||
describe('availablePaintScopes', () => {
|
||||
@@ -76,8 +84,22 @@ function item(id: string, assetId: string): ItemNode {
|
||||
function slab(id: string, polygon: Array<[number, number]>): SlabNode {
|
||||
return { id, type: 'slab', polygon } as unknown as SlabNode
|
||||
}
|
||||
function wall(id: string, start: [number, number], end: [number, number]): AnyNode {
|
||||
return { id, type: 'wall', start, end } as unknown as AnyNode
|
||||
function wall(
|
||||
id: string,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
levelId = 'l1',
|
||||
): WallNode {
|
||||
return {
|
||||
id,
|
||||
type: 'wall',
|
||||
parentId: levelId,
|
||||
start,
|
||||
end,
|
||||
thickness: 0.2,
|
||||
frontSide: 'unknown',
|
||||
backSide: 'unknown',
|
||||
} as unknown as WallNode
|
||||
}
|
||||
function roof(): AnyNode {
|
||||
return { id: 'r', type: 'roof' } as unknown as AnyNode
|
||||
@@ -99,6 +121,7 @@ function resolve(args: {
|
||||
nodes: AnyNode[]
|
||||
spaces?: Space[]
|
||||
slotRolesOf?: (node: AnyNode) => string[]
|
||||
wallHit?: WallPaintHit
|
||||
}) {
|
||||
return resolvePaintScopeTargets({
|
||||
node: args.node,
|
||||
@@ -107,9 +130,23 @@ function resolve(args: {
|
||||
nodes: asMap(args.nodes),
|
||||
spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])),
|
||||
slotRolesOf: args.slotRolesOf ?? noSlotRoles,
|
||||
wallHit: args.wallHit,
|
||||
})
|
||||
}
|
||||
|
||||
function adjacentRooms(levelId = 'l1') {
|
||||
const walls = [
|
||||
wall('bottom-left', [0, 0], [4, 0], levelId),
|
||||
wall('bottom-right', [4, 0], [8, 0], levelId),
|
||||
wall('right', [8, 0], [8, 4], levelId),
|
||||
wall('top-right', [8, 4], [4, 4], levelId),
|
||||
wall('top-left', [4, 4], [0, 4], levelId),
|
||||
wall('left', [0, 4], [0, 0], levelId),
|
||||
wall('shared', [4, 0], [4, 4], levelId),
|
||||
]
|
||||
return { walls, spaces: detectSpacesForLevel(levelId, walls).spaces }
|
||||
}
|
||||
|
||||
describe('resolvePaintScopeTargets', () => {
|
||||
it('single always returns just the clicked surface', () => {
|
||||
const a = item('a', 'sofa')
|
||||
@@ -145,40 +182,136 @@ describe('resolvePaintScopeTargets', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('wall room fans the same side across the walls bounding the room polygon', () => {
|
||||
// A 4×4 room: each wall's endpoints are exact polygon vertices.
|
||||
const w1 = wall('w1', [0, 0], [4, 0])
|
||||
const w2 = wall('w2', [4, 0], [4, 4])
|
||||
const w3 = wall('w3', [4, 4], [0, 4])
|
||||
const w4 = wall('w4', [0, 4], [0, 0])
|
||||
const wOut = wall('wOut', [10, 10], [14, 10]) // not on the room boundary
|
||||
const space: Space = {
|
||||
id: 's1',
|
||||
levelId: 'l1',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
[0, 4],
|
||||
],
|
||||
wallIds: [], // always empty in practice — membership is geometric
|
||||
isExterior: false,
|
||||
}
|
||||
const result = resolve({
|
||||
node: w1,
|
||||
it('wall room selects the enclosed space on the clicked face of a shared wall', () => {
|
||||
const { walls, spaces } = adjacentRooms()
|
||||
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
||||
|
||||
const leftRoom = resolve({
|
||||
node: shared,
|
||||
role: 'interior',
|
||||
scope: 'room',
|
||||
nodes: [w1, w2, w3, w4, wOut],
|
||||
spaces: [space],
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'front', point: [3.9, 2] },
|
||||
})
|
||||
expect(keys(leftRoom).sort()).toEqual([
|
||||
'bottom-left:interior',
|
||||
'left:interior',
|
||||
'shared:interior',
|
||||
'top-left:interior',
|
||||
])
|
||||
|
||||
const rightRoom = resolve({
|
||||
node: shared,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [4.1, 2] },
|
||||
})
|
||||
expect(keys(rightRoom).sort()).toEqual([
|
||||
'bottom-right:interior',
|
||||
'right:interior',
|
||||
'shared:exterior',
|
||||
'top-right:interior',
|
||||
])
|
||||
})
|
||||
|
||||
it('wall room preserves the vertical band while mapping each boundary face side', () => {
|
||||
const { walls, spaces } = adjacentRooms()
|
||||
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
||||
const result = resolve({
|
||||
node: shared,
|
||||
role: 'lowerExterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [4.1, 2] },
|
||||
})
|
||||
expect(keys(result).sort()).toEqual([
|
||||
'w1:interior',
|
||||
'w2:interior',
|
||||
'w3:interior',
|
||||
'w4:interior',
|
||||
'bottom-right:lowerInterior',
|
||||
'right:lowerInterior',
|
||||
'shared:lowerExterior',
|
||||
'top-right:lowerInterior',
|
||||
])
|
||||
})
|
||||
|
||||
it('wall room maps a reversed boundary wall to its rendered side', () => {
|
||||
const { walls } = adjacentRooms()
|
||||
const topRight = walls.find((candidate) => String(candidate.id) === 'top-right')!
|
||||
topRight.start = [4, 4]
|
||||
topRight.end = [8, 4]
|
||||
const spaces = detectSpacesForLevel('l1', walls).spaces
|
||||
const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
|
||||
const result = resolve({
|
||||
node: shared,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [4.1, 2] },
|
||||
})
|
||||
|
||||
expect(keys(result)).toContain('top-right:exterior')
|
||||
expect(keys(result)).not.toContain('top-right:interior')
|
||||
})
|
||||
|
||||
it('wall room excludes duplicate geometry and spaces from another level', () => {
|
||||
const levelA = adjacentRooms('l1')
|
||||
const levelB = adjacentRooms('l2')
|
||||
const levelBWalls = levelB.walls.map((candidate) => ({
|
||||
...candidate,
|
||||
id: `other-${candidate.id}`,
|
||||
})) as unknown as WallNode[]
|
||||
const otherSpaces = detectSpacesForLevel('l2', levelBWalls).spaces
|
||||
const shared = levelA.walls.find((candidate) => String(candidate.id) === 'shared')!
|
||||
const result = resolve({
|
||||
node: shared,
|
||||
role: 'interior',
|
||||
scope: 'room',
|
||||
nodes: [...levelA.walls, ...levelBWalls],
|
||||
spaces: [...levelA.spaces, ...otherSpaces],
|
||||
wallHit: { face: 'front', point: [3.9, 2] },
|
||||
})
|
||||
expect(keys(result).every((key) => !key.startsWith('other-'))).toBe(true)
|
||||
expect(result).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('wall room uses the hit subsegment when one long wall bounds adjacent bays', () => {
|
||||
const long = wall('long', [0, 0], [8, 0])
|
||||
const walls = [
|
||||
long,
|
||||
wall('left', [0, 0], [0, -3]),
|
||||
wall('left-bottom', [0, -3], [4, -3]),
|
||||
wall('divider', [4, -3], [4, 0]),
|
||||
wall('right-bottom', [4, -3], [8, -3]),
|
||||
wall('right', [8, -3], [8, 0]),
|
||||
]
|
||||
const spaces = detectSpacesForLevel('l1', walls).spaces
|
||||
|
||||
const leftBay = resolve({
|
||||
node: long,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [2, -0.1] },
|
||||
})
|
||||
const rightBay = resolve({
|
||||
node: long,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [6, -0.1] },
|
||||
})
|
||||
|
||||
expect(keys(leftBay).some((key) => key.startsWith('left-bottom:'))).toBe(true)
|
||||
expect(keys(leftBay).some((key) => key.startsWith('right-bottom:'))).toBe(false)
|
||||
expect(keys(rightBay).some((key) => key.startsWith('right-bottom:'))).toBe(true)
|
||||
expect(keys(rightBay).some((key) => key.startsWith('left-bottom:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('wall room with no enclosing space falls back to single', () => {
|
||||
const w1 = wall('w1', [0, 0], [4, 0])
|
||||
expect(
|
||||
@@ -186,6 +319,97 @@ describe('resolvePaintScopeTargets', () => {
|
||||
).toEqual(['w1:interior'])
|
||||
})
|
||||
|
||||
it('wall room paints the connected exterior envelope from an exterior face', () => {
|
||||
const walls = [
|
||||
wall('bottom', [0, 0], [4, 0]),
|
||||
wall('right', [4, 0], [4, 4]),
|
||||
wall('top', [4, 4], [0, 4]),
|
||||
wall('left', [0, 4], [0, 0]),
|
||||
]
|
||||
const spaces = detectSpacesForLevel('l1', walls).spaces
|
||||
expect(
|
||||
keys(
|
||||
resolve({
|
||||
node: walls[0]!,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [2, -0.1] },
|
||||
}),
|
||||
).sort(),
|
||||
).toEqual(['bottom:exterior', 'left:exterior', 'right:exterior', 'top:exterior'])
|
||||
})
|
||||
|
||||
it('wall room does not cross to a disconnected exterior envelope', () => {
|
||||
const first = [
|
||||
wall('a-bottom', [0, 0], [4, 0]),
|
||||
wall('a-right', [4, 0], [4, 4]),
|
||||
wall('a-top', [4, 4], [0, 4]),
|
||||
wall('a-left', [0, 4], [0, 0]),
|
||||
]
|
||||
const second = [
|
||||
wall('b-bottom', [10, 0], [14, 0]),
|
||||
wall('b-right', [14, 0], [14, 4]),
|
||||
wall('b-top', [14, 4], [10, 4]),
|
||||
wall('b-left', [10, 4], [10, 0]),
|
||||
]
|
||||
const walls = [...first, ...second]
|
||||
const spaces = detectSpacesForLevel('l1', walls).spaces
|
||||
const result = resolve({
|
||||
node: first[0]!,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [2, -0.1] },
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(keys(result).every((key) => key.startsWith('a-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('wall room excludes shared interior walls from the exterior envelope', () => {
|
||||
const { walls, spaces } = adjacentRooms()
|
||||
const bottomLeft = walls.find((candidate) => String(candidate.id) === 'bottom-left')!
|
||||
const result = resolve({
|
||||
node: bottomLeft,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [2, -0.1] },
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(6)
|
||||
expect(keys(result).some((key) => key.startsWith('shared:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('wall room follows an exterior wall that is logically split across rooms', () => {
|
||||
const long = wall('long', [0, 0], [8, 0])
|
||||
const walls = [
|
||||
long,
|
||||
wall('right', [8, 0], [8, 4]),
|
||||
wall('top-right', [8, 4], [4, 4]),
|
||||
wall('top-left', [4, 4], [0, 4]),
|
||||
wall('left', [0, 4], [0, 0]),
|
||||
wall('divider', [4, 0], [4, 4]),
|
||||
]
|
||||
const spaces = detectSpacesForLevel('l1', walls).spaces
|
||||
const result = resolve({
|
||||
node: long,
|
||||
role: 'exterior',
|
||||
scope: 'room',
|
||||
nodes: walls,
|
||||
spaces,
|
||||
wallHit: { face: 'back', point: [2, -0.1] },
|
||||
})
|
||||
|
||||
expect(keys(result).filter((key) => key === 'long:exterior')).toHaveLength(1)
|
||||
expect(keys(result).some((key) => key.startsWith('divider:'))).toBe(false)
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('slab room fans across slabs whose centroid sits in the same space', () => {
|
||||
const inside = slab('inA', [
|
||||
[1, 1],
|
||||
@@ -215,6 +439,7 @@ describe('resolvePaintScopeTargets', () => {
|
||||
[0, 10],
|
||||
],
|
||||
wallIds: [],
|
||||
boundaryFaces: [],
|
||||
isExterior: false,
|
||||
}
|
||||
const result = resolve({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type MaterialSchema,
|
||||
nodeRegistry,
|
||||
pointInPolygon2D,
|
||||
pointOnSegment,
|
||||
resolveLevelId,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
type SlabNode,
|
||||
@@ -104,47 +104,185 @@ export function slotDisplayLabel(node: AnyNode, role: string): string {
|
||||
|
||||
type SlotsNode = AnyNode & { slots?: Record<string, string> }
|
||||
|
||||
// Room polygons are built from wall *centerline* endpoints (see
|
||||
// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices —
|
||||
// a small tolerance only absorbs float round-trips. `Space.wallIds` is always
|
||||
// empty, so room membership is resolved geometrically here instead.
|
||||
const WALL_ON_BOUNDARY_TOLERANCE = 0.05
|
||||
export type WallPaintHit = {
|
||||
face: 'front' | 'back'
|
||||
point: [number, number]
|
||||
}
|
||||
|
||||
function pointOnPolygonBoundary(
|
||||
type WallBoundaryFace = Space['boundaryFaces'][number]
|
||||
|
||||
function distanceToSegment(
|
||||
point: readonly [number, number],
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
tolerance: number,
|
||||
): boolean {
|
||||
for (let i = 0; i < polygon.length; i += 1) {
|
||||
const a = polygon[i]
|
||||
const b = polygon[(i + 1) % polygon.length]
|
||||
if (
|
||||
a &&
|
||||
b &&
|
||||
pointOnSegment(
|
||||
point as [number, number],
|
||||
a as [number, number],
|
||||
b as [number, number],
|
||||
tolerance,
|
||||
start: readonly [number, number],
|
||||
end: readonly [number, number],
|
||||
): number {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t))
|
||||
}
|
||||
|
||||
// A wall bounds a room when both its endpoints lie on the room polygon's
|
||||
// boundary (a shared wall lies on two rooms' boundaries; a wall radiating out of
|
||||
// a corner has only one endpoint on it and is correctly excluded).
|
||||
function wallBoundsRoom(
|
||||
wall: WallNode,
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
): boolean {
|
||||
return (
|
||||
pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) &&
|
||||
pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE)
|
||||
)
|
||||
function distanceToPolyline(
|
||||
point: readonly [number, number],
|
||||
points: ReadonlyArray<readonly [number, number]>,
|
||||
): number {
|
||||
let distance = Number.POSITIVE_INFINITY
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const start = points[index]
|
||||
const end = points[index + 1]
|
||||
if (!(start && end)) continue
|
||||
distance = Math.min(distance, distanceToSegment(point, start, end))
|
||||
}
|
||||
return distance
|
||||
}
|
||||
|
||||
function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null {
|
||||
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
||||
const fallback = face === 'front' ? 'interior' : 'exterior'
|
||||
const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
|
||||
|
||||
if (role === 'interior' || role === 'exterior') return side
|
||||
if (role.endsWith('Interior'))
|
||||
return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
|
||||
if (role.endsWith('Exterior'))
|
||||
return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveWallPaintSpace(args: {
|
||||
wall: WallNode
|
||||
wallHit: WallPaintHit
|
||||
nodes: Record<string, AnyNode>
|
||||
spaces: Record<string, Space>
|
||||
}): Space | null {
|
||||
const { wall, wallHit, nodes, spaces } = args
|
||||
const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
|
||||
const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
|
||||
let best: { space: Space; distance: number } | null = null
|
||||
|
||||
for (const space of Object.values(spaces)) {
|
||||
if (space.levelId !== levelId) continue
|
||||
for (const boundary of space.boundaryFaces) {
|
||||
if (boundary.wallId !== wall.id || boundary.face !== wallHit.face) continue
|
||||
const distance = distanceToPolyline(wallHit.point, boundary.points)
|
||||
if (distance > tolerance || (best && distance >= best.distance)) continue
|
||||
best = { space, distance }
|
||||
}
|
||||
}
|
||||
|
||||
return best?.space ?? null
|
||||
}
|
||||
|
||||
function boundaryPointKey(point: readonly [number, number]): string {
|
||||
return `${point[0].toFixed(3)},${point[1].toFixed(3)}`
|
||||
}
|
||||
|
||||
function boundarySegmentKey(boundary: WallBoundaryFace): string {
|
||||
const forward = boundary.points.map(boundaryPointKey).join('|')
|
||||
const reverse = [...boundary.points].reverse().map(boundaryPointKey).join('|')
|
||||
return `${boundary.wallId}:${forward < reverse ? forward : reverse}`
|
||||
}
|
||||
|
||||
function oppositeWallFace(face: 'front' | 'back'): 'front' | 'back' {
|
||||
return face === 'front' ? 'back' : 'front'
|
||||
}
|
||||
|
||||
function connectedExteriorBoundaries(args: {
|
||||
wall: WallNode
|
||||
wallHit: WallPaintHit
|
||||
levelId: string
|
||||
spaces: Record<string, Space>
|
||||
}): WallBoundaryFace[] {
|
||||
const { wall, wallHit, levelId, spaces } = args
|
||||
const occurrences = new Map<string, WallBoundaryFace[]>()
|
||||
|
||||
for (const space of Object.values(spaces)) {
|
||||
if (space.levelId !== levelId) continue
|
||||
for (const boundary of space.boundaryFaces) {
|
||||
const key = boundarySegmentKey(boundary)
|
||||
const entries = occurrences.get(key) ?? []
|
||||
entries.push(boundary)
|
||||
occurrences.set(key, entries)
|
||||
}
|
||||
}
|
||||
|
||||
const exterior = [...occurrences.values()].flatMap((entries) => {
|
||||
const boundary = entries.length === 1 ? entries[0] : undefined
|
||||
if (!boundary) return []
|
||||
return [{ ...boundary, face: oppositeWallFace(boundary.face) }]
|
||||
})
|
||||
const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
|
||||
const seed = exterior
|
||||
.filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face)
|
||||
.map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) }))
|
||||
.filter((candidate) => candidate.distance <= tolerance)
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.boundary
|
||||
if (!seed) return []
|
||||
|
||||
const boundariesByEndpoint = new Map<string, WallBoundaryFace[]>()
|
||||
for (const boundary of exterior) {
|
||||
const first = boundary.points[0]
|
||||
const last = boundary.points[boundary.points.length - 1]
|
||||
for (const point of [first, last]) {
|
||||
if (!point) continue
|
||||
const key = boundaryPointKey(point)
|
||||
const entries = boundariesByEndpoint.get(key) ?? []
|
||||
entries.push(boundary)
|
||||
boundariesByEndpoint.set(key, entries)
|
||||
}
|
||||
}
|
||||
|
||||
const connected: WallBoundaryFace[] = []
|
||||
const visited = new Set<string>()
|
||||
const queue = [seed]
|
||||
while (queue.length > 0) {
|
||||
const boundary = queue.shift()
|
||||
if (!boundary) continue
|
||||
const key = `${boundarySegmentKey(boundary)}:${boundary.face}`
|
||||
if (visited.has(key)) continue
|
||||
visited.add(key)
|
||||
connected.push(boundary)
|
||||
|
||||
const first = boundary.points[0]
|
||||
const last = boundary.points[boundary.points.length - 1]
|
||||
for (const point of [first, last]) {
|
||||
if (!point) continue
|
||||
for (const neighbour of boundariesByEndpoint.get(boundaryPointKey(point)) ?? []) {
|
||||
queue.push(neighbour)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
function wallTargetsForBoundaries(args: {
|
||||
boundaries: WallBoundaryFace[]
|
||||
role: string
|
||||
levelId: string
|
||||
nodes: Record<string, AnyNode>
|
||||
}): Array<{ nodeId: AnyNodeId; role: string }> {
|
||||
const { boundaries, role, levelId, nodes } = args
|
||||
const targets = new Map<string, { nodeId: AnyNodeId; role: string }>()
|
||||
for (const boundary of boundaries) {
|
||||
const targetWall = nodes[boundary.wallId]
|
||||
if (
|
||||
targetWall?.type !== 'wall' ||
|
||||
(targetWall.parentId ?? resolveLevelId(targetWall, nodes)) !== levelId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face)
|
||||
if (!targetRole) continue
|
||||
const key = `${targetWall.id}:${targetRole}`
|
||||
targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole })
|
||||
}
|
||||
return [...targets.values()]
|
||||
}
|
||||
|
||||
function polygonCentroid(
|
||||
@@ -178,8 +316,9 @@ export function resolvePaintScopeTargets(args: {
|
||||
nodes: Record<string, AnyNode>
|
||||
spaces: Record<string, Space>
|
||||
slotRolesOf: (node: AnyNode) => string[]
|
||||
wallHit?: WallPaintHit
|
||||
}): Array<{ nodeId: AnyNodeId; role: string }> {
|
||||
const { node, role, scope, nodes, spaces, slotRolesOf } = args
|
||||
const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args
|
||||
const single = [{ nodeId: node.id as AnyNodeId, role }]
|
||||
if (scope === 'single') return single
|
||||
|
||||
@@ -202,11 +341,15 @@ export function resolvePaintScopeTargets(args: {
|
||||
|
||||
if (node.type === 'wall' && scope === 'room') {
|
||||
const wall = node as WallNode
|
||||
const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon))
|
||||
if (!space) return single
|
||||
return Object.values(nodes)
|
||||
.filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon))
|
||||
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
||||
if (!wallHit) return single
|
||||
const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
|
||||
if (!levelId) return single
|
||||
const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
|
||||
const boundaries = space
|
||||
? space.boundaryFaces
|
||||
: connectedExteriorBoundaries({ wall, wallHit, levelId, spaces })
|
||||
const targets = wallTargetsForBoundaries({ boundaries, role, levelId, nodes })
|
||||
return targets.length > 0 ? targets : single
|
||||
}
|
||||
|
||||
if (node.type === 'slab' && scope === 'room') {
|
||||
|
||||
@@ -71,6 +71,8 @@ export type PolygonEdgeSnapContext<N extends PolygonShape & { id: AnyNodeId }> =
|
||||
}
|
||||
|
||||
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
/** Data committed only when the outer boundary (not a hole) is edited. */
|
||||
boundaryCommitData?: Partial<N>
|
||||
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
|
||||
/**
|
||||
* `move-edge` only: absolute edge snap. The point-based resolver runs
|
||||
@@ -107,9 +109,10 @@ function buildRingPatch(
|
||||
node: PolygonShape,
|
||||
holeIndex: number | undefined,
|
||||
nextRing: ReadonlyArray<[number, number]>,
|
||||
boundaryCommitData?: object,
|
||||
): unknown {
|
||||
if (holeIndex === undefined) {
|
||||
return { polygon: nextRing }
|
||||
return { ...boundaryCommitData, polygon: nextRing }
|
||||
}
|
||||
const nextHoles = (node.holes ?? []).map((hole, i) =>
|
||||
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]),
|
||||
@@ -163,7 +166,7 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) =>
|
||||
i === vertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
@@ -225,7 +228,7 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
|
||||
// Apply the insert immediately so the user sees the new vertex
|
||||
// before they even move.
|
||||
const initialPatch = buildRingPatch(node, holeIndex, initialRing)
|
||||
const initialPatch = buildRingPatch(node, holeIndex, initialRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }])
|
||||
@@ -251,7 +254,7 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
const nextRing: [number, number][] = initialRing.map((p, i) =>
|
||||
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
@@ -385,7 +388,7 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
|
||||
}
|
||||
return [p[0], p[1]] as [number, number]
|
||||
})
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing)
|
||||
const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
|
||||
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
|
||||
import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer'
|
||||
import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding } from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export function buildRoofWallOpeningCut(
|
||||
// Only a flat bottom chord may extend; a rounded bottom is never
|
||||
// coplanar and shifting it would distort the profile.
|
||||
const bottom = node.position[1] - node.height / 2
|
||||
const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0
|
||||
const bottomPad = getOpeningCutoutBottomPadding(node, bottom)
|
||||
|
||||
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
|
||||
node.position[0],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, SlabNode, WallNode } from '@pascal-app/core'
|
||||
import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes'
|
||||
|
||||
describe('getRecessedSlabGroundHoles', () => {
|
||||
test('uses the rendered wall-face footprint instead of the stored centerline polygon', () => {
|
||||
const parentId = 'level_ground-holes'
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_ground-holes',
|
||||
parentId,
|
||||
elevation: -0.15,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const walls = [
|
||||
WallNode.parse({ id: 'wall_ground-holes-a', parentId, start: [0, 0], end: [2, 0] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-b', parentId, start: [2, 0], end: [2, 2] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-c', parentId, start: [2, 2], end: [0, 2] }),
|
||||
WallNode.parse({ id: 'wall_ground-holes-d', parentId, start: [0, 2], end: [0, 0] }),
|
||||
]
|
||||
const nodes = Object.fromEntries([slab, ...walls].map((node) => [node.id, node])) as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
|
||||
const [hole] = getRecessedSlabGroundHoles(nodes)
|
||||
const xs = hole!.map(([x]) => x)
|
||||
const zs = hole!.map(([, z]) => z)
|
||||
|
||||
expect(Math.min(...xs)).toBeCloseTo(-0.05)
|
||||
expect(Math.max(...xs)).toBeCloseTo(2.05)
|
||||
expect(Math.min(...zs)).toBeCloseTo(-0.05)
|
||||
expect(Math.max(...zs)).toBeCloseTo(2.05)
|
||||
})
|
||||
|
||||
test('excludes non-recessed slabs', () => {
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_ground-holes-raised',
|
||||
elevation: 0.15,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
|
||||
expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
getRenderableSlabPolygon,
|
||||
type SlabNode,
|
||||
type SlabPolygonContext,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function getRecessedSlabGroundHoles(
|
||||
nodes: Record<string, AnyNode>,
|
||||
): Array<Array<[number, number]>> {
|
||||
const nodeList = Object.values(nodes)
|
||||
const levelIndexById = new Map<string, number>()
|
||||
const wallsByLevel = new Map<string | null, WallNode[]>()
|
||||
const slabsByLevel = new Map<string | null, SlabNode[]>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
|
||||
const pushByLevel = <T>(map: Map<string | null, T[]>, levelId: string | null, node: T) => {
|
||||
const entries = map.get(levelId)
|
||||
if (entries) entries.push(node)
|
||||
else map.set(levelId, [node])
|
||||
}
|
||||
|
||||
for (const node of nodeList) {
|
||||
if (node.type === 'level') {
|
||||
levelIndexById.set(node.id, node.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, node.level)
|
||||
continue
|
||||
}
|
||||
|
||||
const levelId = node.parentId ?? null
|
||||
if (node.type === 'wall') pushByLevel(wallsByLevel, levelId, node)
|
||||
else if (node.type === 'slab') pushByLevel(slabsByLevel, levelId, node)
|
||||
}
|
||||
|
||||
return nodeList
|
||||
.filter(
|
||||
(node): node is SlabNode =>
|
||||
node.type === 'slab' &&
|
||||
node.visible &&
|
||||
node.polygon.length >= 3 &&
|
||||
(node.elevation ?? 0.05) < 0,
|
||||
)
|
||||
.filter((slab) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = slab.parentId ? levelIndexById.get(slab.parentId) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((slab) => {
|
||||
const levelId = slab.parentId ?? null
|
||||
const context: SlabPolygonContext = {
|
||||
walls: wallsByLevel.get(levelId) ?? [],
|
||||
siblingSlabs: (slabsByLevel.get(levelId) ?? []).filter((sibling) => sibling.id !== slab.id),
|
||||
}
|
||||
return getRenderableSlabPolygon(slab, context)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
CircleGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
Path,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
} from 'three'
|
||||
import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl'
|
||||
import { MeshLambertNodeMaterial } from 'three/webgpu'
|
||||
import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
|
||||
@@ -66,6 +65,45 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
||||
|
||||
type S = ReturnType<typeof useScene.getState>
|
||||
|
||||
function polygonsMatch(
|
||||
a: Array<Array<[number, number]>>,
|
||||
b: Array<Array<[number, number]>>,
|
||||
): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(polygon, polygonIndex) =>
|
||||
polygon.length === b[polygonIndex]?.length &&
|
||||
polygon.every(
|
||||
(point, pointIndex) =>
|
||||
point[0] === b[polygonIndex]?.[pointIndex]?.[0] &&
|
||||
point[1] === b[polygonIndex]?.[pointIndex]?.[1],
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function addSlabHoles(
|
||||
shape: Shape,
|
||||
slabPolygons: Array<Array<[number, number]>>,
|
||||
originX = 0,
|
||||
originZ = 0,
|
||||
) {
|
||||
const localPolygons = slabPolygons.map((polygon) =>
|
||||
polygon.map(([x, z]): [number, number] => [x - originX, -(z - originZ)]),
|
||||
)
|
||||
for (const ring of unionPolygons(localPolygons)) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let index = 1; index < ring.length; index += 1) {
|
||||
hole.lineTo(ring[index]![0], ring[index]![1])
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
|
||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
@@ -164,45 +202,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
return material
|
||||
}, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds])
|
||||
|
||||
const horizonGeometry = useMemo(() => {
|
||||
if (!fadeBounds) return null
|
||||
return new CircleGeometry(Math.max(fadeBounds.radius * 8, 400), 64)
|
||||
}, [fadeBounds])
|
||||
useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry])
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
// Cache computed polygons to keep the selector stable across unrelated store updates.
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
const slabPolygons = useScene((state: S) => {
|
||||
const nodeList = Object.values(state.nodes)
|
||||
|
||||
const levelIndexById = new Map<string, number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
nodeList.forEach((n) => {
|
||||
if (n.type !== 'level') return
|
||||
levelIndexById.set(n.id, n.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||
})
|
||||
|
||||
const next = nodeList
|
||||
.filter(
|
||||
(n): n is SlabNode =>
|
||||
n.type === 'slab' &&
|
||||
n.visible &&
|
||||
n.polygon.length >= 3 &&
|
||||
// Only recessed slabs should punch through the site ground.
|
||||
// Positive slabs are real floor geometry and should not create a
|
||||
// ghost footprint in the background ground fill.
|
||||
(n.elevation ?? 0.05) < 0,
|
||||
)
|
||||
.filter((n) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((n) => n.polygon as [number, number][])
|
||||
const next = getRecessedSlabGroundHoles(state.nodes)
|
||||
|
||||
const prev = slabPolygonsCache.current
|
||||
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||
if (polygonsMatch(next, prev)) return prev
|
||||
slabPolygonsCache.current = next
|
||||
return next
|
||||
})
|
||||
@@ -217,20 +223,27 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
addSlabHoles(shape, slabPolygons)
|
||||
|
||||
return shape
|
||||
}, [polygonPoints, slabPolygons])
|
||||
|
||||
const horizonGeometry = useMemo(() => {
|
||||
if (!fadeBounds) return null
|
||||
const radius = Math.max(fadeBounds.radius * 8, 400)
|
||||
const shape = new Shape()
|
||||
const segments = 64
|
||||
shape.moveTo(radius, 0)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const angle = (index / segments) * Math.PI * 2
|
||||
shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
}
|
||||
shape.closePath()
|
||||
addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz)
|
||||
return new ShapeGeometry(shape)
|
||||
}, [fadeBounds, slabPolygons])
|
||||
useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
if (!polygonPoints || polygonPoints.length < 2) return null
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { pointInPolygon2D, SlabNode } from '@pascal-app/core'
|
||||
import { slabDefinition } from '../definition'
|
||||
|
||||
function getHeightHandlePosition(slab: SlabNode) {
|
||||
function getHeightHandle(slab: SlabNode) {
|
||||
const handles =
|
||||
typeof slabDefinition.handles === 'function'
|
||||
? slabDefinition.handles(slab)
|
||||
@@ -13,7 +13,11 @@ function getHeightHandlePosition(slab: SlabNode) {
|
||||
if (!(heightHandle && heightHandle.kind === 'linear-resize')) {
|
||||
throw new Error('Missing slab height handle')
|
||||
}
|
||||
return heightHandle.placement.position(slab, {} as never)
|
||||
return heightHandle
|
||||
}
|
||||
|
||||
function getHeightHandlePosition(slab: SlabNode) {
|
||||
return getHeightHandle(slab).placement.position(slab, {} as never)
|
||||
}
|
||||
|
||||
describe('slabDefinition handles', () => {
|
||||
@@ -40,4 +44,20 @@ describe('slabDefinition handles', () => {
|
||||
expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true)
|
||||
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false)
|
||||
})
|
||||
|
||||
test('allows the elevation arrow to cross zero into a recessed slab', () => {
|
||||
const slab = SlabNode.parse({
|
||||
elevation: 0.05,
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const heightHandle = getHeightHandle(slab)
|
||||
|
||||
expect(heightHandle.min).toBe(-1)
|
||||
expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: fal
|
||||
* Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom
|
||||
* edge starts 0.5m away from the wall.
|
||||
*/
|
||||
function seedScene() {
|
||||
function seedScene(autoFromWalls = false) {
|
||||
const levelId = 'level_slab-move-edge' as AnyNodeId
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
@@ -40,7 +40,7 @@ function seedScene() {
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
autoFromWalls: false,
|
||||
autoFromWalls,
|
||||
parentId: levelId,
|
||||
})
|
||||
const level = {
|
||||
@@ -115,4 +115,22 @@ describe('slabMoveEdgeAffordance', () => {
|
||||
expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5)
|
||||
expect(updated.polygon[1]![1]).toBeCloseTo(1.5, 5)
|
||||
})
|
||||
|
||||
test('editing an auto-generated outer boundary makes the slab manual', () => {
|
||||
const { slab } = seedScene(true)
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
const session = slabMoveEdgeAffordance.start({
|
||||
node: nodes[slab.id] as SlabNodeType,
|
||||
payload: { edgeIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [2, 0.5],
|
||||
gridSnapStep: 0.1,
|
||||
} as never)
|
||||
|
||||
session.apply({ planPoint: [2, 1.5], modifiers: MODIFIERS })
|
||||
|
||||
const updated = useScene.getState().nodes[slab.id] as SlabNodeType
|
||||
expect(updated.autoFromWalls).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
clearSlabSnapFeedback()
|
||||
updateNode(slabId, { polygon: newPolygon })
|
||||
updateNode(slabId, { polygon: newPolygon, autoFromWalls: false })
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
},
|
||||
[slabId, updateNode, setSelection],
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SlabNode } from './schema'
|
||||
import { slabSlots } from './slots'
|
||||
|
||||
const HEIGHT_HANDLE_OFFSET = 0.22
|
||||
const MIN_SLAB_ELEVATION = 0.02
|
||||
const MIN_SLAB_ELEVATION = -1
|
||||
|
||||
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
@@ -89,10 +89,10 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] {
|
||||
}
|
||||
|
||||
// Slab height arrow — vertical chevron on solid slab surface near the
|
||||
// polygon center. Drags elevation (the extrusion thickness) with
|
||||
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows
|
||||
// the pointer. Same registry-handle pipeline as the column height arrow,
|
||||
// so live override + commit-on-release come for free.
|
||||
// polygon center. Drags elevation through zero: positive values extrude
|
||||
// upward from ground while negative values create a recessed floor whose
|
||||
// depth follows the pointer. Same registry-handle pipeline as the column
|
||||
// height arrow, so live override + commit-on-release come for free.
|
||||
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
* Simpler model, no UX downside in practice.
|
||||
*/
|
||||
const slabSnapOptions = {
|
||||
boundaryCommitData: { autoFromWalls: false },
|
||||
resolvePlanPoint({
|
||||
node,
|
||||
nodes,
|
||||
|
||||
@@ -235,6 +235,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
useScene.getState().updateNode(slabId, {
|
||||
polygon: translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
|
||||
autoFromWalls: false,
|
||||
})
|
||||
useScene.getState().markDirty(slabId as AnyNodeId)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const slabParametrics: ParametricDescriptor<SlabNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Elevation',
|
||||
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }],
|
||||
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
|
||||
@@ -30,6 +30,12 @@ const SHADOWS_DISABLED =
|
||||
// 0.9 read too heavy in review.
|
||||
const MAX_SHADOW_INTENSITY = 0.75
|
||||
|
||||
// `normalBias` is measured in world units. The previous 0.3 moved shadow
|
||||
// lookups 30 cm off their surfaces, visibly detaching wall shadows at the
|
||||
// floor. Keep only a small offset for acne, with a tiny depth bias alongside it.
|
||||
const SHADOW_DEPTH_BIAS = -0.0001
|
||||
const SHADOW_NORMAL_BIAS = 0.02
|
||||
|
||||
// Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
|
||||
// camera): we union the bounds of all registered scene nodes, fit a sphere, and
|
||||
// size the directional light's ortho shadow camera to that sphere plus a margin.
|
||||
@@ -259,9 +265,9 @@ export function Lights() {
|
||||
ref={(ref) => {
|
||||
lightRefs.current[index] = ref
|
||||
}}
|
||||
shadow-bias={-0.002}
|
||||
shadow-bias={SHADOW_DEPTH_BIAS}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-normalBias={0.3}
|
||||
shadow-normalBias={SHADOW_NORMAL_BIAS}
|
||||
shadow-radius={2}
|
||||
>
|
||||
{light.castShadow && !SHADOWS_DISABLED ? (
|
||||
|
||||
@@ -177,6 +177,7 @@ export { StairSystem } from './systems/stair/stair-system'
|
||||
// (arch / rounded / frameless opening) identical across both hosts.
|
||||
export {
|
||||
buildOpeningCutoutGeometry,
|
||||
getOpeningCutoutBottomPadding,
|
||||
hasFlatOpeningCutoutBottom,
|
||||
} from './systems/wall/opening-cutout-geometry'
|
||||
export { WallCutout } from './systems/wall/wall-cutout'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DoorNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { buildDoorPreviewMesh } from '../../index'
|
||||
|
||||
const DOOR_TYPES = [
|
||||
'hinged',
|
||||
'double',
|
||||
'french',
|
||||
'folding',
|
||||
'pocket',
|
||||
'barn',
|
||||
'sliding',
|
||||
'garage-sectional',
|
||||
'garage-rollup',
|
||||
'garage-tiltup',
|
||||
] as const
|
||||
|
||||
function visibleBounds(mesh: THREE.Mesh): THREE.Box3 {
|
||||
mesh.updateMatrixWorld(true)
|
||||
const bounds = new THREE.Box3()
|
||||
for (const child of mesh.children) {
|
||||
if (child.name === 'cutout') continue
|
||||
bounds.expandByObject(child, true)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
describe('door floor alignment', () => {
|
||||
for (const doorType of DOOR_TYPES) {
|
||||
test(`${doorType} does not extend below the opening floor`, () => {
|
||||
const node = DoorNode.parse({
|
||||
id: `door_floor-alignment-${doorType}`,
|
||||
doorType,
|
||||
operationState: 0,
|
||||
threshold: true,
|
||||
})
|
||||
const mesh = buildDoorPreviewMesh(node)
|
||||
const bounds = visibleBounds(mesh)
|
||||
|
||||
expect(bounds.min.y).toBeGreaterThanOrEqual(-node.height / 2 - 1e-6)
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps the wall cutout bottom locked to the opening floor', () => {
|
||||
const node = DoorNode.parse({ id: 'door_floor-alignment-cutout' })
|
||||
const mesh = buildDoorPreviewMesh(node)
|
||||
const cutout = mesh.getObjectByName('cutout') as THREE.Mesh
|
||||
cutout.geometry.computeBoundingBox()
|
||||
|
||||
expect(cutout.geometry.boundingBox?.min.y).toBeCloseTo(-node.height / 2, 6)
|
||||
})
|
||||
})
|
||||
@@ -2150,14 +2150,15 @@ function addGarageRollupDoor(
|
||||
addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
|
||||
}
|
||||
|
||||
const bottomBarHeight = 0.028
|
||||
addBox(
|
||||
curtain,
|
||||
revealMaterial,
|
||||
insideWidth - 0.04,
|
||||
0.028,
|
||||
bottomBarHeight,
|
||||
leafDepth + 0.018,
|
||||
0,
|
||||
-visibleHeight,
|
||||
-visibleHeight + bottomBarHeight / 2,
|
||||
leafDepth / 2 + 0.004,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,21 +107,8 @@ function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: T
|
||||
|
||||
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
|
||||
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
|
||||
// A deterministic sub-3mm per-node lift breaks the coplanarity of slabs
|
||||
// duplicated at the exact same position — identical depths z-fight, and no
|
||||
// camera near/far tuning can separate them. Render-only: node data,
|
||||
// snapping and measurements are untouched.
|
||||
const elevation = node.elevation ?? 0.05
|
||||
mesh.position.y = (elevation < 0 ? elevation : 0) + coplanarityEpsilon(node.id)
|
||||
}
|
||||
|
||||
// Stable id hash → 0..2.7 mm in 0.3 mm steps.
|
||||
function coplanarityEpsilon(id: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
}
|
||||
return (Math.abs(hash) % 10) * 0.0003
|
||||
mesh.position.y = elevation < 0 ? elevation : 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import type * as THREE from 'three'
|
||||
import {
|
||||
buildOpeningCutoutGeometry,
|
||||
buildOpeningCutoutShape,
|
||||
getOpeningCutoutBottomPadding,
|
||||
hasFlatOpeningCutoutBottom,
|
||||
} from './opening-cutout-geometry'
|
||||
|
||||
@@ -208,3 +209,16 @@ describe('hasFlatOpeningCutoutBottom', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOpeningCutoutBottomPadding', () => {
|
||||
test('pads floor-level openings with a flat bottom', () => {
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0)).toBe(0.02)
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({ openingShape: 'rounded' }), 0)).toBe(0.02)
|
||||
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'arch' }), 0)).toBe(0.02)
|
||||
})
|
||||
|
||||
test('does not pad openings above the floor or rounded window bottoms', () => {
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0.9)).toBe(0)
|
||||
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'rounded' }), 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,12 +13,12 @@ export type OpeningCutoutRect = {
|
||||
// The cutout proxy doubles as the invisible raycast hit target for an opening:
|
||||
// centered on the wall and extending past both faces so it wins the scene
|
||||
// raycast over the recessed door/window body for front AND back selection +
|
||||
// paint. It only needs to clear the wall thickness plus a small proud margin —
|
||||
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
|
||||
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
|
||||
// a snug depth keeps the cut intact while no longer blanketing the room floor in
|
||||
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
|
||||
// paint. Wall CSG rebuilds opening cuts directly from node data, so this proxy
|
||||
// only needs to clear the wall thickness plus a small proud margin. A snug depth
|
||||
// keeps the hit target useful without blanketing the room floor in a top-down
|
||||
// view (the bug a 1m-deep proxy caused in narrow hallways).
|
||||
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
|
||||
const OPENING_CUTOUT_BOTTOM_PADDING = 0.02
|
||||
|
||||
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
|
||||
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
|
||||
@@ -128,6 +128,14 @@ export function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean
|
||||
return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends floor-level flat cutouts below the host wall so CSG never has to
|
||||
* subtract a face exactly coplanar with the wall base.
|
||||
*/
|
||||
export function getOpeningCutoutBottomPadding(opening: OpeningCutoutNode, bottom: number): number {
|
||||
return bottom < 0.005 && hasFlatOpeningCutoutBottom(opening) ? OPENING_CUTOUT_BOTTOM_PADDING : 0
|
||||
}
|
||||
|
||||
function getRoundedOpeningRadii(
|
||||
opening: OpeningCutoutNode,
|
||||
width: number,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { calculateLevelMiters, DoorNode, sceneRegistry, WallNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { generateExtrudedWall } from './wall-system'
|
||||
|
||||
describe('wall opening cutout', () => {
|
||||
test('cuts a floor-level door directly from node geometry without a proxy mesh', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_floor-opening-cutout',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
height: 2.5,
|
||||
thickness: 0.1,
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_floor-opening-cutout',
|
||||
wallId: wall.id,
|
||||
position: [1, 1.05, 0],
|
||||
width: 0.9,
|
||||
height: 2.1,
|
||||
})
|
||||
const wallMesh = new THREE.Mesh()
|
||||
sceneRegistry.nodes.set(wall.id, wallMesh)
|
||||
|
||||
try {
|
||||
const geometry = generateExtrudedWall(wall, [door], calculateLevelMiters([wall]))
|
||||
const position = geometry.getAttribute('position')
|
||||
const index = geometry.index
|
||||
const openingLeft = door.position[0] - door.width / 2
|
||||
const openingRight = door.position[0] + door.width / 2
|
||||
let wallFaceTrianglesInsideOpening = 0
|
||||
let baseTrianglesInsideOpening = 0
|
||||
|
||||
for (let offset = 0; offset < (index?.count ?? position.count); offset += 3) {
|
||||
const indices = [0, 1, 2].map((corner) =>
|
||||
index ? index.getX(offset + corner) : offset + corner,
|
||||
)
|
||||
const vertices = indices.map(
|
||||
(vertexIndex) =>
|
||||
new THREE.Vector3(
|
||||
position.getX(vertexIndex),
|
||||
position.getY(vertexIndex),
|
||||
position.getZ(vertexIndex),
|
||||
),
|
||||
)
|
||||
const centroid = vertices
|
||||
.reduce((sum, vertex) => sum.add(vertex), new THREE.Vector3())
|
||||
.multiplyScalar(1 / 3)
|
||||
const insideOpeningX = centroid.x > openingLeft + 1e-4 && centroid.x < openingRight - 1e-4
|
||||
if (!insideOpeningX) continue
|
||||
|
||||
const onWallFace = vertices.every(
|
||||
(vertex) => Math.abs(Math.abs(vertex.z) - (wall.thickness ?? 0.1) / 2) < 1e-5,
|
||||
)
|
||||
if (onWallFace && centroid.y > 1e-4 && centroid.y < door.height - 1e-4) {
|
||||
wallFaceTrianglesInsideOpening += 1
|
||||
}
|
||||
|
||||
if (vertices.every((vertex) => Math.abs(vertex.y) < 1e-5)) {
|
||||
baseTrianglesInsideOpening += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(wallFaceTrianglesInsideOpening).toBe(0)
|
||||
expect(baseTrianglesInsideOpening).toBe(0)
|
||||
geometry.dispose()
|
||||
} finally {
|
||||
sceneRegistry.nodes.delete(wall.id)
|
||||
wallMesh.geometry.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,10 @@ import * as THREE from 'three'
|
||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils'
|
||||
import { buildOpeningCutoutGeometry } from './opening-cutout-geometry'
|
||||
import {
|
||||
buildOpeningCutoutGeometry,
|
||||
getOpeningCutoutBottomPadding,
|
||||
} from './opening-cutout-geometry'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
@@ -713,9 +716,8 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
if (child.type !== 'door' && child.type !== 'window') return child
|
||||
// `getEffectiveNode` folds in resize overrides (width/height arrows).
|
||||
// Position moves publish to `useLiveTransforms` instead, so fold that
|
||||
// in too — otherwise shaped openings (arch/rounded/`opening`), whose
|
||||
// cutout brush is rebuilt from `node.position`, lag the live move
|
||||
// (rectangular cutouts already track via the live mesh matrixWorld).
|
||||
// in too — opening cutout brushes are rebuilt directly from the
|
||||
// effective node position rather than from the rendered proxy mesh.
|
||||
const effective = getEffectiveNode(child)
|
||||
const live = useLiveTransforms.getState().get(child.id)
|
||||
if (!live?.position) return effective
|
||||
@@ -1060,8 +1062,9 @@ export function generateExtrudedWall(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects cutout brushes from child items for CSG subtraction
|
||||
* The cutout mesh is a plane, so we extrude it into a box that goes through the wall
|
||||
* Collects opening and item cutout brushes for CSG subtraction. Door/window
|
||||
* cuts come directly from node geometry; item proxy meshes are transformed
|
||||
* into wall-local boxes that pass through the wall.
|
||||
*/
|
||||
function collectCutoutBrushes(
|
||||
wallNode: WallNode,
|
||||
@@ -1079,17 +1082,8 @@ function collectCutoutBrushes(
|
||||
for (const child of childrenNodes) {
|
||||
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
||||
|
||||
if (
|
||||
(child.type === 'door' && child.openingKind === 'opening') ||
|
||||
(child.type === 'door' &&
|
||||
child.openingKind === 'door' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
|
||||
(child.type === 'window' && child.openingKind === 'opening') ||
|
||||
(child.type === 'window' &&
|
||||
child.openingKind === 'window' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded'))
|
||||
) {
|
||||
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
|
||||
if (child.type === 'door' || child.type === 'window') {
|
||||
brushes.push(createOpeningCutoutBrush(child, wallThickness))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1147,17 +1141,16 @@ function collectCutoutBrushes(
|
||||
return brushes
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutBrush(
|
||||
opening: DoorNode | WindowNode,
|
||||
wallThickness: number,
|
||||
): Brush {
|
||||
function createOpeningCutoutBrush(opening: DoorNode | WindowNode, wallThickness: number): Brush {
|
||||
const halfWidth = opening.width / 2
|
||||
const bottom = opening.position[1] - opening.height / 2
|
||||
const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom)
|
||||
const geometry = buildOpeningCutoutGeometry(
|
||||
opening,
|
||||
{
|
||||
left: opening.position[0] - halfWidth,
|
||||
right: opening.position[0] + halfWidth,
|
||||
bottom: opening.position[1] - opening.height / 2,
|
||||
bottom: bottom - bottomPadding,
|
||||
top: opening.position[1] + opening.height / 2,
|
||||
},
|
||||
wallThickness * 2,
|
||||
|
||||
Reference in New Issue
Block a user