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
@@ -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 ? (
+1
View File
@@ -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,