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:
Wassim SAMAD
2026-07-15 16:35:04 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 4fca38a3ef
commit a524da1574
28 changed files with 1291 additions and 499 deletions
+1
View File
@@ -105,6 +105,7 @@ export {
projectAutoSlabsForPlan, projectAutoSlabsForPlan,
resumeSpaceDetection, resumeSpaceDetection,
type Space, type Space,
type SpaceBoundaryFace,
wallClosesRoom, wallClosesRoom,
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
+47 -2
View File
@@ -181,8 +181,14 @@ describe('detectSpacesForLevel', () => {
} }
test('detects an isolated four-wall room', () => { 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(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)', () => { 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] }), 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) const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b)
expect(roomPolygons).toHaveLength(2) expect(roomPolygons).toHaveLength(2)
expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2 expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2
expect(areas[1]).toBeCloseTo(30, 1) // big room: 6×5 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.update).toHaveLength(0)
expect(plan.delete).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)
})
}) })
+56 -19
View File
@@ -19,14 +19,26 @@ import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
export type SpaceBoundaryFace = {
wallId: string
face: 'front' | 'back'
points: Array<[number, number]>
}
export type Space = { export type Space = {
id: string id: string
levelId: string levelId: string
polygon: Array<[number, number]> polygon: Array<[number, number]>
wallIds: string[] wallIds: string[]
boundaryFaces: SpaceBoundaryFace[]
isExterior: boolean isExterior: boolean
} }
type ExtractedRoom = {
polygon: Point2D[]
boundaryFaces: SpaceBoundaryFace[]
}
type WallSideUpdate = { type WallSideUpdate = {
wallId: string wallId: string
frontSide: 'interior' | 'exterior' | 'unknown' 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 // Demoted auto surfaces keep their polygon untouched, so a re-closed room
// usually hits the exact-signature manual check. Mutual footprint coverage // usually hits the exact-signature manual check. Coverage also handles a room
// still guards the case where the user edited the demoted surface's polygon // deliberately split across multiple manual surfaces: their union suppresses
// afterwards — a fresh auto surface must not stack on top of it. // a replacement auto surface as long as the pieces substantially belong to
// and cover the room.
function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
return manualPolygons.some( const roomManualPolygons = manualPolygons.filter(
(manual) => (manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
polygonCoverageRatio(roomPolygon, [manual]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD &&
polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
) )
return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
} }
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
@@ -460,7 +472,7 @@ function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Poi
return ordered return ordered
} }
function extractRoomPolygons(walls: WallNode[]): Point2D[][] { function extractRooms(walls: WallNode[]): ExtractedRoom[] {
if (walls.length < 3) return [] if (walls.length < 3) return []
type HalfEdge = { type HalfEdge = {
@@ -470,6 +482,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey: string toKey: string
angle: number angle: number
points: Point2D[] points: Point2D[]
wallId: string
face: 'front' | 'back'
} }
type Node = { point: Point2D; outgoing: string[] } type Node = { point: Point2D; outgoing: string[] }
@@ -535,6 +549,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey, toKey,
angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x),
points, points,
wallId: wall.id,
face: 'front',
}) })
halfEdges.set(reverseId, { halfEdges.set(reverseId, {
id: reverseId, id: reverseId,
@@ -543,6 +559,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey: fromKey, toKey: fromKey,
angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x),
points: reversePoints, points: reversePoints,
wallId: wall.id,
face: 'back',
}) })
graph.get(fromKey)?.outgoing.push(forwardId) graph.get(fromKey)?.outgoing.push(forwardId)
@@ -572,7 +590,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
} }
const visitedDirected = new Set<string>() 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 // 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. // longest possible cycle. Splitting at junctions can multiply edges per wall.
const maxSteps = Math.min(2000, halfEdges.size + 10) 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 if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon) 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))) rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)))
return faces 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 return snapshots
} }
function buildSpace(levelId: string, polygon: Point2D[]): Space { function buildSpace(levelId: string, room: ExtractedRoom): Space {
const signature = polygonSignature(polygon) const signature = polygonSignature(room.polygon)
return { return {
id: `space-${levelId}-${signature.slice(0, 12)}`, id: `space-${levelId}-${signature.slice(0, 12)}`,
levelId, levelId,
polygon: polygon.map(pointToTuple), polygon: room.polygon.map(pointToTuple),
wallIds: [], wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
boundaryFaces: room.boundaryFaces,
isExterior: false, isExterior: false,
} }
} }
@@ -1169,7 +1205,8 @@ function syncAutoCeilingsForLevel(
} }
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { 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) => ({ const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
wallId: wall.id, wallId: wall.id,
...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick<
@@ -1180,7 +1217,7 @@ function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
return { return {
roomPolygons, roomPolygons,
spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)), spaces: rooms.map((room) => buildSpace(levelId, room)),
wallUpdates, wallUpdates,
} }
} }
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,7 @@ import {
type PaintHoverInfo, type PaintHoverInfo,
resolvePaintScopeTargets, resolvePaintScopeTargets,
slotDisplayLabel, slotDisplayLabel,
type WallPaintHit,
} from '../../lib/paint-scope' } from '../../lib/paint-scope'
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
import { import {
@@ -288,6 +289,29 @@ function meshSlotRoles(node: AnyNode): string[] {
} }
const roofSelectionWorldPoint = new Vector3() 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 { function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
const roof = event.node const roof = event.node
@@ -841,6 +865,7 @@ export const SelectionManager = () => {
// (Shift) re-keys the interaction → the preview re-applies for the new // (Shift) re-keys the interaction → the preview re-applies for the new
// spread instead of being deduped to the single-surface preview. // spread instead of being deduped to the single-surface preview.
const scope = useEditor.getState().paintScope const scope = useEditor.getState().paintScope
const wallHit = resolveWallPaintHit(event)
const scopeTargets = const scopeTargets =
compatible && role compatible && role
? resolvePaintScopeTargets({ ? resolvePaintScopeTargets({
@@ -850,10 +875,15 @@ export const SelectionManager = () => {
nodes: useScene.getState().nodes, nodes: useScene.getState().nodes,
spaces: useEditor.getState().spaces, spaces: useEditor.getState().spaces,
slotRolesOf: () => slotRoles, slotRolesOf: () => slotRoles,
wallHit,
}) })
: [] : []
const scopeTargetKey = scopeTargets
.map((target) => `${target.nodeId}:${target.role}`)
.sort()
.join(',')
return { 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, hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled', hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
paintHover: paintHover:
+255 -30
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'bun:test' 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 { import {
availablePaintScopes, availablePaintScopes,
cyclePaintScope, cyclePaintScope,
@@ -7,6 +14,7 @@ import {
type PaintScope, type PaintScope,
paintScopeLabel, paintScopeLabel,
resolvePaintScopeTargets, resolvePaintScopeTargets,
type WallPaintHit,
} from './paint-scope' } from './paint-scope'
describe('availablePaintScopes', () => { describe('availablePaintScopes', () => {
@@ -76,8 +84,22 @@ function item(id: string, assetId: string): ItemNode {
function slab(id: string, polygon: Array<[number, number]>): SlabNode { function slab(id: string, polygon: Array<[number, number]>): SlabNode {
return { id, type: 'slab', polygon } as unknown as SlabNode return { id, type: 'slab', polygon } as unknown as SlabNode
} }
function wall(id: string, start: [number, number], end: [number, number]): AnyNode { function wall(
return { id, type: 'wall', start, end } as unknown as AnyNode 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 { function roof(): AnyNode {
return { id: 'r', type: 'roof' } as unknown as AnyNode return { id: 'r', type: 'roof' } as unknown as AnyNode
@@ -99,6 +121,7 @@ function resolve(args: {
nodes: AnyNode[] nodes: AnyNode[]
spaces?: Space[] spaces?: Space[]
slotRolesOf?: (node: AnyNode) => string[] slotRolesOf?: (node: AnyNode) => string[]
wallHit?: WallPaintHit
}) { }) {
return resolvePaintScopeTargets({ return resolvePaintScopeTargets({
node: args.node, node: args.node,
@@ -107,9 +130,23 @@ function resolve(args: {
nodes: asMap(args.nodes), nodes: asMap(args.nodes),
spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])), spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])),
slotRolesOf: args.slotRolesOf ?? noSlotRoles, 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', () => { describe('resolvePaintScopeTargets', () => {
it('single always returns just the clicked surface', () => { it('single always returns just the clicked surface', () => {
const a = item('a', 'sofa') 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', () => { it('wall room selects the enclosed space on the clicked face of a shared wall', () => {
// A 4×4 room: each wall's endpoints are exact polygon vertices. const { walls, spaces } = adjacentRooms()
const w1 = wall('w1', [0, 0], [4, 0]) const shared = walls.find((candidate) => String(candidate.id) === 'shared')!
const w2 = wall('w2', [4, 0], [4, 4])
const w3 = wall('w3', [4, 4], [0, 4]) const leftRoom = resolve({
const w4 = wall('w4', [0, 4], [0, 0]) node: shared,
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,
role: 'interior', role: 'interior',
scope: 'room', scope: 'room',
nodes: [w1, w2, w3, w4, wOut], nodes: walls,
spaces: [space], 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([ expect(keys(result).sort()).toEqual([
'w1:interior', 'bottom-right:lowerInterior',
'w2:interior', 'right:lowerInterior',
'w3:interior', 'shared:lowerExterior',
'w4:interior', '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', () => { it('wall room with no enclosing space falls back to single', () => {
const w1 = wall('w1', [0, 0], [4, 0]) const w1 = wall('w1', [0, 0], [4, 0])
expect( expect(
@@ -186,6 +319,97 @@ describe('resolvePaintScopeTargets', () => {
).toEqual(['w1:interior']) ).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', () => { it('slab room fans across slabs whose centroid sits in the same space', () => {
const inside = slab('inA', [ const inside = slab('inA', [
[1, 1], [1, 1],
@@ -215,6 +439,7 @@ describe('resolvePaintScopeTargets', () => {
[0, 10], [0, 10],
], ],
wallIds: [], wallIds: [],
boundaryFaces: [],
isExterior: false, isExterior: false,
} }
const result = resolve({ const result = resolve({
+188 -45
View File
@@ -6,7 +6,7 @@ import {
type MaterialSchema, type MaterialSchema,
nodeRegistry, nodeRegistry,
pointInPolygon2D, pointInPolygon2D,
pointOnSegment, resolveLevelId,
type SceneMaterial, type SceneMaterial,
type SceneMaterialId, type SceneMaterialId,
type SlabNode, type SlabNode,
@@ -104,47 +104,185 @@ export function slotDisplayLabel(node: AnyNode, role: string): string {
type SlotsNode = AnyNode & { slots?: Record<string, string> } type SlotsNode = AnyNode & { slots?: Record<string, string> }
// Room polygons are built from wall *centerline* endpoints (see export type WallPaintHit = {
// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices — face: 'front' | 'back'
// a small tolerance only absorbs float round-trips. `Space.wallIds` is always point: [number, number]
// empty, so room membership is resolved geometrically here instead.
const WALL_ON_BOUNDARY_TOLERANCE = 0.05
function pointOnPolygonBoundary(
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,
)
) {
return true
}
}
return false
} }
// A wall bounds a room when both its endpoints lie on the room polygon's type WallBoundaryFace = Space['boundaryFaces'][number]
// 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 distanceToSegment(
function wallBoundsRoom( point: readonly [number, number],
wall: WallNode, start: readonly [number, number],
polygon: ReadonlyArray<readonly [number, number]>, end: readonly [number, number],
): boolean { ): number {
return ( const dx = end[0] - start[0]
pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) && const dz = end[1] - start[1]
pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE) 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 Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t))
}
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( function polygonCentroid(
@@ -178,8 +316,9 @@ export function resolvePaintScopeTargets(args: {
nodes: Record<string, AnyNode> nodes: Record<string, AnyNode>
spaces: Record<string, Space> spaces: Record<string, Space>
slotRolesOf: (node: AnyNode) => string[] slotRolesOf: (node: AnyNode) => string[]
wallHit?: WallPaintHit
}): Array<{ nodeId: AnyNodeId; role: string }> { }): 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 }] const single = [{ nodeId: node.id as AnyNodeId, role }]
if (scope === 'single') return single if (scope === 'single') return single
@@ -202,11 +341,15 @@ export function resolvePaintScopeTargets(args: {
if (node.type === 'wall' && scope === 'room') { if (node.type === 'wall' && scope === 'room') {
const wall = node as WallNode const wall = node as WallNode
const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon)) if (!wallHit) return single
if (!space) return single const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
return Object.values(nodes) if (!levelId) return single
.filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon)) const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
.map((other) => ({ nodeId: other.id as AnyNodeId, role })) 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') { 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 }> = { 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 resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
/** /**
* `move-edge` only: absolute edge snap. The point-based resolver runs * `move-edge` only: absolute edge snap. The point-based resolver runs
@@ -107,9 +109,10 @@ function buildRingPatch(
node: PolygonShape, node: PolygonShape,
holeIndex: number | undefined, holeIndex: number | undefined,
nextRing: ReadonlyArray<[number, number]>, nextRing: ReadonlyArray<[number, number]>,
boundaryCommitData?: object,
): unknown { ): unknown {
if (holeIndex === undefined) { if (holeIndex === undefined) {
return { polygon: nextRing } return { ...boundaryCommitData, polygon: nextRing }
} }
const nextHoles = (node.holes ?? []).map((hole, i) => const nextHoles = (node.holes ?? []).map((hole, i) =>
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]), 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) => const nextRing: [number, number][] = originalRing.map((p, i) =>
i === vertexIndex ? [snapped[0], snapped[1]] : p, i === vertexIndex ? [snapped[0], snapped[1]] : p,
) )
const patch = buildRingPatch(node, holeIndex, nextRing) const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
useScene useScene
.getState() .getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }]) .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 // Apply the insert immediately so the user sees the new vertex
// before they even move. // before they even move.
const initialPatch = buildRingPatch(node, holeIndex, initialRing) const initialPatch = buildRingPatch(node, holeIndex, initialRing, options?.boundaryCommitData)
useScene useScene
.getState() .getState()
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }]) .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) => const nextRing: [number, number][] = initialRing.map((p, i) =>
i === newVertexIndex ? [snapped[0], snapped[1]] : p, i === newVertexIndex ? [snapped[0], snapped[1]] : p,
) )
const patch = buildRingPatch(node, holeIndex, nextRing) const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
useScene useScene
.getState() .getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }]) .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] return [p[0], p[1]] as [number, number]
}) })
const patch = buildRingPatch(node, holeIndex, nextRing) const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData)
useScene useScene
.getState() .getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }]) .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 type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } 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' import * as THREE from 'three'
/** /**
@@ -31,7 +31,7 @@ export function buildRoofWallOpeningCut(
// Only a flat bottom chord may extend; a rounded bottom is never // Only a flat bottom chord may extend; a rounded bottom is never
// coplanar and shifting it would distort the profile. // coplanar and shifting it would distort the profile.
const bottom = node.position[1] - node.height / 2 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, [ const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0], 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)
})
}
+60 -47
View File
@@ -3,7 +3,6 @@
import { import {
type AnyNodeId, type AnyNodeId,
type SiteNode, type SiteNode,
type SlabNode,
useLiveNodeOverrides, useLiveNodeOverrides,
useRegistry, useRegistry,
useScene, useScene,
@@ -21,7 +20,6 @@ import {
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { import {
BufferGeometry, BufferGeometry,
CircleGeometry,
Float32BufferAttribute, Float32BufferAttribute,
type Group, type Group,
Path, Path,
@@ -30,6 +28,7 @@ import {
} from 'three' } from 'three'
import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl'
import { MeshLambertNodeMaterial } from 'three/webgpu' import { MeshLambertNodeMaterial } from 'three/webgpu'
import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes'
const Y_OFFSET = 0.01 const Y_OFFSET = 0.01
@@ -66,6 +65,45 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
type S = ReturnType<typeof useScene.getState> 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 }) => { export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
@@ -164,45 +202,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
return material return material
}, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds]) }, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds])
const horizonGeometry = useMemo(() => { // Cache computed polygons to keep the selector stable across unrelated store updates.
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
const slabPolygonsCache = useRef<[number, number][][]>([]) const slabPolygonsCache = useRef<[number, number][][]>([])
const slabPolygons = useScene((state: S) => { const slabPolygons = useScene((state: S) => {
const nodeList = Object.values(state.nodes) const next = getRecessedSlabGroundHoles(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 prev = slabPolygonsCache.current 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 slabPolygonsCache.current = next
return 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]) for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath() shape.closePath()
if (slabPolygons.length > 0) { addSlabHoles(shape, slabPolygons)
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)
}
}
return shape return shape
}, [polygonPoints, slabPolygons]) }, [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 // Create boundary line geometry
const lineGeometry = useMemo(() => { const lineGeometry = useMemo(() => {
if (!polygonPoints || polygonPoints.length < 2) return null 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 { pointInPolygon2D, SlabNode } from '@pascal-app/core'
import { slabDefinition } from '../definition' import { slabDefinition } from '../definition'
function getHeightHandlePosition(slab: SlabNode) { function getHeightHandle(slab: SlabNode) {
const handles = const handles =
typeof slabDefinition.handles === 'function' typeof slabDefinition.handles === 'function'
? slabDefinition.handles(slab) ? slabDefinition.handles(slab)
@@ -13,7 +13,11 @@ function getHeightHandlePosition(slab: SlabNode) {
if (!(heightHandle && heightHandle.kind === 'linear-resize')) { if (!(heightHandle && heightHandle.kind === 'linear-resize')) {
throw new Error('Missing slab height handle') 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', () => { 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.polygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) 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 * Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom
* edge starts 0.5m away from the wall. * edge starts 0.5m away from the wall.
*/ */
function seedScene() { function seedScene(autoFromWalls = false) {
const levelId = 'level_slab-move-edge' as AnyNodeId const levelId = 'level_slab-move-edge' as AnyNodeId
const wall = WallNode.parse({ const wall = WallNode.parse({
start: [0, 0], start: [0, 0],
@@ -40,7 +40,7 @@ function seedScene() {
[4, 3], [4, 3],
[0, 3], [0, 3],
], ],
autoFromWalls: false, autoFromWalls,
parentId: levelId, parentId: levelId,
}) })
const level = { const level = {
@@ -115,4 +115,22 @@ describe('slabMoveEdgeAffordance', () => {
expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5) expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5)
expect(updated.polygon[1]![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)
})
}) })
+1 -1
View File
@@ -45,7 +45,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
clearSlabSnapFeedback() clearSlabSnapFeedback()
updateNode(slabId, { polygon: newPolygon }) updateNode(slabId, { polygon: newPolygon, autoFromWalls: false })
setSelection({ selectedIds: [slabId] }) setSelection({ selectedIds: [slabId] })
}, },
[slabId, updateNode, setSelection], [slabId, updateNode, setSelection],
+5 -5
View File
@@ -18,7 +18,7 @@ import { SlabNode } from './schema'
import { slabSlots } from './slots' import { slabSlots } from './slots'
const HEIGHT_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02 const MIN_SLAB_ELEVATION = -1
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] { function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
if (polygon.length === 0) return [0, 0] 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 // Slab height arrow — vertical chevron on solid slab surface near the
// polygon center. Drags elevation (the extrusion thickness) with // polygon center. Drags elevation through zero: positive values extrude
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows // upward from ground while negative values create a recessed floor whose
// the pointer. Same registry-handle pipeline as the column height arrow, // depth follows the pointer. Same registry-handle pipeline as the column
// so live override + commit-on-release come for free. // height arrow, so live override + commit-on-release come for free.
function slabHeightHandle(): HandleDescriptor<SlabNodeType> { function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
return { return {
kind: 'linear-resize', kind: 'linear-resize',
@@ -23,6 +23,7 @@ import {
* Simpler model, no UX downside in practice. * Simpler model, no UX downside in practice.
*/ */
const slabSnapOptions = { const slabSnapOptions = {
boundaryCommitData: { autoFromWalls: false },
resolvePlanPoint({ resolvePlanPoint({
node, node,
nodes, nodes,
+1
View File
@@ -235,6 +235,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
useScene.getState().updateNode(slabId, { useScene.getState().updateNode(slabId, {
polygon: translatePolygon(originalPolygon, deltaX, deltaZ), polygon: translatePolygon(originalPolygon, deltaX, deltaZ),
holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
autoFromWalls: false,
}) })
useScene.getState().markDirty(slabId as AnyNodeId) useScene.getState().markDirty(slabId as AnyNodeId)
} }
+1 -1
View File
@@ -15,7 +15,7 @@ export const slabParametrics: ParametricDescriptor<SlabNode> = {
groups: [ groups: [
{ {
label: 'Elevation', 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'), customPanel: () => import('./panel'),
@@ -30,6 +30,12 @@ const SHADOWS_DISABLED =
// 0.9 read too heavy in review. // 0.9 read too heavy in review.
const MAX_SHADOW_INTENSITY = 0.75 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 // 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 // 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. // size the directional light's ortho shadow camera to that sphere plus a margin.
@@ -259,9 +265,9 @@ export function Lights() {
ref={(ref) => { ref={(ref) => {
lightRefs.current[index] = ref lightRefs.current[index] = ref
}} }}
shadow-bias={-0.002} shadow-bias={SHADOW_DEPTH_BIAS}
shadow-mapSize={[1024, 1024]} shadow-mapSize={[1024, 1024]}
shadow-normalBias={0.3} shadow-normalBias={SHADOW_NORMAL_BIAS}
shadow-radius={2} shadow-radius={2}
> >
{light.castShadow && !SHADOWS_DISABLED ? ( {light.castShadow && !SHADOWS_DISABLED ? (
+1
View File
@@ -177,6 +177,7 @@ export { StairSystem } from './systems/stair/stair-system'
// (arch / rounded / frameless opening) identical across both hosts. // (arch / rounded / frameless opening) identical across both hosts.
export { export {
buildOpeningCutoutGeometry, buildOpeningCutoutGeometry,
getOpeningCutoutBottomPadding,
hasFlatOpeningCutoutBottom, hasFlatOpeningCutoutBottom,
} from './systems/wall/opening-cutout-geometry' } from './systems/wall/opening-cutout-geometry'
export { WallCutout } from './systems/wall/wall-cutout' 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) addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
} }
const bottomBarHeight = 0.028
addBox( addBox(
curtain, curtain,
revealMaterial, revealMaterial,
insideWidth - 0.04, insideWidth - 0.04,
0.028, bottomBarHeight,
leafDepth + 0.018, leafDepth + 0.018,
0, 0,
-visibleHeight, -visibleHeight + bottomBarHeight / 2,
leafDepth / 2 + 0.004, 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 // 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). // 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 const elevation = node.elevation ?? 0.05
mesh.position.y = (elevation < 0 ? elevation : 0) + coplanarityEpsilon(node.id) mesh.position.y = elevation < 0 ? elevation : 0
}
// 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
} }
/** /**
@@ -6,6 +6,7 @@ import type * as THREE from 'three'
import { import {
buildOpeningCutoutGeometry, buildOpeningCutoutGeometry,
buildOpeningCutoutShape, buildOpeningCutoutShape,
getOpeningCutoutBottomPadding,
hasFlatOpeningCutoutBottom, hasFlatOpeningCutoutBottom,
} from './opening-cutout-geometry' } from './opening-cutout-geometry'
@@ -208,3 +209,16 @@ describe('hasFlatOpeningCutoutBottom', () => {
).toBe(false) ).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: // 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 // 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 + // 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 — // paint. Wall CSG rebuilds opening cuts directly from node data, so this proxy
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own // only needs to clear the wall thickness plus a small proud margin. A snug depth
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so // keeps the hit target useful without blanketing the room floor in a top-down
// a snug depth keeps the cut intact while no longer blanketing the room floor in // view (the bug a 1m-deep proxy caused in narrow hallways).
// 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_PROXY_PROUD_MARGIN = 0.08
const OPENING_CUTOUT_BOTTOM_PADDING = 0.02
export function getOpeningCutoutProxyDepth(wallThickness: number): number { export function getOpeningCutoutProxyDepth(wallThickness: number): number {
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN 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 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( function getRoundedOpeningRadii(
opening: OpeningCutoutNode, opening: OpeningCutoutNode,
width: number, 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 { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh' import { computeBoundsTree } from 'three-mesh-bvh'
import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' 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 // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
@@ -713,9 +716,8 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
if (child.type !== 'door' && child.type !== 'window') return child if (child.type !== 'door' && child.type !== 'window') return child
// `getEffectiveNode` folds in resize overrides (width/height arrows). // `getEffectiveNode` folds in resize overrides (width/height arrows).
// Position moves publish to `useLiveTransforms` instead, so fold that // Position moves publish to `useLiveTransforms` instead, so fold that
// in too — otherwise shaped openings (arch/rounded/`opening`), whose // in too — opening cutout brushes are rebuilt directly from the
// cutout brush is rebuilt from `node.position`, lag the live move // effective node position rather than from the rendered proxy mesh.
// (rectangular cutouts already track via the live mesh matrixWorld).
const effective = getEffectiveNode(child) const effective = getEffectiveNode(child)
const live = useLiveTransforms.getState().get(child.id) const live = useLiveTransforms.getState().get(child.id)
if (!live?.position) return effective if (!live?.position) return effective
@@ -1060,8 +1062,9 @@ export function generateExtrudedWall(
} }
/** /**
* Collects cutout brushes from child items for CSG subtraction * Collects opening and item cutout brushes for CSG subtraction. Door/window
* The cutout mesh is a plane, so we extrude it into a box that goes through the wall * cuts come directly from node geometry; item proxy meshes are transformed
* into wall-local boxes that pass through the wall.
*/ */
function collectCutoutBrushes( function collectCutoutBrushes(
wallNode: WallNode, wallNode: WallNode,
@@ -1079,17 +1082,8 @@ function collectCutoutBrushes(
for (const child of childrenNodes) { for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
if ( if (child.type === 'door' || child.type === 'window') {
(child.type === 'door' && child.openingKind === 'opening') || brushes.push(createOpeningCutoutBrush(child, wallThickness))
(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))
continue continue
} }
@@ -1147,17 +1141,16 @@ function collectCutoutBrushes(
return brushes return brushes
} }
function createShapedOpeningCutoutBrush( function createOpeningCutoutBrush(opening: DoorNode | WindowNode, wallThickness: number): Brush {
opening: DoorNode | WindowNode,
wallThickness: number,
): Brush {
const halfWidth = opening.width / 2 const halfWidth = opening.width / 2
const bottom = opening.position[1] - opening.height / 2
const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom)
const geometry = buildOpeningCutoutGeometry( const geometry = buildOpeningCutoutGeometry(
opening, opening,
{ {
left: opening.position[0] - halfWidth, left: opening.position[0] - halfWidth,
right: 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, top: opening.position[1] + opening.height / 2,
}, },
wallThickness * 2, wallThickness * 2,