From 645ccd1129b88c33ec98879f9e094b63b7afb24f Mon Sep 17 00:00:00 2001 From: LalitNarayanYadav Date: Thu, 21 May 2026 16:43:29 +0530 Subject: [PATCH 1/5] Add paletteLerp for p5.strands (closes #8751) --- src/strands/strands_api.js | 38 +++++++++++ src/strands/strands_builtins.js | 2 + src/strands/strands_transpiler.js | 107 ++++++++++++++++++++++++++++++ src/webgl/strands_glslBackend.js | 1 + 4 files changed, 148 insertions(+) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 69bb2f4179..8b83ff0201 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -400,6 +400,44 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); return createStrandsNode(id, dimension, strandsContext); }); + augmentFn(fn, p5, 'paletteLerp', function(colorsNode, positionsNode, tNode) { + if (!strandsContext.active) return; + + const n = colorsNode.length; + + // Wrap raw values into StrandsNodes + const colors = colorsNode.map(c => p5.strandsNode(c)); + const positions = positionsNode.map(p => p5.strandsNode(p)); + const t = p5.strandsNode(tNode); + + // Helper: mix(a, b, clamp((t - pa) / (pb - pa), 0, 1)) + function segmentLerp(ca, cb, pa, pb) { + const zero = p5.strandsNode(0.0); + const one = p5.strandsNode(1.0); + const num = t.sub(pa); + const den = pb.sub(pa); + const localT = num.div(den).clamp(zero, one); + return buildTernary( + strandsContext, + pa.equalTo(pb), // guard: pa == pb → return midpoint + ca.mix(cb, p5.strandsNode(0.5)), + ca.mix(cb, localT) + ); + } + + // Build nested ternary chain from right to left: + // t >= p[last] ? c[last] : (t < p[n-1] ? seg(n-2,n-1) : (...)) + let result = colors[n - 1]; + for (let i = n - 2; i >= 0; i--) { + const seg = segmentLerp(colors[i], colors[i + 1], positions[i], positions[i + 1]); + result = buildTernary(strandsContext, t.lessThan(positions[i + 1]), seg, result); + } + // Clamp edges + result = buildTernary(strandsContext, t.greaterEqual(positions[n - 1]), colors[n - 1], result); + result = buildTernary(strandsContext, t.lessEqual(positions[0]), colors[0], result); + + return result; + }); strandsContext._randomSeed = null; diff --git a/src/strands/strands_builtins.js b/src/strands/strands_builtins.js index d6080ea428..f1b21b00d6 100644 --- a/src/strands/strands_builtins.js +++ b/src/strands/strands_builtins.js @@ -110,3 +110,5 @@ const builtInGLSLFunctions = { export const strandsBuiltinFunctions = { ...builtInGLSLFunctions, } + + diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index bf33907338..6108c6ee25 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -3,6 +3,16 @@ import { ancestor, recursive } from 'acorn-walk'; import escodegen from 'escodegen'; import { UnarySymbolToName } from './ir_types'; import * as FES from './strands_FES'; + +// Registry of strands functions that take raw array literals as arguments. +// Maps functionName → Set of argument indices that should NOT be +// converted to vectors by the ArrayExpression visitor. +// This generalizes the paletteLerp special-case so any future function +// taking array parameters can register here without modifying ArrayExpression. +const ARRAY_ARG_FUNCTIONS = { + paletteLerp: new Set([0]), // argument 0 is the [[color,pos],...] array +}; + let blockVarCounter = 0; let loopVarCounter = 0; function replaceBinaryOperator(codeSource) { @@ -563,12 +573,85 @@ const ASTCallbacks = { node.arguments = []; } }, + + // Rewrite paletteLerp([[color,pos],...], t) → __p5.paletteLerp([c,...], [p,...], t) + // Must run before ArrayExpression so the child arrays carry _isPaletteLerpArg + // and are not wrapped in strandsNode (which would mis-type them as vectors). + + CallExpression(node, state, ancestors) { + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { + return; + } + if (node.callee?.type !== 'Identifier' || node.callee?.name !== 'paletteLerp') { + return; + } + const args = node.arguments; + if (args.length !== 2) { + throw new Error( + `paletteLerp() requires 2 arguments: (colorStops[], t) — got ${args.length}.\n` + + `Usage: paletteLerp([[color(r,g,b), pos], ...], t)` + ); + } + const [stopsArg, tArg] = args; + if (stopsArg.type !== 'ArrayExpression') { + throw new Error( + `paletteLerp() first argument must be an array literal: [[color(...), pos], ...]` + ); + } + const stops = stopsArg.elements; + if (stops.length < 2 || stops.length > 8) { + throw new Error( + `paletteLerp() requires 2–8 color stops, got ${stops.length}.` + ); + } + for (let i = 0; i < stops.length; i++) { + if (stops[i].type !== 'ArrayExpression' || stops[i].elements.length !== 2) { + throw new Error( + `paletteLerp() stop ${i} must be a 2-element array: [color(...), position]` + ); + } + } + // Split pairs into two parallel arrays + const colorsArr = { + type: 'ArrayExpression', + elements: stops.map(s => s.elements[0]), + _isPaletteLerpArg: true, + }; + const positionsArr = { + type: 'ArrayExpression', + elements: stops.map(s => s.elements[1]), + _isPaletteLerpArg: true, + }; + // Rewrite in-place to __p5.paletteLerp(colors, positions, t) + node.callee = { type: 'Identifier', name: '__p5.paletteLerp' }; + node.arguments = [colorsArr, positionsArr, tArg]; + }, + + // The callbacks for AssignmentExpression and BinaryExpression handle // operator overloading including +=, *= assignment expressions + + ArrayExpression(node, state, ancestors) { if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { return; } + // Don't wrap arrays that are arguments to functions expecting raw arrays. + // Walk ancestors to find the nearest CallExpression and check the registry. + for (let i = ancestors.length - 1; i >= 0; i--) { + const a = ancestors[i]; + if (a.type === 'CallExpression') { + const name = a.callee?.name; + if (name && ARRAY_ARG_FUNCTIONS[name]) { + const argIndex = a.arguments.indexOf(node); + if (argIndex !== -1 && ARRAY_ARG_FUNCTIONS[name].has(argIndex)) { + return; + } + } + break; // only check nearest CallExpression + } + } + const original = JSON.parse(JSON.stringify(node)); node.type = 'CallExpression'; node.callee = { @@ -1700,6 +1783,30 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { // First pass: transform .set() calls in control flow to use intermediate variables transformSetCallsInControlFlow(ast, uniformCallbackNames); + // paletteLerp pre-pass: must run before the main pass so ArrayExpression + // doesn't wrap [[color,pos],...] as a vector before we can split the pairs. + ancestor(ast, { + CallExpression(node, state, ancestors) { + if (node.callee?.type !== 'Identifier' || node.callee?.name !== 'paletteLerp') return; + if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) return; + const [stopsArg, tArg] = node.arguments; + if (node.arguments.length !== 2) throw new Error(`paletteLerp() requires 2 arguments: ([[color,pos],...], t)`); + if (stopsArg.type !== 'ArrayExpression') throw new Error(`paletteLerp() first argument must be an array literal`); + const stops = stopsArg.elements; + if (stops.length < 2 || stops.length > 8) throw new Error(`paletteLerp() requires 2–8 color stops, got ${stops.length}`); + for (let i = 0; i < stops.length; i++) { + if (stops[i].type !== 'ArrayExpression' || stops[i].elements.length !== 2) + throw new Error(`paletteLerp() stop ${i} must be [color(...), position]`); + } + node.callee = { type: 'Identifier', name: '__p5.paletteLerp' }; + node.arguments = [ + { type: 'ArrayExpression', elements: stops.map(s => s.elements[0]) }, + { type: 'ArrayExpression', elements: stops.map(s => s.elements[1]) }, + tArg + ]; + } + }, undefined, { uniformCallbackNames }); + // Second pass: transform everything except if/for statements using normal ancestor traversal const nonControlFlowCallbacks = { ...ASTCallbacks }; delete nonControlFlowCallbacks.IfStatement; diff --git a/src/webgl/strands_glslBackend.js b/src/webgl/strands_glslBackend.js index 9ec04b4fae..c64f364802 100644 --- a/src/webgl/strands_glslBackend.js +++ b/src/webgl/strands_glslBackend.js @@ -181,6 +181,7 @@ export const glslBackend = { getRandomVertexShaderSnippet() { return randomVertGLSL; }, + getTypeName(baseType, dimension) { const primitiveTypeName = TypeNames[baseType + dimension] if (!primitiveTypeName) { From 733085856259a601e2d1908657d76e4aef7aa058 Mon Sep 17 00:00:00 2001 From: LalitNarayanYadav Date: Sun, 27 Sep 2026 00:30:49 +0530 Subject: [PATCH 2/5] Generalize array-arg handling via function-level argTypes --- src/strands/strands_api.js | 31 +++++++- src/strands/strands_transpiler.js | 127 ++++++++---------------------- 2 files changed, 60 insertions(+), 98 deletions(-) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 8b83ff0201..bef80afd29 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -400,14 +400,29 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); return createStrandsNode(id, dimension, strandsContext); }); - augmentFn(fn, p5, 'paletteLerp', function(colorsNode, positionsNode, tNode) { + augmentFn(fn, p5, 'paletteLerp', function(stopsNode, tNode) { if (!strandsContext.active) return; - const n = colorsNode.length; + if (!Array.isArray(stopsNode)) { + throw new Error( + 'paletteLerp() first argument must be an array literal: [[color(...), pos], ...]' + ); + } + const n = stopsNode.length; + if (n < 2 || n > 8) { + throw new Error(`paletteLerp() requires 2–8 color stops, got ${n}.`); + } + for (let i = 0; i < n; i++) { + if (!Array.isArray(stopsNode[i]) || stopsNode[i].length !== 2) { + throw new Error( + `paletteLerp() stop ${i} must be a 2-element array: [color(...), position]` + ); + } + } // Wrap raw values into StrandsNodes - const colors = colorsNode.map(c => p5.strandsNode(c)); - const positions = positionsNode.map(p => p5.strandsNode(p)); + const colors = stopsNode.map(([c]) => p5.strandsNode(c)); + const positions = stopsNode.map(([, p]) => p5.strandsNode(p)); const t = p5.strandsNode(tNode); // Helper: mix(a, b, clamp((t - pa) / (pb - pa), 0, 1)) @@ -438,6 +453,14 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return result; }); + // Tell the transpiler that argument 0 (the color-stop pairs) is a raw + // array it should leave untouched rather than converting to a vector — + // including the nested [color, position] pairs inside it, to any depth. + // See strands_transpiler.js's ArrayExpression visitor. + fn.paletteLerp.argTypes = [ + { type: 'Array', subtype: { type: 'any' } }, + { type: 'Number' } + ]; strandsContext._randomSeed = null; diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index 6108c6ee25..ee998f6f06 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -4,14 +4,18 @@ import escodegen from 'escodegen'; import { UnarySymbolToName } from './ir_types'; import * as FES from './strands_FES'; -// Registry of strands functions that take raw array literals as arguments. -// Maps functionName → Set of argument indices that should NOT be -// converted to vectors by the ArrayExpression visitor. -// This generalizes the paletteLerp special-case so any future function -// taking array parameters can register here without modifying ArrayExpression. -const ARRAY_ARG_FUNCTIONS = { - paletteLerp: new Set([0]), // argument 0 is the [[color,pos],...] array -}; +// Strands functions opt out of array-to-vector conversion by declaring +// `argTypes` on themselves (see strands_api.js's `paletteLerp` for an +// example), rather than being special-cased here. A descriptor of +// `{ type: 'Array', subtype: { type: 'any' } }` marks an argument as an +// opaque array: the ArrayExpression visitor leaves it (and anything +// nested inside it, to any depth) untouched instead of converting it to +// a vector/strandsNode call. This lets any current or future function — +// including ones added by addons — take raw array arguments without +// touching this file again. +function getArgTypes(fnName, p5) { + return p5?.prototype?.[fnName]?.argTypes; +} let blockVarCounter = 0; let loopVarCounter = 0; @@ -574,60 +578,6 @@ const ASTCallbacks = { } }, - // Rewrite paletteLerp([[color,pos],...], t) → __p5.paletteLerp([c,...], [p,...], t) - // Must run before ArrayExpression so the child arrays carry _isPaletteLerpArg - // and are not wrapped in strandsNode (which would mis-type them as vectors). - - CallExpression(node, state, ancestors) { - if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { - return; - } - if (node.callee?.type !== 'Identifier' || node.callee?.name !== 'paletteLerp') { - return; - } - const args = node.arguments; - if (args.length !== 2) { - throw new Error( - `paletteLerp() requires 2 arguments: (colorStops[], t) — got ${args.length}.\n` + - `Usage: paletteLerp([[color(r,g,b), pos], ...], t)` - ); - } - const [stopsArg, tArg] = args; - if (stopsArg.type !== 'ArrayExpression') { - throw new Error( - `paletteLerp() first argument must be an array literal: [[color(...), pos], ...]` - ); - } - const stops = stopsArg.elements; - if (stops.length < 2 || stops.length > 8) { - throw new Error( - `paletteLerp() requires 2–8 color stops, got ${stops.length}.` - ); - } - for (let i = 0; i < stops.length; i++) { - if (stops[i].type !== 'ArrayExpression' || stops[i].elements.length !== 2) { - throw new Error( - `paletteLerp() stop ${i} must be a 2-element array: [color(...), position]` - ); - } - } - // Split pairs into two parallel arrays - const colorsArr = { - type: 'ArrayExpression', - elements: stops.map(s => s.elements[0]), - _isPaletteLerpArg: true, - }; - const positionsArr = { - type: 'ArrayExpression', - elements: stops.map(s => s.elements[1]), - _isPaletteLerpArg: true, - }; - // Rewrite in-place to __p5.paletteLerp(colors, positions, t) - node.callee = { type: 'Identifier', name: '__p5.paletteLerp' }; - node.arguments = [colorsArr, positionsArr, tArg]; - }, - - // The callbacks for AssignmentExpression and BinaryExpression handle // operator overloading including +=, *= assignment expressions @@ -636,19 +586,32 @@ const ASTCallbacks = { if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) { return; } - // Don't wrap arrays that are arguments to functions expecting raw arrays. - // Walk ancestors to find the nearest CallExpression and check the registry. + // Don't wrap arrays that are (or are nested inside) an argument a + // function has declared as opaque via argTypes. We look this up + // structurally — via the nearest enclosing CallExpression and which + // of its arguments node descends from — rather than depending on + // that CallExpression's own visitor having already run, since + // acorn-walk's ancestor() calls child callbacks before parent ones. for (let i = ancestors.length - 1; i >= 0; i--) { const a = ancestors[i]; if (a.type === 'CallExpression') { - const name = a.callee?.name; - if (name && ARRAY_ARG_FUNCTIONS[name]) { - const argIndex = a.arguments.indexOf(node); - if (argIndex !== -1 && ARRAY_ARG_FUNCTIONS[name].has(argIndex)) { + const fnName = a.callee?.type === 'Identifier' + ? a.callee.name + : a.callee?.type === 'MemberExpression' && a.callee.property?.type === 'Identifier' + ? a.callee.property.name + : undefined; + const argTypes = fnName && getArgTypes(fnName, state.p5); + if (argTypes) { + // The direct child of `a` on the path down to `node` — either + // `node` itself, or an ancestor array that contains it. + const childOnPath = i + 1 < ancestors.length ? ancestors[i + 1] : node; + const argIndex = a.arguments.indexOf(childOnPath); + const argType = argIndex !== -1 ? argTypes[argIndex] : undefined; + if (argType?.type === 'Array' && argType.subtype?.type === 'any') { return; } } - break; // only check nearest CallExpression + break; // only consider the nearest enclosing call } } @@ -1783,35 +1746,11 @@ export function transpileStrandsToJS(p5, sourceString, srcLocations, scope) { // First pass: transform .set() calls in control flow to use intermediate variables transformSetCallsInControlFlow(ast, uniformCallbackNames); - // paletteLerp pre-pass: must run before the main pass so ArrayExpression - // doesn't wrap [[color,pos],...] as a vector before we can split the pairs. - ancestor(ast, { - CallExpression(node, state, ancestors) { - if (node.callee?.type !== 'Identifier' || node.callee?.name !== 'paletteLerp') return; - if (ancestors.some(a => nodeIsUniform(a) || nodeIsUniformCallbackFn(a, state.uniformCallbackNames))) return; - const [stopsArg, tArg] = node.arguments; - if (node.arguments.length !== 2) throw new Error(`paletteLerp() requires 2 arguments: ([[color,pos],...], t)`); - if (stopsArg.type !== 'ArrayExpression') throw new Error(`paletteLerp() first argument must be an array literal`); - const stops = stopsArg.elements; - if (stops.length < 2 || stops.length > 8) throw new Error(`paletteLerp() requires 2–8 color stops, got ${stops.length}`); - for (let i = 0; i < stops.length; i++) { - if (stops[i].type !== 'ArrayExpression' || stops[i].elements.length !== 2) - throw new Error(`paletteLerp() stop ${i} must be [color(...), position]`); - } - node.callee = { type: 'Identifier', name: '__p5.paletteLerp' }; - node.arguments = [ - { type: 'ArrayExpression', elements: stops.map(s => s.elements[0]) }, - { type: 'ArrayExpression', elements: stops.map(s => s.elements[1]) }, - tArg - ]; - } - }, undefined, { uniformCallbackNames }); - // Second pass: transform everything except if/for statements using normal ancestor traversal const nonControlFlowCallbacks = { ...ASTCallbacks }; delete nonControlFlowCallbacks.IfStatement; delete nonControlFlowCallbacks.ForStatement; - ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {}, uniformCallbackNames }); + ancestor(ast, nonControlFlowCallbacks, undefined, { varyings: {}, uniformCallbackNames, p5 }); // Third pass: transform helper functions with early returns to use __returnValue pattern transformHelperFunctionEarlyReturns(ast, uniformCallbackNames); From c661f118ca7854d1e3ce7601e935fb642eab1d67 Mon Sep 17 00:00:00 2001 From: LalitNarayanYadav Date: Wed, 7 Oct 2026 02:49:58 +0530 Subject: [PATCH 3/5] Make argTypes opacity depth-scoped instead of blanket --- src/strands/strands_api.js | 18 +++++++++++++----- src/strands/strands_transpiler.js | 30 ++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index deb8b18f31..1560a7d5b6 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -822,12 +822,20 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return result; }); - // Tell the transpiler that argument 0 (the color-stop pairs) is a raw - // array it should leave untouched rather than converting to a vector — - // including the nested [color, position] pairs inside it, to any depth. - // See strands_transpiler.js's ArrayExpression visitor. + // Tell the transpiler that argument 0 is a raw array (the stops) whose + // elements are themselves raw arrays (the [color, position] pairs) — + // both levels left untouched rather than converted to vectors. The + // bare 'any' terminal means pair contents aren't constrained any + // further; see strands_transpiler.js's ArrayExpression visitor for how + // this nesting is walked. fn.paletteLerp.argTypes = [ - { type: 'Array', subtype: { type: 'any' } }, + { + type: 'Array', // stops + subtype: { + type: 'Array', // color + position tuples + subtype: 'any' + } + }, { type: 'Number' } ]; diff --git a/src/strands/strands_transpiler.js b/src/strands/strands_transpiler.js index bbecf1ff81..62a835ea90 100644 --- a/src/strands/strands_transpiler.js +++ b/src/strands/strands_transpiler.js @@ -695,6 +695,16 @@ const ASTCallbacks = { // of its arguments node descends from — rather than depending on // that CallExpression's own visitor having already run, since // acorn-walk's ancestor() calls child callbacks before parent ones. + // + // Opacity is depth-scoped, not blanket: argType descriptors nest via + // `.subtype`, one level per ArrayExpression descended through from + // the top-level argument down to this node. We walk that chain in + // lockstep with the descriptor so a function can mark the outer + // array and some number of inner levels as opaque (`type: 'Array'`) + // while still letting arrays beyond that depth resume normal + // vectorization — signaled by the descriptor bottoming out at the + // bare string `'any'` (or running out) before reaching this node's + // depth. for (let i = ancestors.length - 1; i >= 0; i--) { const a = ancestors[i]; if (a.type === 'CallExpression') { @@ -705,12 +715,20 @@ const ASTCallbacks = { : undefined; const argTypes = fnName && getArgTypes(fnName, state.p5); if (argTypes) { - // The direct child of `a` on the path down to `node` — either - // `node` itself, or an ancestor array that contains it. - const childOnPath = i + 1 < ancestors.length ? ancestors[i + 1] : node; - const argIndex = a.arguments.indexOf(childOnPath); - const argType = argIndex !== -1 ? argTypes[argIndex] : undefined; - if (argType?.type === 'Array' && argType.subtype?.type === 'any') { + // The chain of nodes from the top-level argument down to (and + // including) this array. + // ancestors already ends with `node` itself (acorn-walk, in + // this codebase, includes the current node as its own last + // ancestor entry) — so slicing from i + 1 already gives the + // full chain from the top-level argument down to and + // including `node`, with no need to append it again. + const path = ancestors.slice(i + 1); + const argIndex = a.arguments.indexOf(path[0]); + let argType = argIndex !== -1 ? argTypes[argIndex] : undefined; + for (let depth = 1; depth < path.length && argType; depth++) { + argType = argType.subtype; + } + if (argType?.type === 'Array') { return; } } From c7bf260ca3676c73f781c536074a7ccb14b0aad6 Mon Sep 17 00:00:00 2001 From: LalitNarayanYadav Date: Sat, 10 Oct 2026 00:59:09 +0530 Subject: [PATCH 4/5] Fix paletteLerp mix/clamp calls and add unit + visual tests --- src/strands/strands_api.js | 7 +- test/unit/visual/cases/webgl.js | 87 +++++++++++++++ test/unit/visual/cases/webgpu.js | 57 ++++++++++ test/unit/webgl/p5.Shader.js | 182 +++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 3 deletions(-) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 1560a7d5b6..db691d1631 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -800,12 +800,13 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { const one = p5.strandsNode(1.0); const num = t.sub(pa); const den = pb.sub(pa); - const localT = num.div(den).clamp(zero, one); + // mix and clamp are builtin functions, not methods on strands nodes + const localT = fn.clamp(num.div(den), zero, one); return buildTernary( strandsContext, pa.equalTo(pb), // guard: pa == pb → return midpoint - ca.mix(cb, p5.strandsNode(0.5)), - ca.mix(cb, localT) + fn.mix(ca, cb, p5.strandsNode(0.5)), + fn.mix(ca, cb, localT) ); } diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index acc58c9d29..41b5d78a0d 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -1512,6 +1512,93 @@ visualSuite('WebGL', function () { screenshot(); }); + visualTest( + 'paletteLerp() maps texCoord through a multi-stop gradient in a filter', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.33], + [[0, 1, 0, 1], 0.66], + [[0, 0, 1, 1], 1.0] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + + visualTest( + 'paletteLerp() handles uneven stop positions and more than 4 stops', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + // Stops bunched toward the right, with flat regions on either end + p5.filterColor.set( + p5.paletteLerp( + [ + [[0, 0, 0, 1], 0.2], + [[1, 0, 0, 1], 0.4], + [[1, 0.5, 0, 1], 0.55], + [[1, 1, 0, 1], 0.65], + [[1, 1, 1, 1], 0.8] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + + visualTest( + 'paletteLerp() can be driven by a different input than texCoord.x', + (p5, screenshot) => { + p5.createCanvas(50, 50, p5.WEBGL); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + // Diagonal gradient between two stops + const uv = p5.filterColor.texCoord; + const t = (uv.x + uv.y) / 2; + p5.filterColor.set( + p5.paletteLerp( + [ + [[0.1, 0.1, 0.5, 1], 0.0], + [[1, 0.8, 0.2, 1], 1.0] + ], + t + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + screenshot(); + } + ); + visualTest('random() colors a basic shader', (p5, screenshot) => { p5.createCanvas(50, 50, p5.WEBGL); const shader = p5.baseColorShader().modify( diff --git a/test/unit/visual/cases/webgpu.js b/test/unit/visual/cases/webgpu.js index ad93108df9..300844ef5d 100644 --- a/test/unit/visual/cases/webgpu.js +++ b/test/unit/visual/cases/webgpu.js @@ -677,6 +677,63 @@ visualSuite('WebGPU', function () { ); }); + visualTest( + 'paletteLerp() maps texCoord through a multi-stop gradient in a filter (WebGPU)', + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.33], + [[0, 1, 0, 1], 0.66], + [[0, 0, 1, 1], 1.0] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + await screenshot(); + } + ); + + visualTest( + 'paletteLerp() handles uneven stop positions and more than 4 stops (WebGPU)', + async function (p5, screenshot) { + await p5.createCanvas(50, 50, p5.WEBGPU); + const shader = p5.baseFilterShader().modify( + () => { + p5.filterColor.begin(); + p5.filterColor.set( + p5.paletteLerp( + [ + [[0, 0, 0, 1], 0.2], + [[1, 0, 0, 1], 0.4], + [[1, 0.5, 0, 1], 0.55], + [[1, 1, 0, 1], 0.65], + [[1, 1, 1, 1], 0.8] + ], + p5.filterColor.texCoord.x + ) + ); + p5.filterColor.end(); + }, + { p5 } + ); + p5.background(220); + p5.filter(shader); + await screenshot(); + } + ); + visualTest( 'randomGaussian() colors a basic shader (WebGPU)', async function (p5, screenshot) { diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index 8ea5f725a5..f6a489335c 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -769,6 +769,188 @@ suite('p5.Shader', function () { assert.approximately(middle[0], 128, 10); assert.approximately(right[0], 204, 10); }); + test('paletteLerp() blends between multiple stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[0, 1, 0, 1], 0.5], + [[0, 0, 1, 1], 1.0] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const left = myp5.get(1, 25); + const middle = myp5.get(25, 25); + const right = myp5.get(48, 25); + // Near the first stop: mostly red + assert.isAbove(left[0], 200); + assert.isBelow(left[2], 40); + // At the middle stop: mostly green + assert.isAbove(middle[1], 220); + assert.isBelow(middle[0], 40); + assert.isBelow(middle[2], 40); + // Near the last stop: mostly blue + assert.isAbove(right[2], 200); + assert.isBelow(right[0], 40); + }); + + test('paletteLerp() supports more than 4 stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + // Array literals normally have to be 2-4 elements long since they + // become vectors, but the stops list must not be treated that way. + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.0], + [[1, 1, 0, 1], 0.25], + [[0, 1, 0, 1], 0.5], + [[0, 1, 1, 1], 0.75], + [[0, 0, 1, 1], 1.0] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + expect(() => { + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + }).not.toThrowError(); + }); + + test('paletteLerp() clamps t outside of the first and last stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const testShader = myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + // Stops only cover the middle of the 0-1 range + inputs.color = myp5.paletteLerp( + [ + [[1, 0, 0, 1], 0.3], + [[0, 0, 1, 1], 0.7] + ], + inputs.texCoord.x + ); + return inputs; + }); + }, + { myp5 } + ); + + myp5.noStroke(); + myp5.shader(testShader); + myp5.plane(myp5.width, myp5.height); + + const left = myp5.get(1, 25); + const right = myp5.get(48, 25); + assert.approximately(left[0], 255, 10); + assert.approximately(left[2], 0, 10); + assert.approximately(right[0], 0, 10); + assert.approximately(right[2], 255, 10); + }); + + test('paletteLerp() shows an error for malformed stops', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + const makeShader = stops => + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + inputs.color = myp5.paletteLerp(stops, inputs.texCoord.x); + return inputs; + }); + }, + { myp5, stops } + ); + // Not enough stops + expect(() => makeShader([[[1, 0, 0, 1], 0]])).toThrowError(/2.8/); + // A stop that isn't a [color, position] pair + expect(() => + makeShader([[[1, 0, 0, 1], 0], [[0, 0, 1, 1]]]) + ).toThrowError(/2-element array/); + }); + + test('argTypes keeps declared array levels as arrays', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + let received; + // A stand-in for a function that wants a list of [value, number] + // pairs. The outer list and the pairs are plain arrays, but + // anything nested any deeper should still become a vector. + p5.prototype.testArgTypesFn = function (stops) { + received = stops; + }; + p5.prototype.testArgTypesFn.argTypes = [ + { + type: 'Array', + subtype: { + type: 'Array', + subtype: 'any' + } + } + ]; + try { + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + myp5.testArgTypesFn([ + [[1, 0, 0], 0], + [[0, 0, 1], 1] + ]); + return inputs; + }); + }, + { myp5 } + ); + } finally { + delete p5.prototype.testArgTypesFn; + } + + assert.isArray(received); + assert.lengthOf(received, 2); + for (const pair of received) { + // The pair is still a plain array... + assert.isArray(pair); + assert.lengthOf(pair, 2); + // ...but the color inside it has been turned into a vector + assert.isFalse(Array.isArray(pair[0])); + assert.isTrue(pair[0].isStrandsNode); + assert.strictEqual(pair[0].dimension, 3); + } + }); + + test('arrays outside of argTypes arguments still become vectors', () => { + myp5.createCanvas(5, 5, myp5.WEBGL); + // More than 4 elements would be an error if this were a vector + expect(() => { + myp5.baseMaterialShader().modify( + () => { + myp5.getPixelInputs(inputs => { + const tooLong = [1, 2, 3, 4, 5]; + inputs.color = [tooLong, 0, 0, 1]; + return inputs; + }); + }, + { myp5 } + ); + }).toThrowError(/2-4 elements/); + }); test('color() with hex string returns correct vec4 in strands', () => { myp5.createCanvas(50, 50, myp5.WEBGL); const testShader = myp5.baseMaterialShader().modify( From 84a3d76ba74b8ab65f621bcc5d48094359214bb5 Mon Sep 17 00:00:00 2001 From: LalitNarayanYadav Date: Fri, 16 Oct 2026 00:42:38 +0530 Subject: [PATCH 5/5] Preserve function properties when decorating; add paletteLerp visual baselines --- src/core/main.js | 21 ++++++++++++++++++ .../000.png | Bin 0 -> 1269 bytes .../metadata.json | 3 +++ .../000.png | Bin 0 -> 331 bytes .../metadata.json | 3 +++ .../000.png | Bin 0 -> 396 bytes .../metadata.json | 3 +++ test/unit/webgl/p5.Shader.js | 6 +++++ 8 files changed, 36 insertions(+) create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png create mode 100644 test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/metadata.json diff --git a/src/core/main.js b/src/core/main.js index b0f7ad8405..0d5c4cbee7 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -540,6 +540,25 @@ function createBindGlobal(instance) { }; } +// Decorators return a new function, which would otherwise drop any +// properties attached to the original (for example the `argTypes` that +// p5.strands functions declare for the transpiler). Carry those over so +// decorating a function does not strip its metadata. +function copyFunctionProperties(original, decorated) { + if ( + typeof original !== 'function' || + typeof decorated !== 'function' || + original === decorated + ) { + return; + } + for (const key of Object.keys(original)) { + if (!(key in decorated)) { + decorated[key] = original[key]; + } + } +} + // Generic function to decorate classes function decorateClass(Target, decorations, path) { // Static properties @@ -555,6 +574,7 @@ function decorateClass(Target, decorations, path) { static: true }); if (result) { + copyFunctionProperties(Target[key], result); Object.defineProperty(Target, key, { enumerable: true, writable: true, @@ -593,6 +613,7 @@ function decorateClass(Target, decorations, path) { static: false }); if (result) { + copyFunctionProperties(Target.prototype[member], result); Object.defineProperty(Target.prototype, member, { enumerable: true, writable: true, diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/000.png new file mode 100644 index 0000000000000000000000000000000000000000..f6cf862c1431f2530799e50d3dbd2e8f9a11626e GIT binary patch literal 1269 zcmV@GMmjgjWA)<&vWMeSK7>qF(V=@MW5E4TC=c2lL*xB3T6UjICVa{Eh zrfa%qcinvX#Z!F!)f0UC&13xV-3osCeg(h$_y}u%eu&?HeSkmLKEU6<-N%1_+{3!R z?qb6~cd_Z;JJ|C7ZERb=j2#*u8lPd$-=i{_Tr6xMLBAcizC!UDt7J&jL>D zTfnLP*D!Hl9+QWz;{1`Tm_9m(nPXQld;BuyPR?Tf^eh%8F5$-H43^Ga#PZZd+?k%n z{h14RcxehNm#6S#?mV8&pTqNoNxZl|iI{ zYlz+7SLnlM0Mobl(cZ%m2WyxsKfnIW4PRB083|`nvi80lq^}jB$8P(79`k= zrb%XMsQ@Swv%MlHomm+ZeY{kkX@}LlCdjeK>|*MY^v@A^2m<`30ORS^LSNrv6GoUoCfZPA*>ERFLblpFAriV8`a!<=WK4!WZs9;odm%fF!w-7Xha5i2ptp8jFALdR=8~LDC$AjTxlb!Q=8M`tCguAU7Y7DwBNS3}s)2+E=_fNBAXO6yHDD}A zfTJijsB@eKYWET1@^^_wD;wX1V+^tC7h84YIwkv5wF1d};KgQp5uMd0e0X?HEGkpC5;RSnU_w4kv}j4%SNB}AhOS=zu2rpd*wP*0~Pg?fHh-5LQ}<#_-JTM zTxOd>j<)G}O`ZEl&OQTJI?FT5(;-%lWacrxx>z!Ol!LjuSTc2Oj2GF*j2&(O9TiMt z`84hnpd^9i*T;&pRVKm1Q*kN=xfm;_*e|+|6=yjPic`L1lG{^FCM=uB@bJo^d7MQK zBM?TN;Rp5z)d-sD7r=xBBdWP zbOOGjbwNtD5$}X*^v*QnKthKtHW=YIHh6C>KL9AJ=s_G^A(3jwiKK#rS$?xqa6(*u z`QMIPIQc19IT89upUnJD`RZcH^ihs>Bj(@;%SK%rLtX5LoIi5L!GifM>nSU45G$YO z(0*BvGCP-zJV7Uz0pVxdgti`*jk?cI!fWBiDxhK#>fnfyh>qvORgFAwAH$G46Onxy zH#XhzQh=4&Bvx>;NZ!M;QKuYC+QpKoE2GLBeau*$DF1p6#ked#D|HZu%}O2Ad5q`h zCrTK?iL;ih1T&%Z$x1MF{%PXDvN`sHVA3v@OkMF~jy`7W#~`7-%0?BF@B<1qm6Py^ z@Q(PXw1m9fT&|OivTVsTUXrQPZ1N?RlXB-R00000NkvXXu0mjf*YjRO literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() can be driven by a different input than texCoord.x/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/000.png new file mode 100644 index 0000000000000000000000000000000000000000..e3b6419193e3a2aea66d0fc0b0c69c651c5dd609 GIT binary patch literal 331 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2o;fFfw?$IEGX(zL~U;m&sAURbPXL zH9?Rg>VU!olZFFIh9(Uz7Z`Yf90z6-2gV2%UY_@VI&Sk$X8bg{I_=Q8wEK&%oAj0@ z9(aGupjkENA%pskL$YxRe~;yG2+ugg6tcx77+I$E4NQS9xsRwV+u`2uZl_#+8X(OJ0@h zg$ufSz4CAk`f5FWb%(NQ=@O-tzvi9}Ju-2URnWwczvrH6J@W9BUFor^?s<`D;gXu7 zkUie6T~k@*112UMJo-9xNkrraMo~_&m_7gUyY?_LF#P{tYk6)8Fi6-yVZqF>VpHYO Tw^LfGfFcZ@u6{1-oD!M<#JGdj literal 0 HcmV?d00001 diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() handles uneven stop positions and more than 4 stops/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file diff --git a/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png b/test/unit/visual/screenshots/WebGL/p5.strands/paletteLerp() maps texCoord through a multi-stop gradient in a filter/000.png new file mode 100644 index 0000000000000000000000000000000000000000..d5714ff13a6503bd891d186a953d4e19487ce004 GIT binary patch literal 396 zcmV;70dxL|P)k#mvE6l zMiEOFDI!B~2^R@uJeOJjT_mgi$l)#$k&<=fB3boE4tJ4=l&m8c$*MncxQj%jWF5Ik zR{fE~T_hqU>&Qj2>W>`mA`vO?9M#^-MQSTc{D6xj7F>IK7pbi*@dGZBSa9v_zoy7P q00030|4$;5djJ3c21!IgR09An0Ci@#s-4&X0000 { + // p5 wraps prototype methods (e.g. for parameter validation), which + // would drop the argTypes the transpiler reads if they weren't carried over + assert.isArray(p5.prototype.paletteLerp.argTypes); + }); test('arrays outside of argTypes arguments still become vectors', () => { myp5.createCanvas(5, 5, myp5.WEBGL);