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
+1
View File
@@ -105,6 +105,7 @@ export {
projectAutoSlabsForPlan,
resumeSpaceDetection,
type Space,
type SpaceBoundaryFace,
wallClosesRoom,
wallTouchesOthers,
} from './lib/space-detection'
+47 -2
View File
@@ -181,8 +181,14 @@ describe('detectSpacesForLevel', () => {
}
test('detects an isolated four-wall room', () => {
const { roomPolygons } = detectSpacesForLevel('level-1', squareWalls())
const walls = squareWalls()
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
expect(roomPolygons).toHaveLength(1)
expect(spaces[0]?.wallIds.sort()).toEqual(walls.map((wall) => wall.id).sort())
expect(spaces[0]?.boundaryFaces).toHaveLength(4)
expect(
spaces[0]?.boundaryFaces.map((boundary) => [boundary.wallId, boundary.face]).sort(),
).toEqual(walls.map((wall) => [wall.id, 'front']).sort())
})
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
@@ -200,12 +206,24 @@ describe('detectSpacesForLevel', () => {
WallNode.parse({ start: [3, -2], end: [3, 0] }),
]
const { roomPolygons } = detectSpacesForLevel('level-1', walls)
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b)
expect(roomPolygons).toHaveLength(2)
expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2
expect(areas[1]).toBeCloseTo(30, 1) // big room: 6×5
const longWallId = walls[0]!.id
const longWallBoundaries = spaces.flatMap((space) =>
space.boundaryFaces.filter((boundary) => boundary.wallId === longWallId),
)
expect(longWallBoundaries).toHaveLength(4)
expect(longWallBoundaries.filter((boundary) => boundary.face === 'back')).toHaveLength(1)
expect(longWallBoundaries.filter((boundary) => boundary.face === 'front')).toHaveLength(3)
expect(longWallBoundaries.map((boundary) => boundary.points)).toContainEqual([
[1, 0],
[3, 0],
])
})
})
@@ -333,4 +351,31 @@ describe('planAutoSlabsForLevel', () => {
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
test('manual slabs that split one room suppress a replacement full-room slab', () => {
const left = SlabNode.parse({
polygon: [
[0, 0],
[2, 0],
[2, 3],
[0, 3],
],
autoFromWalls: false,
})
const right = SlabNode.parse({
polygon: [
[2, 0],
[4, 0],
[4, 3],
[2, 3],
],
autoFromWalls: false,
})
const plan = planAutoSlabsForLevel([roomPolygon()], [left, right])
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
})
+56 -19
View File
@@ -19,14 +19,26 @@ import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number }
export type SpaceBoundaryFace = {
wallId: string
face: 'front' | 'back'
points: Array<[number, number]>
}
export type Space = {
id: string
levelId: string
polygon: Array<[number, number]>
wallIds: string[]
boundaryFaces: SpaceBoundaryFace[]
isExterior: boolean
}
type ExtractedRoom = {
polygon: Point2D[]
boundaryFaces: SpaceBoundaryFace[]
}
type WallSideUpdate = {
wallId: string
frontSide: 'interior' | 'exterior' | 'unknown'
@@ -240,15 +252,15 @@ function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) {
}
// Demoted auto surfaces keep their polygon untouched, so a re-closed room
// usually hits the exact-signature manual check. Mutual footprint coverage
// still guards the case where the user edited the demoted surface's polygon
// afterwards — a fresh auto surface must not stack on top of it.
// usually hits the exact-signature manual check. Coverage also handles a room
// deliberately split across multiple manual surfaces: their union suppresses
// a replacement auto surface as long as the pieces substantially belong to
// and cover the room.
function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
return manualPolygons.some(
(manual) =>
polygonCoverageRatio(roomPolygon, [manual]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD &&
polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
const roomManualPolygons = manualPolygons.filter(
(manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
)
return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
}
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
@@ -460,7 +472,7 @@ function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Poi
return ordered
}
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
function extractRooms(walls: WallNode[]): ExtractedRoom[] {
if (walls.length < 3) return []
type HalfEdge = {
@@ -470,6 +482,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey: string
angle: number
points: Point2D[]
wallId: string
face: 'front' | 'back'
}
type Node = { point: Point2D; outgoing: string[] }
@@ -535,6 +549,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey,
angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x),
points,
wallId: wall.id,
face: 'front',
})
halfEdges.set(reverseId, {
id: reverseId,
@@ -543,6 +559,8 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
toKey: fromKey,
angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x),
points: reversePoints,
wallId: wall.id,
face: 'back',
})
graph.get(fromKey)?.outgoing.push(forwardId)
@@ -572,7 +590,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
}
const visitedDirected = new Set<string>()
const faces: Point2D[][] = []
const rooms: ExtractedRoom[] = []
// A single face cannot revisit a half-edge, so the half-edge count bounds the
// longest possible cycle. Splitting at junctions can multiply edges per wall.
const maxSteps = Math.min(2000, halfEdges.size + 10)
@@ -620,13 +638,30 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon)
if (faces.some((face) => polygonSignature(face) === signature)) continue
if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue
faces.push(polygon)
rooms.push({
polygon,
boundaryFaces: cycleEdgeIds.flatMap((id) => {
const edge = halfEdges.get(id)
if (!edge) return []
return [
{
wallId: edge.wallId,
face: edge.face,
points: edge.points.map(pointToTuple),
},
]
}),
})
}
faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)))
return faces
rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)))
return rooms
}
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
return extractRooms(walls).map((room) => room.polygon)
}
/**
@@ -760,13 +795,14 @@ function levelStructureSnapshots(nodes: Record<string, any>) {
return snapshots
}
function buildSpace(levelId: string, polygon: Point2D[]): Space {
const signature = polygonSignature(polygon)
function buildSpace(levelId: string, room: ExtractedRoom): Space {
const signature = polygonSignature(room.polygon)
return {
id: `space-${levelId}-${signature.slice(0, 12)}`,
levelId,
polygon: polygon.map(pointToTuple),
wallIds: [],
polygon: room.polygon.map(pointToTuple),
wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
boundaryFaces: room.boundaryFaces,
isExterior: false,
}
}
@@ -1169,7 +1205,8 @@ function syncAutoCeilingsForLevel(
}
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
const roomPolygons = extractRoomPolygons(walls)
const rooms = extractRooms(walls)
const roomPolygons = rooms.map((room) => room.polygon)
const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
wallId: wall.id,
...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick<
@@ -1180,7 +1217,7 @@ function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
return {
roomPolygons,
spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
spaces: rooms.map((room) => buildSpace(levelId, room)),
wallUpdates,
}
}
File diff suppressed because it is too large Load Diff