Skip to content

Add stateful helpers for p5.strands addons - #9309

Merged
davepagurek merged 7 commits into
processing:mainfrom
bhabishnu:feat/strands-registered-state-9259-docs
Oct 22, 2026
Merged

davepagurek merged 7 commits into
processing:mainfrom
bhabishnu:feat/strands-registered-state-9259-docs

Conversation

@bhabishnu

@bhabishnu bhabishnu commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Resolves #9259

Changes:

Adds p5.registerStrandsState() so addons can create independent helpers whose values follow branches and loops. push() and pop() restore scoped values while global values keep their updates.

The optional globalInstance exposes a helper’s methods during a build, then restores the original properties. Nested modify() calls are rejected. Errors escaping hook callbacks or begin()/end() stop code generation, even if the sketch catches them.

Includes addon docs and examples, plus a small WGSL fix needed for matrix state.

Tested locally:

  • WebGL shader tests: 236 passed.
  • WebGPU shader tests: 88 passed, 3 todo, no fallback.
  • Lint: no errors or new warnings.
  • Documentation examples checked in a browser.

Screenshots of the change:

Tests check rendered pixels on both backends.

PR Checklist

AI disclosure: AI tools were used for tests and documentation. I reviewed and approved the decisions, and tested the changes locally.

Journal each temporary install on window, p5.prototype and
p5.Graphics.prototype with its previous property descriptor, and restore
the journal newest first after the build. Absent properties are removed
again and setters never run. A property that can be neither redefined
nor assigned is rejected before it is written, and a failed restore never
replaces a build's own error.

This also restores options.hook properties onto p5.Graphics.prototype
rather than p5.Graphics, and a modify() called from inside another
build's callback hands the enclosing journal back.

Part of processing#9259.
Add p5.registerStrandsState(name, { init, update, read }), which returns
a factory for helpers used inside hooks. Helper state is merged across
if/else branches and carried through for loops, and
pushStrandsState()/popStrandsState() save and restore every helper's
scoped fields.

Uses that cannot work throw clear errors: a helper used outside the hook
call that created it, created inside a branch or loop, or used in a loop
header; break or early return in a loop body while helpers exist;
updates inside ?:, &&, ||, &&= or ||= operands; changing a state's
shape; and unbalanced push/pop.

The addon API is documented in contributor_docs/p5.strands.md.

Part of processing#9259.
If a hook throws and sketch code catches the error inside modify(), the
first failure is rethrown as soon as the callback returns, before code
generation. The helper scope is cleared so the next build starts clean,
and finishHook's "requires a value" check now runs inside the same
cleanup.

The contributor doc notes this behaviour.

Part of processing#9259.
A base shader can pass globalInstance next to hook in modify()'s
options. It is called after that hook's begin() with the sketch as
`this`, and the helper it returns has its methods installed with
_installGlobal() on window, p5.prototype and p5.Graphics.prototype for
the rest of the build, through the temporary install journal.

The contributor doc covers options.hook, globalInstance, _installGlobal()
and the temporary replacement and restoration of names.

Part of processing#9259.
@p5-bot

p5-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Continuous Release

CDN link

Published Packages

Commit hash: 4374918

Previous deployments

462e53f


This is an automated message.

@davepagurek davepagurek left a comment

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.

Thanks for taking this on, it's looking really promising! I left some somewhat minor comments. I also began updating the sdf addon here to use the branch build of this PR: davepagurek/p5.sdf#1

I did notice in examples/macbook there that it hits some other strands compilation errors. It transpiles to:

