From 821dd1e61bb8884d6b005753d5173f8312904169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 22:26:43 +0000 Subject: [PATCH 01/12] Ignore extra colors instead of erroring - createMaterial: accept 2+ colors for two-color materials (was exact match on 2) - createMultiColorGradientMaterial: clamp colors to max 16 matching shader array size --- api/material.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/material.js b/api/material.js index cd4595268..c40436d52 100644 --- a/api/material.js +++ b/api/material.js @@ -900,8 +900,8 @@ export const flockMaterial = { // Normalize single-element array to plain value if (Array.isArray(color) && color.length === 1) color = color[0]; - // Handle two-color case - if (Array.isArray(color) && color.length === 2) { + // Handle two-color case (extra colors beyond 2 are ignored) + if (Array.isArray(color) && color.length >= 2) { // Use gradient for Flat material if (materialName === "none.png") { material = new flock.GradientMaterial(materialName, flock.scene); @@ -989,8 +989,9 @@ export const flockMaterial = { }, ); - // Convert colors to Color3 array + // Convert colors to Color3 array (max 16, matching shader array size) const color3Array = colors + .slice(0, 16) .map((c) => { const hex = flock.getColorFromString(c); const color3 = flock.BABYLON.Color3.FromHexString(hex); @@ -1003,7 +1004,7 @@ export const flockMaterial = { console.log("Color array:", color3Array); } - shaderMaterial.setInt("colorCount", colors.length); + shaderMaterial.setInt("colorCount", Math.min(colors.length, 16)); shaderMaterial.setArray3("colors", color3Array); shaderMaterial.setFloat("alpha", 1.0); shaderMaterial.setVector2("minMax", new flock.BABYLON.Vector2(-1, 1)); // Will be updated when applied to mesh From 59c976f7ec78ea1f8dc71596fe9e578a79f0f38a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 07:14:08 +0000 Subject: [PATCH 02/12] Fix white map when using color array with flat NONE ground setMaterialWithCleanup expects a descriptor object, not a Babylon.js material instance. Passing a StandardMaterial to it caused getOrCreateMaterial to fall back to #ffffff, producing a white ground instead of the gradient. Directly assign mesh.material when the gradient material is pre-built. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/scene.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/scene.js b/api/scene.js index 45b20c2d1..9ae1ce6ef 100644 --- a/api/scene.js +++ b/api/scene.js @@ -216,7 +216,7 @@ export const flockScene = { flock.BABYLON.Texture.CLAMP_ADDRESSMODE; standardMat.diffuseTexture.wrapV = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; - flock.setMaterialWithCleanup(mesh, standardMat); + mesh.material = standardMat; } else { flock.setMaterialWithCleanup(mesh, material); } From 77920c36cc43002675092eef9e56a724e18e2069 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 07:20:29 +0000 Subject: [PATCH 03/12] Fix missing texture warning from dead Texture(null) code in createLinearGradientTexture The function created a Texture(null, scene) that was never returned or used, but was registered in the scene's texture list and triggered a 'missing texture' warning/visual on every call (terrain change or color list update). Also dispose the previous gradient material when replacing it on the same mesh to avoid accumulating orphaned StandardMaterials and DynamicTextures. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/scene.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/scene.js b/api/scene.js index 9ae1ce6ef..256db8e17 100644 --- a/api/scene.js +++ b/api/scene.js @@ -188,10 +188,6 @@ export const flockScene = { } dt.update(false); - const tex = new flock.BABYLON.Texture(null, flock.scene); - tex._texture = dt.getInternalTexture(); - tex.wrapU = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; - tex.wrapV = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; return dt; }, createMap(image, material) { @@ -202,6 +198,7 @@ export const flockScene = { const applyMaterialToGround = (mesh, mat) => { if (Array.isArray(mat) && mat.length === 1) mat = mat[0]; if (Array.isArray(mat) && mat.length >= 2) { + const oldMat = mesh.material; const standardMat = new flock.BABYLON.StandardMaterial( "mapGradientMat", flock.scene, @@ -217,6 +214,9 @@ export const flockScene = { standardMat.diffuseTexture.wrapV = flock.BABYLON.Texture.CLAMP_ADDRESSMODE; mesh.material = standardMat; + if (oldMat && oldMat.name === "mapGradientMat") { + oldMat.dispose(true, true); + } } else { flock.setMaterialWithCleanup(mesh, material); } From 916e5152553488da680528c4459aae0850640f5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 07:58:11 +0000 Subject: [PATCH 04/12] Fix black and red shader error during live Blockly updates with color lists When replacing a GradientMaterial (or StandardMaterial) via setMaterialWithCleanup, the old material was disposed with forceDisposeEffect=true. This force-destroyed the compiled WebGL shader Effect from the global cache, breaking the newly assigned material which shares the same Effect. On the next render frame, Babylon.js would show the "black and red" fallback while recompiling the shader. Changed dispose(true, true) to dispose(false, true) in both setMaterialWithCleanup and applyMaterialToGround so the compiled Effect stays cached and available to the replacement material. Textures are still properly disposed (second param remains true). https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/material.js | 2 +- api/scene.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/material.js b/api/material.js index c40436d52..b19d2c72a 100644 --- a/api/material.js +++ b/api/material.js @@ -1336,7 +1336,7 @@ export const flockMaterial = { if (cacheKey && flock.materialCache[cacheKey]) { delete flock.materialCache[cacheKey]; } - oldMat.dispose(true, true); + oldMat.dispose(false, true); } } }, diff --git a/api/scene.js b/api/scene.js index 256db8e17..54d66d82c 100644 --- a/api/scene.js +++ b/api/scene.js @@ -215,7 +215,7 @@ export const flockScene = { flock.BABYLON.Texture.CLAMP_ADDRESSMODE; mesh.material = standardMat; if (oldMat && oldMat.name === "mapGradientMat") { - oldMat.dispose(true, true); + oldMat.dispose(false, true); } } else { flock.setMaterialWithCleanup(mesh, material); From c0d4cf291becd93ced357c83fc6da0b91628ae93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:08:40 +0000 Subject: [PATCH 05/12] Update GradientMaterial colors in-place to avoid black and red shader flash When a GradientMaterial already exists on the ground mesh and the color list changes, instead of creating a new GradientMaterial and disposing the old one (which could destroy the shared compiled shader Effect and cause a one-frame "black and red" fallback while it recompiles), update the existing material's bottomColor and topColor directly. This avoids any shader lifecycle issues entirely: no disposal, no recompilation, no Effect reference count concerns. The material cache is re-keyed to reflect the new color combination. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/scene.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/api/scene.js b/api/scene.js index 54d66d82c..768e3141e 100644 --- a/api/scene.js +++ b/api/scene.js @@ -218,7 +218,36 @@ export const flockScene = { oldMat.dispose(false, true); } } else { - flock.setMaterialWithCleanup(mesh, material); + // For descriptors with multiple colors + flat texture, update an existing + // GradientMaterial in-place to avoid shader recompilation ("black and red" flash). + const colors = + mat && typeof mat === "object" && Array.isArray(mat.color) + ? mat.color + : null; + if ( + colors?.length >= 2 && + (mat.materialName === "none.png" || !mat.materialName) && + mesh.material instanceof flock.GradientMaterial + ) { + const existingMat = mesh.material; + existingMat.bottomColor = flock.BABYLON.Color3.FromHexString( + flock.getColorFromString(colors[0]), + ); + existingMat.topColor = flock.BABYLON.Color3.FromHexString( + flock.getColorFromString(colors[1]), + ); + // Re-key the material cache to reflect the new colors. + const oldKey = existingMat.metadata?.cacheKey; + if (oldKey) delete flock.materialCache[oldKey]; + const alphaKey = parseFloat(mat.alpha ?? 1).toFixed(2); + const newKey = + `mat_${colors.join("-")}_${alphaKey}_${mat.materialName ?? "none.png"}_noglow`.toLowerCase(); + existingMat.name = newKey; + if (existingMat.metadata) existingMat.metadata.cacheKey = newKey; + flock.materialCache[newKey] = existingMat; + } else { + flock.setMaterialWithCleanup(mesh, material); + } } }; From bc61116dc052673808b2ad809fed19dd4212e489 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:20:01 +0000 Subject: [PATCH 06/12] Retry map update when color list is temporarily empty during mutator operations When a lists_create_with block is mutated (e.g. adding a new color item), Blockly fires a change event while connections are still being rebuilt. readColourValue returns an empty array [] rather than null, so the existing null-check retry guard did not fire. The empty array passed through to createMaterial which fell into the StandardMaterial branch, loading the semi-transparent none.png texture and making the ground invisible ("missing texture"). Extend the retry condition to also cover an empty color list so the update is deferred until after the mutator finishes and all color connections are restored. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- ui/blockmesh.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/blockmesh.js b/ui/blockmesh.js index 34174272d..cd4cb11b3 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -780,7 +780,10 @@ function updateMapFromBlock(mesh, block, changeEvent) { const { textureSet, alpha } = extractMaterialInfo(materialBlock); let read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); - if (read.value == null && !block.__mapRetry) { + const colorIsEmpty = + read.value == null || + (Array.isArray(read.value) && read.value.length === 0); + if (colorIsEmpty && !block.__mapRetry) { block.__mapRetry = true; requestAnimationFrame(() => { block.__mapRetry = false; From c43914a4cac6eef3e2ebfeacda1109135d099594 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:22:01 +0000 Subject: [PATCH 07/12] Fix live map updates when a colour list is connected directly to MATERIAL updateMapFromBlock always treated the MATERIAL input block as a material block, calling readColourFromInputOrShadow(materialBlock, "BASE_COLOR"). When the user connects a lists_create_with block (or a single colour block) directly to MATERIAL instead of via a material block, that input has no BASE_COLOR sub-input, so the read always returned null and the retry loop never resolved. Detect when materialBlock is not a material block and read it directly via readColourValue, defaulting the texture to "none.png". https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- ui/blockmesh.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/blockmesh.js b/ui/blockmesh.js index cd4cb11b3..e068e6809 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -777,8 +777,19 @@ function updateMapFromBlock(mesh, block, changeEvent) { if (!materialBlock) return; - const { textureSet, alpha } = extractMaterialInfo(materialBlock); - let read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); + // A raw colour or list block may be connected directly to MATERIAL (not via a + // material block). In that case there is no BASE_COLOR sub-input to read, so + // call readColourValue on the block itself and use "none.png" as the texture. + const isMaterialBlock = materialBlock.type === "material"; + let textureSet, alpha, read; + if (isMaterialBlock) { + ({ textureSet, alpha } = extractMaterialInfo(materialBlock)); + read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); + } else { + textureSet = "none.png"; + alpha = 1; + read = readColourValue(materialBlock); + } const colorIsEmpty = read.value == null || From 2f9707182092df061dc0f0646ada00958ef87ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:24:56 +0000 Subject: [PATCH 08/12] Match live-update material path to generated-code path for raw colour/list blocks When a colour list (lists_create_with) or single colour block is wired directly to the MATERIAL input, the live-update was wrapping the value in a descriptor { color, materialName: "none.png" } which routed through GradientMaterial. The generated JS passes the raw array/string directly, which routes through the StandardMaterial + canvas-gradient-texture path instead. Pass read.value directly to createMap for non-material blocks so both paths produce the same visual result. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- ui/blockmesh.js | 58 +++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/ui/blockmesh.js b/ui/blockmesh.js index e068e6809..86714a215 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -779,37 +779,47 @@ function updateMapFromBlock(mesh, block, changeEvent) { // A raw colour or list block may be connected directly to MATERIAL (not via a // material block). In that case there is no BASE_COLOR sub-input to read, so - // call readColourValue on the block itself and use "none.png" as the texture. + // call readColourValue on the block itself. Pass the raw value straight to + // createMap so it follows the same code path as the generated JS (which also + // passes the raw array / colour string). const isMaterialBlock = materialBlock.type === "material"; - let textureSet, alpha, read; + let read; if (isMaterialBlock) { - ({ textureSet, alpha } = extractMaterialInfo(materialBlock)); + const { textureSet, alpha } = extractMaterialInfo(materialBlock); read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); + + const colorIsEmpty = + read.value == null || + (Array.isArray(read.value) && read.value.length === 0); + if (colorIsEmpty && !block.__mapRetry) { + block.__mapRetry = true; + requestAnimationFrame(() => { + block.__mapRetry = false; + updateMapFromBlock(mesh, block, changeEvent); + }); + return; + } + + flock.createMap(mapName, { color: read.value, materialName: textureSet, alpha }); } else { - textureSet = "none.png"; - alpha = 1; read = readColourValue(materialBlock); - } - - const colorIsEmpty = - read.value == null || - (Array.isArray(read.value) && read.value.length === 0); - if (colorIsEmpty && !block.__mapRetry) { - block.__mapRetry = true; - requestAnimationFrame(() => { - block.__mapRetry = false; - updateMapFromBlock(mesh, block, changeEvent); - }); - return; - } - const materialOptions = { - color: read.value, - materialName: textureSet, - alpha, - }; + const colorIsEmpty = + read.value == null || + (Array.isArray(read.value) && read.value.length === 0); + if (colorIsEmpty && !block.__mapRetry) { + block.__mapRetry = true; + requestAnimationFrame(() => { + block.__mapRetry = false; + updateMapFromBlock(mesh, block, changeEvent); + }); + return; + } - flock.createMap(mapName, materialOptions); + // Pass the raw value (array or string) directly, matching what the code + // generator outputs for a colour/list block wired to MATERIAL. + flock.createMap(mapName, read.value); + } } function resolveColorAndMaterialForBlock(block) { From 89602d0049440eb55b2daa33fe708d2086e83c1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:34:54 +0000 Subject: [PATCH 09/12] Simplify: deduplicate retry logic and remove narrating comments - blockmesh.js: compute read and mapArg in each branch, then share the single colorIsEmpty + retry guard and createMap call instead of repeating it in both branches - scene.js: remove two comments that just described the following line https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/scene.js | 4 +--- ui/blockmesh.js | 54 +++++++++++++++++-------------------------------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/api/scene.js b/api/scene.js index 768e3141e..dc2549142 100644 --- a/api/scene.js +++ b/api/scene.js @@ -218,8 +218,7 @@ export const flockScene = { oldMat.dispose(false, true); } } else { - // For descriptors with multiple colors + flat texture, update an existing - // GradientMaterial in-place to avoid shader recompilation ("black and red" flash). + // Update an existing GradientMaterial in-place to avoid shader recompilation. const colors = mat && typeof mat === "object" && Array.isArray(mat.color) ? mat.color @@ -236,7 +235,6 @@ export const flockScene = { existingMat.topColor = flock.BABYLON.Color3.FromHexString( flock.getColorFromString(colors[1]), ); - // Re-key the material cache to reflect the new colors. const oldKey = existingMat.metadata?.cacheKey; if (oldKey) delete flock.materialCache[oldKey]; const alphaKey = parseFloat(mat.alpha ?? 1).toFixed(2); diff --git a/ui/blockmesh.js b/ui/blockmesh.js index 86714a215..79510ea3c 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -777,49 +777,33 @@ function updateMapFromBlock(mesh, block, changeEvent) { if (!materialBlock) return; - // A raw colour or list block may be connected directly to MATERIAL (not via a - // material block). In that case there is no BASE_COLOR sub-input to read, so - // call readColourValue on the block itself. Pass the raw value straight to - // createMap so it follows the same code path as the generated JS (which also - // passes the raw array / colour string). + // A raw colour/list block may be connected directly to MATERIAL (not via a + // material block), so dispatch on block type and pass the raw value straight + // to createMap to match the generated-JS code path. const isMaterialBlock = materialBlock.type === "material"; - let read; + let read, mapArg; if (isMaterialBlock) { const { textureSet, alpha } = extractMaterialInfo(materialBlock); read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); - - const colorIsEmpty = - read.value == null || - (Array.isArray(read.value) && read.value.length === 0); - if (colorIsEmpty && !block.__mapRetry) { - block.__mapRetry = true; - requestAnimationFrame(() => { - block.__mapRetry = false; - updateMapFromBlock(mesh, block, changeEvent); - }); - return; - } - - flock.createMap(mapName, { color: read.value, materialName: textureSet, alpha }); + mapArg = { color: read.value, materialName: textureSet, alpha }; } else { read = readColourValue(materialBlock); + mapArg = read.value; + } - const colorIsEmpty = - read.value == null || - (Array.isArray(read.value) && read.value.length === 0); - if (colorIsEmpty && !block.__mapRetry) { - block.__mapRetry = true; - requestAnimationFrame(() => { - block.__mapRetry = false; - updateMapFromBlock(mesh, block, changeEvent); - }); - return; - } - - // Pass the raw value (array or string) directly, matching what the code - // generator outputs for a colour/list block wired to MATERIAL. - flock.createMap(mapName, read.value); + const colorIsEmpty = + read.value == null || + (Array.isArray(read.value) && read.value.length === 0); + if (colorIsEmpty && !block.__mapRetry) { + block.__mapRetry = true; + requestAnimationFrame(() => { + block.__mapRetry = false; + updateMapFromBlock(mesh, block, changeEvent); + }); + return; } + + flock.createMap(mapName, mapArg); } function resolveColorAndMaterialForBlock(block) { From bf0e4e6a31c1cd5aefd05871573e5fd0b09f79dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 08:41:13 +0000 Subject: [PATCH 10/12] Fix three issues flagged by CodeRabbit review - scene.js: apply mat.alpha to the GradientMaterial instance when updating in-place; previously only the cache key reflected the new alpha value - blockmesh.js: normalize the "NONE" sentinel from extractMaterialInfo to "none.png" so the in-place gradient path in scene.js is correctly matched - blockmesh.js: fix infinite requestAnimationFrame loop when a colour list is permanently empty; now retries at most once by tracking wasRetrying before clearing the flag https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/scene.js | 1 + ui/blockmesh.js | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/api/scene.js b/api/scene.js index dc2549142..f5097120c 100644 --- a/api/scene.js +++ b/api/scene.js @@ -235,6 +235,7 @@ export const flockScene = { existingMat.topColor = flock.BABYLON.Color3.FromHexString( flock.getColorFromString(colors[1]), ); + existingMat.alpha = parseFloat(mat.alpha ?? 1); const oldKey = existingMat.metadata?.cacheKey; if (oldKey) delete flock.materialCache[oldKey]; const alphaKey = parseFloat(mat.alpha ?? 1).toFixed(2); diff --git a/ui/blockmesh.js b/ui/blockmesh.js index 79510ea3c..d60d5d0be 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -785,7 +785,9 @@ function updateMapFromBlock(mesh, block, changeEvent) { if (isMaterialBlock) { const { textureSet, alpha } = extractMaterialInfo(materialBlock); read = readColourFromInputOrShadow(materialBlock, "BASE_COLOR"); - mapArg = { color: read.value, materialName: textureSet, alpha }; + const materialName = + !textureSet || textureSet === "NONE" ? "none.png" : textureSet; + mapArg = { color: read.value, materialName, alpha }; } else { read = readColourValue(materialBlock); mapArg = read.value; @@ -794,14 +796,18 @@ function updateMapFromBlock(mesh, block, changeEvent) { const colorIsEmpty = read.value == null || (Array.isArray(read.value) && read.value.length === 0); - if (colorIsEmpty && !block.__mapRetry) { - block.__mapRetry = true; - requestAnimationFrame(() => { - block.__mapRetry = false; - updateMapFromBlock(mesh, block, changeEvent); - }); + if (colorIsEmpty) { + // Retry once — mutator operations briefly leave the colour list empty. + // If still empty after the retry, bail silently to avoid an infinite loop. + const wasRetrying = block.__mapRetry; + block.__mapRetry = false; + if (!wasRetrying) { + block.__mapRetry = true; + requestAnimationFrame(() => updateMapFromBlock(mesh, block, changeEvent)); + } return; } + block.__mapRetry = false; flock.createMap(mapName, mapArg); } From 59c7f517b4cd2b1ec9b313fa35edcb3321da1c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 09:00:33 +0000 Subject: [PATCH 11/12] Fix TypeError when a single-element colour list is used in a material block getRawColor in applyMaterialToHierarchy was returning the raw array (e.g. ["#ff5733"]) for a single-element colour list. getColorFromString then matched the regex (arrays coerce to string for .test()) but tried to call .toLowerCase() on the array, throwing a TypeError that prevented setMaterialWithCleanup from ever being called, leaving the mesh with no material applied. Normalise single-element arrays to a plain string in getRawColor, matching the same normalisation that createMaterial already performs. https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/material.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/material.js b/api/material.js index b19d2c72a..ebf620eaa 100644 --- a/api/material.js +++ b/api/material.js @@ -1443,8 +1443,12 @@ export const flockMaterial = { const isMaterialDescriptor = (v) => typeof v === "object" && v !== null && !Array.isArray(v); - const getRawColor = (v) => - isMaterialDescriptor(v) ? v.color || v.baseColor : v; + const getRawColor = (v) => { + const raw = isMaterialDescriptor(v) ? v.color || v.baseColor : v; + // A single-element colour list produces ["#rrggbb"]; normalise to a string + // so that downstream callers like getColorFromString receive a plain string. + return Array.isArray(raw) && raw.length === 1 ? raw[0] : raw; + }; const getTexName = (v) => isMaterialDescriptor(v) From 2fea38e502fdad2cced283eab35dfa91992a7e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 09:05:33 +0000 Subject: [PATCH 12/12] Add third colour slot to texture pattern shader to replace black The colorReplace shader already handles two colours: color[0] replaces near-white pixels and color[1] tints grey pixels. A third colour in the list now replaces near-black pixels (brightness < 0.05, low saturation). - Added darkColor and colorCount uniforms to the fragment shader - The black-replacement branch only activates when colorCount >= 3 so existing two-colour materials are unaffected - darkColor is set from colors[2] when present; colorCount carries the length of the colours array https://claude.ai/code/session_01C23exQ7PpjerGEgnMYJL6W --- api/material.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/material.js b/api/material.js index ebf620eaa..aaa1d4a29 100644 --- a/api/material.js +++ b/api/material.js @@ -1089,6 +1089,8 @@ export const flockMaterial = { uniform sampler2D textureSampler; uniform vec3 lightColor; // Replaces white uniform vec3 greyTintColor; // Tints greys in proportion + uniform vec3 darkColor; // Replaces black (when colorCount >= 3) + uniform int colorCount; uniform float alpha; uniform float uScale; // Horizontal tiling uniform float vScale; // Vertical tiling @@ -1111,6 +1113,9 @@ export const flockMaterial = { if (brightness > 0.95 && colorDiff < 0.05) { // Replace near-white finalColor = lightColor; + } else if (colorCount >= 3 && brightness < 0.05 && colorDiff < 0.05) { + // Replace near-black (third color) + finalColor = darkColor; } else if (colorDiff < 0.05) { // Tint greys finalColor = brightness * greyTintColor; @@ -1138,6 +1143,8 @@ export const flockMaterial = { "textureSampler", "lightColor", "greyTintColor", + "darkColor", + "colorCount", "alpha", "uScale", "vScale", @@ -1186,6 +1193,19 @@ export const flockMaterial = { ), ); + const colorDark = colors.length >= 3 + ? flock.hexToRgb(flock.getColorFromString(colors[2])) + : { r: 0, g: 0, b: 0 }; + shaderMaterial.setVector3( + "darkColor", + new flock.BABYLON.Vector3( + colorDark.r / 255.0, + colorDark.g / 255.0, + colorDark.b / 255.0, + ), + ); + shaderMaterial.setInt("colorCount", colors.length); + shaderMaterial.setFloat("alpha", 1.0); return shaderMaterial;