Fix room surfaces, wall openings, and paint scope (#498)
* chore(core): point material catalog at KTX2 tiers for wood/flooring/roofing finishes All 48 remaining webp/jpg/png finish entries now reference _512.ktx2 maps and 256px _thumb.webp previews, matching the fabric/leather/concrete/metal convention. flipY set to false on the converted entries — compressed textures can't be flipped at upload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: stabilize room surfaces and wall openings * style(core): format KTX2 material catalog --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4fca38a3ef
commit
a524da1574
@@ -30,6 +30,12 @@ const SHADOWS_DISABLED =
|
||||
// 0.9 read too heavy in review.
|
||||
const MAX_SHADOW_INTENSITY = 0.75
|
||||
|
||||
// `normalBias` is measured in world units. The previous 0.3 moved shadow
|
||||
// lookups 30 cm off their surfaces, visibly detaching wall shadows at the
|
||||
// floor. Keep only a small offset for acne, with a tiny depth bias alongside it.
|
||||
const SHADOW_DEPTH_BIAS = -0.0001
|
||||
const SHADOW_NORMAL_BIAS = 0.02
|
||||
|
||||
// Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
|
||||
// camera): we union the bounds of all registered scene nodes, fit a sphere, and
|
||||
// size the directional light's ortho shadow camera to that sphere plus a margin.
|
||||
@@ -259,9 +265,9 @@ export function Lights() {
|
||||
ref={(ref) => {
|
||||
lightRefs.current[index] = ref
|
||||
}}
|
||||
shadow-bias={-0.002}
|
||||
shadow-bias={SHADOW_DEPTH_BIAS}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-normalBias={0.3}
|
||||
shadow-normalBias={SHADOW_NORMAL_BIAS}
|
||||
shadow-radius={2}
|
||||
>
|
||||
{light.castShadow && !SHADOWS_DISABLED ? (
|
||||
|
||||
@@ -177,6 +177,7 @@ export { StairSystem } from './systems/stair/stair-system'
|
||||
// (arch / rounded / frameless opening) identical across both hosts.
|
||||
export {
|
||||
buildOpeningCutoutGeometry,
|
||||
getOpeningCutoutBottomPadding,
|
||||
hasFlatOpeningCutoutBottom,
|
||||
} from './systems/wall/opening-cutout-geometry'
|
||||
export { WallCutout } from './systems/wall/wall-cutout'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DoorNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { buildDoorPreviewMesh } from '../../index'
|
||||
|
||||
const DOOR_TYPES = [
|
||||
'hinged',
|
||||
'double',
|
||||
'french',
|
||||
'folding',
|
||||
'pocket',
|
||||
'barn',
|
||||
'sliding',
|
||||
'garage-sectional',
|
||||
'garage-rollup',
|
||||
'garage-tiltup',
|
||||
] as const
|
||||
|
||||
function visibleBounds(mesh: THREE.Mesh): THREE.Box3 {
|
||||
mesh.updateMatrixWorld(true)
|
||||
const bounds = new THREE.Box3()
|
||||
for (const child of mesh.children) {
|
||||
if (child.name === 'cutout') continue
|
||||
bounds.expandByObject(child, true)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
describe('door floor alignment', () => {
|
||||
for (const doorType of DOOR_TYPES) {
|
||||
test(`${doorType} does not extend below the opening floor`, () => {
|
||||
const node = DoorNode.parse({
|
||||
id: `door_floor-alignment-${doorType}`,
|
||||
doorType,
|
||||
operationState: 0,
|
||||
threshold: true,
|
||||
})
|
||||
const mesh = buildDoorPreviewMesh(node)
|
||||
const bounds = visibleBounds(mesh)
|
||||
|
||||
expect(bounds.min.y).toBeGreaterThanOrEqual(-node.height / 2 - 1e-6)
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps the wall cutout bottom locked to the opening floor', () => {
|
||||
const node = DoorNode.parse({ id: 'door_floor-alignment-cutout' })
|
||||
const mesh = buildDoorPreviewMesh(node)
|
||||
const cutout = mesh.getObjectByName('cutout') as THREE.Mesh
|
||||
cutout.geometry.computeBoundingBox()
|
||||
|
||||
expect(cutout.geometry.boundingBox?.min.y).toBeCloseTo(-node.height / 2, 6)
|
||||
})
|
||||
})
|
||||
@@ -2150,14 +2150,15 @@ function addGarageRollupDoor(
|
||||
addBox(curtain, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012)
|
||||
}
|
||||
|
||||
const bottomBarHeight = 0.028
|
||||
addBox(
|
||||
curtain,
|
||||
revealMaterial,
|
||||
insideWidth - 0.04,
|
||||
0.028,
|
||||
bottomBarHeight,
|
||||
leafDepth + 0.018,
|
||||
0,
|
||||
-visibleHeight,
|
||||
-visibleHeight + bottomBarHeight / 2,
|
||||
leafDepth / 2 + 0.004,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,21 +107,8 @@ function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: T
|
||||
|
||||
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
|
||||
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
|
||||
// A deterministic sub-3mm per-node lift breaks the coplanarity of slabs
|
||||
// duplicated at the exact same position — identical depths z-fight, and no
|
||||
// camera near/far tuning can separate them. Render-only: node data,
|
||||
// snapping and measurements are untouched.
|
||||
const elevation = node.elevation ?? 0.05
|
||||
mesh.position.y = (elevation < 0 ? elevation : 0) + coplanarityEpsilon(node.id)
|
||||
}
|
||||
|
||||
// Stable id hash → 0..2.7 mm in 0.3 mm steps.
|
||||
function coplanarityEpsilon(id: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
}
|
||||
return (Math.abs(hash) % 10) * 0.0003
|
||||
mesh.position.y = elevation < 0 ? elevation : 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import type * as THREE from 'three'
|
||||
import {
|
||||
buildOpeningCutoutGeometry,
|
||||
buildOpeningCutoutShape,
|
||||
getOpeningCutoutBottomPadding,
|
||||
hasFlatOpeningCutoutBottom,
|
||||
} from './opening-cutout-geometry'
|
||||
|
||||
@@ -208,3 +209,16 @@ describe('hasFlatOpeningCutoutBottom', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOpeningCutoutBottomPadding', () => {
|
||||
test('pads floor-level openings with a flat bottom', () => {
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0)).toBe(0.02)
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({ openingShape: 'rounded' }), 0)).toBe(0.02)
|
||||
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'arch' }), 0)).toBe(0.02)
|
||||
})
|
||||
|
||||
test('does not pad openings above the floor or rounded window bottoms', () => {
|
||||
expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0.9)).toBe(0)
|
||||
expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'rounded' }), 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,12 +13,12 @@ export type OpeningCutoutRect = {
|
||||
// The cutout proxy doubles as the invisible raycast hit target for an opening:
|
||||
// centered on the wall and extending past both faces so it wins the scene
|
||||
// raycast over the recessed door/window body for front AND back selection +
|
||||
// paint. It only needs to clear the wall thickness plus a small proud margin —
|
||||
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
|
||||
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
|
||||
// a snug depth keeps the cut intact while no longer blanketing the room floor in
|
||||
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
|
||||
// paint. Wall CSG rebuilds opening cuts directly from node data, so this proxy
|
||||
// only needs to clear the wall thickness plus a small proud margin. A snug depth
|
||||
// keeps the hit target useful without blanketing the room floor in a top-down
|
||||
// view (the bug a 1m-deep proxy caused in narrow hallways).
|
||||
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
|
||||
const OPENING_CUTOUT_BOTTOM_PADDING = 0.02
|
||||
|
||||
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
|
||||
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
|
||||
@@ -128,6 +128,14 @@ export function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean
|
||||
return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends floor-level flat cutouts below the host wall so CSG never has to
|
||||
* subtract a face exactly coplanar with the wall base.
|
||||
*/
|
||||
export function getOpeningCutoutBottomPadding(opening: OpeningCutoutNode, bottom: number): number {
|
||||
return bottom < 0.005 && hasFlatOpeningCutoutBottom(opening) ? OPENING_CUTOUT_BOTTOM_PADDING : 0
|
||||
}
|
||||
|
||||
function getRoundedOpeningRadii(
|
||||
opening: OpeningCutoutNode,
|
||||
width: number,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { calculateLevelMiters, DoorNode, sceneRegistry, WallNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { generateExtrudedWall } from './wall-system'
|
||||
|
||||
describe('wall opening cutout', () => {
|
||||
test('cuts a floor-level door directly from node geometry without a proxy mesh', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_floor-opening-cutout',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
height: 2.5,
|
||||
thickness: 0.1,
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_floor-opening-cutout',
|
||||
wallId: wall.id,
|
||||
position: [1, 1.05, 0],
|
||||
width: 0.9,
|
||||
height: 2.1,
|
||||
})
|
||||
const wallMesh = new THREE.Mesh()
|
||||
sceneRegistry.nodes.set(wall.id, wallMesh)
|
||||
|
||||
try {
|
||||
const geometry = generateExtrudedWall(wall, [door], calculateLevelMiters([wall]))
|
||||
const position = geometry.getAttribute('position')
|
||||
const index = geometry.index
|
||||
const openingLeft = door.position[0] - door.width / 2
|
||||
const openingRight = door.position[0] + door.width / 2
|
||||
let wallFaceTrianglesInsideOpening = 0
|
||||
let baseTrianglesInsideOpening = 0
|
||||
|
||||
for (let offset = 0; offset < (index?.count ?? position.count); offset += 3) {
|
||||
const indices = [0, 1, 2].map((corner) =>
|
||||
index ? index.getX(offset + corner) : offset + corner,
|
||||
)
|
||||
const vertices = indices.map(
|
||||
(vertexIndex) =>
|
||||
new THREE.Vector3(
|
||||
position.getX(vertexIndex),
|
||||
position.getY(vertexIndex),
|
||||
position.getZ(vertexIndex),
|
||||
),
|
||||
)
|
||||
const centroid = vertices
|
||||
.reduce((sum, vertex) => sum.add(vertex), new THREE.Vector3())
|
||||
.multiplyScalar(1 / 3)
|
||||
const insideOpeningX = centroid.x > openingLeft + 1e-4 && centroid.x < openingRight - 1e-4
|
||||
if (!insideOpeningX) continue
|
||||
|
||||
const onWallFace = vertices.every(
|
||||
(vertex) => Math.abs(Math.abs(vertex.z) - (wall.thickness ?? 0.1) / 2) < 1e-5,
|
||||
)
|
||||
if (onWallFace && centroid.y > 1e-4 && centroid.y < door.height - 1e-4) {
|
||||
wallFaceTrianglesInsideOpening += 1
|
||||
}
|
||||
|
||||
if (vertices.every((vertex) => Math.abs(vertex.y) < 1e-5)) {
|
||||
baseTrianglesInsideOpening += 1
|
||||
}
|
||||
}
|
||||
|
||||
expect(wallFaceTrianglesInsideOpening).toBe(0)
|
||||
expect(baseTrianglesInsideOpening).toBe(0)
|
||||
geometry.dispose()
|
||||
} finally {
|
||||
sceneRegistry.nodes.delete(wall.id)
|
||||
wallMesh.geometry.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,10 @@ import * as THREE from 'three'
|
||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils'
|
||||
import { buildOpeningCutoutGeometry } from './opening-cutout-geometry'
|
||||
import {
|
||||
buildOpeningCutoutGeometry,
|
||||
getOpeningCutoutBottomPadding,
|
||||
} from './opening-cutout-geometry'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
@@ -713,9 +716,8 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
if (child.type !== 'door' && child.type !== 'window') return child
|
||||
// `getEffectiveNode` folds in resize overrides (width/height arrows).
|
||||
// Position moves publish to `useLiveTransforms` instead, so fold that
|
||||
// in too — otherwise shaped openings (arch/rounded/`opening`), whose
|
||||
// cutout brush is rebuilt from `node.position`, lag the live move
|
||||
// (rectangular cutouts already track via the live mesh matrixWorld).
|
||||
// in too — opening cutout brushes are rebuilt directly from the
|
||||
// effective node position rather than from the rendered proxy mesh.
|
||||
const effective = getEffectiveNode(child)
|
||||
const live = useLiveTransforms.getState().get(child.id)
|
||||
if (!live?.position) return effective
|
||||
@@ -1060,8 +1062,9 @@ export function generateExtrudedWall(
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects cutout brushes from child items for CSG subtraction
|
||||
* The cutout mesh is a plane, so we extrude it into a box that goes through the wall
|
||||
* Collects opening and item cutout brushes for CSG subtraction. Door/window
|
||||
* cuts come directly from node geometry; item proxy meshes are transformed
|
||||
* into wall-local boxes that pass through the wall.
|
||||
*/
|
||||
function collectCutoutBrushes(
|
||||
wallNode: WallNode,
|
||||
@@ -1079,17 +1082,8 @@ function collectCutoutBrushes(
|
||||
for (const child of childrenNodes) {
|
||||
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
||||
|
||||
if (
|
||||
(child.type === 'door' && child.openingKind === 'opening') ||
|
||||
(child.type === 'door' &&
|
||||
child.openingKind === 'door' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
|
||||
(child.type === 'window' && child.openingKind === 'opening') ||
|
||||
(child.type === 'window' &&
|
||||
child.openingKind === 'window' &&
|
||||
(child.openingShape === 'arch' || child.openingShape === 'rounded'))
|
||||
) {
|
||||
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
|
||||
if (child.type === 'door' || child.type === 'window') {
|
||||
brushes.push(createOpeningCutoutBrush(child, wallThickness))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1147,17 +1141,16 @@ function collectCutoutBrushes(
|
||||
return brushes
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutBrush(
|
||||
opening: DoorNode | WindowNode,
|
||||
wallThickness: number,
|
||||
): Brush {
|
||||
function createOpeningCutoutBrush(opening: DoorNode | WindowNode, wallThickness: number): Brush {
|
||||
const halfWidth = opening.width / 2
|
||||
const bottom = opening.position[1] - opening.height / 2
|
||||
const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom)
|
||||
const geometry = buildOpeningCutoutGeometry(
|
||||
opening,
|
||||
{
|
||||
left: opening.position[0] - halfWidth,
|
||||
right: opening.position[0] + halfWidth,
|
||||
bottom: opening.position[1] - opening.height / 2,
|
||||
bottom: bottom - bottomPadding,
|
||||
top: opening.position[1] + opening.height / 2,
|
||||
},
|
||||
wallThickness * 2,
|
||||
|
||||
Reference in New Issue
Block a user