function anonymous(__p5
) {
debugger;
const BW = 260, BD = 175, BH = 16;
const LW = 258, LD = 172, LH = 6;
const CR = 4;
const HY = __p5.strandsNode(__p5.neg(BH)).div(2);
const HZ = __p5.strandsNode(__p5.neg(BD)).div(2);
const t = __p5.strandsNode(millis()).mult(0.0008);
const lidAngle = __p5.strandsNode(__p5.strandsNode(__p5.strandsNode(__p5.strandsNode(sin(t)).mult(0.5)).add(0.5)).mult(__p5.strandsNode(__p5.strandsNode(PI).mult(100)).div(180))).add(__p5.strandsNode(__p5.strandsNode(PI).mult(15)).div(180));
push();
fill(185, 185, 190);
roundBox(BW, BH, BD, CR);
pop();
push();
fill(155, 155, 160);
translate(80, HY, HZ);
rotateZ(__p5.strandsNode(Math.PI).div(2));
cylinder(7, 38);
pop();
push();
fill(155, 155, 160);
translate(__p5.neg(80), HY, HZ);
rotateZ(__p5.strandsNode(Math.PI).div(2));
cylinder(7, 38);
pop();
push();
fill(185, 185, 190);
translate(0, HY, HZ);
rotateX(lidAngle);
translate(0, __p5.strandsNode(__p5.neg(LH)).div(2), __p5.strandsNode(BD).div(2));
roundBox(LW, LH, LD, CR);
pop();
push();
fill(15, 15, 20);
translate(0, HY, HZ);
rotateX(lidAngle);
translate(0, __p5.neg(0.5), __p5.strandsNode(BD).div(2));
roundBox(__p5.strandsNode(LW).sub(28), 2, __p5.strandsNode(LD).sub(24), 3);
pop();
push();
fill(10, 10, 12);
translate(0, HY, HZ);
rotateX(lidAngle);
translate(0, __p5.neg(0.5), __p5.strandsNode(__p5.strandsNode(__p5.strandsNode(BD).div(2)).add(__p5.strandsNode(LD).div(2))).sub(10));
roundBox(28, 2, 8, 2);
pop();
push();
fill(38, 38, 42);
translate(0, __p5.strandsNode(HY).sub(0.5), __p5.neg(32));
roundBox(214, 2, 82, 4);
pop();
const cols = 10;
const keyW = 14, keyH = 4, keyD = 14, keyR = 2;
const pitchX = 18, pitchZ = 14;
const kX0 = __p5.strandsNode(__p5.strandsNode(__p5.neg(__p5.strandsNode(cols).sub(1))).mult(pitchX)).div(2);
const kZ0 = __p5.neg(61);
const kY = __p5.strandsNode(HY).sub(2);
{
    const __block_1 = __p5.strandsFor(() => {
        return 0;
    }, loopVar1 =>
        (__p5.strandsNode(loopVar1).lessThan(4)), loopVar1 => {
        return loopVar1 = __p5.strandsNode(loopVar1).add(1);
    }, (loopVar1, vars) => {
        let __copy_loopVar0_0 = vars['loopVar0'].copy();
        __p5.strandsFor(() => {
            return 0;
        }, __copy_loopVar0_0 =>
            (__p5.strandsNode(__copy_loopVar0_0).lessThan(cols)), __copy_loopVar0_0 => {
            return __copy_loopVar0_0 = __p5.strandsNode(__copy_loopVar0_0).add(1);
        }, (__copy_loopVar0_0, vars) => {
            push();
            fill(68, 68, 74);
            translate(__p5.strandsNode(kX0).add(__p5.strandsNode(__copy_loopVar0_0).mult(pitchX)), kY, __p5.strandsNode(kZ0).add(__p5.strandsNode(loopVar1).mult(pitchZ)));
            roundBox(keyW, keyH, keyD, keyR);
            pop();
        }, {});
        return { 'loopVar0': __copy_loopVar0_0 };
    }, { 'loopVar0': __p5.strandsNode(loopVar0) });
    loopVar0 = __block_1['loopVar0'];
}
push();
fill(68, 68, 74);
translate(0, kY, __p5.strandsNode(kZ0).add(__p5.strandsNode(4).mult(pitchZ)));
roundBox(100, keyH, keyD, keyR);
pop();
push();
fill(172, 172, 180);
translate(0, __p5.strandsNode(HY).sub(0.5), 42);
roundBox(110, 2, 62, 6);
pop();
apply();
}

and gets this error:

