Fix stair opening previews and slab cutouts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// @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 { type Point2D, unionPolygons } from './polygon-union'
|
||||
import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union'
|
||||
|
||||
function polygonArea(points: Point2D[]) {
|
||||
let area = 0
|
||||
@@ -75,3 +75,47 @@ describe('unionPolygons', () => {
|
||||
expect(result.map(polygonArea)).toEqual([1, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('subtractPolygonsFromPolygon', () => {
|
||||
test('turns a boundary-overlapping cutter into an indentation', () => {
|
||||
const slab: Point2D[] = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
]
|
||||
const cutout: Point2D[] = [
|
||||
[1, -0.5],
|
||||
[3, -0.5],
|
||||
[3, 1],
|
||||
[1, 1],
|
||||
]
|
||||
|
||||
const result = subtractPolygonsFromPolygon(slab, [cutout])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContainEqual([1, 1])
|
||||
expect(result[0]).toContainEqual([3, 1])
|
||||
expect(polygonArea(result[0]!)).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
test('returns separate contours when a cutter splits the subject', () => {
|
||||
const slab: Point2D[] = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
]
|
||||
const cutout: Point2D[] = [
|
||||
[1.5, -1],
|
||||
[2.5, -1],
|
||||
[2.5, 4],
|
||||
[1.5, 4],
|
||||
]
|
||||
|
||||
const result = subtractPolygonsFromPolygon(slab, [cutout])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map(polygonArea).sort((a, b) => a - b)).toEqual([4.5, 4.5])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,18 @@ function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
|
||||
return dot <= EPSILON
|
||||
}
|
||||
|
||||
function pointInPolygonOrOnBoundary(point: Point2D, polygon: Point2D[]) {
|
||||
if (
|
||||
polygon.some((start, index) =>
|
||||
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return pointInPolygon(point, polygon)
|
||||
}
|
||||
|
||||
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
|
||||
let inside = false
|
||||
|
||||
@@ -292,3 +304,65 @@ export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
|
||||
|
||||
return rings.length > 0 ? rings : validPolygons
|
||||
}
|
||||
|
||||
function buildDifferenceBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
|
||||
const subject = polygons[0]
|
||||
const cutters = polygons.slice(1)
|
||||
if (!subject) return []
|
||||
|
||||
const segments: Segment[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const splits = [...edge.splits].sort((a, b) => a - b)
|
||||
|
||||
for (let i = 0; i < splits.length - 1; i++) {
|
||||
const startT = splits[i]!
|
||||
const endT = splits[i + 1]!
|
||||
if (endT - startT <= EPSILON) continue
|
||||
|
||||
const start = interpolate(edge.start, edge.end, startT)
|
||||
const end = interpolate(edge.start, edge.end, endT)
|
||||
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
|
||||
|
||||
if (edge.polygonIndex === 0) {
|
||||
const insideCutter = cutters.some((cutter) => pointInPolygonOrOnBoundary(mid, cutter))
|
||||
if (!insideCutter) {
|
||||
segments.push({ start, end, used: false })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const insideSubject = pointInPolygon(mid, subject)
|
||||
const insideAnotherCutter = cutters.some(
|
||||
(cutter, cutterIndex) =>
|
||||
cutterIndex !== edge.polygonIndex - 1 && pointInPolygonOrOnBoundary(mid, cutter),
|
||||
)
|
||||
|
||||
if (insideSubject && !insideAnotherCutter) {
|
||||
segments.push({ start: end, end: start, used: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removeDuplicateInteriorSegments(segments)
|
||||
}
|
||||
|
||||
export function subtractPolygonsFromPolygon(subject: Point2D[], cutters: Point2D[][]): Point2D[][] {
|
||||
const validSubject = normalizeRing(subject)
|
||||
if (validSubject.length < 3) return []
|
||||
|
||||
const validCutters = cutters.map(normalizeRing).filter((polygon) => polygon.length >= 3)
|
||||
if (validCutters.length === 0) return [validSubject]
|
||||
|
||||
const polygons = [validSubject, ...validCutters]
|
||||
const edges = buildEdges(polygons)
|
||||
const segments = buildDifferenceBoundarySegments(edges, polygons)
|
||||
const rings = assembleRings(segments)
|
||||
|
||||
if (rings.length > 0) return rings
|
||||
|
||||
const fullyCovered = validSubject.every((point) =>
|
||||
validCutters.some((cutter) => pointInPolygonOrOnBoundary(point, cutter)),
|
||||
)
|
||||
return fullyCovered ? [] : [validSubject]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user