Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/strands/ir_builders.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,11 @@ export function swizzleTrap(id, dimension, strandsContext, onRebind) {
scalars.push(createStrandsNode(id, dimension, strandsContext));
}
} else {
FES.userError('type error', `Swizzle assignment: RHS vector does not match LHS vector (need ${chars.length}, got ${value.dimension}).`);
FES.dimensionMismatchError(
chars.length,
value.dimension,
`${target._originalIdentifier || 'value'}.${property}`
);
}
} else if (Array.isArray(value)) {
const flat = value.flat(Infinity);
Expand Down
7 changes: 7 additions & 0 deletions src/strands/strands_FES.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ export function internalError(errorMessage) {
export function userError(errorType, errorMessage) {
const prefixedMessage = `[p5.strands ${errorType}]: ${errorMessage}`;
throw new Error(prefixedMessage);
}

export function dimensionMismatchError(declaredDim,actualDim,varName){
userError(
'dimension mismatch',
`Cannot assign a value of dimension ${actualDim} to \`${varName}\`, which expects dimension ${declaredDim}.`
);
}
11 changes: 11 additions & 0 deletions src/strands/strands_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,17 @@ function createHookArguments(strandsContext, parameters){
return createStrandsNode(propNode.id, propNode.dimension, strandsContext, onRebind);
},
set(val) {
const valDim = val?.isStrandsNode
? val.dimension
: (Array.isArray(val) ? val.length : 1);
if( valDim !== propertyType.dataType.dimension && valDim !== 1){
FES.dimensionMismatchError(
propertyType.dataType.dimension,
valDim,
`${param.name}.${propertyType.name}`
);
}

const oldDependsOn = dag.dependsOn[structNode.id];
const newDependsOn = [...oldDependsOn];
let newValueID;
Expand Down
21 changes: 21 additions & 0 deletions src/strands/strands_node.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { swizzleTrap, primitiveConstructorNode, variableNode, arrayAccessNode, a
import { BaseType, NodeType, OpCode } from './ir_types';
import { getNodeDataFromID, createNodeData, getOrCreateNode } from './ir_dag';
import { recordInBasicBlock } from './ir_cfg';
import { dimensionMismatchError } from './strands_FES';
export class StrandsNode {
constructor(id, dimension, strandsContext) {
this.id = id;
Expand Down Expand Up @@ -56,6 +57,16 @@ export class StrandsNode {

// For varying variables, we need both assignment generation AND a way to reference by identifier
if (this._originalIdentifier) {
const valueDim = value?.isStrandsNode
? value.dimension
: (Array.isArray(value) ? value.length : 1);
if (valueDim !== this._originalDimension && valueDim !== 1){
dimensionMismatchError(
this._originalDimension,
valueDim,
this._originalIdentifier
);
}
// Create a variable node for the target (the varying variable)
const { id: targetVarID } = variableNode(
this.strandsContext,
Expand Down Expand Up @@ -108,6 +119,16 @@ export class StrandsNode {

// For varying variables, create swizzle assignment
if (this._originalIdentifier) {
const valueDim = value?.isStrandsNode
? value.dimension
: (Array.isArray(value) ? value.length : 1);
if (valueDim !== swizzlePattern.length && valueDim !== 1){
dimensionMismatchError(
swizzlePattern.length,
valueDim,
`${this._originalIdentifier}.${swizzlePattern}`
);
}
// Create a variable node for the target with swizzle
const { id: targetVarID } = variableNode(
this.strandsContext,
Expand Down
79 changes: 72 additions & 7 deletions test/unit/webgl/p5.Shader.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import p5 from '../../../src/app.js';
import { vi } from 'vitest';
import { beforeEach, vi } from 'vitest';

const mockUserError = vi.fn();
vi.mock('../../../src/strands/strands_FES', () => ({
userError: (...args) => {
vi.mock('../../../src/strands/strands_FES', () => {
const userError = (...args) => {
mockUserError(...args);
const prefixedMessage = `[p5.strands ${args[0]}]: ${args[1]}`;
throw new Error(prefixedMessage);
},
internalError: (msg) => { throw new Error(`[p5.strands internal error]: ${msg}`); }
}));
};
return {
userError,
internalError: (msg) => { throw new Error(`[p5.strands internal error]: ${msg}`); },
dimensionMismatchError: (declaredDim, actualDim, varName) => {
userError(
'dimension mismatch',
`Cannot assign a value of dimension ${actualDim} to \`${varName}\`, which expects dimension ${declaredDim}.`
);
},
};
});

suite('p5.Shader', function() {
var myp5;
Expand Down Expand Up @@ -2631,6 +2640,62 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
assert.approximately(pixelColor[1], 0, 5);
assert.approximately(pixelColor[2], 0, 5);
});

test('allows scalar broadcast when assigning a scalar to a sharedVec3 (bridge)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGL);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let worldPosX = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
worldPosX = inputs.position.x; // scalar → vec3, valid broadcast
return inputs;
});
},{myp5});
}).not.toThrow();
});

test('reports a friendly error when assigning a vec2 to a sharedVec3 (bridge)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGL);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec = inputs.position.xy; // vec2 → vec3 mismatch
return inputs;
});
},{myp5});
}).toThrow(/dimension mismatch/);
});

