From 2e3fabcbb7be229ea4a6a1d2e72f48563d20f290 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 14:42:02 +0100 Subject: [PATCH 01/26] Add optional box UV projection for CSG subtraction --- api/csg.js | 80 ++++++++++++++++++++++++++++++++++++++--- tests/materials.test.js | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/api/csg.js b/api/csg.js index c230cc932..1de006a19 100644 --- a/api/csg.js +++ b/api/csg.js @@ -173,6 +173,56 @@ function recenterMeshLocalOrigin(mesh) { mesh.refreshBoundingInfo?.(); } +function applyBoxProjectionUV(mesh, uvScale = 1) { + if (!mesh?.getVerticesData || !mesh?.setVerticesData) return; + + const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); + if (!positions || positions.length === 0) return; + + let normals = mesh.getVerticesData(flock.BABYLON.VertexBuffer.NormalKind); + if (!normals || normals.length !== positions.length) { + const indices = mesh.getIndices ? mesh.getIndices() : null; + if (!indices || indices.length === 0) return; + normals = []; + flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); + } + + const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; + const uvs = new Float32Array((positions.length / 3) * 2); + + for (let i = 0, uvIndex = 0; i < positions.length; i += 3, uvIndex += 2) { + const px = positions[i]; + const py = positions[i + 1]; + const pz = positions[i + 2]; + const nx = normals[i]; + const ny = normals[i + 1]; + const nz = normals[i + 2]; + + const ax = Math.abs(nx); + const ay = Math.abs(ny); + const az = Math.abs(nz); + + let u; + let v; + + if (ax >= ay && ax >= az) { + u = nx >= 0 ? -pz : pz; + v = py; + } else if (ay >= ax && ay >= az) { + u = px; + v = ny >= 0 ? -pz : pz; + } else { + u = nz >= 0 ? px : -px; + v = py; + } + + uvs[uvIndex] = u * scale; + uvs[uvIndex + 1] = v * scale; + } + + mesh.setVerticesData(flock.BABYLON.VertexBuffer.UVKind, uvs, true); +} + function normalizeMeshAttributesForMerge(meshes) { if (!meshes || meshes.length < 2) return; @@ -459,7 +509,7 @@ export const flockCSG = { }); }, - subtractMeshesMerge(modelId, baseMeshName, meshNames) { + subtractMeshesMerge(modelId, baseMeshName, meshNames, options = {}) { const { modelId: resolvedModelId, blockKey } = resolveCsgModelIdentity(modelId); modelId = resolvedModelId; @@ -619,6 +669,9 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); + if (options.uvProjection === "box") { + applyBoxProjectionUV(resultMesh, options.uvScale); + } flock.applyResultMeshProperties( resultMesh, actualBase, @@ -635,7 +688,7 @@ export const flockCSG = { }); }); }, - subtractMeshesIndividual(modelId, baseMeshName, meshNames) { + subtractMeshesIndividual(modelId, baseMeshName, meshNames, options = {}) { const { modelId: resolvedModelId, blockKey } = resolveCsgModelIdentity(modelId); modelId = resolvedModelId; @@ -752,6 +805,9 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); + if (options.uvProjection === "box") { + applyBoxProjectionUV(resultMesh, options.uvScale); + } flock.applyResultMeshProperties( resultMesh, actualBase, @@ -768,11 +824,25 @@ export const flockCSG = { }); }); }, - subtractMeshes(modelId, baseMeshName, meshNames, approach = "merge") { + subtractMeshes(modelId, baseMeshName, meshNames, optionsOrApproach = "merge") { + const options = + optionsOrApproach && typeof optionsOrApproach === "object" + ? optionsOrApproach + : {}; + const approach = + typeof optionsOrApproach === "string" + ? optionsOrApproach + : options.approach || "merge"; + if (approach === "individual") { - return this.subtractMeshesIndividual(modelId, baseMeshName, meshNames); + return this.subtractMeshesIndividual( + modelId, + baseMeshName, + meshNames, + options, + ); } else { - return this.subtractMeshesMerge(modelId, baseMeshName, meshNames); + return this.subtractMeshesMerge(modelId, baseMeshName, meshNames, options); } }, intersectMeshes(modelId, meshList) { diff --git a/tests/materials.test.js b/tests/materials.test.js index 169f255b7..4fdf76886 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -754,6 +754,73 @@ export function runMaterialsTests(flock) { }); }); }); + it("should support box-projected UVs for subtractMeshes", async function () { + await flock.createBox("uvBaseA", { + color: "#ffffff", + materialName: "test.png", + width: 2, + height: 2, + depth: 2, + position: [0, 0, 0], + }); + await flock.createBox("uvCutA", { + color: "#ffffff", + materialName: "test.png", + width: 1.2, + height: 1.2, + depth: 1.2, + position: [0, 0, 0], + }); + await flock.createBox("uvBaseB", { + color: "#ffffff", + materialName: "test.png", + width: 2, + height: 2, + depth: 2, + position: [4, 0, 0], + }); + await flock.createBox("uvCutB", { + color: "#ffffff", + materialName: "test.png", + width: 1.2, + height: 1.2, + depth: 1.2, + position: [4, 0, 0], + }); + boxIds.push( + "uvBaseA", + "uvCutA", + "uvBaseB", + "uvCutB", + "uvSubtractA", + "uvSubtractB", + ); + + await flock.subtractMeshes("uvSubtractA", "uvBaseA", ["uvCutA"], { + uvProjection: "box", + uvScale: 1, + }); + await flock.subtractMeshes("uvSubtractB", "uvBaseB", ["uvCutB"], { + uvProjection: "box", + uvScale: 3, + }); + + const meshA = flock.scene.getMeshByName("uvSubtractA"); + const meshB = flock.scene.getMeshByName("uvSubtractB"); + const uvKind = flock.BABYLON.VertexBuffer.UVKind; + const uvsA = meshA.getVerticesData(uvKind); + const uvsB = meshB.getVerticesData(uvKind); + + expect(uvsA).to.exist; + expect(uvsB).to.exist; + expect(uvsA.length).to.be.greaterThan(0); + expect(uvsB.length).to.equal(uvsA.length); + + const maxAbsA = Math.max(...Array.from(uvsA, (v) => Math.abs(v))); + const maxAbsB = Math.max(...Array.from(uvsB, (v) => Math.abs(v))); + expect(maxAbsA).to.be.greaterThan(0.01); + expect(maxAbsB).to.be.greaterThan(maxAbsA * 2.5); + }); it("should mark resultant material as internal when intersecting", async function () { await flock.createBox("box1", { color: "#9932cc", From 6e922ae22d8a83fc7ebb8b16d3315596dde41de8 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 14:54:19 +0100 Subject: [PATCH 02/26] Auto-generate UVs for subtractMeshes results when missing --- api/csg.js | 45 ++++++++++++++++++++++++++++++++--------- tests/materials.test.js | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/api/csg.js b/api/csg.js index 1de006a19..4a048becf 100644 --- a/api/csg.js +++ b/api/csg.js @@ -179,13 +179,10 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); if (!positions || positions.length === 0) return; - let normals = mesh.getVerticesData(flock.BABYLON.VertexBuffer.NormalKind); - if (!normals || normals.length !== positions.length) { - const indices = mesh.getIndices ? mesh.getIndices() : null; - if (!indices || indices.length === 0) return; - normals = []; - flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); - } + const indices = mesh.getIndices ? mesh.getIndices() : null; + if (!indices || indices.length === 0) return; + const normals = []; + flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; const uvs = new Float32Array((positions.length / 3) * 2); @@ -223,6 +220,36 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { mesh.setVerticesData(flock.BABYLON.VertexBuffer.UVKind, uvs, true); } +function hasUsableUVs(mesh) { + if (!mesh?.getVerticesData) return false; + + const uvs = mesh.getVerticesData(flock.BABYLON.VertexBuffer.UVKind); + if (!uvs || uvs.length < 4) return false; + + let minU = Infinity; + let maxU = -Infinity; + let minV = Infinity; + let maxV = -Infinity; + + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i]; + const v = uvs[i + 1]; + if (!Number.isFinite(u) || !Number.isFinite(v)) return false; + if (u < minU) minU = u; + if (u > maxU) maxU = u; + if (v < minV) minV = v; + if (v > maxV) maxV = v; + } + + return maxU - minU > 1e-5 || maxV - minV > 1e-5; +} + +function shouldApplyBoxProjection(resultMesh, options = {}) { + if (options.uvProjection === "box") return true; + if (options.uvProjection && options.uvProjection !== "auto") return false; + return !hasUsableUVs(resultMesh); +} + function normalizeMeshAttributesForMerge(meshes) { if (!meshes || meshes.length < 2) return; @@ -669,7 +696,7 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); - if (options.uvProjection === "box") { + if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } flock.applyResultMeshProperties( @@ -805,7 +832,7 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); - if (options.uvProjection === "box") { + if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } flock.applyResultMeshProperties( diff --git a/tests/materials.test.js b/tests/materials.test.js index 4fdf76886..2886b5181 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -821,6 +821,50 @@ export function runMaterialsTests(flock) { expect(maxAbsA).to.be.greaterThan(0.01); expect(maxAbsB).to.be.greaterThan(maxAbsA * 2.5); }); + it("should auto-project UVs for subtractMeshes when UVs are missing", async function () { + await flock.createBox("uvAutoBase", { + color: "#ffffff", + materialName: "test.png", + width: 2, + height: 2, + depth: 2, + position: [0, 0, 0], + }); + await flock.createBox("uvAutoCut", { + color: "#ffffff", + materialName: "test.png", + width: 1.2, + height: 1.2, + depth: 1.2, + position: [0, 0, 0], + }); + boxIds.push("uvAutoBase", "uvAutoCut", "uvAutoSubtract"); + + await flock.subtractMeshes("uvAutoSubtract", "uvAutoBase", ["uvAutoCut"]); + + const mesh = flock.scene.getMeshByName("uvAutoSubtract"); + const uvKind = flock.BABYLON.VertexBuffer.UVKind; + const uvs = mesh.getVerticesData(uvKind); + expect(uvs).to.exist; + expect(uvs.length).to.be.greaterThan(0); + + let minU = Infinity; + let maxU = -Infinity; + let minV = Infinity; + let maxV = -Infinity; + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i]; + const v = uvs[i + 1]; + expect(Number.isFinite(u)).to.equal(true); + expect(Number.isFinite(v)).to.equal(true); + if (u < minU) minU = u; + if (u > maxU) maxU = u; + if (v < minV) minV = v; + if (v > maxV) maxV = v; + } + + expect(maxU - minU > 1e-5 || maxV - minV > 1e-5).to.equal(true); + }); it("should mark resultant material as internal when intersecting", async function () { await flock.createBox("box1", { color: "#9932cc", From 1a6ad59f2fca6ae58f90b3fe165339d7b3f03edc Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 15:01:06 +0100 Subject: [PATCH 03/26] Normalize box-projected UVs to mesh bounds --- api/csg.js | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/api/csg.js b/api/csg.js index 4a048becf..caee83c16 100644 --- a/api/csg.js +++ b/api/csg.js @@ -184,6 +184,28 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { const normals = []; flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + + const sizeX = Math.max(maxX - minX, 1e-6); + const sizeY = Math.max(maxY - minY, 1e-6); + const sizeZ = Math.max(maxZ - minZ, 1e-6); + const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; const uvs = new Float32Array((positions.length / 3) * 2); @@ -203,14 +225,20 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { let v; if (ax >= ay && ax >= az) { - u = nx >= 0 ? -pz : pz; - v = py; + const uz = (pz - minZ) / sizeZ; + const vy = (py - minY) / sizeY; + u = nx >= 0 ? 1 - uz : uz; + v = vy; } else if (ay >= ax && ay >= az) { - u = px; - v = ny >= 0 ? -pz : pz; + const ux = (px - minX) / sizeX; + const vz = (pz - minZ) / sizeZ; + u = ux; + v = ny >= 0 ? 1 - vz : vz; } else { - u = nz >= 0 ? px : -px; - v = py; + const ux = (px - minX) / sizeX; + const vy = (py - minY) / sizeY; + u = nz >= 0 ? ux : 1 - ux; + v = vy; } uvs[uvIndex] = u * scale; From 9f2bf5a2f1e2fe67544d20e62abac0c762275e9c Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 15:10:11 +0100 Subject: [PATCH 04/26] Tune box UV scaling to better match source face mapping --- api/csg.js | 84 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/api/csg.js b/api/csg.js index caee83c16..64dbf2e44 100644 --- a/api/csg.js +++ b/api/csg.js @@ -184,32 +184,19 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { const normals = []; flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); - let minX = Infinity; - let minY = Infinity; - let minZ = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - let maxZ = -Infinity; - for (let i = 0; i < positions.length; i += 3) { - const x = positions[i]; - const y = positions[i + 1]; - const z = positions[i + 2]; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; - } - - const sizeX = Math.max(maxX - minX, 1e-6); - const sizeY = Math.max(maxY - minY, 1e-6); - const sizeZ = Math.max(maxZ - minZ, 1e-6); - const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; + const vertexCount = positions.length / 3; const uvs = new Float32Array((positions.length / 3) * 2); + const groups = new Uint8Array(vertexCount); + const coordU = new Float32Array(vertexCount); + const coordV = new Float32Array(vertexCount); - for (let i = 0, uvIndex = 0; i < positions.length; i += 3, uvIndex += 2) { + const groupMinU = new Float32Array(6).fill(Infinity); + const groupMaxU = new Float32Array(6).fill(-Infinity); + const groupMinV = new Float32Array(6).fill(Infinity); + const groupMaxV = new Float32Array(6).fill(-Infinity); + + for (let i = 0, vertexIndex = 0; i < positions.length; i += 3, vertexIndex++) { const px = positions[i]; const py = positions[i + 1]; const pz = positions[i + 2]; @@ -221,26 +208,49 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { const ay = Math.abs(ny); const az = Math.abs(nz); - let u; - let v; + let group; + let rawU; + let rawV; if (ax >= ay && ax >= az) { - const uz = (pz - minZ) / sizeZ; - const vy = (py - minY) / sizeY; - u = nx >= 0 ? 1 - uz : uz; - v = vy; + group = nx >= 0 ? 0 : 1; + rawU = pz; + rawV = py; } else if (ay >= ax && ay >= az) { - const ux = (px - minX) / sizeX; - const vz = (pz - minZ) / sizeZ; - u = ux; - v = ny >= 0 ? 1 - vz : vz; + group = ny >= 0 ? 2 : 3; + rawU = px; + rawV = pz; } else { - const ux = (px - minX) / sizeX; - const vy = (py - minY) / sizeY; - u = nz >= 0 ? ux : 1 - ux; - v = vy; + group = nz >= 0 ? 4 : 5; + rawU = px; + rawV = py; } + groups[vertexIndex] = group; + coordU[vertexIndex] = rawU; + coordV[vertexIndex] = rawV; + + if (rawU < groupMinU[group]) groupMinU[group] = rawU; + if (rawU > groupMaxU[group]) groupMaxU[group] = rawU; + if (rawV < groupMinV[group]) groupMinV[group] = rawV; + if (rawV > groupMaxV[group]) groupMaxV[group] = rawV; + } + + for ( + let vertexIndex = 0, uvIndex = 0; + vertexIndex < vertexCount; + vertexIndex++, uvIndex += 2 + ) { + const group = groups[vertexIndex]; + const spanU = Math.max(groupMaxU[group] - groupMinU[group], 1e-6); + const spanV = Math.max(groupMaxV[group] - groupMinV[group], 1e-6); + + let u = (coordU[vertexIndex] - groupMinU[group]) / spanU; + let v = (coordV[vertexIndex] - groupMinV[group]) / spanV; + + if (group === 0 || group === 3 || group === 5) u = 1 - u; + if (group === 2) v = 1 - v; + uvs[uvIndex] = u * scale; uvs[uvIndex + 1] = v * scale; } From 16a10ea06e50a883e0bdc2dd06553a6be4fcfd6d Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 15:30:42 +0100 Subject: [PATCH 05/26] Align CSG UV projection with standard box material mapping --- api/csg.js | 105 +++++++++++++++++------------------------------------ 1 file changed, 34 insertions(+), 71 deletions(-) diff --git a/api/csg.js b/api/csg.js index 64dbf2e44..f51bab525 100644 --- a/api/csg.js +++ b/api/csg.js @@ -174,88 +174,51 @@ function recenterMeshLocalOrigin(mesh) { } function applyBoxProjectionUV(mesh, uvScale = 1) { - if (!mesh?.getVerticesData || !mesh?.setVerticesData) return; + if (!mesh?.getVerticesData || typeof flock.setSizeBasedBoxUVs !== "function") + return; const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); if (!positions || positions.length === 0) return; const indices = mesh.getIndices ? mesh.getIndices() : null; if (!indices || indices.length === 0) return; - const normals = []; - flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); - const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; - const vertexCount = positions.length / 3; - const uvs = new Float32Array((positions.length / 3) * 2); - const groups = new Uint8Array(vertexCount); - const coordU = new Float32Array(vertexCount); - const coordV = new Float32Array(vertexCount); - - const groupMinU = new Float32Array(6).fill(Infinity); - const groupMaxU = new Float32Array(6).fill(-Infinity); - const groupMinV = new Float32Array(6).fill(Infinity); - const groupMaxV = new Float32Array(6).fill(-Infinity); - - for (let i = 0, vertexIndex = 0; i < positions.length; i += 3, vertexIndex++) { - const px = positions[i]; - const py = positions[i + 1]; - const pz = positions[i + 2]; - const nx = normals[i]; - const ny = normals[i + 1]; - const nz = normals[i + 2]; - - const ax = Math.abs(nx); - const ay = Math.abs(ny); - const az = Math.abs(nz); - - let group; - let rawU; - let rawV; - - if (ax >= ay && ax >= az) { - group = nx >= 0 ? 0 : 1; - rawU = pz; - rawV = py; - } else if (ay >= ax && ay >= az) { - group = ny >= 0 ? 2 : 3; - rawU = px; - rawV = pz; - } else { - group = nz >= 0 ? 4 : 5; - rawU = px; - rawV = py; - } - - groups[vertexIndex] = group; - coordU[vertexIndex] = rawU; - coordV[vertexIndex] = rawV; - - if (rawU < groupMinU[group]) groupMinU[group] = rawU; - if (rawU > groupMaxU[group]) groupMaxU[group] = rawU; - if (rawV < groupMinV[group]) groupMinV[group] = rawV; - if (rawV > groupMaxV[group]) groupMaxV[group] = rawV; + const normalKind = flock.BABYLON.VertexBuffer.NormalKind; + let normals = mesh.getVerticesData(normalKind); + if (!normals || normals.length !== positions.length) { + normals = []; + flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); + mesh.setVerticesData(normalKind, normals, true); } - for ( - let vertexIndex = 0, uvIndex = 0; - vertexIndex < vertexCount; - vertexIndex++, uvIndex += 2 - ) { - const group = groups[vertexIndex]; - const spanU = Math.max(groupMaxU[group] - groupMinU[group], 1e-6); - const spanV = Math.max(groupMaxV[group] - groupMinV[group], 1e-6); - - let u = (coordU[vertexIndex] - groupMinU[group]) / spanU; - let v = (coordV[vertexIndex] - groupMinV[group]) / spanV; - - if (group === 0 || group === 3 || group === 5) u = 1 - u; - if (group === 2) v = 1 - v; - - uvs[uvIndex] = u * scale; - uvs[uvIndex + 1] = v * scale; + // Keep UV behavior aligned with regular box/material workflows: + // createBox uses setSizeBasedBoxUVs(..., texturePhysicalSize=4) by default. + const scale = Number.isFinite(uvScale) && uvScale !== 0 ? uvScale : 1; + const texturePhysicalSize = 4 / scale; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; } - mesh.setVerticesData(flock.BABYLON.VertexBuffer.UVKind, uvs, true); + const width = Math.max(maxX - minX, 1e-6); + const height = Math.max(maxY - minY, 1e-6); + const depth = Math.max(maxZ - minZ, 1e-6); + + flock.setSizeBasedBoxUVs(mesh, width, height, depth, texturePhysicalSize); } function hasUsableUVs(mesh) { From 9d39ff9358dced7ad0fbbd1d619020d4a411925d Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 15:41:17 +0100 Subject: [PATCH 06/26] Stabilize CSG interior UV projection using unindexed faces --- api/csg.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/api/csg.js b/api/csg.js index f51bab525..4a542aa35 100644 --- a/api/csg.js +++ b/api/csg.js @@ -177,19 +177,35 @@ function applyBoxProjectionUV(mesh, uvScale = 1) { if (!mesh?.getVerticesData || typeof flock.setSizeBasedBoxUVs !== "function") return; + // Ensure per-face UV projection behaves predictably: + // indexed meshes share vertices across hard edges, which can blend normals + // and cause face-axis switching/stretching on interior CSG surfaces. + // Unindex first so each triangle has unique vertices for stable face mapping. + const currentIndices = mesh.getIndices ? mesh.getIndices() : null; + if ( + currentIndices && + currentIndices.length > 0 && + currentIndices.length !== + (mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind)?.length || 0) / + 3 && + typeof mesh.convertToUnIndexedMesh === "function" + ) { + mesh.convertToUnIndexedMesh(); + } + const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); if (!positions || positions.length === 0) return; const indices = mesh.getIndices ? mesh.getIndices() : null; - if (!indices || indices.length === 0) return; + const localIndices = + indices && indices.length > 0 + ? indices + : Array.from({ length: positions.length / 3 }, (_, i) => i); const normalKind = flock.BABYLON.VertexBuffer.NormalKind; - let normals = mesh.getVerticesData(normalKind); - if (!normals || normals.length !== positions.length) { - normals = []; - flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); - mesh.setVerticesData(normalKind, normals, true); - } + const normals = []; + flock.BABYLON.VertexData.ComputeNormals(positions, localIndices, normals); + mesh.setVerticesData(normalKind, normals, true); // Keep UV behavior aligned with regular box/material workflows: // createBox uses setSizeBasedBoxUVs(..., texturePhysicalSize=4) by default. From a10493b6db9c520554ec6d7140f4e11721cb2a0a Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 15:57:01 +0100 Subject: [PATCH 07/26] Skip CSG merge path when meshes contain non-finite vertices --- api/csg.js | 69 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/api/csg.js b/api/csg.js index 4a542aa35..bf7673ec0 100644 --- a/api/csg.js +++ b/api/csg.js @@ -350,6 +350,16 @@ function normalizeMeshAttributesForMerge(meshes) { }); } +function hasNonFinitePositions(mesh) { + if (!mesh?.getVerticesData) return true; + const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); + if (!positions || positions.length === 0) return true; + for (let i = 0; i < positions.length; i++) { + if (!Number.isFinite(positions[i])) return true; + } + return false; +} + function resolveCsgModelIdentity(requestedModelId) { let resolvedModelId = requestedModelId; let blockKey = requestedModelId; @@ -433,36 +443,45 @@ export const flockCSG = { const originalMaterial = referenceMesh.material; let mergedMesh = null; let csgSucceeded = false; + const csgUnsafe = meshesToMerge.some((mesh) => + hasNonFinitePositions(mesh), + ); - try { - let baseCSG = flock.BABYLON.CSG2.FromMesh(meshesToMerge[0], false); - - for (let i = 1; i < meshesToMerge.length; i++) { - let meshCSG = flock.BABYLON.CSG2.FromMesh( - meshesToMerge[i], - false, - ); - baseCSG = baseCSG.add(meshCSG); - } + if (!csgUnsafe) { + try { + let baseCSG = flock.BABYLON.CSG2.FromMesh(meshesToMerge[0], false); + + for (let i = 1; i < meshesToMerge.length; i++) { + let meshCSG = flock.BABYLON.CSG2.FromMesh( + meshesToMerge[i], + false, + ); + baseCSG = baseCSG.add(meshCSG); + } - mergedMesh = baseCSG.toMesh(modelId, meshesToMerge[0].getScene(), { - centerMesh: false, - rebuildNormals: true, - }); + mergedMesh = baseCSG.toMesh(modelId, meshesToMerge[0].getScene(), { + centerMesh: false, + rebuildNormals: true, + }); - if (mergedMesh && mergedMesh.getTotalVertices() > 0) { - csgSucceeded = true; - } else { - if (mergedMesh) mergedMesh.dispose(); - mergedMesh = null; + if (mergedMesh && mergedMesh.getTotalVertices() > 0) { + csgSucceeded = true; + } else { + if (mergedMesh) mergedMesh.dispose(); + mergedMesh = null; + } + } catch (error) { + const emptyMeshes = flock.scene.meshes.filter( + (m) => m.name === modelId && m.getTotalVertices() === 0, + ); + emptyMeshes.forEach((m) => m.dispose()); + console.warn("[mergeMeshes] CSG merge attempt failed:", error); + csgSucceeded = false; } - } catch (error) { - const emptyMeshes = flock.scene.meshes.filter( - (m) => m.name === modelId && m.getTotalVertices() === 0, + } else { + console.warn( + "[mergeMeshes] Skipping CSG merge due non-finite positions; using Mesh.MergeMeshes fallback.", ); - emptyMeshes.forEach((m) => m.dispose()); - console.warn("[mergeMeshes] CSG merge attempt failed:", error); - csgSucceeded = false; } if (!csgSucceeded) { From 573956b9d7a41edee88dd575d17650dfa0a43a00 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:01:49 +0100 Subject: [PATCH 08/26] Sanitize non-finite vertex attributes before CSG merge --- api/csg.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/api/csg.js b/api/csg.js index bf7673ec0..45c10d388 100644 --- a/api/csg.js +++ b/api/csg.js @@ -360,6 +360,47 @@ function hasNonFinitePositions(mesh) { return false; } +function arrayHasNonFiniteValues(values) { + if (!values) return false; + for (let i = 0; i < values.length; i++) { + if (!Number.isFinite(values[i])) return true; + } + return false; +} + +function sanitizeMeshVertexDataForCSG(mesh) { + if (!mesh?.getVerticesData || !mesh?.getVerticesDataKinds) return false; + + const positionKind = flock.BABYLON.VertexBuffer.PositionKind; + const normalKind = flock.BABYLON.VertexBuffer.NormalKind; + const positions = mesh.getVerticesData(positionKind); + if (!positions || positions.length === 0 || arrayHasNonFiniteValues(positions)) { + return false; + } + + const kinds = mesh.getVerticesDataKinds() || []; + const indices = mesh.getIndices ? mesh.getIndices() : null; + + kinds.forEach((kind) => { + if (kind === positionKind) return; + const values = mesh.getVerticesData(kind); + if (!arrayHasNonFiniteValues(values)) return; + + if (kind === normalKind && indices && indices.length > 0) { + const normals = []; + flock.BABYLON.VertexData.ComputeNormals(positions, indices, normals); + mesh.setVerticesData(normalKind, normals, true); + return; + } + + if (mesh.removeVerticesData) { + mesh.removeVerticesData(kind); + } + }); + + return true; +} + function resolveCsgModelIdentity(requestedModelId) { let resolvedModelId = requestedModelId; let blockKey = requestedModelId; @@ -443,9 +484,11 @@ export const flockCSG = { const originalMaterial = referenceMesh.material; let mergedMesh = null; let csgSucceeded = false; - const csgUnsafe = meshesToMerge.some((mesh) => - hasNonFinitePositions(mesh), - ); + const csgUnsafe = meshesToMerge.some((mesh) => { + const positionsFinite = !hasNonFinitePositions(mesh); + if (!positionsFinite) return true; + return !sanitizeMeshVertexDataForCSG(mesh); + }); if (!csgUnsafe) { try { From 07a55aba5035bc48bf6c57f5b225f9c9b70df441 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:14:54 +0100 Subject: [PATCH 09/26] Skip CSG merge when mesh vertex attribute kinds differ --- api/csg.js | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/api/csg.js b/api/csg.js index 45c10d388..fb07f9ed5 100644 --- a/api/csg.js +++ b/api/csg.js @@ -267,7 +267,7 @@ function shouldApplyBoxProjection(resultMesh, options = {}) { return !hasUsableUVs(resultMesh); } -function normalizeMeshAttributesForMerge(meshes) { +function normalizeMeshAttributesForMerge(meshes, { logWarning = true } = {}) { if (!meshes || meshes.length < 2) return; const kindUnion = new Set(); @@ -288,13 +288,15 @@ function normalizeMeshAttributesForMerge(meshes) { if (!normalizationRequired) return; - console.warn( - "[mergeMeshes] Normalizing vertex attributes before fallback merge", - { - meshes: getMeshKindsSummary(meshes), - requiredKinds: Array.from(kindUnion), - }, - ); + if (logWarning) { + console.warn( + "[mergeMeshes] Normalizing vertex attributes before fallback merge", + { + meshes: getMeshKindsSummary(meshes), + requiredKinds: Array.from(kindUnion), + }, + ); + } const vertexBuffer = flock.BABYLON.VertexBuffer; @@ -350,6 +352,16 @@ function normalizeMeshAttributesForMerge(meshes) { }); } +function meshesHaveMatchingAttributeKinds(meshes) { + if (!meshes || meshes.length < 2) return true; + const baseline = getMeshAttributeKinds(meshes[0]).slice().sort().join("|"); + for (let i = 1; i < meshes.length; i++) { + const current = getMeshAttributeKinds(meshes[i]).slice().sort().join("|"); + if (current !== baseline) return false; + } + return true; +} + function hasNonFinitePositions(mesh) { if (!mesh?.getVerticesData) return true; const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); @@ -484,13 +496,17 @@ export const flockCSG = { const originalMaterial = referenceMesh.material; let mergedMesh = null; let csgSucceeded = false; + normalizeMeshAttributesForMerge(meshesToMerge, { logWarning: false }); const csgUnsafe = meshesToMerge.some((mesh) => { const positionsFinite = !hasNonFinitePositions(mesh); if (!positionsFinite) return true; return !sanitizeMeshVertexDataForCSG(mesh); }); + const csgIncompatibleKinds = !meshesHaveMatchingAttributeKinds( + meshesToMerge, + ); - if (!csgUnsafe) { + if (!csgUnsafe && !csgIncompatibleKinds) { try { let baseCSG = flock.BABYLON.CSG2.FromMesh(meshesToMerge[0], false); @@ -521,10 +537,14 @@ export const flockCSG = { console.warn("[mergeMeshes] CSG merge attempt failed:", error); csgSucceeded = false; } - } else { + } else if (csgUnsafe) { console.warn( "[mergeMeshes] Skipping CSG merge due non-finite positions; using Mesh.MergeMeshes fallback.", ); + } else { + console.warn( + "[mergeMeshes] Skipping CSG merge due incompatible vertex attribute kinds; using Mesh.MergeMeshes fallback.", + ); } if (!csgSucceeded) { From 1e3ec6298e5f3ed197fe0ea7520b8211fd939225 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:21:46 +0100 Subject: [PATCH 10/26] Silence expected CSG fallback warnings in mergeMeshes --- api/csg.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/api/csg.js b/api/csg.js index fb07f9ed5..2068ac5ea 100644 --- a/api/csg.js +++ b/api/csg.js @@ -537,19 +537,20 @@ export const flockCSG = { console.warn("[mergeMeshes] CSG merge attempt failed:", error); csgSucceeded = false; } - } else if (csgUnsafe) { - console.warn( - "[mergeMeshes] Skipping CSG merge due non-finite positions; using Mesh.MergeMeshes fallback.", - ); - } else { - console.warn( - "[mergeMeshes] Skipping CSG merge due incompatible vertex attribute kinds; using Mesh.MergeMeshes fallback.", + } else if (flock?.materialsDebug) { + const reason = csgUnsafe + ? "non-finite positions" + : "incompatible vertex attribute kinds"; + console.log( + `[mergeMeshes] Skipping CSG merge due ${reason}; using Mesh.MergeMeshes fallback.`, ); } if (!csgSucceeded) { try { - normalizeMeshAttributesForMerge(meshesToMerge); + normalizeMeshAttributesForMerge(meshesToMerge, { + logWarning: false, + }); mergedMesh = flock.BABYLON.Mesh.MergeMeshes( meshesToMerge, false, From aadd0bbae62215a4b0c3f5d9a3bf762a0c26d6c8 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:25:43 +0100 Subject: [PATCH 11/26] Limit auto UV projection to textured CSG results --- api/csg.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/api/csg.js b/api/csg.js index 2068ac5ea..aa7a7c287 100644 --- a/api/csg.js +++ b/api/csg.js @@ -264,7 +264,17 @@ function hasUsableUVs(mesh) { function shouldApplyBoxProjection(resultMesh, options = {}) { if (options.uvProjection === "box") return true; if (options.uvProjection && options.uvProjection !== "auto") return false; - return !hasUsableUVs(resultMesh); + + const materialHasTexture = (material) => { + if (!material) return false; + if (material.diffuseTexture || material.albedoTexture) return true; + if (material.subMaterials && Array.isArray(material.subMaterials)) { + return material.subMaterials.some((sub) => materialHasTexture(sub)); + } + return false; + }; + + return materialHasTexture(resultMesh.material) && !hasUsableUVs(resultMesh); } function normalizeMeshAttributesForMerge(meshes, { logWarning = true } = {}) { @@ -796,15 +806,15 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); - if (shouldApplyBoxProjection(resultMesh, options)) { - applyBoxProjectionUV(resultMesh, options.uvScale); - } flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, ); + if (shouldApplyBoxProjection(resultMesh, options)) { + applyBoxProjectionUV(resultMesh, options.uvScale); + } baseDuplicate.dispose(); subtractDuplicates.forEach((m) => m.dispose()); @@ -932,15 +942,15 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); - if (shouldApplyBoxProjection(resultMesh, options)) { - applyBoxProjectionUV(resultMesh, options.uvScale); - } flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, ); + if (shouldApplyBoxProjection(resultMesh, options)) { + applyBoxProjectionUV(resultMesh, options.uvScale); + } baseDuplicate.dispose(); allToolParts.forEach((t) => t.dispose()); From 75a21c7f14f95cda44c30f301099f40344821936 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:38:17 +0100 Subject: [PATCH 12/26] Ensure CSG sanitization restores missing mesh indices --- api/csg.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/csg.js b/api/csg.js index aa7a7c287..c0db1bb9c 100644 --- a/api/csg.js +++ b/api/csg.js @@ -401,7 +401,12 @@ function sanitizeMeshVertexDataForCSG(mesh) { } const kinds = mesh.getVerticesDataKinds() || []; - const indices = mesh.getIndices ? mesh.getIndices() : null; + let indices = mesh.getIndices ? mesh.getIndices() : null; + if ((!indices || indices.length === 0) && typeof mesh.setIndices === "function") { + indices = Array.from({ length: positions.length / 3 }, (_, i) => i); + mesh.setIndices(indices); + } + if (!indices || indices.length === 0) return false; kinds.forEach((kind) => { if (kind === positionKind) return; From 805730b30ddf261f2365d0bedf3a9ca4facc0532 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:44:45 +0100 Subject: [PATCH 13/26] Force subtract results to reuse base mesh material --- api/csg.js | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/api/csg.js b/api/csg.js index c0db1bb9c..f536464cd 100644 --- a/api/csg.js +++ b/api/csg.js @@ -811,12 +811,9 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); - flock.applyResultMeshProperties( - resultMesh, - actualBase, - modelId, - blockKey, - ); + flock.applyResultMeshProperties(resultMesh, actualBase, modelId, blockKey, { + forceReferenceMaterial: true, + }); if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -947,12 +944,9 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); - flock.applyResultMeshProperties( - resultMesh, - actualBase, - modelId, - blockKey, - ); + flock.applyResultMeshProperties(resultMesh, actualBase, modelId, blockKey, { + forceReferenceMaterial: true, + }); if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -1257,7 +1251,13 @@ export const flockCSG = { }), ).then((meshes) => meshes.filter((mesh) => mesh !== null)); }, - applyResultMeshProperties(resultMesh, referenceMesh, modelId, blockId) { + applyResultMeshProperties( + resultMesh, + referenceMesh, + modelId, + blockId, + { forceReferenceMaterial = false } = {}, + ) { // Copy transformation properties referenceMesh.material.backFaceCulling = false; @@ -1285,6 +1285,12 @@ export const flockCSG = { return referenceMesh.material.clone("clonedMaterial"); }; + if (forceReferenceMaterial) { + resultMesh.material = referenceMesh.material.clone("csgResultMaterial"); + resultMesh.material.backFaceCulling = false; + return; + } + if (resultMesh.material) { if (resultMesh.material instanceof flock.BABYLON.MultiMaterial) { resultMesh.material.subMaterials = resultMesh.material.subMaterials.map( From 652a3b9b59229c83abd86b5712c00c775b9c849c Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 16:56:32 +0100 Subject: [PATCH 14/26] Preserve text-cutter material on subtract cut faces --- api/csg.js | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/api/csg.js b/api/csg.js index f536464cd..4f5160027 100644 --- a/api/csg.js +++ b/api/csg.js @@ -444,6 +444,18 @@ function resolveCsgModelIdentity(requestedModelId) { } export const flockCSG = { + shouldPreserveToolMaterialForSubtract(meshes) { + if (!Array.isArray(meshes) || meshes.length === 0) return false; + return meshes.some((mesh) => { + const name = mesh?.name?.toLowerCase?.() || ""; + const modelName = mesh?.metadata?.modelName?.toLowerCase?.() || ""; + return ( + name.includes("3dtext") || + modelName.includes("3dtext") || + modelName.includes("text") + ); + }); + }, mergeCompositeMesh(meshes) { if (!meshes || meshes.length === 0) return null; @@ -714,6 +726,8 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { + const preserveToolMaterial = + flock.shouldPreserveToolMaterialForSubtract(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = cloneForCSG(actualBase, "baseDuplicate"); let outerCSG = flock.BABYLON.CSG2.FromMesh(baseDuplicate, false); @@ -811,9 +825,15 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); - flock.applyResultMeshProperties(resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: true, - }); + flock.applyResultMeshProperties( + resultMesh, + actualBase, + modelId, + blockKey, + { + forceReferenceMaterial: !preserveToolMaterial, + }, + ); if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -870,6 +890,8 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { + const preserveToolMaterial = + flock.shouldPreserveToolMaterialForSubtract(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = actualBase.clone("baseDuplicate"); baseDuplicate.setParent(null); @@ -944,9 +966,15 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); - flock.applyResultMeshProperties(resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: true, - }); + flock.applyResultMeshProperties( + resultMesh, + actualBase, + modelId, + blockKey, + { + forceReferenceMaterial: !preserveToolMaterial, + }, + ); if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } From 20e643efa48ba69262c69b92380c077a381b73a6 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 17:05:56 +0100 Subject: [PATCH 15/26] Detect text cutters from input mesh names in subtract flow --- api/csg.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/api/csg.js b/api/csg.js index 4f5160027..8c5abb2e4 100644 --- a/api/csg.js +++ b/api/csg.js @@ -446,9 +446,16 @@ function resolveCsgModelIdentity(requestedModelId) { export const flockCSG = { shouldPreserveToolMaterialForSubtract(meshes) { if (!Array.isArray(meshes) || meshes.length === 0) return false; - return meshes.some((mesh) => { - const name = mesh?.name?.toLowerCase?.() || ""; - const modelName = mesh?.metadata?.modelName?.toLowerCase?.() || ""; + return meshes.some((meshOrName) => { + const raw = + typeof meshOrName === "string" + ? meshOrName + : meshOrName?.name || meshOrName?.metadata?.modelName || ""; + const name = raw.toLowerCase(); + const modelName = + typeof meshOrName === "string" + ? "" + : meshOrName?.metadata?.modelName?.toLowerCase?.() || ""; return ( name.includes("3dtext") || modelName.includes("3dtext") || @@ -727,7 +734,7 @@ export const flockCSG = { flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { const preserveToolMaterial = - flock.shouldPreserveToolMaterialForSubtract(validMeshes); + flock.shouldPreserveToolMaterialForSubtract(meshNames); const scene = baseMesh.getScene(); const baseDuplicate = cloneForCSG(actualBase, "baseDuplicate"); let outerCSG = flock.BABYLON.CSG2.FromMesh(baseDuplicate, false); @@ -891,7 +898,7 @@ export const flockCSG = { flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { const preserveToolMaterial = - flock.shouldPreserveToolMaterialForSubtract(validMeshes); + flock.shouldPreserveToolMaterialForSubtract(meshNames); const scene = baseMesh.getScene(); const baseDuplicate = actualBase.clone("baseDuplicate"); baseDuplicate.setParent(null); From 6c5b0db578c92e8797bdbbdef0da020c5f4e081a Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 17:16:39 +0100 Subject: [PATCH 16/26] Flatten preserved subtract submaterials to keep text cut color --- api/csg.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/api/csg.js b/api/csg.js index 8c5abb2e4..375eefbeb 100644 --- a/api/csg.js +++ b/api/csg.js @@ -839,6 +839,7 @@ export const flockCSG = { blockKey, { forceReferenceMaterial: !preserveToolMaterial, + flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); if (shouldApplyBoxProjection(resultMesh, options)) { @@ -980,6 +981,7 @@ export const flockCSG = { blockKey, { forceReferenceMaterial: !preserveToolMaterial, + flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); if (shouldApplyBoxProjection(resultMesh, options)) { @@ -1291,7 +1293,7 @@ export const flockCSG = { referenceMesh, modelId, blockId, - { forceReferenceMaterial = false } = {}, + { forceReferenceMaterial = false, flattenNonReferenceSubMaterials = false } = {}, ) { // Copy transformation properties referenceMesh.material.backFaceCulling = false; @@ -1345,5 +1347,29 @@ export const flockCSG = { resultMesh.material = referenceMesh.material.clone("csgResultMaterial"); resultMesh.material.backFaceCulling = false; } + + if ( + flattenNonReferenceSubMaterials && + resultMesh.material instanceof flock.BABYLON.MultiMaterial + ) { + const baseName = referenceMesh.material?.name; + resultMesh.material.subMaterials = resultMesh.material.subMaterials.map( + (subMaterial) => { + if (!subMaterial) return subMaterial; + if (baseName && subMaterial.name === baseName) return subMaterial; + + if (typeof subMaterial.clone === "function") { + subMaterial = subMaterial.clone(`${subMaterial.name}_csg`); + } + + if (subMaterial.diffuseColor) { + subMaterial.emissiveColor = subMaterial.diffuseColor.clone(); + } + subMaterial.disableLighting = true; + subMaterial.backFaceCulling = false; + return subMaterial; + }, + ); + } }, }; From 6d33f89d1d36e8a5453f8fdc3b957978f32fb7f1 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 17:21:43 +0100 Subject: [PATCH 17/26] Preserve textured cutter materials on subtract results --- api/csg.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 375eefbeb..2a0613d7b 100644 --- a/api/csg.js +++ b/api/csg.js @@ -463,6 +463,24 @@ export const flockCSG = { ); }); }, + toolMeshesUseTextures(meshes) { + if (!Array.isArray(meshes) || meshes.length === 0) return false; + const materialHasTexture = (material) => { + if (!material) return false; + if (material.diffuseTexture || material.albedoTexture) return true; + if (material.subMaterials && Array.isArray(material.subMaterials)) { + return material.subMaterials.some((sub) => materialHasTexture(sub)); + } + return false; + }; + return meshes.some((mesh) => { + if (materialHasTexture(mesh?.material)) return true; + if (!mesh?.getChildMeshes) return false; + return mesh.getChildMeshes().some((child) => + materialHasTexture(child?.material), + ); + }); + }, mergeCompositeMesh(meshes) { if (!meshes || meshes.length === 0) return null; @@ -735,6 +753,7 @@ export const flockCSG = { flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { const preserveToolMaterial = flock.shouldPreserveToolMaterialForSubtract(meshNames); + const preserveTextureMaterial = flock.toolMeshesUseTextures(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = cloneForCSG(actualBase, "baseDuplicate"); let outerCSG = flock.BABYLON.CSG2.FromMesh(baseDuplicate, false); @@ -838,7 +857,8 @@ export const flockCSG = { modelId, blockKey, { - forceReferenceMaterial: !preserveToolMaterial, + forceReferenceMaterial: + !(preserveToolMaterial || preserveTextureMaterial), flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); @@ -900,6 +920,7 @@ export const flockCSG = { flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { const preserveToolMaterial = flock.shouldPreserveToolMaterialForSubtract(meshNames); + const preserveTextureMaterial = flock.toolMeshesUseTextures(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = actualBase.clone("baseDuplicate"); baseDuplicate.setParent(null); @@ -980,7 +1001,8 @@ export const flockCSG = { modelId, blockKey, { - forceReferenceMaterial: !preserveToolMaterial, + forceReferenceMaterial: + !(preserveToolMaterial || preserveTextureMaterial), flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); From 26b3898f4aac108ebbdeeb5a7dfa9f717cd1cdad Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 17:25:51 +0100 Subject: [PATCH 18/26] Force UV reprojection for textured subtract cutters by default --- api/csg.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 2a0613d7b..d52c0724c 100644 --- a/api/csg.js +++ b/api/csg.js @@ -862,7 +862,12 @@ export const flockCSG = { flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); - if (shouldApplyBoxProjection(resultMesh, options)) { + const forceUvProjectionForTexturedTools = + preserveTextureMaterial && !options.uvProjection; + if ( + forceUvProjectionForTexturedTools || + shouldApplyBoxProjection(resultMesh, options) + ) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -1006,7 +1011,12 @@ export const flockCSG = { flattenNonReferenceSubMaterials: preserveToolMaterial, }, ); - if (shouldApplyBoxProjection(resultMesh, options)) { + const forceUvProjectionForTexturedTools = + preserveTextureMaterial && !options.uvProjection; + if ( + forceUvProjectionForTexturedTools || + shouldApplyBoxProjection(resultMesh, options) + ) { applyBoxProjectionUV(resultMesh, options.uvScale); } From 8f4eb4199a9ad5ae11a440fb6a0680f4c9079cad Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 17:47:53 +0100 Subject: [PATCH 19/26] Ignore placeholder textures when preserving subtract tool materials --- api/csg.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index d52c0724c..ae51226b9 100644 --- a/api/csg.js +++ b/api/csg.js @@ -265,9 +265,22 @@ function shouldApplyBoxProjection(resultMesh, options = {}) { if (options.uvProjection === "box") return true; if (options.uvProjection && options.uvProjection !== "auto") return false; + const hasRenderableTexture = (texture) => { + if (!texture) return false; + const textureName = String(texture.name || "").toLowerCase(); + if (!textureName) return false; + if (textureName.endsWith("undefined")) return false; + if (textureName.includes("none.png")) return false; + return true; + }; + const materialHasTexture = (material) => { if (!material) return false; - if (material.diffuseTexture || material.albedoTexture) return true; + if ( + hasRenderableTexture(material.diffuseTexture) || + hasRenderableTexture(material.albedoTexture) + ) + return true; if (material.subMaterials && Array.isArray(material.subMaterials)) { return material.subMaterials.some((sub) => materialHasTexture(sub)); } @@ -465,9 +478,21 @@ export const flockCSG = { }, toolMeshesUseTextures(meshes) { if (!Array.isArray(meshes) || meshes.length === 0) return false; + const hasRenderableTexture = (texture) => { + if (!texture) return false; + const textureName = String(texture.name || "").toLowerCase(); + if (!textureName) return false; + if (textureName.endsWith("undefined")) return false; + if (textureName.includes("none.png")) return false; + return true; + }; const materialHasTexture = (material) => { if (!material) return false; - if (material.diffuseTexture || material.albedoTexture) return true; + if ( + hasRenderableTexture(material.diffuseTexture) || + hasRenderableTexture(material.albedoTexture) + ) + return true; if (material.subMaterials && Array.isArray(material.subMaterials)) { return material.subMaterials.some((sub) => materialHasTexture(sub)); } From cf38ab03187093404a2780edab4d74d463b00484 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 18:07:28 +0100 Subject: [PATCH 20/26] Restore text cut-face shading for single-color materials --- api/csg.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index ae51226b9..3abd10fd8 100644 --- a/api/csg.js +++ b/api/csg.js @@ -1420,9 +1420,12 @@ export const flockCSG = { } if (subMaterial.diffuseColor) { - subMaterial.emissiveColor = subMaterial.diffuseColor.clone(); + subMaterial.emissiveColor = subMaterial.diffuseColor.scale(0.2); + } + subMaterial.disableLighting = false; + if (subMaterial.specularColor) { + subMaterial.specularColor = flock.BABYLON.Color3.Black(); } - subMaterial.disableLighting = true; subMaterial.backFaceCulling = false; return subMaterial; }, From 92b802cfe01a2e8800d1f837efc7592837430d50 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 18:17:48 +0100 Subject: [PATCH 21/26] Stop flattening preserved subtract submaterials by default --- api/csg.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 3abd10fd8..75f9aaa3f 100644 --- a/api/csg.js +++ b/api/csg.js @@ -884,7 +884,7 @@ export const flockCSG = { { forceReferenceMaterial: !(preserveToolMaterial || preserveTextureMaterial), - flattenNonReferenceSubMaterials: preserveToolMaterial, + flattenNonReferenceSubMaterials: false, }, ); const forceUvProjectionForTexturedTools = @@ -1033,7 +1033,7 @@ export const flockCSG = { { forceReferenceMaterial: !(preserveToolMaterial || preserveTextureMaterial), - flattenNonReferenceSubMaterials: preserveToolMaterial, + flattenNonReferenceSubMaterials: false, }, ); const forceUvProjectionForTexturedTools = From e095452ed4e2ceead631fb441b42239b45132dfa Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 18:25:12 +0100 Subject: [PATCH 22/26] Improve non-textured cutout visibility with flat shading --- api/csg.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/csg.js b/api/csg.js index 75f9aaa3f..dff9c2365 100644 --- a/api/csg.js +++ b/api/csg.js @@ -1382,6 +1382,24 @@ export const flockCSG = { if (forceReferenceMaterial) { resultMesh.material = referenceMesh.material.clone("csgResultMaterial"); resultMesh.material.backFaceCulling = false; + const textureName = String( + resultMesh.material.diffuseTexture?.name || + resultMesh.material.albedoTexture?.name || + "", + ).toLowerCase(); + const hasRenderableTexture = + textureName && + !textureName.endsWith("undefined") && + !textureName.includes("none.png"); + if (!hasRenderableTexture && resultMesh.convertToFlatShadedMesh) { + try { + resultMesh.convertToFlatShadedMesh(); + resultMesh.computeWorldMatrix?.(true); + resultMesh.refreshBoundingInfo?.(); + } catch { + // Keep default shading if flat shading conversion fails + } + } return; } From 34beab75a1283a6000b4da1d1a35c86ba6beb953 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 18:48:49 +0100 Subject: [PATCH 23/26] Make subtract UV/material behavior opt-in to preserve legacy look --- api/csg.js | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/api/csg.js b/api/csg.js index dff9c2365..899b4f57e 100644 --- a/api/csg.js +++ b/api/csg.js @@ -263,7 +263,7 @@ function hasUsableUVs(mesh) { function shouldApplyBoxProjection(resultMesh, options = {}) { if (options.uvProjection === "box") return true; - if (options.uvProjection && options.uvProjection !== "auto") return false; + if (options.uvProjection !== "auto") return false; const hasRenderableTexture = (texture) => { if (!texture) return false; @@ -776,9 +776,6 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { - const preserveToolMaterial = - flock.shouldPreserveToolMaterialForSubtract(meshNames); - const preserveTextureMaterial = flock.toolMeshesUseTextures(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = cloneForCSG(actualBase, "baseDuplicate"); let outerCSG = flock.BABYLON.CSG2.FromMesh(baseDuplicate, false); @@ -882,17 +879,12 @@ export const flockCSG = { modelId, blockKey, { - forceReferenceMaterial: - !(preserveToolMaterial || preserveTextureMaterial), - flattenNonReferenceSubMaterials: false, + forceReferenceMaterial: options.forceReferenceMaterial === true, + flattenNonReferenceSubMaterials: + options.flattenNonReferenceSubMaterials === true, }, ); - const forceUvProjectionForTexturedTools = - preserveTextureMaterial && !options.uvProjection; - if ( - forceUvProjectionForTexturedTools || - shouldApplyBoxProjection(resultMesh, options) - ) { + if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -948,9 +940,6 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { - const preserveToolMaterial = - flock.shouldPreserveToolMaterialForSubtract(meshNames); - const preserveTextureMaterial = flock.toolMeshesUseTextures(validMeshes); const scene = baseMesh.getScene(); const baseDuplicate = actualBase.clone("baseDuplicate"); baseDuplicate.setParent(null); @@ -1031,17 +1020,12 @@ export const flockCSG = { modelId, blockKey, { - forceReferenceMaterial: - !(preserveToolMaterial || preserveTextureMaterial), - flattenNonReferenceSubMaterials: false, + forceReferenceMaterial: options.forceReferenceMaterial === true, + flattenNonReferenceSubMaterials: + options.flattenNonReferenceSubMaterials === true, }, ); - const forceUvProjectionForTexturedTools = - preserveTextureMaterial && !options.uvProjection; - if ( - forceUvProjectionForTexturedTools || - shouldApplyBoxProjection(resultMesh, options) - ) { + if (shouldApplyBoxProjection(resultMesh, options)) { applyBoxProjectionUV(resultMesh, options.uvScale); } From 59ced0acf9b8b19149e17e0498be9d231ec54935 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 19:52:43 +0100 Subject: [PATCH 24/26] Infer auto UV projection when textured cutters are detected --- api/csg.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 899b4f57e..40c561b49 100644 --- a/api/csg.js +++ b/api/csg.js @@ -776,6 +776,11 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { + const inferredUvProjection = + options.uvProjection === undefined && + flock.toolMeshesUseTextures(validMeshes) + ? "auto" + : options.uvProjection; const scene = baseMesh.getScene(); const baseDuplicate = cloneForCSG(actualBase, "baseDuplicate"); let outerCSG = flock.BABYLON.CSG2.FromMesh(baseDuplicate, false); @@ -884,7 +889,12 @@ export const flockCSG = { options.flattenNonReferenceSubMaterials === true, }, ); - if (shouldApplyBoxProjection(resultMesh, options)) { + if ( + shouldApplyBoxProjection(resultMesh, { + ...options, + uvProjection: inferredUvProjection, + }) + ) { applyBoxProjectionUV(resultMesh, options.uvScale); } @@ -940,6 +950,11 @@ export const flockCSG = { } flock.prepareMeshes(modelId, meshNames, blockKey).then((validMeshes) => { + const inferredUvProjection = + options.uvProjection === undefined && + flock.toolMeshesUseTextures(validMeshes) + ? "auto" + : options.uvProjection; const scene = baseMesh.getScene(); const baseDuplicate = actualBase.clone("baseDuplicate"); baseDuplicate.setParent(null); @@ -1025,7 +1040,12 @@ export const flockCSG = { options.flattenNonReferenceSubMaterials === true, }, ); - if (shouldApplyBoxProjection(resultMesh, options)) { + if ( + shouldApplyBoxProjection(resultMesh, { + ...options, + uvProjection: inferredUvProjection, + }) + ) { applyBoxProjectionUV(resultMesh, options.uvScale); } From 1d32c27a7c1b891885e5e77433de5c2dd5cf5ac3 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 20:06:10 +0100 Subject: [PATCH 25/26] Prefer CSG merge after normalization; fallback only when unsafe --- api/csg.js | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/api/csg.js b/api/csg.js index 40c561b49..13ec8684b 100644 --- a/api/csg.js +++ b/api/csg.js @@ -375,16 +375,6 @@ function normalizeMeshAttributesForMerge(meshes, { logWarning = true } = {}) { }); } -function meshesHaveMatchingAttributeKinds(meshes) { - if (!meshes || meshes.length < 2) return true; - const baseline = getMeshAttributeKinds(meshes[0]).slice().sort().join("|"); - for (let i = 1; i < meshes.length; i++) { - const current = getMeshAttributeKinds(meshes[i]).slice().sort().join("|"); - if (current !== baseline) return false; - } - return true; -} - function hasNonFinitePositions(mesh) { if (!mesh?.getVerticesData) return true; const positions = mesh.getVerticesData(flock.BABYLON.VertexBuffer.PositionKind); @@ -579,11 +569,8 @@ export const flockCSG = { if (!positionsFinite) return true; return !sanitizeMeshVertexDataForCSG(mesh); }); - const csgIncompatibleKinds = !meshesHaveMatchingAttributeKinds( - meshesToMerge, - ); - if (!csgUnsafe && !csgIncompatibleKinds) { + if (!csgUnsafe) { try { let baseCSG = flock.BABYLON.CSG2.FromMesh(meshesToMerge[0], false); @@ -615,9 +602,7 @@ export const flockCSG = { csgSucceeded = false; } } else if (flock?.materialsDebug) { - const reason = csgUnsafe - ? "non-finite positions" - : "incompatible vertex attribute kinds"; + const reason = "non-finite positions"; console.log( `[mergeMeshes] Skipping CSG merge due ${reason}; using Mesh.MergeMeshes fallback.`, ); From 941ca1cdcafec3b2ed4eecd7a0524060c68869ab Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Mon, 6 Apr 2026 20:20:53 +0100 Subject: [PATCH 26/26] Silence expected CSG property-mismatch warnings in merge path --- api/csg.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/csg.js b/api/csg.js index 13ec8684b..baeccf2bf 100644 --- a/api/csg.js +++ b/api/csg.js @@ -598,7 +598,13 @@ export const flockCSG = { (m) => m.name === modelId && m.getTotalVertices() === 0, ); emptyMeshes.forEach((m) => m.dispose()); - console.warn("[mergeMeshes] CSG merge attempt failed:", error); + const message = String(error?.message || ""); + const expectedPropertyMismatch = message.includes( + "same number of properties", + ); + if (!expectedPropertyMismatch || flock?.materialsDebug) { + console.warn("[mergeMeshes] CSG merge attempt failed:", error); + } csgSucceeded = false; } } else if (flock?.materialsDebug) {