diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e51840b3..069fab34 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -105,6 +105,7 @@ export { projectAutoSlabsForPlan, resumeSpaceDetection, type Space, + type SpaceBoundaryFace, wallClosesRoom, wallTouchesOthers, } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 57e7eff3..af71b550 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -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) + }) }) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 37679f56..fb11ad8c 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -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() - 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) { 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, } } diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 602739e2..f887d36b 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -89,13 +89,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_basecolor.webp', + previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/finewood_27/finewood_27_basecolor.webp', - aoMap: '/material/wood/finewood_27/finewood_27_ambientocclusion.webp', - normalMap: '/material/wood/finewood_27/finewood_27_normal.webp', - roughnessMap: '/material/wood/finewood_27/finewood_27_roughness.webp', + albedoMap: '/material/wood/finewood_27/finewood_27_basecolor_512.ktx2', + aoMap: '/material/wood/finewood_27/finewood_27_ao_512.ktx2', + normalMap: '/material/wood/finewood_27/finewood_27_normal_512.ktx2', + roughnessMap: '/material/wood/finewood_27/finewood_27_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -111,7 +111,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -127,12 +127,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Wood plank finish', - previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', + previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank_1_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', - aoMap: '/material/wood/floor_plank_1/floor_plank-ao.webp', - normalMap: '/material/wood/floor_plank_1/floor_plank-normal.webp', + albedoMap: '/material/wood/floor_plank_1/floor_plank_1_basecolor_512.ktx2', + aoMap: '/material/wood/floor_plank_1/floor_plank_1_ao_512.ktx2', + normalMap: '/material/wood/floor_plank_1/floor_plank_1_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -148,7 +148,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -164,12 +164,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', + previewThumbnailUrl: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', - normalMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp', - roughnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp', + albedoMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_basecolor_512.ktx2', + normalMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_normal_512.ktx2', + roughnessMap: '/material/wood/hungarian_parquet_10/hungarian_parquet_10_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -185,7 +185,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -201,12 +201,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', + previewThumbnailUrl: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', - normalMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp', - roughnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp', + albedoMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_basecolor_512.ktx2', + normalMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_normal_512.ktx2', + roughnessMap: '/material/wood/hungarian_parquet_2/hungarian_parquet_2_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -222,7 +222,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -238,13 +238,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: - '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', + previewThumbnailUrl: '/material/wood/square_parquet_21/square_parquet_21_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', - normalMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp', - roughnessMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp', + albedoMap: '/material/wood/square_parquet_21/square_parquet_21_basecolor_512.ktx2', + normalMap: '/material/wood/square_parquet_21/square_parquet_21_normal_512.ktx2', + roughnessMap: '/material/wood/square_parquet_21/square_parquet_21_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -260,7 +259,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -276,14 +275,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: - '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', + previewThumbnailUrl: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', - normalMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp', + albedoMap: + '/material/wood/square_wood_parquet_23/square_wood_parquet_23_basecolor_512.ktx2', + normalMap: '/material/wood/square_wood_parquet_23/square_wood_parquet_23_normal_512.ktx2', roughnessMap: - '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp', + '/material/wood/square_wood_parquet_23/square_wood_parquet_23_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -299,7 +298,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -315,12 +314,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', - aoMap: '/material/wood/wood_fine/wood_fine_1-ao.webp', - normalMap: '/material/wood/wood_fine/wood_fine_1-normal.webp', + albedoMap: '/material/wood/wood_fine/wood_fine_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine/wood_fine_ao_512.ktx2', + normalMap: '/material/wood/wood_fine/wood_fine_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -336,7 +335,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -352,12 +351,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', - aoMap: '/material/wood/wood_fine_11/wood_fine_11-ao.webp', - normalMap: '/material/wood/wood_fine_11/wood_fine_11-normal.webp', + albedoMap: '/material/wood/wood_fine_11/wood_fine_11_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine_11/wood_fine_11_ao_512.ktx2', + normalMap: '/material/wood/wood_fine_11/wood_fine_11_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -373,7 +372,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -389,12 +388,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', - aoMap: '/material/wood/wood_fine_13/wood_fine_13-ao.webp', - normalMap: '/material/wood/wood_fine_13/wood_fine_13-normal.webp', + albedoMap: '/material/wood/wood_fine_13/wood_fine_13_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine_13/wood_fine_13_ao_512.ktx2', + normalMap: '/material/wood/wood_fine_13/wood_fine_13_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -410,7 +409,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -426,12 +425,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', - aoMap: '/material/wood/wood_fine_2/wood_fine_2-ao.webp', - normalMap: '/material/wood/wood_fine_2/wood_fine_2-normal.webp', + albedoMap: '/material/wood/wood_fine_2/wood_fine_2_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine_2/wood_fine_2_ao_512.ktx2', + normalMap: '/material/wood/wood_fine_2/wood_fine_2_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -447,7 +446,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -463,12 +462,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', - aoMap: '/material/wood/wood_fine_22/wood_fine_22-ao.webp', - normalMap: '/material/wood/wood_fine_22/wood_fine_22-normal.webp', + albedoMap: '/material/wood/wood_fine_22/wood_fine_22_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine_22/wood_fine_22_ao_512.ktx2', + normalMap: '/material/wood/wood_fine_22/wood_fine_22_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -484,7 +483,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -500,12 +499,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall', 'furniture'], description: 'Fine wood finish', - previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', + previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', - aoMap: '/material/wood/wood_fine_24/wood_fine_24-ao.webp', - normalMap: '/material/wood/wood_fine_24/wood_fine_24-normal.webp', + albedoMap: '/material/wood/wood_fine_24/wood_fine_24_basecolor_512.ktx2', + aoMap: '/material/wood/wood_fine_24/wood_fine_24_ao_512.ktx2', + normalMap: '/material/wood/wood_fine_24/wood_fine_24_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -521,7 +520,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -537,14 +536,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp', + previewThumbnailUrl: '/material/wood/wood_parquet_14/wood_parquet_14_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp', - aoMap: '/material/wood/wood_parquet_14/woodparquet_14_ambientocclusion.webp', - metalnessMap: '/material/wood/wood_parquet_14/woodparquet_14_metallic.webp', - normalMap: '/material/wood/wood_parquet_14/woodparquet_14_normal.webp', - roughnessMap: '/material/wood/wood_parquet_14/woodparquet_14_roughness.webp', + albedoMap: '/material/wood/wood_parquet_14/wood_parquet_14_basecolor_512.ktx2', + aoMap: '/material/wood/wood_parquet_14/wood_parquet_14_ao_512.ktx2', + metalnessMap: '/material/wood/wood_parquet_14/wood_parquet_14_metallic_512.ktx2', + normalMap: '/material/wood/wood_parquet_14/wood_parquet_14_normal_512.ktx2', + roughnessMap: '/material/wood/wood_parquet_14/wood_parquet_14_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -560,7 +559,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -576,12 +575,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', + previewThumbnailUrl: '/material/wood/wooden_parquet_11/wooden_parquet_11_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', - normalMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp', - roughnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp', + albedoMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_basecolor_512.ktx2', + normalMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_normal_512.ktx2', + roughnessMap: '/material/wood/wooden_parquet_11/wooden_parquet_11_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -597,7 +596,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -613,13 +612,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_basecolor.webp', + previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodparquet_121/woodparquet_121_basecolor.webp', - aoMap: '/material/wood/woodparquet_121/woodparquet_121_ambientocclusion.webp', - normalMap: '/material/wood/woodparquet_121/woodparquet_121_normal.webp', - roughnessMap: '/material/wood/woodparquet_121/woodparquet_121_roughness.webp', + albedoMap: '/material/wood/woodparquet_121/woodparquet_121_basecolor_512.ktx2', + aoMap: '/material/wood/woodparquet_121/woodparquet_121_ao_512.ktx2', + normalMap: '/material/wood/woodparquet_121/woodparquet_121_normal_512.ktx2', + roughnessMap: '/material/wood/woodparquet_121/woodparquet_121_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -635,7 +634,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -651,14 +650,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_basecolor.webp', + previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodparquet_56/woodparquet_56_basecolor.webp', - aoMap: '/material/wood/woodparquet_56/woodparquet_56_ambientocclusion.webp', - metalnessMap: '/material/wood/woodparquet_56/woodparquet_56_metallic.webp', - normalMap: '/material/wood/woodparquet_56/woodparquet_56_normal.webp', - roughnessMap: '/material/wood/woodparquet_56/woodparquet_56_roughness.webp', + albedoMap: '/material/wood/woodparquet_56/woodparquet_56_basecolor_512.ktx2', + aoMap: '/material/wood/woodparquet_56/woodparquet_56_ao_512.ktx2', + metalnessMap: '/material/wood/woodparquet_56/woodparquet_56_metallic_512.ktx2', + normalMap: '/material/wood/woodparquet_56/woodparquet_56_normal_512.ktx2', + roughnessMap: '/material/wood/woodparquet_56/woodparquet_56_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -674,7 +673,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -690,14 +689,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp', + previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp', - aoMap: '/material/wood/woodparquet_65/woodparquet_65_AmbientOcclusion.webp', - metalnessMap: '/material/wood/woodparquet_65/woodparquet_65_Metallic.webp', - normalMap: '/material/wood/woodparquet_65/woodparquet_65_Normal.webp', - roughnessMap: '/material/wood/woodparquet_65/woodparquet_65_Roughness.webp', + albedoMap: '/material/wood/woodparquet_65/woodparquet_65_basecolor_512.ktx2', + aoMap: '/material/wood/woodparquet_65/woodparquet_65_ao_512.ktx2', + metalnessMap: '/material/wood/woodparquet_65/woodparquet_65_metallic_512.ktx2', + normalMap: '/material/wood/woodparquet_65/woodparquet_65_normal_512.ktx2', + roughnessMap: '/material/wood/woodparquet_65/woodparquet_65_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -713,7 +712,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -729,14 +728,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Parquet wood finish', - previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_basecolor.webp', + previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodparquet_99/woodparquet_99_basecolor.webp', - aoMap: '/material/wood/woodparquet_99/woodparquet_99_ambientocclusion.webp', - metalnessMap: '/material/wood/woodparquet_99/woodparquet_99_metallic.webp', - normalMap: '/material/wood/woodparquet_99/woodparquet_99_normal.webp', - roughnessMap: '/material/wood/woodparquet_99/woodparquet_99_roughness.webp', + albedoMap: '/material/wood/woodparquet_99/woodparquet_99_basecolor_512.ktx2', + aoMap: '/material/wood/woodparquet_99/woodparquet_99_ao_512.ktx2', + metalnessMap: '/material/wood/woodparquet_99/woodparquet_99_metallic_512.ktx2', + normalMap: '/material/wood/woodparquet_99/woodparquet_99_normal_512.ktx2', + roughnessMap: '/material/wood/woodparquet_99/woodparquet_99_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -752,7 +751,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -768,13 +767,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall'], description: 'Wood plank finish', - previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_basecolor.webp', + previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodplank_19/woodplank_19_basecolor.webp', - aoMap: '/material/wood/woodplank_19/woodplank_19_ambientocclusion.webp', - normalMap: '/material/wood/woodplank_19/woodplank_19_normal.webp', - roughnessMap: '/material/wood/woodplank_19/woodplank_19_roughness.webp', + albedoMap: '/material/wood/woodplank_19/woodplank_19_basecolor_512.ktx2', + aoMap: '/material/wood/woodplank_19/woodplank_19_ao_512.ktx2', + normalMap: '/material/wood/woodplank_19/woodplank_19_normal_512.ktx2', + roughnessMap: '/material/wood/woodplank_19/woodplank_19_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -790,7 +789,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -806,13 +805,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor', 'wall'], description: 'Wood plank finish', - previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_BaseColor.webp', + previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_thumb.webp', preset: { maps: { - albedoMap: '/material/wood/woodplank_48/woodplank_48_BaseColor.webp', - aoMap: '/material/wood/woodplank_48/woodplank_48_AmbientOcclusion.webp', - normalMap: '/material/wood/woodplank_48/woodplank_48_Normal.webp', - roughnessMap: '/material/wood/woodplank_48/woodplank_48_Roughness.webp', + albedoMap: '/material/wood/woodplank_48/woodplank_48_basecolor_512.ktx2', + aoMap: '/material/wood/woodplank_48/woodplank_48_ao_512.ktx2', + normalMap: '/material/wood/woodplank_48/woodplank_48_normal_512.ktx2', + roughnessMap: '/material/wood/woodplank_48/woodplank_48_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -828,7 +827,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -844,13 +843,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Floor tile finish', - previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_basecolor.webp', + previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tile_quarry/tile_quarry_basecolor.webp', - aoMap: '/material/flooring/tile_quarry/tile_quarry_ambientocclusion.webp', - normalMap: '/material/flooring/tile_quarry/tile_quarry_normal.webp', - roughnessMap: '/material/flooring/tile_quarry/tile_quarry_roughness.webp', + albedoMap: '/material/flooring/tile_quarry/tile_quarry_basecolor_512.ktx2', + aoMap: '/material/flooring/tile_quarry/tile_quarry_ao_512.ktx2', + normalMap: '/material/flooring/tile_quarry/tile_quarry_normal_512.ktx2', + roughnessMap: '/material/flooring/tile_quarry/tile_quarry_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -866,7 +865,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -882,14 +881,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', - previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg', - aoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_ambientocclusion.jpg', - metalnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic.jpg', - normalMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_normal.jpg', - roughnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness.jpg', + albedoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor_512.ktx2', + aoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_ao_512.ktx2', + metalnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic_512.ktx2', + normalMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_normal_512.ktx2', + roughnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -905,7 +904,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -921,14 +920,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', - previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg', - aoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_ambientocclusion.jpg', - metalnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_metallic.jpg', - normalMap: '/material/flooring/brick_wall_aged/brick_wall_aged_normal.jpg', - roughnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_roughness.jpg', + albedoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor_512.ktx2', + aoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_ao_512.ktx2', + metalnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_metallic_512.ktx2', + normalMap: '/material/flooring/brick_wall_aged/brick_wall_aged_normal_512.ktx2', + roughnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -944,7 +943,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -960,14 +959,15 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'brick', surfaces: ['wall', 'floor', 'outdoor'], description: 'Brick finish', - previewThumbnailUrl: - '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/brick_wall_weathered/brick_wall_weathered_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg', - aoMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_ambientocclusion.jpg', - normalMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_normal.jpg', - roughnessMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness.jpg', + albedoMap: + '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor_512.ktx2', + aoMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_ao_512.ktx2', + normalMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_normal_512.ktx2', + roughnessMap: + '/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -983,7 +983,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -999,12 +999,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'metal', surfaces: ['wall', 'furniture'], description: 'Panel finish', - previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', - aoMap: '/material/flooring/garage_panel/garage_panel_ao.jpg', - normalMap: '/material/flooring/garage_panel/garage_panel_normal.jpg', + albedoMap: '/material/flooring/garage_panel/garage_panel_basecolor_512.ktx2', + aoMap: '/material/flooring/garage_panel/garage_panel_ao_512.ktx2', + normalMap: '/material/flooring/garage_panel/garage_panel_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1020,7 +1020,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1036,12 +1036,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'stone', surfaces: ['floor', 'wall'], description: 'Stone flooring finish', - previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', - aoMap: '/material/flooring/green_labradorite/green_labradorite_ao.jpg', - normalMap: '/material/flooring/green_labradorite/green_labradorite_normal.jpg', + albedoMap: '/material/flooring/green_labradorite/green_labradorite_basecolor_512.ktx2', + aoMap: '/material/flooring/green_labradorite/green_labradorite_ao_512.ktx2', + normalMap: '/material/flooring/green_labradorite/green_labradorite_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1057,7 +1057,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1073,14 +1073,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'ground', surfaces: ['outdoor', 'floor'], description: 'Ground surface finish', - previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/ground_earth/ground_earth_basecolor.jpg', - aoMap: '/material/flooring/ground_earth/ground_earth_ambientocclusion.jpg', - metalnessMap: '/material/flooring/ground_earth/ground_earth_metallic.jpg', - normalMap: '/material/flooring/ground_earth/ground_earth_normal.jpg', - roughnessMap: '/material/flooring/ground_earth/ground_earth_roughness.jpg', + albedoMap: '/material/flooring/ground_earth/ground_earth_basecolor_512.ktx2', + aoMap: '/material/flooring/ground_earth/ground_earth_ao_512.ktx2', + metalnessMap: '/material/flooring/ground_earth/ground_earth_metallic_512.ktx2', + normalMap: '/material/flooring/ground_earth/ground_earth_normal_512.ktx2', + roughnessMap: '/material/flooring/ground_earth/ground_earth_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1096,7 +1096,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1112,12 +1112,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor', 'outdoor'], description: 'Pool tile finish', - previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', - aoMap: '/material/flooring/pool_tiles/pool_tiles_ao.jpg', - normalMap: '/material/flooring/pool_tiles/pool_tiles_normal.jpg', + albedoMap: '/material/flooring/pool_tiles/pool_tiles_basecolor_512.ktx2', + aoMap: '/material/flooring/pool_tiles/pool_tiles_ao_512.ktx2', + normalMap: '/material/flooring/pool_tiles/pool_tiles_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1133,7 +1133,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1149,12 +1149,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Tile flooring finish', - previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', - aoMap: '/material/flooring/tiles_checker/tiles_checker_ao.jpg', - normalMap: '/material/flooring/tiles_checker/tiles_checker_normal.jpg', + albedoMap: '/material/flooring/tiles_checker/tiles_checker_basecolor_512.ktx2', + aoMap: '/material/flooring/tiles_checker/tiles_checker_ao_512.ktx2', + normalMap: '/material/flooring/tiles_checker/tiles_checker_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1170,7 +1170,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1186,12 +1186,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Tile flooring finish', - previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', - aoMap: '/material/flooring/tiles_grid/tiles_grid_ao.jpg', - normalMap: '/material/flooring/tiles_grid/tiles_grid_normal.jpg', + albedoMap: '/material/flooring/tiles_grid/tiles_grid_basecolor_512.ktx2', + aoMap: '/material/flooring/tiles_grid/tiles_grid_ao_512.ktx2', + normalMap: '/material/flooring/tiles_grid/tiles_grid_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1207,7 +1207,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1223,12 +1223,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'stone', surfaces: ['wall', 'floor', 'outdoor'], description: 'Stone finish', - previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_diffuse.webp', + previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/stone_wall/stone_wall_diffuse.webp', - aoMap: '/material/flooring/stone_wall/stone_wall_ao.webp', - normalMap: '/material/flooring/stone_wall/stone_wall_normal.webp', + albedoMap: '/material/flooring/stone_wall/stone_wall_basecolor_512.ktx2', + aoMap: '/material/flooring/stone_wall/stone_wall_ao_512.ktx2', + normalMap: '/material/flooring/stone_wall/stone_wall_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1244,7 +1244,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1260,12 +1260,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', - previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', + previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', - aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp', - normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp', + albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_basecolor_512.ktx2', + aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_ao_512.ktx2', + normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic_3_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1281,7 +1281,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1297,14 +1297,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor', 'wall'], description: 'Ceramic flooring finish', - previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg', - aoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_ambientocclusion.jpg', - metalnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic.jpg', - normalMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_normal.png', - roughnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness.jpg', + albedoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor_512.ktx2', + aoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_ao_512.ktx2', + metalnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic_512.ktx2', + normalMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_normal_512.ktx2', + roughnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1320,7 +1320,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1336,13 +1336,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'stone', surfaces: ['floor', 'wall'], description: 'Terrazzo flooring finish', - previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/terrazzo/terrazzo_basecolor.jpg', - metalnessMap: '/material/flooring/terrazzo/terrazzo_metallic.jpg', - normalMap: '/material/flooring/terrazzo/terrazzo_normal.jpg', - roughnessMap: '/material/flooring/terrazzo/terrazzo_roughness.jpg', + albedoMap: '/material/flooring/terrazzo/terrazzo_basecolor_512.ktx2', + metalnessMap: '/material/flooring/terrazzo/terrazzo_metallic_512.ktx2', + normalMap: '/material/flooring/terrazzo/terrazzo_normal_512.ktx2', + roughnessMap: '/material/flooring/terrazzo/terrazzo_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1358,7 +1358,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1374,13 +1374,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'stone', surfaces: ['floor', 'wall'], description: 'Floor tile finish', - previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_basecolor.webp', + previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tile_stone/tile_stone_basecolor.webp', - aoMap: '/material/flooring/tile_stone/tile_stone_ambientocclusion.webp', - normalMap: '/material/flooring/tile_stone/tile_stone_normal.webp', - roughnessMap: '/material/flooring/tile_stone/tile_stone_roughness.webp', + albedoMap: '/material/flooring/tile_stone/tile_stone_basecolor_512.ktx2', + aoMap: '/material/flooring/tile_stone/tile_stone_ao_512.ktx2', + normalMap: '/material/flooring/tile_stone/tile_stone_normal_512.ktx2', + roughnessMap: '/material/flooring/tile_stone/tile_stone_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1396,7 +1396,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1412,13 +1412,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Floor tile finish', - previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp', + previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp', - aoMap: '/material/flooring/tile_terracotta/tile_terracotta_ambientocclusion.webp', - normalMap: '/material/flooring/tile_terracotta/tile_terracotta_normal.webp', - roughnessMap: '/material/flooring/tile_terracotta/tile_terracotta_roughness.webp', + albedoMap: '/material/flooring/tile_terracotta/tile_terracotta_basecolor_512.ktx2', + aoMap: '/material/flooring/tile_terracotta/tile_terracotta_ao_512.ktx2', + normalMap: '/material/flooring/tile_terracotta/tile_terracotta_normal_512.ktx2', + roughnessMap: '/material/flooring/tile_terracotta/tile_terracotta_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1434,7 +1434,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1451,12 +1451,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['floor', 'wall'], description: 'Green quartzite flooring finish', previewThumbnailUrl: - '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', + '/material/flooring/green_glass_quartzite/green_glass_quartzite_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', - aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg', - normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg', + albedoMap: + '/material/flooring/green_glass_quartzite/green_glass_quartzite_basecolor_512.ktx2', + aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao_512.ktx2', + normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1472,7 +1473,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1488,14 +1489,16 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Dark ceramic flooring finish', - previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg', - aoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ambientocclusion.jpg', - metalnessMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic.jpg', - normalMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal.jpg', - roughnessMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness.jpg', + albedoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor_512.ktx2', + aoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ao_512.ktx2', + metalnessMap: + '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic_512.ktx2', + normalMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal_512.ktx2', + roughnessMap: + '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1511,7 +1514,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1527,15 +1530,17 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Light ceramic flooring finish', - previewThumbnailUrl: - '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg', + previewThumbnailUrl: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg', - aoMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ambientocclusion.jpg', - metalnessMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic.jpg', - normalMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal.jpg', - roughnessMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness.jpg', + albedoMap: + '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor_512.ktx2', + aoMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ao_512.ktx2', + metalnessMap: + '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic_512.ktx2', + normalMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal_512.ktx2', + roughnessMap: + '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1551,7 +1556,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1567,12 +1572,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'stone', surfaces: ['floor', 'wall'], description: 'White marble flooring finish', - previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', + previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', - aoMap: '/material/flooring/statuaretto/statuaretto_ao.jpg', - normalMap: '/material/flooring/statuaretto/statuaretto_normal.jpg', + albedoMap: '/material/flooring/statuaretto/statuaretto_basecolor_512.ktx2', + aoMap: '/material/flooring/statuaretto/statuaretto_ao_512.ktx2', + normalMap: '/material/flooring/statuaretto/statuaretto_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1588,7 +1593,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1604,14 +1609,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor', 'wall'], description: 'Floor tile finish', - previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp', + previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp', - aoMap: '/material/flooring/tile_mosaic/tile_mosaic_ambientocclusion.webp', - metalnessMap: '/material/flooring/tile_mosaic/tile_mosaic_metallic.webp', - normalMap: '/material/flooring/tile_mosaic/tile_mosaic_normal.webp', - roughnessMap: '/material/flooring/tile_mosaic/tile_mosaic_roughness.webp', + albedoMap: '/material/flooring/tile_mosaic/tile_mosaic_basecolor_512.ktx2', + aoMap: '/material/flooring/tile_mosaic/tile_mosaic_ao_512.ktx2', + metalnessMap: '/material/flooring/tile_mosaic/tile_mosaic_metallic_512.ktx2', + normalMap: '/material/flooring/tile_mosaic/tile_mosaic_normal_512.ktx2', + roughnessMap: '/material/flooring/tile_mosaic/tile_mosaic_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1627,7 +1632,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1643,14 +1648,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor', 'wall'], description: 'Floor tile finish', - previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_basecolor.webp', + previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/tile_pattern/tile_pattern_basecolor.webp', - aoMap: '/material/flooring/tile_pattern/tile_pattern_ambientocclusion.webp', - metalnessMap: '/material/flooring/tile_pattern/tile_pattern_metallic.webp', - normalMap: '/material/flooring/tile_pattern/tile_pattern_normal.webp', - roughnessMap: '/material/flooring/tile_pattern/tile_pattern_roughness.webp', + albedoMap: '/material/flooring/tile_pattern/tile_pattern_basecolor_512.ktx2', + aoMap: '/material/flooring/tile_pattern/tile_pattern_ao_512.ktx2', + metalnessMap: '/material/flooring/tile_pattern/tile_pattern_metallic_512.ktx2', + normalMap: '/material/flooring/tile_pattern/tile_pattern_normal_512.ktx2', + roughnessMap: '/material/flooring/tile_pattern/tile_pattern_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1666,7 +1671,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1682,12 +1687,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'tile', surfaces: ['floor'], description: 'Wood-look ceramic flooring finish', - previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', + previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', - aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp', - normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp', + albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_basecolor_512.ktx2', + aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_ao_512.ktx2', + normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic_2_normal_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1703,7 +1708,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1719,14 +1724,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'wood', surfaces: ['floor'], description: 'Wood parquet flooring finish', - previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_basecolor.webp', + previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_thumb.webp', preset: { maps: { - albedoMap: '/material/flooring/woodparquet/woodparquet_basecolor.webp', - aoMap: '/material/flooring/woodparquet/woodparquet_ambientocclusion.webp', - metalnessMap: '/material/flooring/woodparquet/woodparquet_metallic.webp', - normalMap: '/material/flooring/woodparquet/woodparquet_normal.webp', - roughnessMap: '/material/flooring/woodparquet/woodparquet_roughness.webp', + albedoMap: '/material/flooring/woodparquet/woodparquet_basecolor_512.ktx2', + aoMap: '/material/flooring/woodparquet/woodparquet_ao_512.ktx2', + metalnessMap: '/material/flooring/woodparquet/woodparquet_metallic_512.ktx2', + normalMap: '/material/flooring/woodparquet/woodparquet_normal_512.ktx2', + roughnessMap: '/material/flooring/woodparquet/woodparquet_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1742,7 +1747,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1758,17 +1763,17 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'roofing', surfaces: ['roof'], description: 'Classic roof shingle finish', - previewThumbnailUrl: - '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp', + previewThumbnailUrl: '/material/roofing/roof_shingles_classic/roof_shingles_classic_thumb.webp', preset: { maps: { - albedoMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp', - aoMap: - '/material/roofing/roof_shingles_classic/roof_shingles_classic_ambientocclusion.webp', - metalnessMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic.webp', - normalMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_normal.webp', + albedoMap: + '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor_512.ktx2', + aoMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_ao_512.ktx2', + metalnessMap: + '/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic_512.ktx2', + normalMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_normal_512.ktx2', roughnessMap: - '/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness.webp', + '/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1784,7 +1789,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1800,14 +1805,14 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'roofing', surfaces: ['roof'], description: 'Clay roof tile finish', - previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp', + previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_thumb.webp', preset: { maps: { - albedoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp', - aoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_ambientocclusion.webp', - metalnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic.png', - normalMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_normal.webp', - roughnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness.webp', + albedoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor_512.ktx2', + aoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_ao_512.ktx2', + metalnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic_512.ktx2', + normalMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_normal_512.ktx2', + roughnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1823,7 +1828,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1839,17 +1844,17 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ category: 'roofing', surfaces: ['roof'], description: 'Terracotta roof tile finish', - previewThumbnailUrl: - '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp', + previewThumbnailUrl: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_thumb.webp', preset: { maps: { - albedoMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp', - aoMap: - '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ambientocclusion.webp', - metalnessMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic.webp', - normalMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal.webp', + albedoMap: + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor_512.ktx2', + aoMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ao_512.ktx2', + metalnessMap: + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic_512.ktx2', + normalMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal_512.ktx2', roughnessMap: - '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness.webp', + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1865,7 +1870,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, @@ -1882,18 +1887,18 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ surfaces: ['roof'], description: 'Weathered roof shingle finish', previewThumbnailUrl: - '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp', + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_thumb.webp', preset: { maps: { albedoMap: - '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp', - aoMap: - '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ambientocclusion.webp', + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor_512.ktx2', + aoMap: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ao_512.ktx2', metalnessMap: - '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic.webp', - normalMap: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal.webp', + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic_512.ktx2', + normalMap: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal_512.ktx2', roughnessMap: - '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness.webp', + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness_512.ktx2', }, mapProperties: { color: '#ffffff', @@ -1909,7 +1914,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ emissiveIntensity: 1, displacementScale: 0, transparent: false, - flipY: true, + flipY: false, bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b7278c90..898c5969 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -56,6 +56,7 @@ import { type PaintHoverInfo, resolvePaintScopeTargets, slotDisplayLabel, + type WallPaintHit, } from '../../lib/paint-scope' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { @@ -288,6 +289,29 @@ function meshSlotRoles(node: AnyNode): string[] { } const roofSelectionWorldPoint = new Vector3() +const wallPaintWorldPoint = new Vector3() + +function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined { + const wall = event.node + if (wall.type !== 'wall') return undefined + const root = getRegisteredNodeObject(wall.id) + if (!root) return undefined + + root.updateWorldMatrix(true, false) + wallPaintWorldPoint.set(...event.position) + const local = root.worldToLocal(wallPaintWorldPoint) + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + + return { + face: local.z >= 0 ? 'front' : 'back', + point: [ + wall.start[0] + local.x * cos - local.z * sin, + wall.start[1] + local.x * sin + local.z * cos, + ], + } +} function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { const roof = event.node @@ -841,6 +865,7 @@ export const SelectionManager = () => { // (Shift) re-keys the interaction → the preview re-applies for the new // spread instead of being deduped to the single-surface preview. const scope = useEditor.getState().paintScope + const wallHit = resolveWallPaintHit(event) const scopeTargets = compatible && role ? resolvePaintScopeTargets({ @@ -850,10 +875,15 @@ export const SelectionManager = () => { nodes: useScene.getState().nodes, spaces: useEditor.getState().spaces, slotRolesOf: () => slotRoles, + wallHit, }) : [] + const scopeTargetKey = scopeTargets + .map((target) => `${target.nodeId}:${target.role}`) + .sort() + .join(',') return { - key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}`, + key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}:${scopeTargetKey}`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', paintHover: diff --git a/packages/editor/src/lib/paint-scope.test.ts b/packages/editor/src/lib/paint-scope.test.ts index 7460f898..71ec4849 100644 --- a/packages/editor/src/lib/paint-scope.test.ts +++ b/packages/editor/src/lib/paint-scope.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'bun:test' -import type { AnyNode, ItemNode, SlabNode, Space } from '@pascal-app/core' +import { + type AnyNode, + detectSpacesForLevel, + type ItemNode, + type SlabNode, + type Space, + type WallNode, +} from '@pascal-app/core' import { availablePaintScopes, cyclePaintScope, @@ -7,6 +14,7 @@ import { type PaintScope, paintScopeLabel, resolvePaintScopeTargets, + type WallPaintHit, } from './paint-scope' describe('availablePaintScopes', () => { @@ -76,8 +84,22 @@ function item(id: string, assetId: string): ItemNode { function slab(id: string, polygon: Array<[number, number]>): SlabNode { return { id, type: 'slab', polygon } as unknown as SlabNode } -function wall(id: string, start: [number, number], end: [number, number]): AnyNode { - return { id, type: 'wall', start, end } as unknown as AnyNode +function wall( + id: string, + start: [number, number], + end: [number, number], + levelId = 'l1', +): WallNode { + return { + id, + type: 'wall', + parentId: levelId, + start, + end, + thickness: 0.2, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as WallNode } function roof(): AnyNode { return { id: 'r', type: 'roof' } as unknown as AnyNode @@ -99,6 +121,7 @@ function resolve(args: { nodes: AnyNode[] spaces?: Space[] slotRolesOf?: (node: AnyNode) => string[] + wallHit?: WallPaintHit }) { return resolvePaintScopeTargets({ node: args.node, @@ -107,9 +130,23 @@ function resolve(args: { nodes: asMap(args.nodes), spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])), slotRolesOf: args.slotRolesOf ?? noSlotRoles, + wallHit: args.wallHit, }) } +function adjacentRooms(levelId = 'l1') { + const walls = [ + wall('bottom-left', [0, 0], [4, 0], levelId), + wall('bottom-right', [4, 0], [8, 0], levelId), + wall('right', [8, 0], [8, 4], levelId), + wall('top-right', [8, 4], [4, 4], levelId), + wall('top-left', [4, 4], [0, 4], levelId), + wall('left', [0, 4], [0, 0], levelId), + wall('shared', [4, 0], [4, 4], levelId), + ] + return { walls, spaces: detectSpacesForLevel(levelId, walls).spaces } +} + describe('resolvePaintScopeTargets', () => { it('single always returns just the clicked surface', () => { const a = item('a', 'sofa') @@ -145,40 +182,136 @@ describe('resolvePaintScopeTargets', () => { ]) }) - it('wall room fans the same side across the walls bounding the room polygon', () => { - // A 4×4 room: each wall's endpoints are exact polygon vertices. - const w1 = wall('w1', [0, 0], [4, 0]) - const w2 = wall('w2', [4, 0], [4, 4]) - const w3 = wall('w3', [4, 4], [0, 4]) - const w4 = wall('w4', [0, 4], [0, 0]) - const wOut = wall('wOut', [10, 10], [14, 10]) // not on the room boundary - const space: Space = { - id: 's1', - levelId: 'l1', - polygon: [ - [0, 0], - [4, 0], - [4, 4], - [0, 4], - ], - wallIds: [], // always empty in practice — membership is geometric - isExterior: false, - } - const result = resolve({ - node: w1, + it('wall room selects the enclosed space on the clicked face of a shared wall', () => { + const { walls, spaces } = adjacentRooms() + const shared = walls.find((candidate) => String(candidate.id) === 'shared')! + + const leftRoom = resolve({ + node: shared, role: 'interior', scope: 'room', - nodes: [w1, w2, w3, w4, wOut], - spaces: [space], + nodes: walls, + spaces, + wallHit: { face: 'front', point: [3.9, 2] }, + }) + expect(keys(leftRoom).sort()).toEqual([ + 'bottom-left:interior', + 'left:interior', + 'shared:interior', + 'top-left:interior', + ]) + + const rightRoom = resolve({ + node: shared, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [4.1, 2] }, + }) + expect(keys(rightRoom).sort()).toEqual([ + 'bottom-right:interior', + 'right:interior', + 'shared:exterior', + 'top-right:interior', + ]) + }) + + it('wall room preserves the vertical band while mapping each boundary face side', () => { + const { walls, spaces } = adjacentRooms() + const shared = walls.find((candidate) => String(candidate.id) === 'shared')! + const result = resolve({ + node: shared, + role: 'lowerExterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [4.1, 2] }, }) expect(keys(result).sort()).toEqual([ - 'w1:interior', - 'w2:interior', - 'w3:interior', - 'w4:interior', + 'bottom-right:lowerInterior', + 'right:lowerInterior', + 'shared:lowerExterior', + 'top-right:lowerInterior', ]) }) + it('wall room maps a reversed boundary wall to its rendered side', () => { + const { walls } = adjacentRooms() + const topRight = walls.find((candidate) => String(candidate.id) === 'top-right')! + topRight.start = [4, 4] + topRight.end = [8, 4] + const spaces = detectSpacesForLevel('l1', walls).spaces + const shared = walls.find((candidate) => String(candidate.id) === 'shared')! + const result = resolve({ + node: shared, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [4.1, 2] }, + }) + + expect(keys(result)).toContain('top-right:exterior') + expect(keys(result)).not.toContain('top-right:interior') + }) + + it('wall room excludes duplicate geometry and spaces from another level', () => { + const levelA = adjacentRooms('l1') + const levelB = adjacentRooms('l2') + const levelBWalls = levelB.walls.map((candidate) => ({ + ...candidate, + id: `other-${candidate.id}`, + })) as unknown as WallNode[] + const otherSpaces = detectSpacesForLevel('l2', levelBWalls).spaces + const shared = levelA.walls.find((candidate) => String(candidate.id) === 'shared')! + const result = resolve({ + node: shared, + role: 'interior', + scope: 'room', + nodes: [...levelA.walls, ...levelBWalls], + spaces: [...levelA.spaces, ...otherSpaces], + wallHit: { face: 'front', point: [3.9, 2] }, + }) + expect(keys(result).every((key) => !key.startsWith('other-'))).toBe(true) + expect(result).toHaveLength(4) + }) + + it('wall room uses the hit subsegment when one long wall bounds adjacent bays', () => { + const long = wall('long', [0, 0], [8, 0]) + const walls = [ + long, + wall('left', [0, 0], [0, -3]), + wall('left-bottom', [0, -3], [4, -3]), + wall('divider', [4, -3], [4, 0]), + wall('right-bottom', [4, -3], [8, -3]), + wall('right', [8, -3], [8, 0]), + ] + const spaces = detectSpacesForLevel('l1', walls).spaces + + const leftBay = resolve({ + node: long, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [2, -0.1] }, + }) + const rightBay = resolve({ + node: long, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [6, -0.1] }, + }) + + expect(keys(leftBay).some((key) => key.startsWith('left-bottom:'))).toBe(true) + expect(keys(leftBay).some((key) => key.startsWith('right-bottom:'))).toBe(false) + expect(keys(rightBay).some((key) => key.startsWith('right-bottom:'))).toBe(true) + expect(keys(rightBay).some((key) => key.startsWith('left-bottom:'))).toBe(false) + }) + it('wall room with no enclosing space falls back to single', () => { const w1 = wall('w1', [0, 0], [4, 0]) expect( @@ -186,6 +319,97 @@ describe('resolvePaintScopeTargets', () => { ).toEqual(['w1:interior']) }) + it('wall room paints the connected exterior envelope from an exterior face', () => { + const walls = [ + wall('bottom', [0, 0], [4, 0]), + wall('right', [4, 0], [4, 4]), + wall('top', [4, 4], [0, 4]), + wall('left', [0, 4], [0, 0]), + ] + const spaces = detectSpacesForLevel('l1', walls).spaces + expect( + keys( + resolve({ + node: walls[0]!, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [2, -0.1] }, + }), + ).sort(), + ).toEqual(['bottom:exterior', 'left:exterior', 'right:exterior', 'top:exterior']) + }) + + it('wall room does not cross to a disconnected exterior envelope', () => { + const first = [ + wall('a-bottom', [0, 0], [4, 0]), + wall('a-right', [4, 0], [4, 4]), + wall('a-top', [4, 4], [0, 4]), + wall('a-left', [0, 4], [0, 0]), + ] + const second = [ + wall('b-bottom', [10, 0], [14, 0]), + wall('b-right', [14, 0], [14, 4]), + wall('b-top', [14, 4], [10, 4]), + wall('b-left', [10, 4], [10, 0]), + ] + const walls = [...first, ...second] + const spaces = detectSpacesForLevel('l1', walls).spaces + const result = resolve({ + node: first[0]!, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [2, -0.1] }, + }) + + expect(result).toHaveLength(4) + expect(keys(result).every((key) => key.startsWith('a-'))).toBe(true) + }) + + it('wall room excludes shared interior walls from the exterior envelope', () => { + const { walls, spaces } = adjacentRooms() + const bottomLeft = walls.find((candidate) => String(candidate.id) === 'bottom-left')! + const result = resolve({ + node: bottomLeft, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [2, -0.1] }, + }) + + expect(result).toHaveLength(6) + expect(keys(result).some((key) => key.startsWith('shared:'))).toBe(false) + }) + + it('wall room follows an exterior wall that is logically split across rooms', () => { + const long = wall('long', [0, 0], [8, 0]) + const walls = [ + long, + wall('right', [8, 0], [8, 4]), + wall('top-right', [8, 4], [4, 4]), + wall('top-left', [4, 4], [0, 4]), + wall('left', [0, 4], [0, 0]), + wall('divider', [4, 0], [4, 4]), + ] + const spaces = detectSpacesForLevel('l1', walls).spaces + const result = resolve({ + node: long, + role: 'exterior', + scope: 'room', + nodes: walls, + spaces, + wallHit: { face: 'back', point: [2, -0.1] }, + }) + + expect(keys(result).filter((key) => key === 'long:exterior')).toHaveLength(1) + expect(keys(result).some((key) => key.startsWith('divider:'))).toBe(false) + expect(result).toHaveLength(5) + }) + it('slab room fans across slabs whose centroid sits in the same space', () => { const inside = slab('inA', [ [1, 1], @@ -215,6 +439,7 @@ describe('resolvePaintScopeTargets', () => { [0, 10], ], wallIds: [], + boundaryFaces: [], isExterior: false, } const result = resolve({ diff --git a/packages/editor/src/lib/paint-scope.ts b/packages/editor/src/lib/paint-scope.ts index 7a42f0fc..7feacf03 100644 --- a/packages/editor/src/lib/paint-scope.ts +++ b/packages/editor/src/lib/paint-scope.ts @@ -6,7 +6,7 @@ import { type MaterialSchema, nodeRegistry, pointInPolygon2D, - pointOnSegment, + resolveLevelId, type SceneMaterial, type SceneMaterialId, type SlabNode, @@ -104,47 +104,185 @@ export function slotDisplayLabel(node: AnyNode, role: string): string { type SlotsNode = AnyNode & { slots?: Record } -// Room polygons are built from wall *centerline* endpoints (see -// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices — -// a small tolerance only absorbs float round-trips. `Space.wallIds` is always -// empty, so room membership is resolved geometrically here instead. -const WALL_ON_BOUNDARY_TOLERANCE = 0.05 - -function pointOnPolygonBoundary( - point: readonly [number, number], - polygon: ReadonlyArray, - tolerance: number, -): boolean { - for (let i = 0; i < polygon.length; i += 1) { - const a = polygon[i] - const b = polygon[(i + 1) % polygon.length] - if ( - a && - b && - pointOnSegment( - point as [number, number], - a as [number, number], - b as [number, number], - tolerance, - ) - ) { - return true - } - } - return false +export type WallPaintHit = { + face: 'front' | 'back' + point: [number, number] } -// A wall bounds a room when both its endpoints lie on the room polygon's -// boundary (a shared wall lies on two rooms' boundaries; a wall radiating out of -// a corner has only one endpoint on it and is correctly excluded). -function wallBoundsRoom( - wall: WallNode, - polygon: ReadonlyArray, -): boolean { - return ( - pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) && - pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE) +type WallBoundaryFace = Space['boundaryFaces'][number] + +function distanceToSegment( + point: readonly [number, number], + start: readonly [number, number], + end: readonly [number, number], +): number { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1]) + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), ) + return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t)) +} + +function distanceToPolyline( + point: readonly [number, number], + points: ReadonlyArray, +): number { + let distance = Number.POSITIVE_INFINITY + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index] + const end = points[index + 1] + if (!(start && end)) continue + distance = Math.min(distance, distanceToSegment(point, start, end)) + } + return distance +} + +function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null { + const semantic = face === 'front' ? wall.frontSide : wall.backSide + const fallback = face === 'front' ? 'interior' : 'exterior' + const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback + + if (role === 'interior' || role === 'exterior') return side + if (role.endsWith('Interior')) + return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}` + if (role.endsWith('Exterior')) + return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}` + return null +} + +function resolveWallPaintSpace(args: { + wall: WallNode + wallHit: WallPaintHit + nodes: Record + spaces: Record +}): Space | null { + const { wall, wallHit, nodes, spaces } = args + const levelId = wall.parentId ?? resolveLevelId(wall, nodes) + const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08 + let best: { space: Space; distance: number } | null = null + + for (const space of Object.values(spaces)) { + if (space.levelId !== levelId) continue + for (const boundary of space.boundaryFaces) { + if (boundary.wallId !== wall.id || boundary.face !== wallHit.face) continue + const distance = distanceToPolyline(wallHit.point, boundary.points) + if (distance > tolerance || (best && distance >= best.distance)) continue + best = { space, distance } + } + } + + return best?.space ?? null +} + +function boundaryPointKey(point: readonly [number, number]): string { + return `${point[0].toFixed(3)},${point[1].toFixed(3)}` +} + +function boundarySegmentKey(boundary: WallBoundaryFace): string { + const forward = boundary.points.map(boundaryPointKey).join('|') + const reverse = [...boundary.points].reverse().map(boundaryPointKey).join('|') + return `${boundary.wallId}:${forward < reverse ? forward : reverse}` +} + +function oppositeWallFace(face: 'front' | 'back'): 'front' | 'back' { + return face === 'front' ? 'back' : 'front' +} + +function connectedExteriorBoundaries(args: { + wall: WallNode + wallHit: WallPaintHit + levelId: string + spaces: Record +}): WallBoundaryFace[] { + const { wall, wallHit, levelId, spaces } = args + const occurrences = new Map() + + for (const space of Object.values(spaces)) { + if (space.levelId !== levelId) continue + for (const boundary of space.boundaryFaces) { + const key = boundarySegmentKey(boundary) + const entries = occurrences.get(key) ?? [] + entries.push(boundary) + occurrences.set(key, entries) + } + } + + const exterior = [...occurrences.values()].flatMap((entries) => { + const boundary = entries.length === 1 ? entries[0] : undefined + if (!boundary) return [] + return [{ ...boundary, face: oppositeWallFace(boundary.face) }] + }) + const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08 + const seed = exterior + .filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face) + .map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) })) + .filter((candidate) => candidate.distance <= tolerance) + .sort((a, b) => a.distance - b.distance)[0]?.boundary + if (!seed) return [] + + const boundariesByEndpoint = new Map() + for (const boundary of exterior) { + const first = boundary.points[0] + const last = boundary.points[boundary.points.length - 1] + for (const point of [first, last]) { + if (!point) continue + const key = boundaryPointKey(point) + const entries = boundariesByEndpoint.get(key) ?? [] + entries.push(boundary) + boundariesByEndpoint.set(key, entries) + } + } + + const connected: WallBoundaryFace[] = [] + const visited = new Set() + const queue = [seed] + while (queue.length > 0) { + const boundary = queue.shift() + if (!boundary) continue + const key = `${boundarySegmentKey(boundary)}:${boundary.face}` + if (visited.has(key)) continue + visited.add(key) + connected.push(boundary) + + const first = boundary.points[0] + const last = boundary.points[boundary.points.length - 1] + for (const point of [first, last]) { + if (!point) continue + for (const neighbour of boundariesByEndpoint.get(boundaryPointKey(point)) ?? []) { + queue.push(neighbour) + } + } + } + + return connected +} + +function wallTargetsForBoundaries(args: { + boundaries: WallBoundaryFace[] + role: string + levelId: string + nodes: Record +}): Array<{ nodeId: AnyNodeId; role: string }> { + const { boundaries, role, levelId, nodes } = args + const targets = new Map() + for (const boundary of boundaries) { + const targetWall = nodes[boundary.wallId] + if ( + targetWall?.type !== 'wall' || + (targetWall.parentId ?? resolveLevelId(targetWall, nodes)) !== levelId + ) { + continue + } + const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face) + if (!targetRole) continue + const key = `${targetWall.id}:${targetRole}` + targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole }) + } + return [...targets.values()] } function polygonCentroid( @@ -178,8 +316,9 @@ export function resolvePaintScopeTargets(args: { nodes: Record spaces: Record slotRolesOf: (node: AnyNode) => string[] + wallHit?: WallPaintHit }): Array<{ nodeId: AnyNodeId; role: string }> { - const { node, role, scope, nodes, spaces, slotRolesOf } = args + const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args const single = [{ nodeId: node.id as AnyNodeId, role }] if (scope === 'single') return single @@ -202,11 +341,15 @@ export function resolvePaintScopeTargets(args: { if (node.type === 'wall' && scope === 'room') { const wall = node as WallNode - const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon)) - if (!space) return single - return Object.values(nodes) - .filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon)) - .map((other) => ({ nodeId: other.id as AnyNodeId, role })) + if (!wallHit) return single + const levelId = wall.parentId ?? resolveLevelId(wall, nodes) + if (!levelId) return single + const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces }) + const boundaries = space + ? space.boundaryFaces + : connectedExteriorBoundaries({ wall, wallHit, levelId, spaces }) + const targets = wallTargetsForBoundaries({ boundaries, role, levelId, nodes }) + return targets.length > 0 ? targets : single } if (node.type === 'slab' && scope === 'room') { diff --git a/packages/nodes/src/shared/polygon-vertex-affordance.ts b/packages/nodes/src/shared/polygon-vertex-affordance.ts index 73c851d8..3d4c72db 100644 --- a/packages/nodes/src/shared/polygon-vertex-affordance.ts +++ b/packages/nodes/src/shared/polygon-vertex-affordance.ts @@ -71,6 +71,8 @@ export type PolygonEdgeSnapContext = } type PolygonAffordanceOptions = { + /** Data committed only when the outer boundary (not a hole) is edited. */ + boundaryCommitData?: Partial resolvePlanPoint?: (context: PolygonAffordanceSnapContext) => WallPlanPoint /** * `move-edge` only: absolute edge snap. The point-based resolver runs @@ -107,9 +109,10 @@ function buildRingPatch( node: PolygonShape, holeIndex: number | undefined, nextRing: ReadonlyArray<[number, number]>, + boundaryCommitData?: object, ): unknown { if (holeIndex === undefined) { - return { polygon: nextRing } + return { ...boundaryCommitData, polygon: nextRing } } const nextHoles = (node.holes ?? []).map((hole, i) => i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]), @@ -163,7 +166,7 @@ export function createPolygonVertexAffordance i === vertexIndex ? [snapped[0], snapped[1]] : p, ) - const patch = buildRingPatch(node, holeIndex, nextRing) + const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData) useScene .getState() .updateNodes([{ id: node.id, data: patch as Partial as never }]) @@ -225,7 +228,7 @@ export function createPolygonAddVertexAffordance as never }]) @@ -251,7 +254,7 @@ export function createPolygonAddVertexAffordance i === newVertexIndex ? [snapped[0], snapped[1]] : p, ) - const patch = buildRingPatch(node, holeIndex, nextRing) + const patch = buildRingPatch(node, holeIndex, nextRing, options?.boundaryCommitData) useScene .getState() .updateNodes([{ id: node.id, data: patch as Partial as never }]) @@ -385,7 +388,7 @@ export function createPolygonMoveEdgeAffordance as never }]) diff --git a/packages/nodes/src/shared/roof-wall-opening-cut.ts b/packages/nodes/src/shared/roof-wall-opening-cut.ts index d8af5cb9..ae2b5b8d 100644 --- a/packages/nodes/src/shared/roof-wall-opening-cut.ts +++ b/packages/nodes/src/shared/roof-wall-opening-cut.ts @@ -1,6 +1,6 @@ import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core' import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core' -import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer' +import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding } from '@pascal-app/viewer' import * as THREE from 'three' /** @@ -31,7 +31,7 @@ export function buildRoofWallOpeningCut( // Only a flat bottom chord may extend; a rounded bottom is never // coplanar and shifting it would distort the profile. const bottom = node.position[1] - node.height / 2 - const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0 + const bottomPad = getOpeningCutoutBottomPadding(node, bottom) const center = roofFacePointToSegment(hostSegment, node.roofFace, [ node.position[0], diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts new file mode 100644 index 00000000..92f28a7d --- /dev/null +++ b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, SlabNode, WallNode } from '@pascal-app/core' +import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' + +describe('getRecessedSlabGroundHoles', () => { + test('uses the rendered wall-face footprint instead of the stored centerline polygon', () => { + const parentId = 'level_ground-holes' + const slab = SlabNode.parse({ + id: 'slab_ground-holes', + parentId, + elevation: -0.15, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const walls = [ + WallNode.parse({ id: 'wall_ground-holes-a', parentId, start: [0, 0], end: [2, 0] }), + WallNode.parse({ id: 'wall_ground-holes-b', parentId, start: [2, 0], end: [2, 2] }), + WallNode.parse({ id: 'wall_ground-holes-c', parentId, start: [2, 2], end: [0, 2] }), + WallNode.parse({ id: 'wall_ground-holes-d', parentId, start: [0, 2], end: [0, 0] }), + ] + const nodes = Object.fromEntries([slab, ...walls].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + + const [hole] = getRecessedSlabGroundHoles(nodes) + const xs = hole!.map(([x]) => x) + const zs = hole!.map(([, z]) => z) + + expect(Math.min(...xs)).toBeCloseTo(-0.05) + expect(Math.max(...xs)).toBeCloseTo(2.05) + expect(Math.min(...zs)).toBeCloseTo(-0.05) + expect(Math.max(...zs)).toBeCloseTo(2.05) + }) + + test('excludes non-recessed slabs', () => { + const slab = SlabNode.parse({ + id: 'slab_ground-holes-raised', + elevation: 0.15, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + + expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) + }) +}) diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.ts b/packages/nodes/src/site/recessed-slab-ground-holes.ts new file mode 100644 index 00000000..e14a38f5 --- /dev/null +++ b/packages/nodes/src/site/recessed-slab-ground-holes.ts @@ -0,0 +1,57 @@ +import { + type AnyNode, + getRenderableSlabPolygon, + type SlabNode, + type SlabPolygonContext, + type WallNode, +} from '@pascal-app/core' + +export function getRecessedSlabGroundHoles( + nodes: Record, +): Array> { + const nodeList = Object.values(nodes) + const levelIndexById = new Map() + const wallsByLevel = new Map() + const slabsByLevel = new Map() + let lowestLevelIndex = Number.POSITIVE_INFINITY + + const pushByLevel = (map: Map, levelId: string | null, node: T) => { + const entries = map.get(levelId) + if (entries) entries.push(node) + else map.set(levelId, [node]) + } + + for (const node of nodeList) { + if (node.type === 'level') { + levelIndexById.set(node.id, node.level) + lowestLevelIndex = Math.min(lowestLevelIndex, node.level) + continue + } + + const levelId = node.parentId ?? null + if (node.type === 'wall') pushByLevel(wallsByLevel, levelId, node) + else if (node.type === 'slab') pushByLevel(slabsByLevel, levelId, node) + } + + return nodeList + .filter( + (node): node is SlabNode => + node.type === 'slab' && + node.visible && + node.polygon.length >= 3 && + (node.elevation ?? 0.05) < 0, + ) + .filter((slab) => { + if (!Number.isFinite(lowestLevelIndex)) return true + const parentLevel = slab.parentId ? levelIndexById.get(slab.parentId) : undefined + return parentLevel === lowestLevelIndex + }) + .map((slab) => { + const levelId = slab.parentId ?? null + const context: SlabPolygonContext = { + walls: wallsByLevel.get(levelId) ?? [], + siblingSlabs: (slabsByLevel.get(levelId) ?? []).filter((sibling) => sibling.id !== slab.id), + } + return getRenderableSlabPolygon(slab, context) + }) +} diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 21b30048..023f9533 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -3,7 +3,6 @@ import { type AnyNodeId, type SiteNode, - type SlabNode, useLiveNodeOverrides, useRegistry, useScene, @@ -21,7 +20,6 @@ import { import { useEffect, useMemo, useRef } from 'react' import { BufferGeometry, - CircleGeometry, Float32BufferAttribute, type Group, Path, @@ -30,6 +28,7 @@ import { } from 'three' import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' +import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' const Y_OFFSET = 0.01 @@ -66,6 +65,45 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom type S = ReturnType +function polygonsMatch( + a: Array>, + b: Array>, +): boolean { + return ( + a.length === b.length && + a.every( + (polygon, polygonIndex) => + polygon.length === b[polygonIndex]?.length && + polygon.every( + (point, pointIndex) => + point[0] === b[polygonIndex]?.[pointIndex]?.[0] && + point[1] === b[polygonIndex]?.[pointIndex]?.[1], + ), + ) + ) +} + +function addSlabHoles( + shape: Shape, + slabPolygons: Array>, + originX = 0, + originZ = 0, +) { + const localPolygons = slabPolygons.map((polygon) => + polygon.map(([x, z]): [number, number] => [x - originX, -(z - originZ)]), + ) + for (const ring of unionPolygons(localPolygons)) { + if (ring.length < 3) continue + const hole = new Path() + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let index = 1; index < ring.length; index += 1) { + hole.lineTo(ring[index]![0], ring[index]![1]) + } + hole.closePath() + shape.holes.push(hole) + } +} + export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) @@ -164,45 +202,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return material }, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds]) - const horizonGeometry = useMemo(() => { - if (!fadeBounds) return null - return new CircleGeometry(Math.max(fadeBounds.radius * 8, 400), 64) - }, [fadeBounds]) - useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) - - // Cache slab polygon references to keep the selector stable across unrelated store updates + // Cache computed polygons to keep the selector stable across unrelated store updates. const slabPolygonsCache = useRef<[number, number][][]>([]) const slabPolygons = useScene((state: S) => { - const nodeList = Object.values(state.nodes) - - const levelIndexById = new Map() - let lowestLevelIndex = Number.POSITIVE_INFINITY - nodeList.forEach((n) => { - if (n.type !== 'level') return - levelIndexById.set(n.id, n.level) - lowestLevelIndex = Math.min(lowestLevelIndex, n.level) - }) - - const next = nodeList - .filter( - (n): n is SlabNode => - n.type === 'slab' && - n.visible && - n.polygon.length >= 3 && - // Only recessed slabs should punch through the site ground. - // Positive slabs are real floor geometry and should not create a - // ghost footprint in the background ground fill. - (n.elevation ?? 0.05) < 0, - ) - .filter((n) => { - if (!Number.isFinite(lowestLevelIndex)) return true - const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined - return parentLevel === lowestLevelIndex - }) - .map((n) => n.polygon as [number, number][]) + const next = getRecessedSlabGroundHoles(state.nodes) const prev = slabPolygonsCache.current - if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev + if (polygonsMatch(next, prev)) return prev slabPolygonsCache.current = next return next }) @@ -217,20 +223,27 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) shape.closePath() - if (slabPolygons.length > 0) { - for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) { - if (ring.length < 3) continue - const hole = new Path() - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) - hole.closePath() - shape.holes.push(hole) - } - } + addSlabHoles(shape, slabPolygons) return shape }, [polygonPoints, slabPolygons]) + const horizonGeometry = useMemo(() => { + if (!fadeBounds) return null + const radius = Math.max(fadeBounds.radius * 8, 400) + const shape = new Shape() + const segments = 64 + shape.moveTo(radius, 0) + for (let index = 1; index <= segments; index += 1) { + const angle = (index / segments) * Math.PI * 2 + shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius) + } + shape.closePath() + addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz) + return new ShapeGeometry(shape) + }, [fadeBounds, slabPolygons]) + useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) + // Create boundary line geometry const lineGeometry = useMemo(() => { if (!polygonPoints || polygonPoints.length < 2) return null diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index 710400d0..e66434f0 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { pointInPolygon2D, SlabNode } from '@pascal-app/core' import { slabDefinition } from '../definition' -function getHeightHandlePosition(slab: SlabNode) { +function getHeightHandle(slab: SlabNode) { const handles = typeof slabDefinition.handles === 'function' ? slabDefinition.handles(slab) @@ -13,7 +13,11 @@ function getHeightHandlePosition(slab: SlabNode) { if (!(heightHandle && heightHandle.kind === 'linear-resize')) { throw new Error('Missing slab height handle') } - return heightHandle.placement.position(slab, {} as never) + return heightHandle +} + +function getHeightHandlePosition(slab: SlabNode) { + return getHeightHandle(slab).placement.position(slab, {} as never) } describe('slabDefinition handles', () => { @@ -40,4 +44,20 @@ describe('slabDefinition handles', () => { expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true) expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) }) + + test('allows the elevation arrow to cross zero into a recessed slab', () => { + const slab = SlabNode.parse({ + elevation: 0.05, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const heightHandle = getHeightHandle(slab) + + expect(heightHandle.min).toBe(-1) + expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 }) + }) }) diff --git a/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts b/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts index 97302809..19c5d0ed 100644 --- a/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts +++ b/packages/nodes/src/slab/__tests__/move-edge-affordance.test.ts @@ -25,7 +25,7 @@ const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: fal * Level + one wall (centerline z=0, t=0.1) + one manual slab whose bottom * edge starts 0.5m away from the wall. */ -function seedScene() { +function seedScene(autoFromWalls = false) { const levelId = 'level_slab-move-edge' as AnyNodeId const wall = WallNode.parse({ start: [0, 0], @@ -40,7 +40,7 @@ function seedScene() { [4, 3], [0, 3], ], - autoFromWalls: false, + autoFromWalls, parentId: levelId, }) const level = { @@ -115,4 +115,22 @@ describe('slabMoveEdgeAffordance', () => { expect(updated.polygon[0]![1]).toBeCloseTo(1.5, 5) expect(updated.polygon[1]![1]).toBeCloseTo(1.5, 5) }) + + test('editing an auto-generated outer boundary makes the slab manual', () => { + const { slab } = seedScene(true) + const nodes = useScene.getState().nodes + + const session = slabMoveEdgeAffordance.start({ + node: nodes[slab.id] as SlabNodeType, + payload: { edgeIndex: 0 }, + nodes, + initialPlanPoint: [2, 0.5], + gridSnapStep: 0.1, + } as never) + + session.apply({ planPoint: [2, 1.5], modifiers: MODIFIERS }) + + const updated = useScene.getState().nodes[slab.id] as SlabNodeType + expect(updated.autoFromWalls).toBe(false) + }) }) diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index 78108b66..8aebd54a 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -45,7 +45,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { clearSlabSnapFeedback() - updateNode(slabId, { polygon: newPolygon }) + updateNode(slabId, { polygon: newPolygon, autoFromWalls: false }) setSelection({ selectedIds: [slabId] }) }, [slabId, updateNode, setSelection], diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 1a18bb33..3ae6ddac 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -18,7 +18,7 @@ import { SlabNode } from './schema' import { slabSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 -const MIN_SLAB_ELEVATION = 0.02 +const MIN_SLAB_ELEVATION = -1 function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] { if (polygon.length === 0) return [0, 0] @@ -89,10 +89,10 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { } // Slab height arrow — vertical chevron on solid slab surface near the -// polygon center. Drags elevation (the extrusion thickness) with -// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows -// the pointer. Same registry-handle pipeline as the column height arrow, -// so live override + commit-on-release come for free. +// polygon center. Drags elevation through zero: positive values extrude +// upward from ground while negative values create a recessed floor whose +// depth follows the pointer. Same registry-handle pipeline as the column +// height arrow, so live override + commit-on-release come for free. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 7f43b699..26b7fa1f 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -23,6 +23,7 @@ import { * Simpler model, no UX downside in practice. */ const slabSnapOptions = { + boundaryCommitData: { autoFromWalls: false }, resolvePlanPoint({ node, nodes, diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 46480871..59602861 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -235,6 +235,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { useScene.getState().updateNode(slabId, { polygon: translatePolygon(originalPolygon, deltaX, deltaZ), holes: originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)), + autoFromWalls: false, }) useScene.getState().markDirty(slabId as AnyNodeId) } diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index e1d148e6..292e0073 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -15,7 +15,7 @@ export const slabParametrics: ParametricDescriptor = { groups: [ { label: 'Elevation', - fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }], + fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }], }, ], customPanel: () => import('./panel'), diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 03d3ec34..fef95cb3 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -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 ? ( diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 85a84da6..b0752610 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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' diff --git a/packages/viewer/src/systems/door/door-floor-alignment.test.ts b/packages/viewer/src/systems/door/door-floor-alignment.test.ts new file mode 100644 index 00000000..eeb90af0 --- /dev/null +++ b/packages/viewer/src/systems/door/door-floor-alignment.test.ts @@ -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) + }) +}) diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 28a0e403..3a2d96cb 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -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, ) } diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 60bd6984..f1d220e2 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -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 } /** diff --git a/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts b/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts index dc25c38b..1af472b3 100644 --- a/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts +++ b/packages/viewer/src/systems/wall/opening-cutout-geometry.test.ts @@ -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) + }) +}) diff --git a/packages/viewer/src/systems/wall/opening-cutout-geometry.ts b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts index f6bf89d9..4ad58e9b 100644 --- a/packages/viewer/src/systems/wall/opening-cutout-geometry.ts +++ b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts @@ -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, diff --git a/packages/viewer/src/systems/wall/wall-opening-cutout.test.ts b/packages/viewer/src/systems/wall/wall-opening-cutout.test.ts new file mode 100644 index 00000000..f7ed08e9 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-opening-cutout.test.ts @@ -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() + } + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index fe9b026a..20b0eaa8 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -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,