test('reports a friendly error on dimension mismatch via swizzle write (bridgeSwizzle)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGL);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec.xy = inputs.position; // vec3 → 2-component swizzle mismatch
return inputs;
});
},{myp5});
}).toThrow(/dimension mismatch/);
});

test('does not error when shared variable assignment dimensions match', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGL);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec = inputs.position; // vec3 → vec3, OK
return inputs;
});
},{myp5});
}).not.toThrow();
});
});

suite('p5.strands error messages', () => {
Expand All @@ -2648,7 +2713,7 @@ test('returns numbers for builtin globals outside hooks and a strandNode when ca
assert.include(err.message, '// noprotect');
};

afterEach(() => {
beforeEach(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this breaking something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah — this fixes a pre-existing isolation issue that my new tests exposed. mockUserError is module-level, so its .mock.calls accumulate across the whole file. The p5.strands error messages suite reads mock.calls[0], and with afterEach clearing it was relying on nothing upstream in the file having fired userError before it ran. My new bridge/bridgeSwizzle throw-tests do fire it (via dimensionMismatchError), so by the time that suite started, mock.calls[0] was a stale dimension-mismatch call and its first test failed. Switching to beforeEach guarantees a clean mock at the start of each test in the suite, which fixes it and guards against the same thing happening from any future test added above it.

mockUserError.mockClear();
});

Expand Down
56 changes: 56 additions & 0 deletions test/unit/webgpu/p5.Shader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,62 @@ suite('WebGPU p5.Shader', function() {
myp5.compute(s4, 4);
}).not.toThrow();
});

test('allows scalar broadcast when assigning a scalar to a sharedVec3 (bridge)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGPU);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let worldPosX = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
worldPosX = inputs.position.x; // scalar → vec3, valid broadcast
return inputs;
});
}, { myp5 });
}).not.toThrow();
});

test('reports a friendly error when assigning a vec2 to a sharedVec3 (bridge)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGPU);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec = inputs.position.xy; // vec2 → vec3 mismatch
return inputs;
});
}, { myp5 });
}).toThrow(/dimension mismatch/);
});

test('reports a friendly error on dimension mismatch via swizzle write (bridgeSwizzle)', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGPU);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec.xy = inputs.position; // vec3 → 2-component swizzle mismatch
return inputs;
});
},{myp5});
}).toThrow(/dimension mismatch/);
});

test('does not error when shared variable assignment dimensions match', async () => {
await myp5.createCanvas(5, 5, myp5.WEBGPU);

expect(() => {
myp5.baseMaterialShader().modify(() => {
let myVec = myp5.sharedVec3();
myp5.getWorldInputs(inputs => {
myVec = inputs.position; // vec3 → vec3, OK
return inputs;
});
},{myp5});
}).not.toThrow();
});
});
});
});