From cca6d4c18f2d9b0698794a664b8a2097bacb6cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 08:22:24 +0000 Subject: [PATCH 01/11] fix: add rebuildNormals to subtractMeshes CSG toMesh calls mergeMeshes already used rebuildNormals: true but subtractMeshesMerge and subtractMeshesIndividual were missing it, causing incorrect shading on cutout faces after boolean subtraction operations. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/csg.js b/api/csg.js index baeccf2bf..16cd42e17 100644 --- a/api/csg.js +++ b/api/csg.js @@ -839,6 +839,7 @@ export const flockCSG = { try { resultMesh = outerCSG.toMesh("resultMesh", scene, { centerMesh: false, + rebuildNormals: true, }); if (!resultMesh || resultMesh.getTotalVertices() === 0) { @@ -981,6 +982,7 @@ export const flockCSG = { try { resultMesh = outerCSG.toMesh("resultMesh", scene, { centerMesh: false, + rebuildNormals: true, }); if (!resultMesh || resultMesh.getTotalVertices() === 0) { From bd321cdff4da9ec20a046bd141fba040f006a589 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 09:05:50 +0000 Subject: [PATCH 02/11] fix: correct cutout normals when subtracting Manifold 3D text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create3DText calls flipFaces() on Manifold meshes to match Babylon.js display conventions. subtractMeshesIndividual already compensates for this by unconditionally re-flipping all tool parts before CSG, but subtractMeshesMerge only flipped meshes with metadata.modelName (imported models), leaving 3D text with inverted winding. The inverted winding causes CSG to produce cutout walls whose normals point inward, resulting in incorrect shading on text engravings. Fix: mark Manifold text meshes with metadata.facesFlippedForDisplay and include that flag in the subtractMeshesMerge flip condition, so the display-flip is undone before CSG — matching subtractMeshesIndividual's existing behaviour. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 3 ++- api/shapes.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/csg.js b/api/csg.js index 16cd42e17..181bbc339 100644 --- a/api/csg.js +++ b/api/csg.js @@ -809,7 +809,8 @@ export const flockCSG = { if (unified) { unified.forceSharedVertices(); if ( - mesh.metadata?.modelName && + (mesh.metadata?.modelName || + mesh.metadata?.facesFlippedForDisplay) && typeof unified.flipFaces === "function" ) unified.flipFaces(); diff --git a/api/shapes.js b/api/shapes.js index ccfc7105e..6a5b5de11 100644 --- a/api/shapes.js +++ b/api/shapes.js @@ -777,6 +777,7 @@ export const flockShapes = { vertexData.positions = centeredPositions; vertexData.applyToMesh(mesh); mesh.flipFaces(); + mesh.metadata.facesFlippedForDisplay = true; } catch (manifoldError) { console.warn( "[create3DText] Manifold approach failed, falling back to standard:", From 50a830116b30535e8686fca7220d63887c85628d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 09:12:46 +0000 Subject: [PATCH 03/11] fix: remove specular highlight from subtracted text cutout walls When CSG subtracts 3D text from a mesh, the newly-cut walls inherit the text mesh's StandardMaterial which has default white specular, making the cutout look unrealistically shiny. flattenNonReferenceSubMaterials already existed to handle this (sets specular to black, keeps the diffuse colour) but defaulted to false. Change both subtractMeshesMerge and subtractMeshesIndividual to default it to true so callers can opt out with flattenNonReferenceSubMaterials:false rather than having to opt in. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 181bbc339..3f174d45c 100644 --- a/api/csg.js +++ b/api/csg.js @@ -879,7 +879,7 @@ export const flockCSG = { { forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: - options.flattenNonReferenceSubMaterials === true, + options.flattenNonReferenceSubMaterials !== false, }, ); if ( @@ -1031,7 +1031,7 @@ export const flockCSG = { { forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: - options.flattenNonReferenceSubMaterials === true, + options.flattenNonReferenceSubMaterials !== false, }, ); if ( From d2fc54ac0eda077b9e5c42bc37518b36e47aa7fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 09:17:07 +0000 Subject: [PATCH 04/11] fix: initialise mesh.metadata before setting facesFlippedForDisplay mesh.metadata is null at the point of the flipFaces() call inside the Manifold try-block; the null dereference was throwing, causing the catch to discard the Manifold mesh and fall back to the standard path. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/shapes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/shapes.js b/api/shapes.js index 6a5b5de11..ceefb13ba 100644 --- a/api/shapes.js +++ b/api/shapes.js @@ -777,6 +777,7 @@ export const flockShapes = { vertexData.positions = centeredPositions; vertexData.applyToMesh(mesh); mesh.flipFaces(); + mesh.metadata = mesh.metadata || {}; mesh.metadata.facesFlippedForDisplay = true; } catch (manifoldError) { console.warn( From 03cf370b48da890232d971ae2e611bae92d91299 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 09:22:24 +0000 Subject: [PATCH 05/11] revert: remove facesFlippedForDisplay winding fix The text mesh from create3DText already has the correct winding for CSG to carve a hole. Flipping it again in subtractMeshesMerge inverted the solid so CSG saw an anti-text and cut nothing, making the engraving invisible. The shininess was a material issue (text material's default white specular on the cut walls), already fixed by defaulting flattenNonReferenceSubMaterials to true. No winding change is needed. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 3 +-- api/shapes.js | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/api/csg.js b/api/csg.js index 3f174d45c..e456cb5ff 100644 --- a/api/csg.js +++ b/api/csg.js @@ -809,8 +809,7 @@ export const flockCSG = { if (unified) { unified.forceSharedVertices(); if ( - (mesh.metadata?.modelName || - mesh.metadata?.facesFlippedForDisplay) && + mesh.metadata?.modelName && typeof unified.flipFaces === "function" ) unified.flipFaces(); diff --git a/api/shapes.js b/api/shapes.js index ceefb13ba..ccfc7105e 100644 --- a/api/shapes.js +++ b/api/shapes.js @@ -777,8 +777,6 @@ export const flockShapes = { vertexData.positions = centeredPositions; vertexData.applyToMesh(mesh); mesh.flipFaces(); - mesh.metadata = mesh.metadata || {}; - mesh.metadata.facesFlippedForDisplay = true; } catch (manifoldError) { console.warn( "[create3DText] Manifold approach failed, falling back to standard:", From 582083ddf7c2e5d9d954670cb4e4a70b36cdb981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:05:12 +0000 Subject: [PATCH 06/11] fix: set specularColor black on 3D text material StandardMaterial defaults to white specular. When CSG assigns the text material to the cut walls of a subtraction, those faces appear shiny. Setting specularColor to black at creation time prevents this regardless of how the material ends up on the result mesh, consistent with how other Flock materials (gradients, PBR replacements) are configured. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/shapes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/shapes.js b/api/shapes.js index ccfc7105e..f95b73303 100644 --- a/api/shapes.js +++ b/api/shapes.js @@ -814,6 +814,7 @@ export const flockShapes = { ); material.backFaceCulling = false; material.emissiveColor = material.diffuseColor.scale(0.2); + material.specularColor = flock.BABYLON.Color3.Black(); material.alpha = toAlpha(alpha); mesh.material = material; From 27e54643f3f7b1764aa7a7140f693ca3df0b0305 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 11:30:42 +0000 Subject: [PATCH 07/11] fix: flat-shade subtract results to match base mesh appearance Regular shapes (e.g. cylinders) are flat-shaded after material assignment (material.js:677). The CSG subtract result was smooth-shaded, so cut walls appeared shiny compared to the surrounding flat-shaded surface. Apply convertToFlatShadedMesh() after both subtractMeshesMerge and subtractMeshesIndividual when the result has no texture, the same guard used everywhere else in the codebase. Also reverts flattenNonReferenceSubMaterials back to opt-in (=== true) since defaulting it to true was adding emissiveColor to all sub-materials and making everything appear brighter. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index e456cb5ff..66f624574 100644 --- a/api/csg.js +++ b/api/csg.js @@ -878,9 +878,31 @@ export const flockCSG = { { forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: - options.flattenNonReferenceSubMaterials !== false, + options.flattenNonReferenceSubMaterials === true, }, ); + // Apply flat shading to match how regular shapes look after material + // assignment (material.js:677). Without this the CSG result is + // smooth-shaded while the base was flat-shaded, making cut walls + // appear shiny relative to the surrounding surface. + const texName = String( + resultMesh.material?.diffuseTexture?.name || + resultMesh.material?.albedoTexture?.name || + "", + ).toLowerCase(); + const noTexture = + !texName || + texName.endsWith("undefined") || + texName.includes("none.png"); + if (noTexture && typeof resultMesh.convertToFlatShadedMesh === "function") { + try { + resultMesh.convertToFlatShadedMesh(); + resultMesh.computeWorldMatrix?.(true); + resultMesh.refreshBoundingInfo?.(); + } catch { + // keep smooth shading if conversion fails + } + } if ( shouldApplyBoxProjection(resultMesh, { ...options, @@ -1030,9 +1052,27 @@ export const flockCSG = { { forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: - options.flattenNonReferenceSubMaterials !== false, + options.flattenNonReferenceSubMaterials === true, }, ); + const texNameI = String( + resultMesh.material?.diffuseTexture?.name || + resultMesh.material?.albedoTexture?.name || + "", + ).toLowerCase(); + const noTextureI = + !texNameI || + texNameI.endsWith("undefined") || + texNameI.includes("none.png"); + if (noTextureI && typeof resultMesh.convertToFlatShadedMesh === "function") { + try { + resultMesh.convertToFlatShadedMesh(); + resultMesh.computeWorldMatrix?.(true); + resultMesh.refreshBoundingInfo?.(); + } catch { + // keep smooth shading if conversion fails + } + } if ( shouldApplyBoxProjection(resultMesh, { ...options, From f80752d1268c4ffff6551b2b5377d788f197c7d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:04:13 +0000 Subject: [PATCH 08/11] fix: guard convertToFlatShadedMesh to single-material results only Calling convertToFlatShadedMesh on a MultiMaterial mesh unindexes all geometry together, shifting submesh index boundaries so faces end up assigned to the wrong sub-material (outer surface gets inner colour). Only apply flat shading when the result has a single material, which is the case that was actually shiny. MultiMaterial results (e.g. when subtracting from a merged multi-colour mesh) keep smooth shading where the per-face material assignment is already correct. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/csg.js b/api/csg.js index 66f624574..f37140a47 100644 --- a/api/csg.js +++ b/api/csg.js @@ -885,6 +885,9 @@ export const flockCSG = { // assignment (material.js:677). Without this the CSG result is // smooth-shaded while the base was flat-shaded, making cut walls // appear shiny relative to the surrounding surface. + // Only safe on single-material meshes: convertToFlatShadedMesh on a + // MultiMaterial shifts submesh boundaries and corrupts face-material + // assignment. const texName = String( resultMesh.material?.diffuseTexture?.name || resultMesh.material?.albedoTexture?.name || @@ -894,7 +897,8 @@ export const flockCSG = { !texName || texName.endsWith("undefined") || texName.includes("none.png"); - if (noTexture && typeof resultMesh.convertToFlatShadedMesh === "function") { + const isSingleMaterial = !(resultMesh.material instanceof flock.BABYLON.MultiMaterial); + if (noTexture && isSingleMaterial && typeof resultMesh.convertToFlatShadedMesh === "function") { try { resultMesh.convertToFlatShadedMesh(); resultMesh.computeWorldMatrix?.(true); @@ -1064,7 +1068,8 @@ export const flockCSG = { !texNameI || texNameI.endsWith("undefined") || texNameI.includes("none.png"); - if (noTextureI && typeof resultMesh.convertToFlatShadedMesh === "function") { + const isSingleMaterialI = !(resultMesh.material instanceof flock.BABYLON.MultiMaterial); + if (noTextureI && isSingleMaterialI && typeof resultMesh.convertToFlatShadedMesh === "function") { try { resultMesh.convertToFlatShadedMesh(); resultMesh.computeWorldMatrix?.(true); From 160ddef15dbbe357e78d709b1e8f0a92d8b20c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:13:06 +0000 Subject: [PATCH 09/11] fix: force reference material for single-material subtract results All previous attempts (specular=black, flattenNonReferenceSubMaterials, convertToFlatShadedMesh) failed because the CSG result is always a MultiMaterial - the text material ends up on the cut walls and all the fixes were never reaching it. For single-material bases, default forceReferenceMaterial to true so the reference (base) material is applied to every face including cut walls, and convertToFlatShadedMesh() is run (existing forceReferenceMaterial path) to match the flat-shaded appearance of the original shape. For multi-material bases, keep forceReferenceMaterial false (unchanged) so the per-face colour assignment from the CSG MultiMaterial is preserved. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 60 ++++++++++++------------------------------------------ 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/api/csg.js b/api/csg.js index f37140a47..7cfc11906 100644 --- a/api/csg.js +++ b/api/csg.js @@ -870,43 +870,25 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); + // For single-material bases, force the reference material onto all + // faces (including cut walls) and flat-shade the result, matching + // how shapes look after initial material assignment (material.js:677). + // For multi-material bases keep the CSG-assigned MultiMaterial so + // per-face colour assignment is preserved. + const baseIsMultiMaterial = + actualBase.material instanceof flock.BABYLON.MultiMaterial; flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: options.forceReferenceMaterial === true, + forceReferenceMaterial: + options.forceReferenceMaterial !== false && !baseIsMultiMaterial, flattenNonReferenceSubMaterials: options.flattenNonReferenceSubMaterials === true, }, ); - // Apply flat shading to match how regular shapes look after material - // assignment (material.js:677). Without this the CSG result is - // smooth-shaded while the base was flat-shaded, making cut walls - // appear shiny relative to the surrounding surface. - // Only safe on single-material meshes: convertToFlatShadedMesh on a - // MultiMaterial shifts submesh boundaries and corrupts face-material - // assignment. - const texName = String( - resultMesh.material?.diffuseTexture?.name || - resultMesh.material?.albedoTexture?.name || - "", - ).toLowerCase(); - const noTexture = - !texName || - texName.endsWith("undefined") || - texName.includes("none.png"); - const isSingleMaterial = !(resultMesh.material instanceof flock.BABYLON.MultiMaterial); - if (noTexture && isSingleMaterial && typeof resultMesh.convertToFlatShadedMesh === "function") { - try { - resultMesh.convertToFlatShadedMesh(); - resultMesh.computeWorldMatrix?.(true); - resultMesh.refreshBoundingInfo?.(); - } catch { - // keep smooth shading if conversion fails - } - } if ( shouldApplyBoxProjection(resultMesh, { ...options, @@ -1048,36 +1030,20 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); + const baseIsMultiMaterialI = + actualBase.material instanceof flock.BABYLON.MultiMaterial; flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: options.forceReferenceMaterial === true, + forceReferenceMaterial: + options.forceReferenceMaterial !== false && !baseIsMultiMaterialI, flattenNonReferenceSubMaterials: options.flattenNonReferenceSubMaterials === true, }, ); - const texNameI = String( - resultMesh.material?.diffuseTexture?.name || - resultMesh.material?.albedoTexture?.name || - "", - ).toLowerCase(); - const noTextureI = - !texNameI || - texNameI.endsWith("undefined") || - texNameI.includes("none.png"); - const isSingleMaterialI = !(resultMesh.material instanceof flock.BABYLON.MultiMaterial); - if (noTextureI && isSingleMaterialI && typeof resultMesh.convertToFlatShadedMesh === "function") { - try { - resultMesh.convertToFlatShadedMesh(); - resultMesh.computeWorldMatrix?.(true); - resultMesh.refreshBoundingInfo?.(); - } catch { - // keep smooth shading if conversion fails - } - } if ( shouldApplyBoxProjection(resultMesh, { ...options, From 99ce3d6e2f43bbfe2ad774cac0357eed8f72eb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 14:34:54 +0000 Subject: [PATCH 10/11] revert: undo forceReferenceMaterial default for single-material bases The approach caused the entire subtract result to appear lighter because convertToFlatShadedMesh recomputes normals on the CSG-modified geometry (which has extra triangles at cut boundaries) differently from the original uniform tessellation. The correct workaround is to subtract from a MultiMaterial merged base so the existing per-face material paths handle the result correctly. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/api/csg.js b/api/csg.js index 7cfc11906..a5c4cdfe7 100644 --- a/api/csg.js +++ b/api/csg.js @@ -870,21 +870,13 @@ export const flockCSG = { resultMesh.rotation.set(0, 0, 0); resultMesh.scaling.set(1, 1, 1); resultMesh.computeWorldMatrix(true); - // For single-material bases, force the reference material onto all - // faces (including cut walls) and flat-shade the result, matching - // how shapes look after initial material assignment (material.js:677). - // For multi-material bases keep the CSG-assigned MultiMaterial so - // per-face colour assignment is preserved. - const baseIsMultiMaterial = - actualBase.material instanceof flock.BABYLON.MultiMaterial; flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: - options.forceReferenceMaterial !== false && !baseIsMultiMaterial, + forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: options.flattenNonReferenceSubMaterials === true, }, From 129936327baadbe9bcac888627687fc0d4c97ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 14:38:21 +0000 Subject: [PATCH 11/11] revert: complete revert of forceReferenceMaterial default in subtractMeshesIndividual The baseIsMultiMaterialI variable and the !== false condition were unintentionally left in subtractMeshesIndividual when subtractMeshesMerge was reverted. This restores forceReferenceMaterial: === true (opt-in only) to match the merge path. https://claude.ai/code/session_0115UHWf4RSsDTSEnqXo652j --- api/csg.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/csg.js b/api/csg.js index a5c4cdfe7..16cd42e17 100644 --- a/api/csg.js +++ b/api/csg.js @@ -1022,16 +1022,13 @@ export const flockCSG = { ); resultMesh.position.subtractInPlace(localCenter); resultMesh.computeWorldMatrix(true); - const baseIsMultiMaterialI = - actualBase.material instanceof flock.BABYLON.MultiMaterial; flock.applyResultMeshProperties( resultMesh, actualBase, modelId, blockKey, { - forceReferenceMaterial: - options.forceReferenceMaterial !== false && !baseIsMultiMaterialI, + forceReferenceMaterial: options.forceReferenceMaterial === true, flattenNonReferenceSubMaterials: options.flattenNonReferenceSubMaterials === true, },