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
@@ -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 {
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