Uncaught (in promise) ReferenceError: loopVar0 is not defined
    anonymous https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js line 1872 > Function:80
    p5 https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:1872
    modify https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:1876
    buildSDF file:///Users/dpagurek/Documents/Projects/p5.sdf/p5.sdf.js:497
    setup file:///Users/dpagurek/Documents/Projects/p5.sdf/example/macbook.js:6
    #n https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:323
    #t https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:323
    t https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:323
    p5 https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:1877
    promise callback*p5< https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:1877
    <anonymous> https://raw.esm.sh/pr/p5@462e53f/lib/p5.min.js:1877

Lmk if you have thoughts on the cause! I'm testing things out on that library on the side to help validate the API etc.

Comment thread contributor_docs/p5.strands.md Outdated

`p5.registerStrandsState(name, { init, update, read })` returns a factory. Assign it to `fn`; registration installs nothing. `name` identifies errors. `update` and `read` are required but may be empty objects.

- `init(...args)` receives the factory arguments and returns `{ config, state }`. Any `config` value is passed unchanged to methods. `state` contains `scoped` and `global` objects with at least one field overall. Fields must be Strands float/int scalars or vectors of up to 4 components, for example using `p5.strandsNode()`.

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.

We recently added strands matrix nodes too, it could be those too right? Should we update the restriction so that it's mostly that it needs to be a strands node of some kind?

One other thought: in the current p5.sdf, it also pushes functions to a stack to handle modifiers like twist, etc, since unlike positions, they can't be eagerly evaluated into strands nodes as they modify the distance and not the source point. I think there's nothing we can do about that for now because we would need some way to dynamically create functions in glsl, which we don't have. For p5.sdf that might either mean using a fixed pipeline of shape modifiers or to change the API to use an explicit wrapping function like round(4, () => box(60)) instead of using state. No action needed I think, just mentioning it in case you have any ideas!

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.

Also, we should explain the difference between scoped and global (maybe showing an example of a thing you'd use each for?)

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.

Added matrix support and an example showing scoped values being restored by pop(), while global values keep their updates. Backend limitations are documented too.

Comment thread src/strands/p5.strands.js Outdated
let modified;
let restoreErrors = [];
// Preserve the outer install journal; nested builds remain unsupported.
const nested = modifyDepth > 0;

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.

Agreed that nested modify calls is unsupported; should we just throw an error here?

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.

Agreed, nested modify() now throws before touching the outer build’s state. Removed the nesting bookkeeping too.

export function installTemporaryProperty(strandsContext, target, name, value) {
const descriptor = Object.getOwnPropertyDescriptor(target, name);
const assignable = descriptor && 'value' in descriptor && descriptor.writable;
if (descriptor && !descriptor.configurable && !assignable) {

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.

Are there known things that would trigger this out of curiosity?

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.

window.top and document are examples, and location has a setter that could navigate the page. The check protects those; writable sketch globals are still replaced and restored.

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.

got it, makes sense! I could see those possibly being names an addon might overload without thinking about it.

Comment thread test/unit/webgl/p5.Shader.js Outdated
});
});

suite('registered state (#9259)', () => {

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.

We generally don't need a PR numbers in the unit tests, especially as multiple PRs modify functionality over time it can get messy trying to reference them all. We can describe the feature instead

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.

Done, removed the issue numbers.

Comment thread src/strands/strands_api.js Outdated
}
scope.stack.push(frame);
};
augmentFn(fn, p5, 'pushStrandsState', p5.pushStrandsState);

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.

Are we able to call these just push and pop? That would make the UX look as similar as possible to p5 outside of strands. Since regular push/pop cannot be used outside of strands, I think it should be safe to override their behaviour while strands is active

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.

Done, switched to push()/pop(). I also tested p5.sdf locally with its old push/pop wrappers removed.

@bhabishnu

Copy link
Copy Markdown
Contributor Author

I reproduced the MacBook error on the base revision too and tracked it to nested-loop handling. I’ve prepared a separate fix. With that fix and the old p5.sdf push/pop wrappers removed locally, the example renders correctly.

@davepagurek davepagurek left a comment

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.

This is looking good now, thanks!

@davepagurek
davepagurek merged commit d96aea3 into processing:main Oct 22, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implicit state handling for p5.strands addons

2 participants