diff --git a/api/material.js b/api/material.js index 0807d2b8..86fd3cd8 100644 --- a/api/material.js +++ b/api/material.js @@ -4,6 +4,23 @@ export function setFlockReference(ref) { flock = ref; } +// Gradient direction follows CSS linear-gradient: 0 is bottom to top, increasing +// clockwise, rotating within the object's local XY plane. +function gradientAxisFor(direction) { + const radians = ((Number(direction) || 0) * Math.PI) / 180; + return { x: Math.sin(radians), y: Math.cos(radians) }; +} + +// The angle is part of a gradient's identity, so it has to reach the material cache key. +function withGradientDirection(colorKey, direction) { + return Number.isFinite(direction) ? `${colorKey}@${direction}` : colorKey; +} + +function readGradientDirection(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return Number.isFinite(value.direction) ? value.direction : undefined; +} + export const flockMaterial = { adjustMaterialTilingToMesh(mesh, material, _unitsPerTile = null) { return; // Don't scale textures - need to change the mesh UVs instead @@ -47,6 +64,17 @@ export const flockMaterial = { return `rgba(${r}, ${g}, ${b}, ${alpha})`; }, getColorFromString(colourString) { + // A colour list or a material/gradient descriptor can reach solid-colour APIs + // such as tint and highlight; fall back to the first colour rather than black. + if (Array.isArray(colourString)) { + return flock.getColorFromString(colourString[0]); + } + + if (typeof colourString === 'object' && colourString !== null) { + const inner = colourString.color ?? colourString.baseColor; + return inner === undefined ? '#000000' : flock.getColorFromString(inner); + } + if (typeof colourString !== 'string') { return '#000000'; } @@ -193,11 +221,13 @@ export const flockMaterial = { const parts = mat.metadata.cacheKey.split('_'); const lastPart = parts[parts.length - 1]; const hasGlowPart = lastPart === 'glow' || lastPart === 'noglow'; - const colorPart = parts[1]; + const [colorPart, directionPart] = parts[1].split('@'); const color = colorPart.includes('-') ? colorPart.split('-') : colorPart; + const parsedDirection = parseFloat(directionPart); const parsedAlpha = parseFloat(parts[2]); return { color, + ...(Number.isFinite(parsedDirection) ? { direction: parsedDirection } : {}), materialName: mat.metadata.texName || parts.slice(3, hasGlowPart ? -1 : parts.length).join('_') || @@ -507,6 +537,14 @@ export const flockMaterial = { return; } + // A gradient is one material spanning the object, not a colour per sub-mesh, + // so it goes straight to the material pipeline. + if (readGradientDirection(color) !== undefined) { + flock.applyMaterialToHierarchy(mesh, color, { applyColor: true }); + if (mesh.metadata?.glow) flock.glowMesh(mesh); + return; + } + const getPartNameFromMesh = flock.getCanonicalPartName; const getRootMesh = (node) => { @@ -878,7 +916,7 @@ export const flockMaterial = { }); }); }, - createMaterial({ color, materialName, alpha, glow = false } = {}) { + createMaterial({ color, materialName, alpha, glow = false, direction } = {}) { if (flock?.materialsDebug) console.log(`Create material: ${materialName}`); let material; const texturePath = flock.texturePath + materialName; @@ -896,7 +934,12 @@ export const flockMaterial = { if (Array.isArray(color) && color.length >= 2) { // Use gradient for Flat material if (materialName === 'none.png') { - if (color.length === 2) { + if (Number.isFinite(direction)) { + // An angled gradient needs the shader path even for two colours; + // GradientMaterial only runs bottom to top. + material = flock.createMultiColorGradientMaterial(materialName, color, direction); + material.backFaceCulling = false; + } else if (color.length === 2) { material = new flock.GradientMaterial(materialName, flock.scene); material.bottomColor = flock.BABYLON.Color3.FromHexString( flock.getColorFromString(color[0]) @@ -961,7 +1004,7 @@ export const flockMaterial = { if (flock.materialsDebug) console.log(`Created the material: ${material.name}`); return material; }, - createMultiColorGradientMaterial(name, colors) { + createMultiColorGradientMaterial(name, colors, direction = 0) { if (!flock.BABYLON.Effect.ShadersStore['multiColorGradientFogVertexShader']) { flock.BABYLON.Effect.ShadersStore['multiColorGradientFogVertexShader'] = ` precision highp float; @@ -970,6 +1013,7 @@ export const flockMaterial = { uniform mat4 world; uniform mat4 view; uniform vec2 minMax; + uniform vec2 gradientAxis; varying float vGradient; varying vec3 vFogPosition; @@ -977,7 +1021,7 @@ export const flockMaterial = { vec4 worldPosition = world * vec4(position, 1.0); vec4 viewPosition = view * worldPosition; gl_Position = worldViewProjection * vec4(position, 1.0); - vGradient = (position.y - minMax.x) / (minMax.y - minMax.x); + vGradient = (dot(position.xy, gradientAxis) - minMax.x) / max(1e-5, minMax.y - minMax.x); vFogPosition = viewPosition.xyz; } `; @@ -1050,6 +1094,7 @@ export const flockMaterial = { 'colors', 'alpha', 'minMax', + 'gradientAxis', 'fogColor', 'fogDensity', 'fogStart', @@ -1074,10 +1119,18 @@ export const flockMaterial = { console.log('Color array:', color3Array); } + const axis = gradientAxisFor(direction); + 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)); + shaderMaterial.setVector2('gradientAxis', new flock.BABYLON.Vector2(axis.x, axis.y)); + + shaderMaterial.metadata = { + ...(shaderMaterial.metadata || {}), + gradientDirection: Number(direction) || 0, + }; flock.registerFogAwareShaderMaterial(shaderMaterial); flock.updateFogUniformsForShaderMaterial(shaderMaterial); @@ -1436,6 +1489,7 @@ export const flockMaterial = { let texName = 'none.png'; let finalAlpha = alpha; let finalGlow = false; + let finalDirection; if (isObject) { const inner = colorInput.color || colorInput.baseColor; @@ -1456,17 +1510,22 @@ export const flockMaterial = { ? colorInput.alpha : alpha; finalGlow = inner.glow !== undefined ? inner.glow : (colorInput.glow ?? false); + finalDirection = readGradientDirection(inner) ?? readGradientDirection(colorInput); } else { rawColor = inner || '#ffffff'; texName = colorInput.materialName || colorInput.textureSet || 'none.png'; finalAlpha = colorInput.alpha !== undefined ? colorInput.alpha : alpha; finalGlow = colorInput.glow ?? false; + finalDirection = readGradientDirection(colorInput); } } else { rawColor = colorInput || '#ffffff'; } - const colorKey = Array.isArray(rawColor) ? rawColor.join('-') : rawColor; + const colorKey = withGradientDirection( + Array.isArray(rawColor) ? rawColor.join('-') : rawColor, + finalDirection + ); const alphaKey = parseFloat(finalAlpha).toFixed(2); const glowKey = finalGlow ? 'glow' : 'noglow'; const cacheKey = `mat_${colorKey}_${alphaKey}_${texName}_${glowKey}`.toLowerCase(); @@ -1479,6 +1538,7 @@ export const flockMaterial = { materialName: texName, alpha: finalAlpha, glow: finalGlow, + ...(Number.isFinite(finalDirection) ? { direction: finalDirection } : {}), }; const newMat = flock.createMaterial(materialParams); @@ -1536,9 +1596,12 @@ export const flockMaterial = { const makeTargetCacheKey = (v) => { const rawColor = getRawColor(v); - const colorKey = Array.isArray(rawColor) - ? rawColor.join('-') - : flock.getColorFromString(rawColor) || '#ffffff'; + const colorKey = withGradientDirection( + Array.isArray(rawColor) + ? rawColor.join('-') + : flock.getColorFromString(rawColor) || '#ffffff', + readGradientDirection(v) + ); const texName = String(getTexName(v)); const alphaKey = parseFloat(getAlpha(v)).toFixed(2); const glow = @@ -1572,7 +1635,17 @@ export const flockMaterial = { const mat = m.material; mat.metadata._minMaxObserver = mat.onBindObservable.add((boundMesh) => { const bb = boundMesh.getBoundingInfo().boundingBox; - mat.setVector2('minMax', new flock.BABYLON.Vector2(bb.minimum.y, bb.maximum.y)); + const axis = gradientAxisFor(mat.metadata?.gradientDirection ?? 0); + let low = Infinity; + let high = -Infinity; + for (const x of [bb.minimum.x, bb.maximum.x]) { + for (const y of [bb.minimum.y, bb.maximum.y]) { + const projected = x * axis.x + y * axis.y; + low = Math.min(low, projected); + high = Math.max(high, projected); + } + } + mat.setVector2('minMax', new flock.BABYLON.Vector2(low, high)); }); } } diff --git a/api/scene.js b/api/scene.js index b5169bb3..0c98c9e0 100644 --- a/api/scene.js +++ b/api/scene.js @@ -15,13 +15,25 @@ export const flockScene = { let color = input; + // The gradient colour block hands over a descriptor. Unwrap to its colours + // before the material conversion below, so the sky builds its own gradient + // and sizes minMax to the sky sphere; the sky only does bottom to top, so + // the direction is ignored. + if (color && typeof color === 'object' && !Array.isArray(color) && Array.isArray(color.color)) { + // Only for an untextured descriptor: with a texture the colour list means + // palette replacement, which the material conversion has to handle. + const texName = color.materialName || color.textureSet || 'none.png'; + if (texName === 'none.png') color = color.color; + } + // Convert object input to a material (handles texture + colors) if ( - typeof input === 'object' && - !(input instanceof flock.BABYLON.Material) && - !Array.isArray(input) + color && + typeof color === 'object' && + !(color instanceof flock.BABYLON.Material) && + !Array.isArray(color) ) { - color = flock.createMaterial(input); + color = flock.createMaterial(color); } if (!color) return; diff --git a/blocks/materials.js b/blocks/materials.js index 6e737219..bafadacc 100644 --- a/blocks/materials.js +++ b/blocks/materials.js @@ -30,7 +30,7 @@ export function defineMaterialsBlocks() { { type: 'input_value', name: 'COLOR', - check: ['Colour', 'Array'], // Accepts either Colour or Array + check: ['Colour', 'Array', 'Gradient'], // Accepts a Colour, an Array or a Gradient }, ], inputsInline: true, @@ -688,6 +688,8 @@ export function defineMaterialsBlocks() { type: 'input_value', name: 'BASE_COLOR', colour: '#ffffff', // Default to white + // No Gradient: a texture and a gradient can't share one material. + check: ['Colour', 'Array'], }, { type: 'input_value', @@ -718,7 +720,7 @@ export function defineMaterialsBlocks() { type: 'input_value', name: 'COLOR', colour: '#6495ED', - check: ['Colour', 'Array'], + check: ['Colour', 'Array', 'Gradient'], }, { type: 'input_value', @@ -739,6 +741,36 @@ export function defineMaterialsBlocks() { }, }; + Blockly.Blocks['gradient_colour'] = { + init: function () { + this.jsonInit({ + type: 'gradient_colour', + message0: translate('gradient_colour'), + args0: [ + { + type: 'input_value', + name: 'COLORS', + check: 'Array', + }, + { + type: 'field_number', + name: 'DIRECTION', + value: 0, + min: 0, + max: 360, + precision: 1, + }, + ], + output: ['Gradient', 'Material'], + inputsInline: true, + colour: categoryColours['Materials'], + tooltip: getTooltip('gradient_colour'), + }); + this.setHelpUrl(getHelpUrlFor(this.type)); + this.setStyle('materials_blocks'); + }, + }; + function attachSetMaterialOnChange(block) { const ws = block.workspace; const touches = makeTouchesInputSubtree(block, ws, 'MATERIAL'); diff --git a/generators/generators-material.js b/generators/generators-material.js index e2596d90..c5258940 100644 --- a/generators/generators-material.js +++ b/generators/generators-material.js @@ -176,4 +176,15 @@ export function registerMaterialGenerators(javascriptGenerator) { const code = `{ color: ${color}, materialName: "none.png", alpha: ${alpha} }`; return [code, javascriptGenerator.ORDER_ATOMIC]; }; + + // Gradient colour ---------------------------------------------- + javascriptGenerator.forBlock['gradient_colour'] = function (block) { + const colors = + javascriptGenerator.valueToCode(block, 'COLORS', javascriptGenerator.ORDER_ATOMIC) || '[]'; + const direction = Number(block.getFieldValue('DIRECTION')) || 0; + + // No alpha key, so the surrounding block's alpha still applies. + const code = `{ color: ${colors}, materialName: "none.png", direction: ${direction} }`; + return [code, javascriptGenerator.ORDER_ATOMIC]; + }; } diff --git a/locale/en.js b/locale/en.js index 750efc11..d2508f65 100644 --- a/locale/en.js +++ b/locale/en.js @@ -244,6 +244,7 @@ export default { random_colour: 'random color', material: 'material %1 %2 alpha %3', gradient_material: 'material %1 alpha %2', + gradient_colour: 'gradient %1 direction %2°', set_material: 'set material of %1 to %2', // Custom block translations - Physics blocks @@ -538,6 +539,8 @@ export default { random_colour_tooltip: 'Generate a random color.\nKeyword: randcol', material_tooltip: 'Define material properties', gradient_material_tooltip: 'Define material properties', + gradient_colour_tooltip: + 'Blend between two or more colors. Direction is in degrees: 0 is bottom to top, 90 is left to right.\nKeyword: gradient', set_material_tooltip: 'Set the specified material on the given object.', // Tooltip translations - Physics blocks diff --git a/locale/es.js b/locale/es.js index 668c5fa1..54707b24 100644 --- a/locale/es.js +++ b/locale/es.js @@ -238,6 +238,7 @@ export default { random_colour: 'color aleatorio', // human material: 'material %1 %2 alfa %3', // human gradient_material: 'material %1 alfa %2', // human + gradient_colour: 'degradado %1 dirección %2°', // ai set_material: 'establecer material de %1 a %2', // human // Custom block translations - Physics blocks @@ -539,6 +540,8 @@ export default { random_colour_tooltip: 'Genera un color aleatorio.\nPalabra clave: color aleatorio', // human material_tooltip: 'Define propiedades del material', // human gradient_material_tooltip: 'Define propiedades del material (gradiente)', // human + gradient_colour_tooltip: + 'Mezcla dos o más colores. La dirección se indica en grados: 0 es de abajo a arriba, 90 es de izquierda a derecha.\nPalabra clave: degradado', // ai set_material_tooltip: 'Establecer el material especificado al objeto indicado.', // human // Tooltip translations - Physics blocks diff --git a/tests/generators-material.test.js b/tests/generators-material.test.js index 64e2b268..1d708ae4 100644 --- a/tests/generators-material.test.js +++ b/tests/generators-material.test.js @@ -35,5 +35,35 @@ export function runMaterialGeneratorTests() { it('falls back to black for invalid input', function () { expect(colourFromString('notacolour')).to.equal('#000000'); }); + + // The colour list arrives through valueToCode, so stub it rather than + // building a workspace just to hold a lists_create_with block. + function gradientColour(colorsCode, direction) { + const originalValueToCode = javascriptGenerator.valueToCode; + javascriptGenerator.valueToCode = () => colorsCode; + try { + const block = { getFieldValue: () => direction }; + const [code] = javascriptGenerator.forBlock['gradient_colour'](block); + return code; + } finally { + javascriptGenerator.valueToCode = originalValueToCode; + } + } + + it('emits a flat material descriptor carrying the direction', function () { + expect(gradientColour('["#ff5733", "#fdfd96"]', 45)).to.equal( + '{ color: ["#ff5733", "#fdfd96"], materialName: "none.png", direction: 45 }' + ); + }); + + it('omits alpha so the surrounding block keeps control of it', function () { + expect(gradientColour('["#ff5733", "#fdfd96"]', 0)).to.not.include('alpha'); + }); + + it('defaults to an empty list and zero degrees', function () { + expect(gradientColour('', undefined)).to.equal( + '{ color: [], materialName: "none.png", direction: 0 }' + ); + }); }); } diff --git a/tests/materials.test.js b/tests/materials.test.js index c02a4c6b..76766070 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -1293,4 +1293,146 @@ export function runMaterialsTests(flock) { expect(matAfterFirst).to.equal(matAfterSecond); }); }); + + describe('gradient direction @materials', function () { + const boxIds = []; + + beforeEach(async function () { + flock.scene ??= {}; + }); + + afterEach(function () { + boxIds.forEach((id) => flock.dispose(id)); + boxIds.length = 0; + }); + + async function createDirectedGradientBox(id, colors, direction) { + await flock.createBox(id, { + width: 1, + height: 2, + depth: 1, + color: { color: colors, materialName: 'none.png', direction }, + position: [0, 0, 0], + }); + boxIds.push(id); + } + + function getTarget(id) { + const mesh = flock.scene.getMeshByName(id); + const children = mesh + .getDescendants(false) + .filter((n) => n.getTotalVertices && n.getTotalVertices() > 0); + return children.length ? children[0] : mesh; + } + + it('should use the shader for a 2-colour gradient with a direction', function () { + const material = flock.createMaterial({ + color: ['#ff0000', '#0000ff'], + materialName: 'none.png', + alpha: 1, + direction: 45, + }); + + expect(material.getClassName()).to.equal('ShaderMaterial'); + expect(material.metadata.gradientDirection).to.equal(45); + material.dispose(); + }); + + it('should still use GradientMaterial for 2 colours with no direction', function () { + const material = flock.createMaterial({ + color: ['#ff0000', '#0000ff'], + materialName: 'none.png', + alpha: 1, + }); + + expect(material.getClassName()).to.equal('GradientMaterial'); + material.dispose(); + }); + + it('should point the gradient axis up at 0 degrees and right at 90', function () { + const up = flock.createMaterial({ + color: ['#ff0000', '#0000ff'], + materialName: 'none.png', + alpha: 1, + direction: 0, + }); + const right = flock.createMaterial({ + color: ['#ff0000', '#0000ff'], + materialName: 'none.png', + alpha: 1, + direction: 90, + }); + + expect(up._vectors2.gradientAxis.x).to.be.closeTo(0, 1e-6); + expect(up._vectors2.gradientAxis.y).to.be.closeTo(1, 1e-6); + expect(right._vectors2.gradientAxis.x).to.be.closeTo(1, 1e-6); + expect(right._vectors2.gradientAxis.y).to.be.closeTo(0, 1e-6); + + up.dispose(); + right.dispose(); + }); + + it('should compile the directed gradient shader', async function () { + await createDirectedGradientBox('gradDirCompile', ['#ff0000', '#0000ff'], 45); + const target = getTarget('gradDirCompile'); + const material = target.material; + + const deadline = Date.now() + 5000; + while (!material.isReady(target) && Date.now() < deadline) { + flock.scene.render(); + await new Promise((resolve) => setTimeout(resolve, 16)); + } + + const compilationError = material.getEffect()?.getCompilationError?.() || ''; + expect(compilationError).to.equal(''); + expect(material.isReady(target)).to.equal(true); + }); + + it('should cache gradients of different directions separately', async function () { + await createDirectedGradientBox('gradDir0', ['#ff0000', '#0000ff'], 0); + await createDirectedGradientBox('gradDir90', ['#ff0000', '#0000ff'], 90); + + const flat = getTarget('gradDir0').material; + const angled = getTarget('gradDir90').material; + + expect(flat).to.not.equal(angled); + expect(flat.metadata.gradientDirection).to.equal(0); + expect(angled.metadata.gradientDirection).to.equal(90); + }); + + it('should preserve the direction through setAlpha and clearEffects', async function () { + await createDirectedGradientBox('gradDirEffects', ['#ff0000', '#0000ff'], 45); + + await flock.setAlpha('gradDirEffects', { value: 0.5 }); + let target = getTarget('gradDirEffects'); + expect(target.material.getClassName()).to.equal('ShaderMaterial'); + expect(target.material.metadata.gradientDirection).to.equal(45); + + await flock.glow('gradDirEffects'); + await flock.clearEffects('gradDirEffects'); + target = getTarget('gradDirEffects'); + expect(target.material.getClassName()).to.equal('ShaderMaterial'); + expect(target.material.metadata.gradientDirection).to.equal(45); + }); + + it('should apply a directed gradient through changeColor', async function () { + const id = 'gradDirChangeColor'; + await flock.createBox(id, { + width: 1, + height: 2, + depth: 1, + color: '#ffffff', + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.changeColor(id, { + color: { color: ['#ff0000', '#0000ff'], materialName: 'none.png', direction: 30 }, + }); + + const target = getTarget(id); + expect(target.material.getClassName()).to.equal('ShaderMaterial'); + expect(target.material.metadata.gradientDirection).to.equal(30); + }); + }); } diff --git a/toolbox.js b/toolbox.js index 278ba782..9f6fada7 100644 --- a/toolbox.js +++ b/toolbox.js @@ -3284,6 +3284,38 @@ const toolboxMaterials = { type: 'colour', keyword: 'setcol', }, + { + kind: 'block', + type: 'gradient_colour', + keyword: 'gradient', + inputs: { + COLORS: { + block: { + type: 'lists_create_with', + extraState: { itemCount: 2 }, + inline: true, + inputs: { + ADD0: { + shadow: { + type: 'colour', + fields: { + COLOR: '#FF5733', + }, + }, + }, + ADD1: { + shadow: { + type: 'colour', + fields: { + COLOR: '#FDFD96', + }, + }, + }, + }, + }, + }, + }, + }, { kind: 'block', type: 'lists_create_with', diff --git a/ui/blockmesh.js b/ui/blockmesh.js index aea803d8..ed9cc4ef 100644 --- a/ui/blockmesh.js +++ b/ui/blockmesh.js @@ -433,6 +433,21 @@ export function readColourValue(block) { return { value: c, kind: 'single' }; } + if (block.type === 'gradient_colour') { + const colors = readColourValue(block.getInputTargetBlock('COLORS')); + const list = Array.isArray(colors.value) ? colors.value : colors.value ? [colors.value] : []; + if (!list.length) return { value: null, kind: 'none' }; + + return { + value: { + color: list, + materialName: 'none.png', + direction: Number(safeGetFieldValue(block, 'DIRECTION')) || 0, + }, + kind: 'gradient', + }; + } + const single = safeGetFieldValue(block, 'COLOR') ?? safeGetFieldValue(block, 'COLOUR') ?? null; return { value: single, kind: single ? 'single' : 'none' };