Fix stair opening previews and slab cutouts

This commit is contained in:
Aymeric Rabot
2026-06-07 00:46:51 -04:00
parent 21b79c0d62
commit 03f57b1f4f
21 changed files with 1766 additions and 195 deletions
@@ -0,0 +1,70 @@
// @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 { SlabNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system'
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) {
if (Math.abs(positions.getX(index) - x) < 1e-6 && Math.abs(positions.getZ(index) - z) < 1e-6) {
return true
}
}
return false
}
describe('generateSlabGeometry', () => {
test('renders a boundary-overlapping hole as an open indentation', () => {
const slab = SlabNode.parse({
elevation: 0.05,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
const slab = SlabNode.parse({
elevation: -0.2,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
})
+113 -66
View File
@@ -1,6 +1,10 @@
import {
type AnyNodeId,
getEffectiveNode,
getRenderableSlabPolygon,
type PolygonPoint2D,
pointInPolygon2D,
polygonsIntersect,
type SlabNode,
sceneRegistry,
useScene,
@@ -8,6 +12,7 @@ import {
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -47,7 +52,7 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) {
updateSlabGeometry(node as SlabNode, mesh)
updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh)
clearDirty(id as AnyNodeId)
}
// If mesh not found, keep it dirty for next frame
@@ -95,6 +100,40 @@ function ensureCounterClockwisePolygon(polygon: Array<[number, number]>): Array<
return area2 < 0 ? [...polygon].reverse() : polygon
}
function isStrictInteriorHole(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
hole.every((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) &&
!polygonsIntersect(contour, hole)
)
}
function affectsContour(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
polygonsIntersect(contour, hole) ||
hole.some((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) ||
contour.some((point) => pointInPolygon2D(point, hole, { includeBoundary: false }))
)
}
function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) {
const containedHoles: PolygonPoint2D[][] = []
const edgeCutouts: PolygonPoint2D[][] = []
for (const hole of holes) {
if (hole.length < 3) continue
if (isStrictInteriorHole(contour, hole)) containedHoles.push(hole)
else if (affectsContour(contour, hole)) edgeCutouts.push(hole)
}
const contours =
edgeCutouts.length > 0 ? subtractPolygonsFromPolygon(contour, edgeCutouts) : [contour]
return contours.map((regionContour) => ({
contour: regionContour,
holes: containedHoles.filter((hole) => isStrictInteriorHole(regionContour, hole)),
}))
}
/**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*
@@ -118,35 +157,6 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
const uvs: number[] = []
const indices: number[] = []
const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holes2d = holePolygons
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
// --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
// --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls
@@ -171,15 +181,49 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
}
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
const b = hole[(i + 1) % hole.length]!
addWall(a, b, false)
addWall(a, b, true)
for (const region of buildSlabRegions(polygon, holePolygons)) {
const contour2d = ensureCounterClockwisePolygon(region.contour).map(
([x, z]) => new THREE.Vector2(x!, z!),
)
const holes2d = region.holes
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
// --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
const b = hole[(i + 1) % hole.length]!
addWall(a, b, false)
addWall(a, b, true)
}
}
}
@@ -210,7 +254,6 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
const n = polygon.length
const bounds = new THREE.Box2()
for (const [x, z] of polygon) {
@@ -235,37 +278,41 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
uvs.push(u, v)
}
// --- Floor at Y=0 ---
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
for (const region of buildSlabRegions(polygon, holePolygons)) {
const contour = ensureCounterClockwisePolygon(region.contour)
const floorBase = positions.length / 3
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of holePolygons) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
// --- Floor at Y=0 ---
for (const [x, z] of contour) pushFloorVertex(x!, 0, z!)
const pts2d = contour.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = region.holes.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of region.holes) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane
indices.push(floorBase + tri[0]!, floorBase + tri[2]!, floorBase + tri[1]!)
}
// --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]!
const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
// --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < contour.length; i++) {
const j = (i + 1) % contour.length
const [x0, z0] = contour[i]!
const [x1, z1] = contour[j]!
const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
}
}
const geo = new THREE.BufferGeometry()