diff --git a/docs/adr/2026-07-28-effects-are-projects.md b/docs/adr/2026-07-28-effects-are-projects.md new file mode 100644 index 000000000..a23f64c5c --- /dev/null +++ b/docs/adr/2026-07-28-effects-are-projects.md @@ -0,0 +1,120 @@ +# ADR: Effects are projects — embeddable projects, promoted controls, vendoring by copy + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Deciders:** Photomancer +- **Supersedes:** The deliberate authoring guard "kind Project cannot be + created as a child node" (`lpc-registry` node authoring; the guard + existed because child-project semantics were undefined — this ADR + defines them). Builds on scoped buses + (`2026-07-28-scoped-buses.md`), node authoring operations + (`2026-07-27-node-authoring-operations.md`), and node card faces + (`2026-07-26-node-card-faces.md`). +- **Superseded by:** None + +## Context + +The product goal is a library of visual effects that are easy to author +standalone and easy to drop into an existing project's playlist. The +kickoff design sketched a new `composite` node kind: a folder with a +`composite.json` boundary file, promoted controls, and a forwarded +output. Discovery collapsed that design: + +- `ProjectDef` is already a minimal container — `format`, `uid`, + `name`, `nodes{}`. No fixture/board/bus baggage lives in the def. +- The loader already treats a project node as a generic no-op container; + the inventory walk follows `nodes{}` refs generically; nothing in the + load path prevents nesting. The only hard block was the authoring + guard. +- A separate `composite` kind would duplicate the container shape, + split the gallery/open/authoring flows into two parallel paths, and + force an answer to "when does a composite graduate into a project?" + +An effect kinda *is* a project. What an effect needs beyond the bare +container — bus isolation, a playable output, curated knobs, +provenance — are additive properties, not a different structure. + +## Decision + +**No new node kind.** The kind string stays `project`; there is no +format bump and no migration. "Effect" is semantic — a project that +declares promoted controls and ships in the Effects gallery category — +not structural. + +The effect boundary contract is: + +1. **Bus scope** — every project node is a bus scope (scoped-buses + ADR): embedding is isolation-safe by construction. +2. **Output mirror** — every project node exposes produced `output` + mirroring its scope's `visual.out` (scoped-buses ADR): an effect is + playlist-playable like any visual node. +3. **Promoted controls** — `ProjectDef` gains an optional + `controls: { : PromotedControlDef }` map. A promoted control is + an **alias, not a mirror**: it carries a target (`node:./child#slot`, + a direct child's slot) plus optional label/unit/min/max display + overrides, and **no value** — values live on the target slot, so + overlay dirty state, transient edits, and bound-violet UI state all + observe the one real slot with no sync machinery. (This deliberately + differs from `ShaderSlotDef`, which owns defaults; an alias that + carried a default would create two sources of truth.) +4. **Provenance** — `ProjectDef` gains optional `author`, `version`, + `license` strings (no semver semantics yet). `uid` and `name` + already exist. + +Supporting decisions: + +- **Promotion is Studio-side DTO aliasing.** The effect card face emits + panel controls whose slot **address is the inner child's**; the + standard address-routed write path does the rest. No server-side + alias slots, no new write machinery. Targets are restricted to direct + children in this slice. +- **Vendoring by copy.** Importing an effect copies its folder into the + host project (`effects//`) and attaches it with the existing + byte-oriented create operation (`WireCreateNodeRequest`'s + file+assets seam, exactly the reuse the node-authoring ADR designed + for). No reference/link semantics, no update/diff flow this slice; + the effect's own provenance fields are the only lineage. +- **Non-root `format` tolerance.** `format` is only probed at the + project root; a child `project.json` carrying `format` loads with the + field ignored. Vendored effect folders keep their `format` so a + copied-out folder remains standalone-openable; a future offline + upgrader owns skew between root and vendored formats. +- **Relative refs survive vendoring by construction.** `node:` binding + refs are node-tree-relative and artifact refs are file-relative, so a + copied folder's internal wiring is location-independent. + +## Consequences + +- One authoring surface: effects are opened, edited, created, and + version-controlled exactly like projects. The gallery's Effects + category and the declared `controls` are the only distinctions. +- Existing artifacts are byte-identical: all new `ProjectDef` fields are + optional and serialize skip-if-default. +- Old firmware rejecting the new optional fields is a non-issue (fields + are additive; device never upgrades formats — offline upgrading is + Studio's job). +- The one hard error class this introduces: a `controls` entry whose + target does not resolve to a direct child's slot is a load error with + a path-qualified message (fail loud at load, not silently dead knobs). +- Naming: "project" remains the formal name; UI copy leans on the + Effects category so users mostly meet "effect". The copy ambiguity + with "the project you have open" is handled in Studio copy, not in + the model. + +## Alternatives considered + +- **A dedicated `composite`/`effect` node kind** — rejected: duplicates + the container structure, forks every authoring/gallery/open flow, + requires schema+kind churn, and leaves "project vs composite" as a + permanent taxonomy question. The unification costs one authoring + guard and two optional field groups. +- **Auto-collected controls** (promote every panel-flagged inner slot) + — rejected for effects: the promoted set is the effect's public API + and should be curated. (Auto-collect may still make sense as a + default for plain projects later.) +- **Server-side alias slots** for promotion — rejected: heavyweight + (new slot kind, write-path forwarding, dirty-state sync) for what the + DTO layer does with an address. +- **Import by reference** (shared library folder, linked updates) — + deferred: update/versioning semantics deserve their own slice; + copy-vendoring is predictable and offline-safe. diff --git a/docs/adr/2026-07-28-scoped-buses.md b/docs/adr/2026-07-28-scoped-buses.md new file mode 100644 index 000000000..78ed84efa --- /dev/null +++ b/docs/adr/2026-07-28-scoped-buses.md @@ -0,0 +1,182 @@ +# ADR: Scoped buses with writer-shadowing + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Deciders:** Photomancer +- **Supersedes:** The playlist-entry "suppress produced visual defaults" + loader rule (an undocumented special case of this model; see Context). + Builds on declarative default bindings + (`2026-07-09-declarative-default-bindings.md`) and the + effects-are-projects decision + (`2026-07-28-effects-are-projects.md`). +- **Superseded by:** None + +## Context + +The bus is the engine's "just works" wiring: a shader's consumed `time` +slot declares `default_bind: bus:time`, a clock's produced `seconds` +declares `default_bind: bus:time`, and a project with both animates with +zero authored bindings. Channels were global — one flat +`ChannelName → bindings` namespace per loaded project. + +Embedding a project as a child node (the effects slice: an effect **is** +a project) breaks the flat namespace: the effect's inner shader would +publish `visual.out` onto the host's bus and clobber the host's visual, +and two effects side by side would fight over every conventional channel +name. The pre-existing loader rule that suppressed produced visual +defaults for playlist-entry children was exactly this problem in +miniature, solved as a special case: entry shaders are *alternatives*, +so their produced `visual.out` defaults were silently dropped and the +playlist published the blended result instead. + +Constraints that shaped the decision: + +- **No embedded-mode branches.** An effect must behave identically + standalone and embedded; any "if embedded, disable X" rule creates + context-dependent behavior (works alone, breaks inside a host, or vice + versa). +- **Keep the magic.** Consumed defaults like `time` must keep flowing + into effects from the host — an effect with no clock of its own should + inherit host time, or it does not animate. +- **Zero migration.** Every existing project is a flat single-project + tree; the model change must leave their load results and artifact + bytes bit-for-bit identical, with no format bump. +- **Device parity.** Whatever the mechanism, it must run identically on + sim and firmware paths — which means the loader (shared via + lpa-server) rather than any host-specific service. + +## Decision + +Bus channels are keyed by `(scope, name)`. Scopes form a tree computed +at load time from the projected node spine: + +1. **Every project node introduces a named scope** around its children. + The root project is simply the outermost scope. Scope introduction is + a property of the node kind, not the invocation site. +2. **Isolating invocation sites introduce an anonymous scope per owned + child.** Today: playlist `entries[k].node` children. This *replaces* + the suppression rule — an entry shader's produced `visual.out` + default now lands in a scope nobody reads, which is behaviorally + identical but falls out of the general model. Playlist entries are + *alternatives* (must be isolated from each other); project children + are *collaborators* (must share one scope). Container kinds differ in + child semantics, which is why "container ⇒ scope" would be the wrong + generalization and the model has two primitives instead of one. +3. **Consume resolution (writer-shadowing):** a consumed `bus:` + endpoint — authored or declared default — resolves to the nearest + enclosing scope, starting at the consumer's own scope and walking + outward, **that has at least one writer** for that channel. If no + scope has a writer, it resolves to the **root** scope, so unfilled + channels surface on the root bus exactly as before and a host can + later fill them. +4. **Produce resolution (locality):** a produced `bus:` endpoint + always writes the producer's **own nearest scope**. Never outward. An + effect cannot clobber a host channel by construction. +5. **Output mirror:** every project node exposes a produced `output` + slot mirroring its own scope's `visual.out` channel. Root included — + uniformity is the point; the root's mirror is simply unread today. + The mirror is what makes an effect playlist-playable with zero + playlist changes: the playlist already reads each entry child's + produced `output` slot directly (slot reads are node-addressed and + bus-independent). A scope with no visual writer renders cleared — + a project without a visual is a legitimate shape, not an error. + + A **non-root** project node's mirror additionally carries a produced + *default* onto `visual.out` at fallback priority. Per rule 4 that + lands in the project node's OWN nearest scope — the parent's — because + the node itself sits there; only its children are inside the scope it + introduces. So an embedded effect contributes its visual to its host + by default and is genuinely drop-in, while its interior stays + isolated. Root is excluded (its containing scope is the scope its + mirror reads, so a default there would be self-referential). + + Accepted consequence (2026-07-29): embedding an effect in a project + that already drives `visual.out` leaves two equal-priority writers, + which resolves as **ambiguous** until the author picks one. This was + chosen over staying explicit (which left effects inert on arrival) + and over auto-publishing only when unclaimed (order-dependent, and + hard to explain when a later shader silently displaces the effect). + `ProjectDef` carries no `bindings` map yet, so that pick cannot + currently be authored on the project node — a gap to close when + contention shows up in practice. + +The consume/produce asymmetry is deliberate: reads inherit (that is the +"just works" magic — lexical-style shadowing means an effect without a +clock inherits host time, and an effect *with* a clock shadows time for +its own subtree only, which is the self-contained-speed story), while +writes stay local (that is the encapsulation guarantee). + +### Implementation shape + +- Writer sets are computed at load time, per `(scope, channel)`: authored + bus-target bindings plus produce-direction declared defaults (after + rule 4 assigns them to their own scope), minus defaults overridden by + an authored target on the same slot. Registration is two-pass — pass 1 + collects writers, pass 2 registers all bindings with consume endpoints + resolved per rule 3. Binding registration order is unchanged (probe + output ordering rides binding indices). Everything recomputes on + refresh/mutation like all binding registration. +- The engine keys channels with an internal + `ScopedChannel { scope: ScopeId, channel: ChannelName }`; + `ScopeId::Project(node)` for named scopes, `ScopeId::Entry(child)` + for anonymous entry scopes (distinct variants, so an entry-owned + project node gets both). The type lives entirely inside `lpc-engine`; + authored artifacts, schemas, and the wire format are untouched. At the + probe boundary, root-scope channels display as bare names (identical + to before) and non-root channels display scope-qualified. +- The mirror is a `ProjectNode` runtime node on the playlist pattern, + minus the blending: `produce("output")` resolves its own scope's + `visual.out` on demand and remembers the producer's `VisualProduct` + handle, the published row carries the project node's *own* handle + (product rows always name their owning node — playlist parity), and + `RenderNode` dispatch forwards one hop to the remembered producer + (cleared when there is none). The only binding the mirror registers is + the non-root produced default from rule 5; existing flat projects have + no non-root project nodes, so their binding graph and bus pane are + unchanged. Its state root is engine-side (not part of the model's + static shape catalog): no schema surface. +- `bus:time`'s default writer is the clock node's produced + `ClockState.seconds` slot (`default_bind = "bus:time"`); there is no + engine-injected time. Inheritance therefore covers effects (host + clock's writer is in an enclosing scope), but a standalone effect + project with no clock anywhere gets no time — effect workbench + projects must include a clock in their preview rig. + +## Consequences + +- Existing projects: single root scope; every resolution identical; + artifact bytes identical; no format bump. Nesting could not exist + before, so the change is backward compatible by construction. +- Playlists: entry isolation now falls out of anonymous scopes; the + suppression special case is deleted. Entry children's produced + defaults now *register* (into the anonymous scope) instead of being + dropped, which is invisible on the root bus pane but makes the entry + shader's output slot show as bound — Studio labeling handles the + scope qualifier (M3 of the composite-effects roadmap). +- A consumed channel written nowhere resolves to root and surfaces + unfilled — hosts can fill an effect's dangling consumes later, which + is the promotion story's escape hatch. +- Cross-scope references are intentionally not expressible. `bus:^foo` + (parent) / `bus:/foo` (root) syntax is **reserved** for a future + slice; the `BindingRef` parser continues to reject such strings + naturally. Channel visibility/export declarations are likewise + deferred. + +## Alternatives considered + +- **No auto-bind when embedded** — rejected: context-dependent behavior + (an effect works standalone, silently stops animating when embedded, + or vice versa). Violates the no-embedded-branches constraint. +- **Scope only `visual.out`** — rejected: partial scoping is confusing + (why is `visual.out` isolated but a custom `energy` channel shared?); + two effects would still collide on any non-visual channel. +- **Lint forbidding buses inside effects** — rejected: breaks the + `time` default, i.e. breaks "just works" for the most common case. +- **Path-prefixed channel names as the authored model** (authors write + `fx.visual.out`) — rejected: same semantics, worse model — pushes + scope bookkeeping onto authors and makes vendoring a rename problem. + (The internal `ScopedChannel` key is not this: authors never see it.) +- **One shared scope per playlist** (instead of per-entry anonymous + scopes) — rejected: moves the `visual.out` collision from host level + to playlist level instead of fixing it; entries are alternatives, not + collaborators. diff --git a/docs/debt/example-shaders-not-compile-gated.md b/docs/debt/example-shaders-not-compile-gated.md new file mode 100644 index 000000000..34a6248ef --- /dev/null +++ b/docs/debt/example-shaders-not-compile-gated.md @@ -0,0 +1,47 @@ +--- +status: open +opened: 2026-07-29 +area: examples/**/*.glsl, CI (just check / build-ci), lps-filetests +class: missing-gate +related: + - docs/defects/2026-07-29-uniform-struct-array-runtime-index.md +--- +# Shipped example shaders are not compile-gated on non-host targets + +**Condition** — nothing in CI compiles the GLSL under `examples/` for the +device- and browser-canonical shader targets. `examples_valid.rs` loads +every example as a project (which exercises the HOST backend only), and the +filetest suite covers `lp-shader/lps-filetests/filetests/**`, never +`examples/`. An example can therefore ship a construct that compiles on the +host and fails on `rv32n` / `rv32c` / `wasm` / `interp` — the targets that +actually run on device and in the browser sim. + +**How it bites** — the failure surfaces only when a human opens that +example in Studio. It presents as a runtime shader-compile error on a node +that otherwise mounted and "runs", which is easy to read as a preview glitch +rather than broken shipped content. First occurrence: +`docs/defects/2026-07-29-uniform-struct-array-runtime-index.md`, where the +construct failed on 4 of 5 targets while every automated gate stayed green. + +**Why it is not just "add an assertion"** — the natural-looking guard does +not work. An engine-level render test (load the example, tick, render, +assert nonzero pixels) runs the host backend by construction, so it cannot +observe another target's lowering. Asserting node runtime status does not +help either: on the host there is no error to report. The gate has to +compile the example sources against the other targets explicitly. + +**Workarounds until fixed** +- When authoring example GLSL, copy the shape of an existing example that + is already known-good on device rather than inventing one; the uniform + struct-array idiom in `examples/events/shader.glsl` is the reference. +- After adding or editing an example shader, open it in the browser sim + once and read the node's status — that is currently the only end-to-end + check. + +**Fix direction** — extend the filetest runner (or add a small harness) to +compile every `examples/**/*.glsl` for `ALL_TARGETS`, run-free +(compile-only), and fail on any target that rejects a shipped example. +Compile-only keeps it cheap and needs no uniform values or expected +outputs. Note the filetest harness currently treats `compile-fail` as an +expected-failure category, so this gate must assert on it rather than +reuse that path. diff --git a/docs/defects/2026-07-29-uniform-struct-array-runtime-index.md b/docs/defects/2026-07-29-uniform-struct-array-runtime-index.md new file mode 100644 index 000000000..8d4a6771e --- /dev/null +++ b/docs/defects/2026-07-29-uniform-struct-array-runtime-index.md @@ -0,0 +1,60 @@ +--- +status: fixed +found: 2026-07-29 # how: live-debugging (Yona opened the meteor sim) +area: examples/effects/meteor (render.glsl), lps-frontend lowering, example shader coverage +class: missing-coverage +related: + - docs/debt/example-shaders-not-compile-gated.md +--- +# Meteor's render shader indexed a uniform struct array with a runtime value + +**Symptom** — opening the imported meteor effect in the browser sim: + +``` +shader compile: lower: in function 'drawMeteor': unsupported expression: +AccessIndex: struct value behind Load: Access base has no uniform element addr +path /studio.show/meteor.project/render.shader +``` + +The effect mounted and ran; only its visual failed to compile. + +**Root cause** — `drawMeteor(vec3 accum, int slot, vec2 uv)` read +`meteors[slot].…` where `slot` is a runtime parameter. A uniform array of +structs has no element address resolvable at lower time, so members must be +read through **constant** indices at the call site and passed in as +scalars/vectors. `examples/events/shader.glsl` already carries exactly that +shape (`drawEvent(color, 0, events[0].id, events[0].seq, uv)`) — the +existing example was the idiom, and the new one departed from it. + +Measured breadth (filetest probe, 2026-07-29): the runtime-indexed form +**compile-fails on 4 of 5 targets** — `rv32n.q32`, `rv32c.q32`, `wasm.q32`, +`interp.f32` — and compiles only on `rv32lpn.q32`. It is broadly +unsupported, not a single-target quirk. + +**Fix** — `examples/effects/meteor/meteor/render.glsl` now takes the +members as parameters (`uint id, vec2 head, vec3 color, float intensity`) +and indexes with constants at the call site. + +**Regression coverage** — the miss is the interesting part, and it was two +independent holes: + +1. **The engine render test could not see it.** The new + `effect_examples_render_through_their_mirrors` (lpc-engine) renders each + effect and asserts nonzero RGB. It runs the HOST backend, whose lowering + accepts the construct — verified empirically by restoring the broken + shader, which still passed. Adding a per-node + `NodeRuntimeStatus::Error` assertion did not catch it either: on the + host path there is genuinely no error to report. An engine-level render + test is structurally incapable of gating shader-lowering support. + +2. **Example shaders are not compile-gated on the other targets.** Nothing + in CI compiles `examples/**/*.glsl` for the device/browser-canonical + targets, so any example can ship a construct that fails everywhere but + the host. That condition is broader than this defect — see the debt + entry. + +Added `lp-shader/lps-filetests/filetests/uniform/array-of-struct.glsl`, +which pins the SUPPORTED idiom (constant-index member reads, including +through a helper) green on all five targets. Note a negative test would not +gate anything: the filetest harness records `compile-fail` as an +*expected-failure*, not a failure. diff --git a/examples/effects/README.md b/examples/effects/README.md new file mode 100644 index 000000000..aab620516 --- /dev/null +++ b/examples/effects/README.md @@ -0,0 +1,32 @@ +# Effect examples + +Each directory here is a **workbench project** — the unit the gallery +opens — wrapping a vendorable **effect**: + +``` +examples/effects// + project.json workbench root: clock + preview fixture + output, + plus one nodes{} entry referencing the effect + fixture.json the preview rig (its input reads the effect's + output mirror via a node: ref) + / THE EFFECT — the folder Studio vendors by copy + project.json kind Project, with promoted controls{} and + author/version/license provenance + … the effect's shaders and assets +``` + +The effect is a plain project (effects-are-projects ADR, +`docs/adr/2026-07-28-effects-are-projects.md`): its bus channels are +scoped to its own subtree, it inherits `time` from whatever hosts it, and +its produced `output` mirror is what playlists and fixtures read. Copying +the inner folder out keeps it standalone-openable (it carries its own +`format`). + +- `plasma/` — pure visual: classic folded-sine plasma, promoted `speed` + and `scale`. +- `meteor/` — compute + visual pair: particle heads persisted in compute + state (`sim`), trail rendering (`render`), promoted controls from both + children (`speed`, `count`, `decay`). + +All sample content in this repository (examples and effects) is CC0 +unless otherwise noted. diff --git a/examples/effects/meteor/clock.json b/examples/effects/meteor/clock.json new file mode 100644 index 000000000..253ac2aae --- /dev/null +++ b/examples/effects/meteor/clock.json @@ -0,0 +1,3 @@ +{ + "kind": "Clock" +} diff --git a/examples/effects/meteor/fixture.json b/examples/effects/meteor/fixture.json new file mode 100644 index 000000000..867ce0167 --- /dev/null +++ b/examples/effects/meteor/fixture.json @@ -0,0 +1,267 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 6 + }, + "bindings": { + "output": { + "target": "bus:control.out" + } + }, + "sampling": "direct", + "mapping": { + "kind": "PathPoints", + "paths": { + "0": { + "kind": "PointList", + "first_channel": 0, + "points": { + "0": [ + 0.008333, + 0.5 + ], + "1": [ + 0.025, + 0.5 + ], + "2": [ + 0.041667, + 0.5 + ], + "3": [ + 0.058333, + 0.5 + ], + "4": [ + 0.075, + 0.5 + ], + "5": [ + 0.091667, + 0.5 + ], + "6": [ + 0.108333, + 0.5 + ], + "7": [ + 0.125, + 0.5 + ], + "8": [ + 0.141667, + 0.5 + ], + "9": [ + 0.158333, + 0.5 + ], + "10": [ + 0.175, + 0.5 + ], + "11": [ + 0.191667, + 0.5 + ], + "12": [ + 0.208333, + 0.5 + ], + "13": [ + 0.225, + 0.5 + ], + "14": [ + 0.241667, + 0.5 + ], + "15": [ + 0.258333, + 0.5 + ], + "16": [ + 0.275, + 0.5 + ], + "17": [ + 0.291667, + 0.5 + ], + "18": [ + 0.308333, + 0.5 + ], + "19": [ + 0.325, + 0.5 + ], + "20": [ + 0.341667, + 0.5 + ], + "21": [ + 0.358333, + 0.5 + ], + "22": [ + 0.375, + 0.5 + ], + "23": [ + 0.391667, + 0.5 + ], + "24": [ + 0.408333, + 0.5 + ], + "25": [ + 0.425, + 0.5 + ], + "26": [ + 0.441667, + 0.5 + ], + "27": [ + 0.458333, + 0.5 + ], + "28": [ + 0.475, + 0.5 + ], + "29": [ + 0.491667, + 0.5 + ], + "30": [ + 0.508333, + 0.5 + ], + "31": [ + 0.525, + 0.5 + ], + "32": [ + 0.541667, + 0.5 + ], + "33": [ + 0.558333, + 0.5 + ], + "34": [ + 0.575, + 0.5 + ], + "35": [ + 0.591667, + 0.5 + ], + "36": [ + 0.608333, + 0.5 + ], + "37": [ + 0.625, + 0.5 + ], + "38": [ + 0.641667, + 0.5 + ], + "39": [ + 0.658333, + 0.5 + ], + "40": [ + 0.675, + 0.5 + ], + "41": [ + 0.691667, + 0.5 + ], + "42": [ + 0.708333, + 0.5 + ], + "43": [ + 0.725, + 0.5 + ], + "44": [ + 0.741667, + 0.5 + ], + "45": [ + 0.758333, + 0.5 + ], + "46": [ + 0.775, + 0.5 + ], + "47": [ + 0.791667, + 0.5 + ], + "48": [ + 0.808333, + 0.5 + ], + "49": [ + 0.825, + 0.5 + ], + "50": [ + 0.841667, + 0.5 + ], + "51": [ + 0.858333, + 0.5 + ], + "52": [ + 0.875, + 0.5 + ], + "53": [ + 0.891667, + 0.5 + ], + "54": [ + 0.908333, + 0.5 + ], + "55": [ + 0.925, + 0.5 + ], + "56": [ + 0.941667, + 0.5 + ], + "57": [ + 0.958333, + 0.5 + ], + "58": [ + 0.975, + 0.5 + ], + "59": [ + 0.991667, + 0.5 + ] + } + } + }, + "sample_diameter": 1.5 + }, + "color_order": "rgb", + "brightness": 255 +} diff --git a/examples/effects/meteor/meteor/project.json b/examples/effects/meteor/meteor/project.json new file mode 100644 index 000000000..199dfefe6 --- /dev/null +++ b/examples/effects/meteor/meteor/project.json @@ -0,0 +1,27 @@ +{ + "kind": "Project", + "format": 1, + "name": "Meteor", + "nodes": { + "render": { + "ref": "./render.json" + }, + "sim": { + "ref": "./sim.json" + } + }, + "controls": { + "speed": { + "target": "node:sim#speed" + }, + "count": { + "target": "node:sim#count" + }, + "decay": { + "target": "node:render#decay" + } + }, + "author": "Photomancer", + "version": "1", + "license": "CC0-1.0" +} diff --git a/examples/effects/meteor/meteor/render.glsl b/examples/effects/meteor/meteor/render.glsl new file mode 100644 index 000000000..119b08155 --- /dev/null +++ b/examples/effects/meteor/meteor/render.glsl @@ -0,0 +1,46 @@ +struct Meteor { + uint id; + vec2 pos; + vec2 dir; + float radius; + vec3 color; + float velocity; + float intensity; +}; + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform Meteor meteors[4]; +layout(binding = 2) uniform float decay; + +// One meteor's contribution: a bright head with an exponential tail behind +// it (the tail wraps with the head). +// +// The uniform array is indexed with CONSTANT indices at the call site and +// its fields arrive as scalars/vectors: a runtime index into a uniform +// struct array has no uniform element address to lower against. +// `examples/events/shader.glsl` carries the same shape for the same reason. +vec3 drawMeteor(vec3 accum, uint id, vec2 head, vec3 color, float intensity, vec2 uv) { + if (id == 0u) { + return accum; + } + if (intensity <= 0.0) { + return accum; + } + float behind = head.x - uv.x; + if (behind < 0.0) { + behind = behind + 1.0; + } + float tail = exp(-behind * decay * 6.0); + float lane = 1.0 - smoothstep(0.0, 0.45, abs(uv.y - head.y)); + return accum + color * tail * lane * intensity; +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + vec3 accum = vec3(0.0, 0.0, 0.0); + accum = drawMeteor(accum, meteors[0].id, meteors[0].pos, meteors[0].color, meteors[0].intensity, uv); + accum = drawMeteor(accum, meteors[1].id, meteors[1].pos, meteors[1].color, meteors[1].intensity, uv); + accum = drawMeteor(accum, meteors[2].id, meteors[2].pos, meteors[2].color, meteors[2].intensity, uv); + accum = drawMeteor(accum, meteors[3].id, meteors[3].pos, meteors[3].color, meteors[3].intensity, uv); + return vec4(min(accum, vec3(1.0, 1.0, 1.0)), 1.0); +} diff --git a/examples/effects/meteor/meteor/render.json b/examples/effects/meteor/meteor/render.json new file mode 100644 index 000000000..aeb87f9ba --- /dev/null +++ b/examples/effects/meteor/meteor/render.json @@ -0,0 +1,38 @@ +{ + "kind": "Shader", + "source": "render.glsl", + "render_order": 0, + "bindings": { + "meteors": { + "source": "node:../sim#meteors" + } + }, + "glsl_opts": { + "add_sub": "wrapping", + "mul": "wrapping", + "div": "reciprocal" + }, + "consumed": { + "meteors": { + "kind": "map", + "value": "lp::fluid::Emitter", + "key": "u32", + "default": 0, + "mapping": { + "kind": "sentinel", + "len": 4, + "key": "id", + "empty_key": 0 + } + }, + "decay": { + "kind": "value", + "value": "f32", + "default": 3, + "min": 0.5, + "max": 8, + "label": "Decay", + "panel": true + } + } +} diff --git a/examples/effects/meteor/meteor/sim.glsl b/examples/effects/meteor/meteor/sim.glsl new file mode 100644 index 000000000..ce8f0f8f7 --- /dev/null +++ b/examples/effects/meteor/meteor/sim.glsl @@ -0,0 +1,65 @@ +// Meteor heads: per-meteor position/energy persisted across ticks in the +// produced map (compute globals are the engine's persistent state). +float prev_time; + +void tick() { + float active = clamp(count, 1.0, 4.0); + + if (meteors[0].id == 0u) { + // Seed the clock alongside the table so the first tick advances by + // zero rather than by the whole elapsed project time. + prev_time = time; + + meteors[0].id = 1u; + meteors[0].pos = vec2(0.0, 0.5); + meteors[0].velocity = 0.22; + meteors[0].color = vec3(1.0, 0.55, 0.15); + meteors[0].radius = 0.14; + + meteors[1].id = 2u; + meteors[1].pos = vec2(0.4, 0.5); + meteors[1].velocity = 0.31; + meteors[1].color = vec3(0.25, 0.55, 1.0); + meteors[1].radius = 0.11; + + meteors[2].id = 3u; + meteors[2].pos = vec2(0.7, 0.5); + meteors[2].velocity = 0.26; + meteors[2].color = vec3(0.4, 1.0, 0.35); + meteors[2].radius = 0.12; + + meteors[3].id = 4u; + meteors[3].pos = vec2(0.15, 0.5); + meteors[3].velocity = 0.37; + meteors[3].color = vec3(1.0, 0.3, 0.8); + meteors[3].radius = 0.09; + } + + // True elapsed time, deliberately UNCLAMPED: integrating constant + // velocity over the real delta is exact at any frame rate, so the + // meteors travel the same distance per second whether the tier renders + // at 60fps or 1fps. A clamp here (there was one) throttles motion on + // slow frames only, which reads as stutter on the CPU tier while the + // GPU tier looks fine. + float dt = max(time - prev_time, 0.0); + prev_time = time; + + meteors[0].pos = vec2(mod(meteors[0].pos.x + meteors[0].velocity * speed * dt, 1.0), 0.5); + meteors[1].pos = vec2(mod(meteors[1].pos.x + meteors[1].velocity * speed * dt, 1.0), 0.5); + meteors[2].pos = vec2(mod(meteors[2].pos.x + meteors[2].velocity * speed * dt, 1.0), 0.5); + meteors[3].pos = vec2(mod(meteors[3].pos.x + meteors[3].velocity * speed * dt, 1.0), 0.5); + + meteors[0].intensity = 1.0; + meteors[1].intensity = 0.0; + meteors[2].intensity = 0.0; + meteors[3].intensity = 0.0; + if (active >= 2.0) { + meteors[1].intensity = 1.0; + } + if (active >= 3.0) { + meteors[2].intensity = 1.0; + } + if (active >= 4.0) { + meteors[3].intensity = 1.0; + } +} diff --git a/examples/effects/meteor/meteor/sim.json b/examples/effects/meteor/meteor/sim.json new file mode 100644 index 000000000..5de6a1d88 --- /dev/null +++ b/examples/effects/meteor/meteor/sim.json @@ -0,0 +1,53 @@ +{ + "kind": "ComputeShader", + "source": "sim.glsl", + "bindings": { + "time": { + "source": "bus:time" + } + }, + "glsl_opts": { + "add_sub": "wrapping", + "mul": "wrapping", + "div": "reciprocal" + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0 + }, + "speed": { + "kind": "value", + "value": "f32", + "default": 1, + "min": 0, + "max": 4, + "label": "Speed", + "panel": true + }, + "count": { + "kind": "value", + "value": "f32", + "default": 3, + "min": 1, + "max": 4, + "label": "Meteors", + "panel": true + } + }, + "produced": { + "meteors": { + "kind": "map", + "value": "lp::fluid::Emitter", + "key": "u32", + "default": 0, + "mapping": { + "kind": "sentinel", + "len": 4, + "key": "id", + "empty_key": 0 + } + } + } +} diff --git a/examples/effects/meteor/output.json b/examples/effects/meteor/output.json new file mode 100644 index 000000000..a6b6682ae --- /dev/null +++ b/examples/effects/meteor/output.json @@ -0,0 +1,20 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:D10", + "bindings": { + "input": { + "source": "bus:control.out" + } + }, + "options": { + "white_point": [ + 0.9, + 1, + 1 + ], + "brightness": 0.12, + "interpolation_enabled": true, + "dithering_enabled": false, + "lut_enabled": true + } +} diff --git a/examples/effects/meteor/project.json b/examples/effects/meteor/project.json new file mode 100644 index 000000000..1056395c8 --- /dev/null +++ b/examples/effects/meteor/project.json @@ -0,0 +1,19 @@ +{ + "kind": "Project", + "format": 1, + "name": "meteor", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "meteor": { + "ref": "./meteor/project.json" + }, + "output": { + "ref": "./output.json" + } + } +} diff --git a/examples/effects/plasma/clock.json b/examples/effects/plasma/clock.json new file mode 100644 index 000000000..253ac2aae --- /dev/null +++ b/examples/effects/plasma/clock.json @@ -0,0 +1,3 @@ +{ + "kind": "Clock" +} diff --git a/examples/effects/plasma/fixture.json b/examples/effects/plasma/fixture.json new file mode 100644 index 000000000..6da84596c --- /dev/null +++ b/examples/effects/plasma/fixture.json @@ -0,0 +1,19 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 32, + "height": 32 + }, + "bindings": { + "output": { + "target": "bus:control.out" + } + }, + "sampling": "direct", + "mapping": { + "kind": "Map2d", + "source": "fixture.map2d.json" + }, + "color_order": "rgb", + "brightness": 255 +} diff --git a/examples/effects/plasma/fixture.map2d.json b/examples/effects/plasma/fixture.map2d.json new file mode 100644 index 000000000..934e1aac3 --- /dev/null +++ b/examples/effects/plasma/fixture.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 2.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { "name": "center", "shape": { "grid": { "origin": [50, 50], "cols": 1, "rows": 1, "pitch": 1 } } }, + { "name": "disc", "shape": { "ring": { + "center": [50, 50], "radius": 50, "outer_count": 60, "rings": 8, + "counts": [60, 48, 40, 32, 24, 16, 12, 8], + "order": "inner_first", "start_angle_deg": 0 + } } } + ] +} diff --git a/examples/effects/plasma/output.json b/examples/effects/plasma/output.json new file mode 100644 index 000000000..a6b6682ae --- /dev/null +++ b/examples/effects/plasma/output.json @@ -0,0 +1,20 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:D10", + "bindings": { + "input": { + "source": "bus:control.out" + } + }, + "options": { + "white_point": [ + 0.9, + 1, + 1 + ], + "brightness": 0.12, + "interpolation_enabled": true, + "dithering_enabled": false, + "lut_enabled": true + } +} diff --git a/examples/effects/plasma/plasma/main.glsl b/examples/effects/plasma/plasma/main.glsl new file mode 100644 index 000000000..fcf22955d --- /dev/null +++ b/examples/effects/plasma/plasma/main.glsl @@ -0,0 +1,17 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; +layout(binding = 2) uniform float speed; +layout(binding = 3) uniform float scale; + +// Classic plasma: three folded sine fields plus a radial term, hue-cycled. +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float t = time * speed; + float v = sin((uv.x * scale + t * 0.13) * 6.2831853) + + sin((uv.y * scale + t * 0.09) * 6.2831853) + + sin(((uv.x + uv.y) * scale * 0.5 + t * 0.11) * 6.2831853) + + sin((length(uv - vec2(0.5, 0.5)) * scale + t * 0.15) * 6.2831853); + float hue = v * 0.125 + t * 0.05; + vec3 rgb = 0.5 + 0.5 * cos(6.2831853 * (hue + vec3(0.0, 0.33, 0.67))); + return vec4(rgb, 1.0); +} diff --git a/examples/effects/plasma/plasma/project.json b/examples/effects/plasma/plasma/project.json new file mode 100644 index 000000000..45bd90461 --- /dev/null +++ b/examples/effects/plasma/plasma/project.json @@ -0,0 +1,21 @@ +{ + "kind": "Project", + "format": 1, + "name": "Plasma", + "nodes": { + "shader": { + "ref": "./shader.json" + } + }, + "controls": { + "speed": { + "target": "node:shader#speed" + }, + "scale": { + "target": "node:shader#scale" + } + }, + "author": "Photomancer", + "version": "1", + "license": "CC0-1.0" +} diff --git a/examples/effects/plasma/plasma/shader.json b/examples/effects/plasma/plasma/shader.json new file mode 100644 index 000000000..ccca870df --- /dev/null +++ b/examples/effects/plasma/plasma/shader.json @@ -0,0 +1,36 @@ +{ + "kind": "Shader", + "source": "main.glsl", + "render_order": 0, + "glsl_opts": { + "add_sub": "wrapping", + "mul": "wrapping", + "div": "reciprocal" + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "default_bind": "bus:time" + }, + "speed": { + "kind": "value", + "value": "f32", + "default": 1, + "min": 0, + "max": 4, + "label": "Speed", + "panel": true + }, + "scale": { + "kind": "value", + "value": "f32", + "default": 1, + "min": 0.25, + "max": 4, + "label": "Scale", + "panel": true + } + } +} diff --git a/examples/effects/plasma/project.json b/examples/effects/plasma/project.json new file mode 100644 index 000000000..3f8cf9a0a --- /dev/null +++ b/examples/effects/plasma/project.json @@ -0,0 +1,19 @@ +{ + "kind": "Project", + "format": 1, + "name": "plasma", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "fixture": { + "ref": "./fixture.json" + }, + "output": { + "ref": "./output.json" + }, + "plasma": { + "ref": "./plasma/project.json" + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/home/embedded_example.rs b/lp-app/lpa-studio-core/src/app/home/embedded_example.rs index 7d68a1c86..10c09c463 100644 --- a/lp-app/lpa-studio-core/src/app/home/embedded_example.rs +++ b/lp-app/lpa-studio-core/src/app/home/embedded_example.rs @@ -4,6 +4,12 @@ //! section lists these. The id doubles as the seed-once provenance source //! (`SeededFrom { source }`), so a package seeded by the pre-M4 demo flow //! and one opened from the gallery are the same package. +//! +//! Effect examples (kind `Effect`) are workbench projects: the openable +//! unit is the whole rig (clock + preview fixture + output), and the +//! vendorable unit is the effect subfolder its root references +//! (effects-are-projects ADR). All shipped example content is CC0 unless +//! otherwise noted. use crate::app::project::demo_project::{DEMO_PROJECT_ID, demo_project_files}; @@ -15,12 +21,68 @@ pub struct EmbeddedExample { pub kind: &'static str, } +/// One compiled-in example file, path relative to the example root. +pub struct EmbeddedExampleFile { + pub relative_path: &'static str, + pub bytes: &'static [u8], +} + +/// Compiled-in files for one `examples/<...>` directory. +macro_rules! example_files { + ($base:literal, [$($file:literal),+ $(,)?]) => { + &[$(EmbeddedExampleFile { + relative_path: $file, + bytes: include_bytes!(concat!("../../../../../examples/", $base, "/", $file)), + }),+] + }; +} + +pub const PLASMA_EXAMPLE_ID: &str = "examples/effects/plasma"; +pub const METEOR_EXAMPLE_ID: &str = "examples/effects/meteor"; + +static PLASMA_FILES: &[EmbeddedExampleFile] = example_files!( + "effects/plasma", + [ + "project.json", + "clock.json", + "fixture.json", + "fixture.map2d.json", + "output.json", + "plasma/project.json", + "plasma/shader.json", + "plasma/main.glsl", + ] +); + +static METEOR_FILES: &[EmbeddedExampleFile] = example_files!( + "effects/meteor", + [ + "project.json", + "clock.json", + "fixture.json", + "output.json", + "meteor/project.json", + "meteor/sim.json", + "meteor/sim.glsl", + "meteor/render.json", + "meteor/render.glsl", + ] +); + impl EmbeddedExample { /// The example's package files as (relative path, bytes). pub fn files(&self) -> Vec<(String, Vec)> { - // every embedded example currently ships the demo file set; a second - // example would grow a match here - demo_project_files() + let files: &[EmbeddedExampleFile] = match self.id { + PLASMA_EXAMPLE_ID => PLASMA_FILES, + METEOR_EXAMPLE_ID => METEOR_FILES, + _ => { + return demo_project_files() + .iter() + .map(|file| (file.relative_path.to_string(), file.bytes.to_vec())) + .collect(); + } + }; + files .iter() .map(|file| (file.relative_path.to_string(), file.bytes.to_vec())) .collect() @@ -29,11 +91,23 @@ impl EmbeddedExample { /// All embedded examples, gallery order. pub fn embedded_examples() -> &'static [EmbeddedExample] { - &[EmbeddedExample { - id: DEMO_PROJECT_ID, - name: "Fyeah Sign", - kind: "Project", - }] + &[ + EmbeddedExample { + id: DEMO_PROJECT_ID, + name: "Fyeah Sign", + kind: "Project", + }, + EmbeddedExample { + id: PLASMA_EXAMPLE_ID, + name: "Plasma", + kind: "Effect", + }, + EmbeddedExample { + id: METEOR_EXAMPLE_ID, + name: "Meteor", + kind: "Effect", + }, + ] } /// Look up an embedded example by id. @@ -61,6 +135,81 @@ mod tests { ); } + #[test] + fn effect_examples_are_embedded_workbenches() { + for (id, name, effect_def) in [ + (PLASMA_EXAMPLE_ID, "Plasma", "plasma/project.json"), + (METEOR_EXAMPLE_ID, "Meteor", "meteor/project.json"), + ] { + let example = embedded_example(id).expect("effect example is embedded"); + assert_eq!(example.name, name); + assert_eq!(example.kind, "Effect"); + let files = example.files(); + assert!( + files.iter().any(|(path, _)| path == "project.json"), + "{id}: workbench root present" + ); + assert!( + files.iter().any(|(path, _)| path == effect_def), + "{id}: nested effect def present" + ); + // Every asset the workbench's fixture references must travel + // with the copy — a missing mapping document would open as a + // fixture with no lamps. + for (path, bytes) in &files { + assert!(!bytes.is_empty(), "{id}: {path} is empty"); + } + } + } + + /// Every asset an embedded example's node defs reference must be in + /// its file list: a gallery-opened copy has only these bytes, so a + /// missing mapping document or shader source opens broken. + #[test] + fn effect_example_files_carry_every_referenced_asset() { + for id in [PLASMA_EXAMPLE_ID, METEOR_EXAMPLE_ID] { + let files = embedded_example(id).expect("example").files(); + let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); + for (path, bytes) in &files { + if !path.ends_with(".json") { + continue; + } + let text = core::str::from_utf8(bytes).expect("utf-8"); + let dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); + // Asset refs are file-relative bare strings on `source` + // fields (shader/compute source, Map2d mapping document). + for needle in ["\"source\": \""] { + let mut rest = text; + while let Some(at) = rest.find(needle) { + rest = &rest[at + needle.len()..]; + let Some(end) = rest.find('"') else { break }; + let asset = &rest[..end]; + // Binding endpoints share the `source` key name but + // are refs, not files. + if asset.is_empty() + || asset.contains('{') + || asset.starts_with("node:") + || asset.starts_with("bus:") + { + continue; + } + let stripped = asset.strip_prefix("./").unwrap_or(asset); + let expected = if dir.is_empty() { + stripped.to_string() + } else { + format!("{dir}/{stripped}") + }; + assert!( + paths.contains(&expected.as_str()), + "{id}: {path} references {asset}, which is not in the package \ + (paths: {paths:?})" + ); + } + } + } + } + } + #[test] fn unknown_example_is_none() { assert!(embedded_example("examples/unknown").is_none()); diff --git a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs index c96b9c223..0b2f9b87b 100644 --- a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs +++ b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs @@ -1030,7 +1030,7 @@ mod tests { assert_eq!( view.render_text_lines(), vec![ - "Home: 0 devices, 0 projects, 1 examples".to_string(), + "Home: 0 devices, 0 projects, 3 examples".to_string(), " opening prj_x".to_string(), " issue: boom".to_string(), ] diff --git a/lp-app/lpa-studio-core/src/app/node/face/mod.rs b/lp-app/lpa-studio-core/src/app/node/face/mod.rs index d5439b751..a257ca10a 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/mod.rs @@ -6,12 +6,14 @@ //! hand-built per node kind; only the front-panel metadata (which slots are //! on the panel, widget kind, range/unit) is data-driven from slot shapes. //! -//! Faces exist for shader, fixture, and playlist nodes, derived from the +//! Faces exist for shader, fixture, playlist, and effect (embedded +//! project) nodes, derived from the //! finished section DTOs in `node_face_builder` (project layer) so a panel //! control and its backing slot row can never disagree. Every other kind //! gets `None` and renders today's generic sections — the universal //! fallback, also always available inside the advanced drawer. +mod ui_effect_face; mod ui_fixture_face; mod ui_node_face; mod ui_panel_control; @@ -20,6 +22,7 @@ mod ui_playlist_entry; mod ui_playlist_face; mod ui_shader_face; +pub use ui_effect_face::UiEffectFace; pub use ui_fixture_face::UiFixtureFace; pub use ui_node_face::UiNodeFace; pub use ui_panel_control::UiPanelControl; diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_effect_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_effect_face.rs new file mode 100644 index 000000000..c65aa0fca --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_effect_face.rs @@ -0,0 +1,24 @@ +//! The effect card's permanent face (effects-are-projects ADR). + +use crate::{UiPanelControl, UiProducedProduct}; + +/// Permanent face for an embedded project ("effect") node card. +/// +/// Renders top-down as: output-mirror preview → promoted-control knob row → +/// provenance line; the advanced drawer keeps the full slot view. Promotion +/// is DTO-level aliasing: each control's `address` is the **inner child's** +/// slot address, so edits, dirty state, and bound-violet ride the standard +/// slot machinery with no forwarding layer. +#[derive(Clone, Debug, PartialEq)] +pub struct UiEffectFace { + /// The project node's produced `output` mirror, rendered as the face + /// hero (the scope's `visual.out`, forwarded). + pub preview: UiProducedProduct, + /// Promoted controls aliased onto inner-child slots. A control whose + /// target does not resolve renders disabled with an Invalid affordance + /// (never dropped silently). + pub controls: Vec, + /// Compact provenance line ("author · v1 · CC0-1.0"); `None` when the + /// def carries no provenance fields. + pub provenance: Option, +} diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs index 1a5ad89f3..7c80575c4 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs @@ -1,6 +1,6 @@ //! The kind-specific face variants a node card can render. -use crate::{UiFixtureFace, UiPlaylistFace, UiShaderFace}; +use crate::{UiEffectFace, UiFixtureFace, UiPlaylistFace, UiShaderFace}; /// Kind-specific permanent face for a node card. /// @@ -16,4 +16,7 @@ pub enum UiNodeFace { /// Playlist card: entry strip; the active child's real card renders /// below via the existing [`crate::UiNodeChild`]. Playlist(UiPlaylistFace), + /// Effect card (embedded project): mirror preview + promoted-control + /// knobs aliasing inner-child slots; children render as sibling cards. + Effect(UiEffectFace), } diff --git a/lp-app/lpa-studio-core/src/app/node/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs index bd9021f84..b3832790e 100644 --- a/lp-app/lpa-studio-core/src/app/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/mod.rs @@ -38,8 +38,8 @@ mod ui_slot_unit; mod ui_slot_value; pub use face::{ - UiFixtureFace, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, - UiShaderFace, + UiEffectFace, UiFixtureFace, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, + UiPlaylistFace, UiShaderFace, }; pub use ui_asset_editor::UiAssetEditor; pub use ui_binding_authoring::{UiBindingAuthoring, UiBindingAuthoringDirection, UiChannelChoice}; diff --git a/lp-app/lpa-studio-core/src/app/project/mod.rs b/lp-app/lpa-studio-core/src/app/project/mod.rs index 5bf48e338..396db7312 100644 --- a/lp-app/lpa-studio-core/src/app/project/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -57,7 +57,7 @@ pub use asset::{ pub use dirty_summary::DirtySummary; pub use loaded_project_choice::LoadedProjectChoice; pub use node::{ - NodeController, NodeControllerState, NodeCreateOp, NodeRemoveOp, NodeRevertOp, + EffectImportOp, NodeController, NodeControllerState, NodeCreateOp, NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget, ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, UiAttachTarget, UiNodeRemovePreflight, }; diff --git a/lp-app/lpa-studio-core/src/app/project/node/mod.rs b/lp-app/lpa-studio-core/src/app/project/node/mod.rs index a56f22d6d..b3e35f200 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/mod.rs @@ -19,7 +19,7 @@ pub mod ui_add_node_menu; pub(in crate::app::project) use node_controller::root_slot_key; pub use node_controller::{NodeController, NodeControllerState, ProjectProductSubscriptionIntent}; -pub use node_create_op::{NodeCreateOp, UiAttachTarget}; +pub use node_create_op::{EffectImportOp, NodeCreateOp, UiAttachTarget}; pub use node_remove_op::NodeRemoveOp; pub use node_remove_preflight::UiNodeRemovePreflight; pub use node_revert_op::NodeRevertOp; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs index 2275d5482..e9aae17fe 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs @@ -35,8 +35,10 @@ pub enum UiAttachTarget { /// the new node. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NodeCreateOp { - /// The node kind to create (every kind except `Project` is offered by - /// the picker — see [`crate::UiAddNodeMenu`]). + /// The node kind to create. `Project` is the picker's **Effect** + /// source: the controller expands it into the effect folder starter + /// (`effects//…`) instead of a flat starter file — see + /// [`crate::UiAddNodeMenu`]. pub kind: NodeKind, /// Where the new node attaches. pub attach: UiAttachTarget, @@ -76,6 +78,51 @@ impl ControllerOp for NodeCreateOp { } } +/// Import a shipped effect example into the project ([`crate::UiAddNodeMenu`]'s +/// Effects section): vendor the example's effect subfolder by copy into +/// `effects//` and attach it at `attach` — one gesture from "shipped +/// effect" to "entry in my playlist" (effects-are-projects ADR). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectImportOp { + /// The embedded example's stable id (e.g. `examples/effects/plasma`). + pub example: String, + /// Where the vendored effect attaches. + pub attach: UiAttachTarget, +} + +impl ControllerOp for EffectImportOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + "Add effect", + "Copy a shipped effect into this project.", + ActionPriority::Secondary, + ) + .with_icon("add") + } + + fn action_class(&self) -> ActionClass { + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs index 16ae31414..3273595cd 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs @@ -28,14 +28,15 @@ //! - Every other kind returns `None` — the card keeps today's generic //! sections. -use lpc_model::{FixtureDef, PlaylistDef, ShaderDef}; +use lpc_model::{FixtureDef, PlaylistDef, ProjectDef, ShaderDef}; use crate::{ ControllerId, PlaylistActivateOp, ProjectController, ProjectNodeAddress, ProjectSlotAddress, - UiAction, UiAssetEditor, UiAssetEditorKind, UiConfigSlot, UiConfigSlotBody, UiFixtureFace, - UiNodeChild, UiNodeFace, UiNodeSection, UiPanelControl, UiPanelWidget, UiPlaylistEntry, - UiPlaylistFace, UiProducedProduct, UiProductKind, UiProductPreview, UiShaderFace, UiSlotAspect, - UiSlotAspectKind, UiSlotEditorHint, UiSlotSourceState, UiSlotValue, UiSlotValueKind, + UiAction, UiAssetEditor, UiAssetEditorKind, UiConfigSlot, UiConfigSlotBody, UiEffectFace, + UiFixtureFace, UiNodeChild, UiNodeFace, UiNodeSection, UiPanelControl, UiPanelWidget, + UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, UiProductKind, UiProductPreview, + UiShaderFace, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotEditorHint, + UiSlotFieldState, UiSlotSourceState, UiSlotValue, UiSlotValueKind, }; /// Build the kind-specific face for a node's card from its projected @@ -76,11 +77,235 @@ pub(in crate::app::project) fn kind_face( // ACTIVE placard is the active-ness presentation. Some(UiNodeFace::Playlist(face)) } + // An embedded project renders as an effect card; the ROOT project + // is not a card and keeps whatever surface renders it today. + ProjectDef::KIND if address.path().0.len() > 1 => { + effect_face(sections, children).map(UiNodeFace::Effect) + } // Unknown kinds stay on the generic fallback permanently. _ => None, } } +// -- effect face --------------------------------------------------------------- + +/// The effect card's face: output-mirror hero, promoted-control knobs, and +/// a provenance line. `None` — generic fallback — when the node has no +/// produced visual row (the mirror has not projected yet). An effect with +/// no `controls{}` entries still gets its face (preview + provenance); +/// curation is optional. +fn effect_face(sections: &[UiNodeSection], children: &[UiNodeChild]) -> Option { + let preview = product_of_kind(sections, UiProductKind::Visual)?; + let rows = config_rows(sections); + let controls = rows + .iter() + .find(|row| row.key == "controls") + .map(|row| &row.body) + .and_then(|body| match body { + UiConfigSlotBody::Record(map) => Some(map), + _ => None, + }) + .map(|map| { + map.fields + .iter() + .filter_map(|entry| promoted_control(entry, children)) + .collect() + }) + .unwrap_or_default(); + Some(UiEffectFace { + preview, + controls, + provenance: provenance_line(&rows), + }) +} + +/// Compact "author · v · " line from the def's provenance +/// rows; `None` when none of the fields are present. +fn provenance_line(rows: &[&UiConfigSlot]) -> Option { + let field = |name: &str| { + rows.iter() + .find(|row| row.key == name) + .filter(|row| row.optionality.is_none_or(|opt| opt.included)) + .and_then(|row| match &row.body { + UiConfigSlotBody::Value(UiSlotValue { + kind: UiSlotValueKind::String(value), + .. + }) if !value.is_empty() => Some(value.clone()), + _ => None, + }) + }; + let parts: Vec = [ + field("author"), + field("version").map(|version| format!("v{version}")), + field("license"), + ] + .into_iter() + .flatten() + .collect(); + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +/// One `controls[]` record row → its promoted control: the alias +/// resolves to the inner child's slot row and the control copies that row's +/// address/value/state/aspects (DTO-level aliasing — the write path routes +/// purely by address, so dirty/violet/popovers ride the child's machinery). +/// An unresolvable target renders DISABLED with an Invalid affordance — +/// never dropped silently. +fn promoted_control(entry: &UiConfigSlot, children: &[UiNodeChild]) -> Option { + let UiConfigSlotBody::Record(record) = &entry.body else { + return None; + }; + let fields = &record.fields; + let name = map_entry_name(entry); + let label_override = string_field(fields, "label").filter(|label| !label.is_empty()); + let unit_override = string_field(fields, "unit") + .filter(|unit| !unit.is_empty()) + .map(|unit| crate::app::project::slot::slot_controller::ui_slot_unit(&unit)); + let min_override = option_f32_field(fields, "min"); + let max_override = option_f32_field(fields, "max"); + + let resolved = + string_field(fields, "target").and_then(|target| resolve_alias_control(&target, children)); + + let mut control = match resolved { + Some(control) => control, + None => { + // Disabled placeholder: the effect author (or a rename) broke + // the alias; surface it instead of dropping the knob. + let mut state = UiSlotFieldState::readonly(); + state.invalid = Some(String::from("promoted control target does not resolve")); + UiPanelControl { + label: name.clone(), + address: None, + widget: UiPanelWidget::Knob { + min: 0.0, + max: 1.0, + step: None, + }, + value: UiSlotValue::unset(), + live_value: None, + unit: None, + state, + aspects: vec![ + UiSlotAspect::new(UiSlotAspectKind::Validation, "Promoted control") + .with_affordance(UiSlotAffordance::Invalid), + ], + } + } + }; + + if let Some(label) = label_override { + control.label = label; + } else if control.label.is_empty() { + control.label = name; + } + if unit_override.is_some() { + control.unit = unit_override; + } + if min_override.is_some() || max_override.is_some() { + control.widget = match control.widget { + UiPanelWidget::Knob { min, max, step } => UiPanelWidget::Knob { + min: min_override.unwrap_or(min), + max: max_override.unwrap_or(max), + step, + }, + UiPanelWidget::Fader { min, max, step } => UiPanelWidget::Fader { + min: min_override.unwrap_or(min), + max: max_override.unwrap_or(max), + step, + }, + other => other, + }; + } + Some(control) +} + +/// Resolve a promoted-control target (`node:#`) to a panel +/// control built from the child's own slot rows: shader consumed uniforms +/// reuse the shader-knob derivation (address = `consumed[].default +/// .some` on the CHILD), any other child slot goes through the generic +/// panel-row projection. `None` when the child or its slot row is missing. +fn resolve_alias_control(target: &str, children: &[UiNodeChild]) -> Option { + let lpc_model::BindingRef::Node(node_slot) = lpc_model::BindingRef::parse(target).ok()? else { + return None; + }; + // Depth-1 alias targets only in this slice (validated at load). + let node_ref = node_slot.node(); + if node_ref.parent_hops() != 0 || node_ref.segments().len() != 1 { + return None; + } + let child_name = node_ref.segments().first()?.as_str(); + let slot = node_slot.slot().to_string(); + let child = children + .iter() + .find(|child| child_tree_name(child) == Some(child_name))?; + let child_rows = config_rows(&child.sections); + + // Shader-style: the target names a consumed uniform. + if let Some(UiConfigSlotBody::Record(consumed)) = child_rows + .iter() + .find(|row| row.key == "consumed") + .map(|row| &row.body) + && let Some(entry) = consumed + .fields + .iter() + .find(|row| map_entry_name(row) == slot) + { + return shader_uniform_control_any(entry, &child_rows); + } + + // Generic: a top-level row on the child keyed by the slot name. + let row = child_rows.iter().find(|row| row.key == slot)?; + let widget = panel_widget(row).unwrap_or(UiPanelWidget::Knob { + min: 0.0, + max: 1.0, + step: None, + }); + panel_control_from_row(row, widget) +} + +/// [`shader_uniform_control`] without the `panel` gate: promotion IS the +/// curation, so a promoted uniform need not be panel-flagged on the child. +fn shader_uniform_control_any( + entry: &UiConfigSlot, + top_rows: &[&UiConfigSlot], +) -> Option { + let UiConfigSlotBody::Record(record) = &entry.body else { + return None; + }; + let fields = &record.fields; + let default_row = uniform_field(fields, "default")?; + let control = panel_control_from_row( + default_row, + UiPanelWidget::Knob { + min: option_f32_field(fields, "min").unwrap_or(0.0), + max: option_f32_field(fields, "max").unwrap_or(1.0), + step: None, + }, + )?; + let name = map_entry_name(entry); + let label = string_field(fields, "label") + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| entry.label.clone()); + let unit = string_field(fields, "unit") + .filter(|unit| !unit.is_empty()) + .map(|unit| crate::app::project::slot::slot_controller::ui_slot_unit(&unit)); + let mut control = UiPanelControl { label, ..control }; + if unit.is_some() { + control.unit = unit; + } + if let Some(binding) = uniform_binding_aspect(top_rows, &name) { + replace_binding_aspect(&mut control.aspects, binding); + } + if control.live_value.is_none() { + control.live_value = top_rows + .iter() + .find(|row| row.key == name) + .and_then(|row| bound_live_value(row)); + } + Some(control) +} + /// The shader card's face: visual hero, panel knobs, code drawer. `None` /// when the node produces no visual output row (nothing to be a face of). fn shader_face(sections: &[UiNodeSection]) -> Option { @@ -873,6 +1098,165 @@ mod tests { assert!(entry.action.is_some(), "matched via the entry_ rule"); } + // -- effect face --------------------------------------------------------- + + /// Effect sections: the mirror's produced visual row plus the def's + /// `controls{}` map (one alias to the child shader's `speed` uniform, + /// with a label + max override) and provenance strings. + fn effect_sections() -> Vec { + let control = UiConfigSlot::record( + "controls[speed]", + "speed", + vec![ + UiConfigSlot::value( + "controls[speed].target", + "Target", + UiSlotValue::string("node:shader#speed"), + ), + UiConfigSlot::value( + "controls[speed].label", + "Label", + UiSlotValue::string("Pulse rate"), + ), + option_f32("controls[speed].max", "Max", 8.0), + ], + ); + vec![ + UiNodeSection::ProducedProducts(vec![UiProducedProduct::visual("Output")]), + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::record("controls", "Controls", vec![control]), + UiConfigSlot::value("author", "Author", UiSlotValue::string("photomancer")), + UiConfigSlot::value("license", "License", UiSlotValue::string("CC0-1.0")), + ]), + ] + } + + /// An effect child shader named `shader` carrying the `speed` uniform's + /// full slot rows (the alias resolves against these). + fn effect_child_shader() -> UiNodeChild { + let mut child = + UiNodeChild::new("Shader", "Shader", "/main.project/fx.project/shader.shader"); + child.sections = shader_sections(); + child + } + + fn effect_address() -> ProjectNodeAddress { + ProjectNodeAddress::parse("/demo.project/fx.project").expect("valid address") + } + + #[test] + fn effect_face_aliases_promoted_controls_onto_the_child_slot_address() { + let sections = effect_sections(); + let mut children = vec![effect_child_shader()]; + + let Some(UiNodeFace::Effect(face)) = + kind_face("project", &effect_address(), §ions, &mut children) + else { + panic!("expected an effect face"); + }; + + assert_eq!(face.preview.kind, UiProductKind::Visual); + assert_eq!(face.controls.len(), 1); + let control = &face.controls[0]; + // The alias's own label beats the child's authored one. + assert_eq!(control.label, "Pulse rate"); + // THE aliasing invariant: the edit address is the CHILD's slot — + // a knob drag dispatches into the child's artifact through the + // standard address-routed write path. + assert_eq!( + control + .address + .as_ref() + .expect("promoted knob edits are addressed") + .path + .to_string(), + "consumed[speed].default.some" + ); + assert_eq!( + control.address.as_ref().unwrap().node.to_string(), + "/demo.project/node.shader", + "the address names the child node (test fixture's address root)" + ); + // The child's authored min plus the alias's max override. + assert_eq!( + control.widget, + UiPanelWidget::Knob { + min: 0.0, + max: 8.0, + step: None + } + ); + assert_eq!(control.value.kind, UiSlotValueKind::F32(2.0)); + assert_eq!(face.provenance.as_deref(), Some("photomancer · CC0-1.0")); + + // Effect children are collaborators: no one-active-child filtering. + assert_eq!(children.len(), 1); + } + + #[test] + fn effect_face_promotes_non_panel_uniforms_and_marks_broken_aliases() { + // Promotion IS the curation: the child's `time` uniform carries no + // panel flag but still promotes; a dangling target renders a + // disabled Invalid control instead of disappearing. + let mut sections = effect_sections(); + if let UiNodeSection::ConfigSlots(rows) = &mut sections[1] + && let UiConfigSlotBody::Record(map) = &mut rows[0].body + { + map.fields.push(UiConfigSlot::record( + "controls[warp]", + "warp", + vec![UiConfigSlot::value( + "controls[warp].target", + "Target", + UiSlotValue::string("node:shader#time"), + )], + )); + map.fields.push(UiConfigSlot::record( + "controls[gone]", + "gone", + vec![UiConfigSlot::value( + "controls[gone].target", + "Target", + UiSlotValue::string("node:missing#speed"), + )], + )); + } + let mut children = vec![effect_child_shader()]; + + let Some(UiNodeFace::Effect(face)) = + kind_face("project", &effect_address(), §ions, &mut children) + else { + panic!("expected an effect face"); + }; + assert_eq!(face.controls.len(), 3); + let warp = &face.controls[1]; + assert!(warp.address.is_some(), "non-panel uniform still promotes"); + let gone = &face.controls[2]; + assert!(gone.address.is_none()); + assert!(!gone.state.editable, "broken alias renders disabled"); + assert_eq!( + gone.primary_affordance(), + crate::UiSlotAffordance::Invalid, + "broken alias wears the Invalid affordance" + ); + assert_eq!(gone.label, "gone", "falls back to the control's name"); + } + + #[test] + fn root_project_never_wears_the_effect_face() { + let sections = effect_sections(); + let root = ProjectNodeAddress::parse("/demo.project").expect("valid address"); + assert_eq!( + kind_face( + "project", + &root, + §ions, + &mut vec![effect_child_shader()] + ), + None + ); + } + // -- fixtures ------------------------------------------------------------ fn address(path: &str) -> ProjectSlotAddress { diff --git a/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs b/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs index 69232ce33..b6746b634 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs @@ -4,9 +4,11 @@ use lpc_model::NodeKind; use crate::{ControllerId, UiAction}; -use super::node_create_op::{NodeCreateOp, UiAttachTarget}; +use super::node_create_op::{EffectImportOp, NodeCreateOp, UiAttachTarget}; use super::node_naming::{node_kind_label, node_kind_slug}; +use crate::app::home::embedded_examples; + /// Picker order: the common authoring targets first, hardware-/niche kinds /// last. Stable — the picker never reorders. `Project` is excluded (it is /// the artifact root; nested sub-projects are future work). @@ -30,6 +32,10 @@ const PICKER_KINDS: &[NodeKind] = &[ #[derive(Clone, Debug, PartialEq)] pub struct UiAddNodeMenu { pub entries: Vec, + /// The Effects source section: shipped effect examples, vendored by + /// copy on selection ([`EffectImportOp`]). Rendered as its own picker + /// section below the kind rows. + pub effects: Vec, } /// One picker entry. `action` is the ready-to-dispatch create (pane grammar: @@ -45,31 +51,68 @@ pub struct UiAddNodeMenuEntry { pub action: UiAction, } -/// Build the picker for one attach site: every kind except `Project`, in -/// [`PICKER_KINDS`] order. +/// Build the picker for one attach site: every plain kind in +/// [`PICKER_KINDS`] order, then the **Effect** source — not a kind row but +/// a folder starter: it dispatches the same [`NodeCreateOp`] with +/// `NodeKind::Project`, which the controller expands into the effect +/// folder starter (`effects//…`, effects-are-projects ADR). pub fn add_node_menu(attach: &UiAttachTarget) -> UiAddNodeMenu { - UiAddNodeMenu { - entries: PICKER_KINDS - .iter() - .map(|kind| { - let label = node_kind_label(*kind); - UiAddNodeMenuEntry { - kind: *kind, - label: label.to_string(), - icon: node_kind_slug(*kind).to_string(), - action: UiAction::from_op( - ControllerId::new(crate::ProjectController::NODE_ID), - NodeCreateOp { - kind: *kind, - attach: attach.clone(), - }, - ) - .with_label(format!("Add {label}")) - .with_summary(format!("Create a new {} node.", label.to_lowercase())), - } - }) - .collect(), - } + let mut entries: Vec = PICKER_KINDS + .iter() + .map(|kind| { + let label = node_kind_label(*kind); + UiAddNodeMenuEntry { + kind: *kind, + label: label.to_string(), + icon: node_kind_slug(*kind).to_string(), + action: UiAction::from_op( + ControllerId::new(crate::ProjectController::NODE_ID), + NodeCreateOp { + kind: *kind, + attach: attach.clone(), + }, + ) + .with_label(format!("Add {label}")) + .with_summary(format!("Create a new {} node.", label.to_lowercase())), + } + }) + .collect(); + entries.push(UiAddNodeMenuEntry { + kind: NodeKind::Project, + label: String::from("Effect"), + icon: String::from("effect"), + action: UiAction::from_op( + ControllerId::new(crate::ProjectController::NODE_ID), + NodeCreateOp { + kind: NodeKind::Project, + attach: attach.clone(), + }, + ) + .with_label("New Effect") + .with_summary("Create a new effect: a small embedded project with promoted controls."), + }); + let effects = embedded_examples() + .iter() + .filter(|example| example.kind == "Effect") + .map(|example| UiAddNodeMenuEntry { + kind: NodeKind::Project, + label: example.name.to_string(), + icon: String::from("effect"), + action: UiAction::from_op( + ControllerId::new(crate::ProjectController::NODE_ID), + EffectImportOp { + example: example.id.to_string(), + attach: attach.clone(), + }, + ) + .with_label(format!("Add {}", example.name)) + .with_summary(format!( + "Copy the {} effect into this project.", + example.name + )), + }) + .collect(); + UiAddNodeMenu { entries, effects } } #[cfg(test)] @@ -77,14 +120,17 @@ mod tests { use super::*; #[test] - fn menu_offers_every_kind_except_project_in_stable_order() { + fn menu_offers_every_kind_plus_the_effect_source_in_stable_order() { let menu = add_node_menu(&UiAttachTarget::ProjectRoot); - assert_eq!(menu.entries.len(), 10, "all kinds except Project"); - assert!(menu.entries.iter().all(|e| e.kind != NodeKind::Project)); + assert_eq!(menu.entries.len(), 11, "all plain kinds plus Effect"); assert_eq!(menu.entries[0].kind, NodeKind::Shader); assert_eq!(menu.entries[0].label, "Shader"); assert_eq!(menu.entries[0].icon, "shader"); + let effect = menu.entries.last().expect("effect entry"); + assert_eq!(effect.kind, NodeKind::Project); + assert_eq!(effect.label, "Effect"); + assert_eq!(effect.action.meta().label, "New Effect"); // Rebuilding yields the identical menu (stable order, stable data). assert_eq!(menu, add_node_menu(&UiAttachTarget::ProjectRoot)); } diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 04e74c874..0d68a436c 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -2783,7 +2783,16 @@ impl ProjectController { attach: &UiAttachTarget, ) -> Result { let handle_id = self.ready_handle_id()?; - let name = self.unique_node_name_for(kind); + // `kind == Project` is the picker's Effect SOURCE (not a kind row): + // the created unit is a folder (`effects//…`) seeded from the + // effect starter, attached exactly like a flat node + // (effects-are-projects ADR). + let is_effect = kind == NodeKind::Project; + let name = if is_effect { + self.unique_effect_name() + } else { + self.unique_node_name_for(kind) + }; let (site, parent, expected_name) = match attach { UiAttachTarget::ProjectRoot => { let Some(root) = self.root_nodes.first() else { @@ -2830,34 +2839,186 @@ impl ProjectController { } }; - // Starter bytes: the kind's starter template (bare default when the - // table has no entry), stem-substituted, canonically serialized. - let starter = starter_for_kind(kind) - .unwrap_or_else(|| NodeStarter { - def: lpc_model::NodeDef::default_for_kind(kind), - assets: Vec::new(), - }) - .for_stem(&name); - let body = starter.def.write_json(&self.slot_shapes).map_err(|err| { - UiError::Project(format!("cannot serialize the new node definition: {err}")) - })?; - let assets = starter - .assets - .into_iter() - .map(|(file, bytes)| { + let request = if is_effect { + // Effect folder: `effects//project.json` as the def file, + // every sibling starter file as an asset (the byte-oriented + // create seam carries multi-file payloads by design). + let mut files = + lpc_model::effect_starter_files(&name, &self.slot_shapes).map_err(|err| { + UiError::Project(format!("cannot serialize the effect starter: {err}")) + })?; + let def_index = files + .iter() + .position(|(file, _)| file == "project.json") + .expect("the effect starter carries project.json"); + let (_, body) = files.remove(def_index); + let assets = files + .into_iter() + .map(|(file, bytes)| { + ( + lpc_model::LpPathBuf::from(format!("./effects/{name}/{file}").as_str()), + bytes, + ) + }) + .collect::>(); + WireCreateNodeRequest::new( + lpc_model::LpPathBuf::from(format!("./effects/{name}/project.json").as_str()), + body, + assets, + site, + ) + } else { + // Starter bytes: the kind's starter template (bare default when + // the table has no entry), stem-substituted, canonically + // serialized. + let starter = starter_for_kind(kind) + .unwrap_or_else(|| NodeStarter { + def: lpc_model::NodeDef::default_for_kind(kind), + assets: Vec::new(), + }) + .for_stem(&name); + let body = starter.def.write_json(&self.slot_shapes).map_err(|err| { + UiError::Project(format!("cannot serialize the new node definition: {err}")) + })?; + let assets = starter + .assets + .into_iter() + .map(|(file, bytes)| { + ( + lpc_model::LpPathBuf::from(format!("./{file}").as_str()), + bytes, + ) + }) + .collect::>(); + WireCreateNodeRequest::new( + lpc_model::LpPathBuf::from(format!("./{name}.json").as_str()), + body.into_bytes(), + assets, + site, + ) + }; + + self.send_create_node_request(server, handle_id, request, parent, expected_name, name) + .await + } + + /// Import a shipped effect example ([`crate::EffectImportOp`]): extract + /// the effect subfolder from the example's workbench files (the single + /// `nodes{}` entry of the workbench root that refs a subfolder + /// `project.json` — resolved from the root's bytes, never a hardcoded + /// name), vendor it by copy into `effects//…`, and attach it via + /// the same `CreateNode` seam as every other create. + pub async fn import_effect( + &mut self, + server: &mut StudioServerClient, + example_id: &str, + attach: &UiAttachTarget, + ) -> Result { + let handle_id = self.ready_handle_id()?; + let Some(example) = crate::app::home::embedded_example(example_id) else { + return Ok(ProjectEditRun::notice(UiNotice::warning(format!( + "Unknown effect example {example_id}" + )))); + }; + let files = example.files(); + let Some((folder, effect_files)) = extract_effect_subfolder(&files) else { + return Ok(ProjectEditRun::notice(UiNotice::warning(format!( + "Example {example_id} carries no effect subfolder" + )))); + }; + + // Vendor under effects//, deduping against node keys and + // existing effect folders. + let mut taken = self.taken_node_names(); + for artifact in self.def_artifacts.values() { + let path = artifact.file_path().as_str(); + if let Some(rest) = path.strip_prefix("/effects/") + && let Some((existing, _)) = rest.split_once('/') + { + taken.insert(existing.to_string()); + } + } + let name = unique_node_name(&folder, &taken); + + let (site, parent, expected_name) = match attach { + UiAttachTarget::ProjectRoot => { + let Some(root) = self.root_nodes.first() else { + return Ok(ProjectEditRun::notice(UiNotice::warning( + "Cannot add an effect before the project tree has synced", + ))); + }; ( - lpc_model::LpPathBuf::from(format!("./{file}").as_str()), - bytes, + NodeAttachSite::ProjectNodes { key: name.clone() }, + root.address().clone(), + name.clone(), ) - }) - .collect::>(); + } + UiAttachTarget::Playlist { node } => { + let Some(playlist) = self.node(node) else { + return Ok(ProjectEditRun::notice(UiNotice::warning(format!( + "Cannot add to {node}: the playlist is not in the synced project" + )))); + }; + let Some(artifact) = self.def_artifacts.get(&playlist.target().node_id) else { + return Ok(ProjectEditRun::notice(UiNotice::warning(format!( + "Cannot add to {node}: its definition artifact is unknown" + )))); + }; + let staged = self.overlay_entry_keys(artifact); + let key = playlist_next_entry_key(playlist, &staged); + let path = SlotPath::parse(&format!("entries[{key}].node")) + .expect("entries[].node is a valid slot path"); + ( + NodeAttachSite::Slot { + artifact: artifact.clone(), + path, + }, + node.clone(), + format!("entry_{key}"), + ) + } + }; + + let mut body = None; + let mut assets = Vec::new(); + for (relative, bytes) in effect_files { + if relative == "project.json" { + body = Some(bytes); + } else { + assets.push(( + lpc_model::LpPathBuf::from(format!("./effects/{name}/{relative}").as_str()), + bytes, + )); + } + } + let Some(body) = body else { + return Ok(ProjectEditRun::notice(UiNotice::warning(format!( + "Example {example_id}'s effect folder has no project.json" + )))); + }; let request = WireCreateNodeRequest::new( - lpc_model::LpPathBuf::from(format!("./{name}.json").as_str()), - body.into_bytes(), + lpc_model::LpPathBuf::from(format!("./effects/{name}/project.json").as_str()), + body, assets, site, ); + self.send_create_node_request(server, handle_id, request, parent, expected_name, name) + .await + } + /// Send one `CreateNode` request and run the shared post-ack flow: + /// pending focus, immediate refresh, def-artifact re-read, save-pull, + /// and the added/rejected notices. Used by blank creates, effect + /// folder creates, and effect imports. + async fn send_create_node_request( + &mut self, + server: &mut StudioServerClient, + handle_id: u32, + request: WireCreateNodeRequest, + parent: ProjectNodeAddress, + expected_name: String, + name: String, + ) -> Result { let outcome = server.project_create_node(handle_id, request).await?; let mut logs = outcome.logs; match outcome.response { @@ -3198,7 +3359,31 @@ impl ProjectController { /// file the client knows (def artifacts, overlay artifacts, cached /// bodies, resolved shader sources). The server's occupied-path checks /// remain authoritative; a race simply rejects and toasts. + /// Auto-name for a new effect folder: `effect`, `effect_2`, … deduped + /// against node keys, file stems, AND existing `effects//` + /// folders (whose def stems are all `project` and would never collide + /// via the stem set). + fn unique_effect_name(&self) -> String { + let mut taken = self.taken_node_names(); + for artifact in self.def_artifacts.values() { + let path = artifact.file_path().as_str(); + if let Some(rest) = path.strip_prefix("/effects/") + && let Some((folder, _)) = rest.split_once('/') + { + taken.insert(folder.to_string()); + } + } + unique_node_name("effect", &taken) + } + fn unique_node_name_for(&self, kind: NodeKind) -> String { + let taken = self.taken_node_names(); + unique_node_name(node_kind_slug(kind), &taken) + } + + /// Names already taken by tree children, def/asset file stems, and + /// staged overlay artifacts — the create auto-namer's dedup set. + fn taken_node_names(&self) -> BTreeSet { let mut taken: BTreeSet = BTreeSet::new(); if let Some(root) = self.root_nodes.first() { for child in root.children() { @@ -3238,7 +3423,7 @@ impl ProjectController { taken.insert(file_stem(artifact.file_path().as_str()).to_string()); } } - unique_node_name(node_kind_slug(kind), &taken) + taken } /// Focus a freshly created node once its tree entry lands: called at the @@ -3903,6 +4088,34 @@ fn mutation_touches_bindings(mutation: &MutationOp) -> bool { } } +/// The workbench's effect subfolder: parse the workbench root's +/// `project.json`, find the single `nodes{}` ref pointing at a subfolder +/// `project.json`, and return that folder's name plus its files rebased +/// folder-relative. `None` when the root has no subfolder-project child. +fn extract_effect_subfolder( + files: &[(String, Vec)], +) -> Option<(String, Vec<(String, Vec)>)> { + let (_, root_bytes) = files.iter().find(|(path, _)| path == "project.json")?; + let root = lpc_model::NodeDef::from_json_str(core::str::from_utf8(root_bytes).ok()?).ok()?; + let project = root.as_project()?; + let folder = project.nodes.entries.values().find_map(|invocation| { + let spec = invocation.value().ref_specifier()?; + let spec = spec.to_string(); + let rest = spec.strip_prefix("./").unwrap_or(&spec); + let (folder, file) = rest.split_once('/')?; + (file == "project.json").then(|| folder.to_string()) + })?; + let prefix = format!("{folder}/"); + let effect_files: Vec<(String, Vec)> = files + .iter() + .filter_map(|(path, bytes)| { + path.strip_prefix(&prefix) + .map(|relative| (relative.to_string(), bytes.clone())) + }) + .collect(); + (!effect_files.is_empty()).then_some((folder, effect_files)) +} + fn rejection_text(rejection: &MutationRejection) -> String { if rejection.message.is_empty() { format!("{:?}", rejection.reason) @@ -8072,7 +8285,7 @@ mod tests { // the workspace button, both fed by the picker data on the view. assert!(view.header_actions.is_empty()); let menu = view.add_node_menu.expect("picker data rides the editor"); - assert_eq!(menu.entries.len(), 10); + assert_eq!(menu.entries.len(), 11, "plain kinds plus the Effect source"); // Root children carry the ungated delete action with confirmation. let clock = view diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index e534ce916..8eabac3de 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -23,12 +23,13 @@ use crate::core::log::{LogClock, LogFilter, LogRing}; use crate::core::notice::UiNotices; use crate::{ AssetContentFetchOp, AssetEditOp, ConnectFlowState, Controller, ControllerContext, - DeviceController, DeviceOp, NodeCreateOp, NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, - ProjectConnectResult, ProjectController, ProjectEditRun, ProjectOp, ProjectRefreshOutcome, - ProjectState, ProjectSyncRun, RuntimePayload, RuntimePool, ServerFailureKind, ServerSnapshot, - ServerState, SlotEditOp, StudioSnapshot, UiAction, UiActions, UiActivityView, UiError, - UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, UiPaneView, UiProgress, UiResult, - UiStatus, UiStudioView, UiViewContent, UxActivityTarget, UxUpdate, UxUpdateSink, + DeviceController, DeviceOp, EffectImportOp, NodeCreateOp, NodeRemoveOp, NodeRevertOp, + PlaylistActivateOp, ProjectConnectResult, ProjectController, ProjectEditRun, ProjectOp, + ProjectRefreshOutcome, ProjectState, ProjectSyncRun, RuntimePayload, RuntimePool, + ServerFailureKind, ServerSnapshot, ServerState, SlotEditOp, StudioSnapshot, UiAction, + UiActions, UiActivityView, UiError, UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, + UiPaneView, UiProgress, UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, + UxUpdate, UxUpdateSink, }; /// How often the quiet PortHeld retry re-attempts the granted attach @@ -1989,6 +1990,10 @@ impl StudioController { let op = action.into_op::()?; return self.execute_node_create_op(op).await; } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_effect_import_op(op).await; + } if action.op_as::().is_some() { let op = action.into_op::()?; return self.execute_node_remove_op(op).await; @@ -3108,6 +3113,16 @@ impl StudioController { self.record_project_edit_run(run) } + async fn execute_effect_import_op(&mut self, op: EffectImportOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project + .import_effect(server, &op.example, &op.attach) + .await + }; + self.record_project_edit_run(run) + } + async fn execute_node_remove_op(&mut self, op: NodeRemoveOp) -> UiResult { let run = { let server = self.pool.lens_session_mut()?.client_mut()?; diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 771a26a97..11139f537 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -45,15 +45,15 @@ pub use app::home::{ pub use app::node::{ UiAssetEditor, UiAssetEditorKind, UiBindingAuthoring, UiBindingAuthoringDirection, UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, - UiControlSampleFormat, UiFixtureFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, UiNodeHeader, - UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, UiPanelWidget, - UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, UiProducedProduct, - UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, UiProductRef, - UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, UiSlotAspect, - UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, UiSlotComposite, UiSlotEditorHint, - UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, UiSlotOption, - UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, - UiSlotValue, UiSlotValueKind, + UiControlSampleFormat, UiEffectFace, UiFixtureFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, + UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, + UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, + UiProducedProduct, UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, + UiProductRef, UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, + UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, UiSlotComposite, + UiSlotEditorHint, UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, + UiSlotOption, UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, + UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, }; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] pub use app::preview_host::{PreviewHost, PreviewSlotHandle}; @@ -62,10 +62,10 @@ pub use app::preview_host::{ PreviewTier, }; pub use app::project::{ - AgentEngineStatus, AssetContentFetchOp, AssetEditOp, DirtySummary, LoadedProjectChoice, - MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeController, NodeControllerState, - NodeCreateOp, NodeRemoveOp, NodeRevertOp, NodeUiOp, PendingAssetEdit, PendingEdit, - PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProjectAssetContentRun, + AgentEngineStatus, AssetContentFetchOp, AssetEditOp, DirtySummary, EffectImportOp, + LoadedProjectChoice, MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeController, + NodeControllerState, NodeCreateOp, NodeRemoveOp, NodeRevertOp, NodeUiOp, PendingAssetEdit, + PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProjectAssetContentRun, ProjectConnectResult, ProjectController, ProjectEditRun, ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 2bc7cface..5f3ffb093 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -46,6 +46,7 @@ --tw-default-transition-duration: 150ms; --tw-default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --tw-color-background: var(--studio-color-bg); + --tw-color-foreground: var(--studio-color-text); --tw-color-card: var(--studio-color-surface); --tw-color-card-subtle: var(--studio-color-surface-subtle); --tw-color-card-muted: var(--studio-color-surface-muted); @@ -1157,15 +1158,15 @@ .tw\:bg-card-subtle { background-color: var(--tw-color-card-subtle); } - .tw\:bg-card-subtle\/60 { - background-color: var(--tw-color-card-subtle); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--tw-color-card-subtle) 60%, transparent); - } - } .tw\:bg-dim-foreground { background-color: var(--tw-color-dim-foreground); } + .tw\:bg-foreground\/10 { + background-color: var(--tw-color-foreground); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--tw-color-foreground) 10%, transparent); + } + } .tw\:bg-panel-primary { background-color: var(--tw-color-panel-primary); } @@ -1647,6 +1648,9 @@ .tw\:whitespace-pre-wrap { white-space: pre-wrap; } + .tw\:text-\[var\(--studio-fg-muted\)\] { + color: var(--studio-fg-muted); + } .tw\:text-\[var\(--studio-status-error-text\)\] { color: var(--studio-status-error-text); } @@ -1662,6 +1666,9 @@ .tw\:text-dim-foreground { color: var(--tw-color-dim-foreground); } + .tw\:text-foreground { + color: var(--tw-color-foreground); + } .tw\:text-heading { color: var(--tw-color-heading); } diff --git a/lp-app/lpa-studio-web/src/app/home/home_gallery.rs b/lp-app/lpa-studio-web/src/app/home/home_gallery.rs index 40ce5a05d..c1e788446 100644 --- a/lp-app/lpa-studio-web/src/app/home/home_gallery.rs +++ b/lp-app/lpa-studio-web/src/app/home/home_gallery.rs @@ -38,6 +38,27 @@ pub fn HomeGallery( on_action: EventHandler, ) -> Element { let mut drag_active = use_signal(|| 0_i32); + // Example kind filter (chips): `None` = all kinds. + let mut example_filter: Signal> = use_signal(|| None); + let example_kinds: Vec = { + let mut kinds: Vec = Vec::new(); + for card in &home.examples { + if !kinds.contains(&card.kind) { + kinds.push(card.kind.clone()); + } + } + kinds + }; + let filtered_examples: Vec<_> = home + .examples + .iter() + .filter(|card| { + example_filter() + .as_ref() + .is_none_or(|kind| &card.kind == kind) + }) + .cloned() + .collect(); // only touch the browser's serial API when the caller didn't already // answer the grant question (stories always do — headless Chrome's // getPorts is crash-prone, and the probe is pointless there anyway) @@ -227,14 +248,28 @@ pub fn HomeGallery( section { class: "tw:grid tw:gap-3", header { class: "tw:flex tw:items-center tw:gap-3", h2 { class: section_title_class(), "Examples" } - // kind filter chips: Modules stays hidden while no module - // examples exist (M6 grows this) - span { class: "tw:rounded-full tw:border tw:border-border tw:px-2.5 tw:py-0.5 tw:text-xs tw:font-semibold tw:text-muted-foreground", - "Projects" + // Kind filter chips, real since the Effects category + // exists: shown only when more than one kind is present + // (a single-kind gallery keeps its pre-chip look). + if example_kinds.len() > 1 { + for kind in example_kinds.clone() { + button { + class: example_chip_class(example_filter() == Some(kind.clone())), + onclick: { + let kind = kind.clone(); + move |_| { + let next = (example_filter() != Some(kind.clone())) + .then(|| kind.clone()); + example_filter.set(next); + } + }, + "{example_kind_chip_label(&kind)}" + } + } } } div { class: card_grid_class(), - for card in home.examples.clone() { + for card in filtered_examples.clone() { ExampleCard { key: "{card.id}", opening: home.opening.as_deref() == Some(card.id.as_str()), @@ -329,3 +364,17 @@ fn card_grid_class() -> &'static str { fn device_grid_class() -> &'static str { "tw:grid tw:grid-cols-[repeat(auto-fill,minmax(260px,1fr))] tw:gap-3.5 tw:[grid-auto-rows:minmax(300px,auto)]" } + +/// Chip styling: the selected kind fills; unselected chips stay quiet. +fn example_chip_class(selected: bool) -> &'static str { + if selected { + "tw:rounded-full tw:border tw:border-border tw:bg-foreground/10 tw:px-2.5 tw:py-0.5 tw:text-xs tw:font-semibold tw:text-foreground tw:cursor-pointer" + } else { + "tw:rounded-full tw:border tw:border-border tw:px-2.5 tw:py-0.5 tw:text-xs tw:font-semibold tw:text-muted-foreground tw:cursor-pointer" + } +} + +/// Pluralized chip label for an example kind ("Project" → "Projects"). +fn example_kind_chip_label(kind: &str) -> String { + format!("{kind}s") +} diff --git a/lp-app/lpa-studio-web/src/app/node/add_node_picker.rs b/lp-app/lpa-studio-web/src/app/node/add_node_picker.rs index b3225af9c..91054b2c2 100644 --- a/lp-app/lpa-studio-web/src/app/node/add_node_picker.rs +++ b/lp-app/lpa-studio-web/src/app/node/add_node_picker.rs @@ -59,6 +59,17 @@ pub fn AddNodePicker( } } } + // Shipped effects, vendored by copy on selection (the source + // dimension growing inside this panel, as promised above). + if !menu.effects.is_empty() { + DetailSection { title: "Effects", + div { class: "tw:grid tw:gap-0.5", + for entry in menu.effects.clone() { + AddNodeMenuRow { entry, on_action } + } + } + } + } } } } diff --git a/lp-app/lpa-studio-web/src/app/node/effect_face_stories.rs b/lp-app/lpa-studio-web/src/app/node/effect_face_stories.rs new file mode 100644 index 000000000..7b5090566 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/effect_face_stories.rs @@ -0,0 +1,50 @@ +//! Stories for the effect card face (effects-are-projects ADR). +//! +//! The face is the embedded project's output mirror plus its promoted +//! controls — the effect's curated public API, aliasing inner-child slots. +//! Coverage: default (clean/bound-violet/dirty knobs plus one broken-alias +//! disabled control and the provenance line) and the advanced drawer open. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::NodePane; +use crate::app::node::face_story_fixtures::effect_node_view; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn EffectCardCanvas(children: Element) -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-md", {children} } + } +} + +#[story( + description = "Effect card: output-mirror hero, promoted knobs (clean, dirty-dot, bound-violet, and one broken-alias disabled control), provenance line; advanced drawer collapsed." +)] +fn default() -> Element { + rsx! { + EffectCardCanvas { + NodePane { + view: effect_node_view(), + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Advanced drawer open: the effect project's generic slot rows (name/provenance) under the face." +)] +fn advanced_open() -> Element { + let mut view = effect_node_view(); + view.card_ui.advanced_open = true; + rsx! { + EffectCardCanvas { + NodePane { + view, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/face/effect_face.rs b/lp-app/lpa-studio-web/src/app/node/face/effect_face.rs new file mode 100644 index 000000000..4dca89d01 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/face/effect_face.rs @@ -0,0 +1,60 @@ +//! The effect card's permanent face: output-mirror hero → promoted knobs → +//! provenance line. +//! +//! The controls are the effect's curated public API (effects-are-projects +//! ADR): each [`lpa_studio_core::UiPanelControl`] carries the INNER child's +//! slot address, so a knob drag dispatches into the child's artifact through +//! the standard slot write path — dirty dots, bound-violet, and the detail +//! popover are the child row's own. The advanced drawer renders below via +//! [`super::NodeCardDrawers`]; the effect's children render outside as +//! sibling cards (collaborators, all live — no active-child suppression). + +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiEffectFace as UiEffectFaceData}; + +use crate::app::node::produced_product_view::ProductPreview; +use crate::app::node::{NodeCardSection, PanelControl}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn EffectFace( + face: UiEffectFaceData, + /// Open this control's label-trigger detail popover on first render + /// (stories). + #[props(default = None)] + detail_open_control: Option, + #[props(default)] on_action: Option>, +) -> Element { + let preview = face.preview.clone(); + rsx! { + NodeCardSection { label: "output", first: true, + ProductPreview { + kind: preview.kind, + preview: preview.preview.clone(), + tracking: preview.tracking, + frame: preview.frame, + focus_action: None, + on_action, + } + } + if !face.controls.is_empty() { + NodeCardSection { label: "controls", + div { class: "tw:flex tw:flex-wrap tw:items-start tw:gap-4 tw:px-4 tw:py-3", + for control in face.controls.clone() { + PanelControl { + key: "{control.label}", + detail_initially_open: detail_open_control.as_deref() == Some(control.label.as_str()), + control, + on_action, + } + } + } + } + } + if let Some(provenance) = face.provenance.clone() { + div { class: "tw:px-4 tw:py-2 tw:text-xs tw:text-[var(--studio-fg-muted)]", + "{provenance}" + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/face/mod.rs b/lp-app/lpa-studio-web/src/app/node/face/mod.rs index 3b7d25a1e..3b053a6da 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/mod.rs @@ -13,6 +13,7 @@ use lpa_studio_core::{NodeUiOp, ProjectEditorOp, ProjectEditorTarget, UiAction}; +mod effect_face; mod fixture_face; mod node_card_drawers; mod node_card_section; @@ -20,6 +21,7 @@ mod node_face_body; mod playlist_face; mod shader_face; +pub use effect_face::EffectFace; pub use fixture_face::FixtureFace; pub use node_card_drawers::NodeCardDrawers; pub use node_card_section::NodeCardSection; diff --git a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs index 9223779d4..55cf16bb1 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs @@ -22,7 +22,7 @@ use lpa_studio_core::{ use crate::app::node::NodeDirtyTint; use crate::base::Platform; -use super::{FixtureFace, NodeCardDrawers, PlaylistFace, ShaderFace}; +use super::{EffectFace, FixtureFace, NodeCardDrawers, PlaylistFace, ShaderFace}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -105,6 +105,22 @@ pub fn NodeFaceBody( on_action, } }, + UiNodeFace::Effect(effect) => rsx! { + EffectFace { + face: effect, + detail_open_control, + on_action, + } + NodeCardDrawers { + node, + sections, + advanced_open: card_ui.advanced_open, + platform, + pending_edits, + dirty_tint, + on_action, + } + }, }} } } diff --git a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs index 2f4f23f80..1fc1040ec 100644 --- a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs @@ -10,11 +10,11 @@ use lpa_studio_core::{ ArtifactLocation, ControllerId, ProjectEditorOp, ProjectNodeAddress, ProjectSlotAddress, ProjectSlotRoot, SlotPath, UiAction, UiAgentAvailability, UiAgentStatus, UiAgentToolRow, UiAgentTurn, UiAgentUsage, UiAgentView, UiAssetContent, UiAssetEditor, UiAssetEditorKind, - UiBindingEndpoint, UiConfigSlot, UiFixtureFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, - UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiPanelControl, UiPanelWidget, + UiBindingEndpoint, UiConfigSlot, UiEffectFace, UiFixtureFace, UiNodeChild, UiNodeDirtyState, + UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiPanelControl, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, UiProductPreview, UiProductPreviewFrame, - UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotFieldState, UiSlotSourceState, - UiSlotUnit, UiSlotValue, UiStatus, + UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, UiSlotAspect, + UiSlotAspectKind, UiSlotFieldState, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiStatus, }; use crate::app::node::node_story_fixtures::{ @@ -386,6 +386,81 @@ pub(crate) fn fixture_sections() -> Vec { ])] } +/// The effect card's face: mirror hero + promoted knobs (clean, bound, +/// dirty, and one broken alias) + provenance line. +pub(crate) fn effect_face() -> UiEffectFace { + let mut broken_state = UiSlotFieldState::readonly(); + broken_state.invalid = Some("promoted control target does not resolve".to_string()); + let broken = UiPanelControl { + label: "shimmer".to_string(), + address: None, + widget: UiPanelWidget::Knob { + min: 0.0, + max: 1.0, + step: None, + }, + value: UiSlotValue::unset(), + live_value: None, + unit: None, + state: broken_state, + aspects: vec![ + UiSlotAspect::new(UiSlotAspectKind::Validation, "Promoted control") + .with_affordance(UiSlotAffordance::Invalid), + ], + }; + UiEffectFace { + preview: shader_hero_product(), + controls: vec![ + knob_control( + "Speed", + 1.4, + 0.0, + 4.0, + UiSlotFieldState::editable(), + UiSlotSourceState::Unset, + ), + knob_control( + "Scale", + 0.6, + 0.0, + 1.0, + UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty), + UiSlotSourceState::Unset, + ), + knob_control( + "Energy", + 0.8, + 0.0, + 1.0, + UiSlotFieldState::editable(), + UiSlotSourceState::Bound(UiBindingEndpoint::new("bus:energy")), + ), + broken, + ], + provenance: Some("photomancer · v1 · CC0-1.0".to_string()), + } +} + +/// Advanced-drawer sections for the effect card (the generic project rows). +pub(crate) fn effect_sections() -> Vec { + vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value("name", "Name", UiSlotValue::string("plasma")), + UiConfigSlot::value("author", "Author", UiSlotValue::string("photomancer")), + UiConfigSlot::value("license", "License", UiSlotValue::string("CC0-1.0")), + ])] +} + +/// A full effect node card view with the face installed. +pub(crate) fn effect_node_view() -> UiNodeView { + let header = UiNodeHeader::new("Plasma", "Effect", "/fyeah_sign.show/plasma.project") + .with_source("effects/plasma/project.json") + .with_status(UiStatus::good("Running")); + let mut view = UiNodeView::new(header, vec![UiNodeTab::main(effect_sections())]) + .with_node_id("effect-plasma"); + view.face = Some(UiNodeFace::Effect(effect_face())); + view +} + /// A full fixture node card view with the face installed. pub(crate) fn fixture_node_view() -> UiNodeView { let header = UiNodeHeader::new("Halo ring", "Fixture", "/fyeah_sign.show/halo.fixture") diff --git a/lp-app/lpa-studio-web/src/app/node/mod.rs b/lp-app/lpa-studio-web/src/app/node/mod.rs index cd4cf83a0..555befb51 100644 --- a/lp-app/lpa-studio-web/src/app/node/mod.rs +++ b/lp-app/lpa-studio-web/src/app/node/mod.rs @@ -15,6 +15,8 @@ mod binding_chip; mod config_slot_row; #[cfg(feature = "stories")] pub(crate) mod config_slot_row_stories; +#[cfg(feature = "stories")] +pub(crate) mod effect_face_stories; mod face; #[cfg(feature = "stories")] pub(crate) mod face_story_fixtures; diff --git a/lp-app/lpa-studio-web/src/base/icon.rs b/lp-app/lpa-studio-web/src/base/icon.rs index b63804c89..3cdcac72d 100644 --- a/lp-app/lpa-studio-web/src/base/icon.rs +++ b/lp-app/lpa-studio-web/src/base/icon.rs @@ -220,7 +220,7 @@ pub fn node_kind_icon(kind_label: &str) -> StudioIconName { "Compute" | "Compute shader" | "compute_shader" => NodeKindIcon::Compute, "Output" | "output" => NodeKindIcon::Output, "Playlist" | "playlist" => NodeKindIcon::Playlist, - "Project" | "project" => NodeKindIcon::Project, + "Project" | "project" | "Effect" | "effect" => NodeKindIcon::Project, "Texture" | "texture" => NodeKindIcon::Texture, "Control Radio" | "Radio" | "radio" => NodeKindIcon::Radio, "Button" | "button" => NodeKindIcon::Button, diff --git a/lp-core/lpc-engine/README.md b/lp-core/lpc-engine/README.md index cf3096a8b..610cc816e 100644 --- a/lp-core/lpc-engine/README.md +++ b/lp-core/lpc-engine/README.md @@ -15,6 +15,16 @@ store, frame state, slot shape registry, runtime buffers, and demand roots. runtime vocabulary for labeled channels, but resolved values are cached by the engine resolver rather than by a bus object. +**Bus scopes:** channels are keyed by `(scope, name)` +(`dataflow::bus::ScopedChannel`). Every project node introduces a named scope +around its children (the root project is the outermost scope) and playlist +entries wrap each owned child in an anonymous scope; the loader resolves +consumed endpoints to the nearest enclosing scope with a writer (else the +root scope) and produced endpoints to the producer's own scope. Every project +node's runtime (`nodes::project::ProjectNode`) mirrors its scope's +`visual.out` as a produced `output`. See +`docs/adr/2026-07-28-scoped-buses.md`. + `resolver::Resolver` owns same-frame query cache state. `ResolveSession` is the active per-frame/per-demand object that resolves `QueryKey`s through the active `ResolveHost`, calls that host on cache misses, and carries a diff --git a/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs b/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs index 6d21a92aa..e0840f514 100644 --- a/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs +++ b/lp-core/lpc-engine/src/dataflow/binding/binding_entry.rs @@ -1,4 +1,6 @@ -use lpc_model::{ChannelName, Kind, LpValue, NodeId, Revision, SlotPath}; +use lpc_model::{Kind, LpValue, NodeId, Revision, SlotPath}; + +use crate::dataflow::bus::ScopedChannel; /// Stable address of a binding owned by a node entry. /// @@ -42,14 +44,14 @@ pub struct BindingDraft { pub enum BindingSource { Literal(LpValue), ProducedSlot { node: NodeId, slot: SlotPath }, - BusChannel(ChannelName), + BusChannel(ScopedChannel), } /// Where a binding writes to. #[derive(Clone, Debug)] pub enum BindingTarget { ConsumedSlot { node: NodeId, slot: SlotPath }, - BusChannel(ChannelName), + BusChannel(ScopedChannel), } /// Writer priority for the same bus channel; higher wins at resolution time. @@ -99,12 +101,12 @@ pub(crate) fn channels_touched<'a>( } pub(crate) struct ChannelsTouched<'a> { - source: Option<&'a ChannelName>, - target: Option<&'a ChannelName>, + source: Option<&'a ScopedChannel>, + target: Option<&'a ScopedChannel>, } impl<'a> Iterator for ChannelsTouched<'a> { - type Item = &'a ChannelName; + type Item = &'a ScopedChannel; fn next(&mut self) -> Option { self.source.take().or_else(|| self.target.take()) diff --git a/lp-core/lpc-engine/src/dataflow/binding/binding_error.rs b/lp-core/lpc-engine/src/dataflow/binding/binding_error.rs index 9bcf5cd9e..2230af3f2 100644 --- a/lp-core/lpc-engine/src/dataflow/binding/binding_error.rs +++ b/lp-core/lpc-engine/src/dataflow/binding/binding_error.rs @@ -2,6 +2,8 @@ use core::fmt; use lpc_model::{ChannelName, Kind, NodeId}; +use crate::dataflow::bus::ScopedChannel; + use super::BindingPriority; /// Errors from validating node-owned runtime bindings. @@ -11,7 +13,7 @@ pub enum BindingError { UnknownOwner { owner: NodeId }, /// Another binding on the same channel already uses a different [`Kind`]. KindMismatch { - channel: ChannelName, + channel: ScopedChannel, established: Kind, attempted: Kind, }, diff --git a/lp-core/lpc-engine/src/dataflow/bus/mod.rs b/lp-core/lpc-engine/src/dataflow/bus/mod.rs index bda020bec..d85b18fe0 100644 --- a/lp-core/lpc-engine/src/dataflow/bus/mod.rs +++ b/lp-core/lpc-engine/src/dataflow/bus/mod.rs @@ -6,7 +6,9 @@ pub mod bus; pub mod bus_error; pub mod channel_entry; +pub mod scope; pub use bus::Bus; pub use bus_error::BusError; pub use channel_entry::ChannelEntry; +pub use scope::{ScopeId, ScopedChannel}; diff --git a/lp-core/lpc-engine/src/dataflow/bus/scope.rs b/lp-core/lpc-engine/src/dataflow/bus/scope.rs new file mode 100644 index 000000000..5105417cf --- /dev/null +++ b/lp-core/lpc-engine/src/dataflow/bus/scope.rs @@ -0,0 +1,64 @@ +//! Bus scopes — hierarchical channel visibility (scoped-buses ADR). +//! +//! Every project node introduces a **named scope** around its children (the +//! root project's scope is simply the outermost one), and isolating +//! invocation sites — playlist entries today — wrap each owned child in an +//! **anonymous scope**. Channels are keyed by `(scope, name)`: a consumed +//! `bus:` endpoint resolves to the nearest enclosing scope with a writer +//! (else the root scope, so unfilled channels surface where a host can fill +//! them), while a produced endpoint always writes its own nearest scope. +//! Scope assignment happens entirely at load time in the project loader; the +//! resolver and binding index just see more channel keys. + +use core::fmt; + +use lpc_model::{ChannelName, NodeId}; + +/// Identity of one bus scope. +/// +/// `Project` is the named scope a project node introduces around its +/// children. `Entry` is the anonymous scope an isolating invocation site +/// wraps around one owned child, keyed by that child — distinct from +/// `Project` so a project node owned by a playlist entry gets both (its own +/// named scope nested inside the entry's anonymous one). +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ScopeId { + /// The scope introduced by the project node with this id. + Project(NodeId), + /// The anonymous scope wrapped around the entry-owned child with this id. + Entry(NodeId), +} + +impl fmt::Display for ScopeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Project(node) => write!(f, "project#{}", node.as_u32()), + Self::Entry(node) => write!(f, "entry#{}", node.as_u32()), + } + } +} + +/// A bus channel keyed by the scope it lives in. +/// +/// Two scopes each carrying a `visual.out` channel are two independent +/// channels; only the loader's scope resolution decides which one an +/// endpoint touches. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ScopedChannel { + pub scope: ScopeId, + pub channel: ChannelName, +} + +impl ScopedChannel { + pub fn new(scope: ScopeId, channel: ChannelName) -> Self { + Self { scope, channel } + } +} + +impl fmt::Display for ScopedChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Diagnostic form only; probe/wire display formats the scope as a + // node path (bare for the root scope) via the engine's tree. + write!(f, "{}@{}", self.channel, self.scope) + } +} diff --git a/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs b/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs index 07d66f02b..6ff250486 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/query_key.rs @@ -3,12 +3,14 @@ //! Produced and consumed endpoints use [`lpc_model::SlotPath`] because they //! address slot identity, not projection inside a leaf value. -use lpc_model::{ChannelName, NodeId, SlotAccessor, SlotPath}; +use lpc_model::{NodeId, SlotAccessor, SlotPath}; + +use crate::dataflow::bus::ScopedChannel; /// Demand/cache key for one resolved value in the engine resolver. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum QueryKey { - Bus(ChannelName), + Bus(ScopedChannel), ProducedSlot { node: NodeId, slot: SlotPath, @@ -36,6 +38,7 @@ impl QueryKey { #[cfg(test)] mod tests { use super::QueryKey; + use crate::dataflow::bus::{ScopeId, ScopedChannel}; use alloc::string::String; use alloc::vec::Vec; use lp_collection::VecMap; @@ -43,11 +46,18 @@ mod tests { use lpc_model::NodeId; use lpc_model::SlotPath; + fn scoped(name: &str) -> ScopedChannel { + ScopedChannel::new( + ScopeId::Project(NodeId::new(0)), + ChannelName(String::from(name)), + ) + } + #[test] fn query_key_works_as_btree_map_key() { let mut m = VecMap::new(); - let k1 = QueryKey::Bus(ChannelName(String::from("a"))); - let k2 = QueryKey::Bus(ChannelName(String::from("b"))); + let k1 = QueryKey::Bus(scoped("a")); + let k2 = QueryKey::Bus(scoped("b")); m.insert(k1.clone(), 1u32); m.insert( QueryKey::ProducedSlot { diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs index 2cef63bf8..5d8c1ccee 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_error.rs @@ -6,9 +6,9 @@ use alloc::format; use alloc::string::String; -use lpc_model::ChannelName; use lpc_model::NodeId; +use crate::dataflow::bus::ScopedChannel; use crate::shader_abi::LpsValueToModelConversionError; use super::query_key::QueryKey; @@ -21,10 +21,10 @@ pub enum SessionResolveError { query: QueryKey, }, NoBusProvider { - channel: ChannelName, + channel: ScopedChannel, }, AmbiguousBusBinding { - channel: ChannelName, + channel: ScopedChannel, }, UnresolvedConsumedSlot { node: NodeId, diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs index 571397fb4..45cb7cbd0 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_host.rs @@ -11,9 +11,10 @@ use crate::products::control::{ use crate::products::visual::{RenderTextureRequest, TextureRenderProduct, VisualProduct}; use crate::resource::{RuntimeBuffer, RuntimeBufferId}; use alloc::vec::Vec; -use lpc_model::{ChannelName, NodeId, Revision, SlotMerge, SlotPath}; +use lpc_model::{NodeId, Revision, SlotMerge, SlotPath}; use crate::dataflow::binding::{BindingEntry, BindingRef}; +use crate::dataflow::bus::ScopedChannel; /// Engine or test fake that can satisfy demand for uncached queries. pub trait ResolveHost { @@ -45,7 +46,7 @@ pub trait ResolveHost { SlotMerge::Latest } - fn providers_for_bus(&self, _channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, _channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { Vec::new() } diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs index 3b064a6e1..2b1a3646f 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolve_session.rs @@ -5,6 +5,7 @@ use alloc::vec::Vec; use lp_collection::VecMap; use crate::dataflow::binding::{BindingEntry, BindingRef, BindingSource}; +use crate::dataflow::bus::ScopedChannel; use crate::dataflow::resolver::production::{Production, ProductionSource}; use crate::dataflow::resolver::query_key::QueryKey; use crate::dataflow::resolver::resolve_error::SessionResolveError; @@ -12,7 +13,7 @@ use crate::dataflow::resolver::resolve_host::ResolveHost; use crate::dataflow::resolver::resolve_trace::ResolveTrace; use crate::dataflow::resolver::resolver::Resolver; use crate::dataflow::resolver::resolver::materialize_literal_product; -use lpc_model::{ChannelName, NodeId, Revision, SlotData, SlotMapDyn, SlotMerge, SlotPath}; +use lpc_model::{NodeId, Revision, SlotData, SlotMapDyn, SlotMerge, SlotPath}; /// Active engine session for one frame (or nested test scope). /// @@ -110,7 +111,7 @@ impl<'a> EngineSession<'a> { fn resolve_bus( &mut self, host: &mut (impl ResolveHost + ?Sized), - channel: &ChannelName, + channel: &ScopedChannel, query: &QueryKey, ) -> Result { let candidates = host.providers_for_bus(channel); @@ -307,7 +308,7 @@ fn merge_maps_by_key( } fn select_highest_priority_bus_provider( - channel: &ChannelName, + channel: &ScopedChannel, candidates: &[(BindingRef, BindingEntry)], ) -> Result<(BindingRef, BindingEntry), SessionResolveError> { if candidates.is_empty() { @@ -338,6 +339,7 @@ mod tests { use crate::dataflow::binding::BindingDraft; use crate::dataflow::binding::BindingPriority; use crate::dataflow::binding::BindingTarget; + use crate::dataflow::bus::ScopeId; use crate::dataflow::resolver::resolve_trace::ResolveLogLevel; use crate::dataflow::resolver::resolve_trace::ResolveTraceEvent; use alloc::string::String; @@ -346,8 +348,11 @@ mod tests { use lpc_model::{ChannelName, LpValue, SlotMapKey, WithRevision}; use lps_shared::LpsValueF32; - fn ch(s: &str) -> ChannelName { - ChannelName(String::from(s)) + fn ch(s: &str) -> ScopedChannel { + ScopedChannel::new( + ScopeId::Project(NodeId::new(0)), + ChannelName(String::from(s)), + ) } fn path(s: &str) -> SlotPath { @@ -429,7 +434,7 @@ mod tests { .collect() } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { self.entries .iter() .filter_map(|(binding_ref, entry)| { @@ -479,7 +484,7 @@ mod tests { self.bindings.bindings_for_consumed_slot(node, slot) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { self.bindings.providers_for_bus(channel) } } @@ -646,7 +651,7 @@ mod tests { )) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { self.bindings.providers_for_bus(channel) } } @@ -746,7 +751,7 @@ mod tests { self.bindings.binding_for_consumed_slot(node, slot) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { self.bindings.providers_for_bus(channel) } @@ -929,7 +934,7 @@ mod tests { } } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { self.bindings.providers_for_bus(channel) } } @@ -967,7 +972,7 @@ mod tests { let evs = session.trace().events(); assert!(evs.iter().any(|e| { - matches!(e, ResolveTraceEvent::BeginQuery(QueryKey::Bus(b)) if b.0 == "out") + matches!(e, ResolveTraceEvent::BeginQuery(QueryKey::Bus(b)) if b.channel.0 == "out") })); assert!( evs.iter() @@ -983,7 +988,7 @@ mod tests { ))); assert!(evs.iter().any(|e| matches!( e, - ResolveTraceEvent::CacheHit(QueryKey::Bus(b)) if b.0 == "out" + ResolveTraceEvent::CacheHit(QueryKey::Bus(b)) if b.channel.0 == "out" ))); } } diff --git a/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs b/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs index 9ef05957c..90c80f7d3 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/resolver_cache.rs @@ -68,6 +68,7 @@ impl ResolverCache { mod tests { use super::{Production, QueryKey, ResolverCache}; use crate::dataflow::binding::BindingRef; + use crate::dataflow::bus::{ScopeId, ScopedChannel}; use crate::dataflow::resolver::production::ProductionSource; use crate::dataflow::resolver::{ResolveLogLevel, ResolveTrace, ResolveTraceEvent}; use lpc_model::ChannelName; @@ -78,7 +79,10 @@ mod tests { use lps_shared::LpsValueF32; fn sample_bus_key(name: &str) -> QueryKey { - QueryKey::Bus(ChannelName(alloc::string::String::from(name))) + QueryKey::Bus(ScopedChannel::new( + ScopeId::Project(NodeId::new(0)), + ChannelName(alloc::string::String::from(name)), + )) } fn make_produced(frame: i64, source: ProductionSource) -> Production { diff --git a/lp-core/lpc-engine/src/dataflow/resolver/tick_resolver.rs b/lp-core/lpc-engine/src/dataflow/resolver/tick_resolver.rs index bcfb32b0b..e0f705b4f 100644 --- a/lp-core/lpc-engine/src/dataflow/resolver/tick_resolver.rs +++ b/lp-core/lpc-engine/src/dataflow/resolver/tick_resolver.rs @@ -16,6 +16,19 @@ use lpc_model::{NodeId, Revision, SlotPath}; pub trait TickResolver { fn resolve(&mut self, query: QueryKey) -> Result; + /// Resolve a bus channel, mapping "no writer" to `Ok(None)` instead of + /// an error — the project-node mirror reads a scope channel that may + /// legitimately have no writer. + fn resolve_bus( + &mut self, + channel: crate::dataflow::bus::ScopedChannel, + ) -> Result, ResolveError> { + let _ = channel; + Err(ResolveError::new( + "resolve_bus unsupported by this resolver", + )) + } + fn publish_produced_slot( &mut self, node: NodeId, @@ -60,6 +73,17 @@ impl<'sess, 'resolver, 'host> TickResolver for SessionHostResolver<'sess, 'resol .map_err(|e: SessionResolveError| ResolveError::new(alloc::format!("{e}"))) } + fn resolve_bus( + &mut self, + channel: crate::dataflow::bus::ScopedChannel, + ) -> Result, ResolveError> { + match self.session.resolve(self.host, QueryKey::Bus(channel)) { + Ok(production) => Ok(Some(production)), + Err(SessionResolveError::NoBusProvider { .. }) => Ok(None), + Err(e) => Err(ResolveError::new(alloc::format!("{e}"))), + } + } + fn publish_produced_slot( &mut self, node: NodeId, diff --git a/lp-core/lpc-engine/src/engine/bus_scopes.rs b/lp-core/lpc-engine/src/engine/bus_scopes.rs new file mode 100644 index 000000000..cba7b4ee7 --- /dev/null +++ b/lp-core/lpc-engine/src/engine/bus_scopes.rs @@ -0,0 +1,205 @@ +//! Load-time bus scope assignment and writer-shadowing resolution +//! (scoped-buses ADR, `docs/adr/2026-07-28-scoped-buses.md`). +//! +//! Scopes are computed from the projected spine each time the binding phase +//! runs: every project node introduces a named scope around its children, +//! and every playlist-entry-owned child gets an anonymous scope of its own. +//! Pass 1 collects the writer set per `(scope, channel)`; the registration +//! pass then resolves consumed endpoints to the nearest enclosing scope with +//! a writer (else the root scope) and produced endpoints to the producer's +//! own scope. + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use lp_collection::{VecMap, VecSet}; + +use lpc_model::{ChannelName, NodeDef, NodeId, NodeKind, SlotDirection}; +use lpc_registry::ProjectRegistry; + +use crate::dataflow::bus::{ScopeId, ScopedChannel}; + +use super::project_loader::{ + ProjectedNode, ProjectedNodeOwnership, binding_target, declared_default_binds, +}; + +/// Scope assignment over one projected spine, plus the pass-1 writer set. +pub(super) struct BusScopes { + /// The scope each projected node's own bus endpoints resolve against. + scope_of: VecMap, + /// Enclosing scope per scope; the root scope has no entry. + parent: VecMap, + root: ScopeId, + /// `(scope, channel)` pairs with at least one writer. + writers: VecSet, +} + +impl BusScopes { + /// Compute scope assignment (structure only) and the writer set + /// (pass 1). `projected_nodes` is parent-before-child ordered — the + /// spine build sorts it that way. + pub(super) fn compute( + registry: &ProjectRegistry, + projected_nodes: &[ProjectedNode], + root_node: NodeId, + ) -> Self { + let root = ScopeId::Project(root_node); + let mut scopes = Self { + scope_of: VecMap::new(), + parent: VecMap::new(), + root, + writers: VecSet::new(), + }; + + for node in projected_nodes { + let containing = match node.ownership { + ProjectedNodeOwnership::Root => root, + ProjectedNodeOwnership::PlaylistEntry { .. } => { + // Anonymous per-child scope; its parent is the scope + // the owning playlist lives in. + let anon = ScopeId::Entry(node.id); + let playlist_scope = node + .parent + .and_then(|parent| scopes.scope_of.get(&parent).copied()) + .unwrap_or(root); + scopes.parent.insert(anon, playlist_scope); + anon + } + ProjectedNodeOwnership::ProjectChild => node + .parent + .map(ScopeId::Project) + .filter(|scope| scopes.parent.contains_key(scope) || *scope == root) + .unwrap_or(root), + }; + scopes.scope_of.insert(node.id, containing); + + // A project node (root included) introduces a named scope + // around its children. Error-state defs project with the + // `Project` fallback kind and no children — introducing a + // scope for them is harmless and keeps this structural. + if node.kind == NodeKind::Project { + let introduced = ScopeId::Project(node.id); + if introduced != root { + scopes.parent.insert(introduced, containing); + } + } + } + + scopes.collect_writers(registry, projected_nodes); + scopes + } + + /// Rule 4: a produced `bus:` endpoint always writes the producer's own + /// nearest scope. + pub(super) fn write_channel(&self, producer: NodeId, channel: &ChannelName) -> ScopedChannel { + ScopedChannel::new(self.scope_containing(producer), channel.clone()) + } + + /// Rule 3: a consumed `bus:` endpoint resolves to the nearest enclosing + /// scope with a writer for the channel, else the root scope (unfilled + /// channels surface where a host can later fill them). + pub(super) fn read_channel(&self, consumer: NodeId, channel: &ChannelName) -> ScopedChannel { + let mut scope = self.scope_containing(consumer); + loop { + let candidate = ScopedChannel::new(scope, channel.clone()); + if self.writers.contains(&candidate) { + return candidate; + } + match self.parent.get(&scope) { + Some(parent) => scope = *parent, + None => return ScopedChannel::new(self.root, channel.clone()), + } + } + } + + /// The `(scope, channel)` writer set — exposed for the loader's + /// scan-vs-registered consistency test. + #[cfg(test)] + pub(super) fn writers(&self) -> &VecSet { + &self.writers + } + + fn scope_containing(&self, node: NodeId) -> ScopeId { + self.scope_of.get(&node).copied().unwrap_or(self.root) + } + + /// Pass 1: collect `(scope, channel)` writers — authored bus targets on + /// the slots the registration arms register, plus produce-direction + /// declared defaults not overridden by an authored target on the same + /// slot. Mirrors `register_node_bindings`; the loader test + /// `example_projects_resolve_bus_endpoints_to_written_or_root_scopes` + /// pins the rule-3 postcondition over the shipped examples, and the + /// nested-project loader tests pin nearest-writer resolution. + fn collect_writers(&mut self, registry: &ProjectRegistry, projected_nodes: &[ProjectedNode]) { + for node in projected_nodes { + if node.kind == NodeKind::Project { + // Real project defs register no bindings; error-state defs + // project with the fallback kind and register nothing. + continue; + } + let Some(def) = loaded_node_def(registry, node) else { + continue; + }; + let Some((bindings, target_slots)) = authored_target_surface(def) else { + continue; + }; + for slot in target_slots { + if let Some(lpc_model::BindingRef::Bus(bus)) = binding_target(bindings, &slot) { + let scoped = self.write_channel(node.id, bus.channel()); + self.writers.insert(scoped); + } + } + for (name, direction, endpoint) in declared_default_binds(node.kind) { + if direction != SlotDirection::Produced || binding_target(bindings, &name).is_some() + { + continue; + } + if let Ok(lpc_model::BindingRef::Bus(bus)) = lpc_model::BindingRef::parse(&endpoint) + { + let scoped = self.write_channel(node.id, bus.channel()); + self.writers.insert(scoped); + } + } + } + } +} + +/// The def's authored bindings plus the slots whose `target` refs +/// `register_node_bindings` actually registers, per kind. An authored target +/// on any other slot never becomes a binding, so it must not count as a +/// writer either. +fn authored_target_surface(def: &NodeDef) -> Option<(&lpc_model::BindingDefs, Vec)> { + let (bindings, names): (&lpc_model::BindingDefs, Vec<&str>) = match def { + NodeDef::Clock(config) => (&config.bindings, vec!["seconds", "delta_seconds"]), + NodeDef::Button(config) => (&config.bindings, vec!["down", "held", "up"]), + NodeDef::ControlRadio(config) => (&config.bindings, vec!["output"]), + NodeDef::Shader(config) => (&config.bindings, vec!["output"]), + NodeDef::ComputeShader(config) => { + let produced = config + .produced_slots + .entries + .keys() + .map(|name| name.to_string()) + .collect(); + return Some((&config.bindings, produced)); + } + NodeDef::Fluid(config) => (&config.bindings, vec!["output"]), + NodeDef::Playlist(config) => (&config.bindings, vec!["output"]), + NodeDef::Fixture(config) => (&config.bindings, vec!["output"]), + NodeDef::Output(config) => (&config.bindings, vec![]), + NodeDef::Project(_) | NodeDef::Texture(_) => return None, + }; + Some(( + bindings, + names.into_iter().map(ToString::to_string).collect(), + )) +} + +/// The node's loaded def, or `None` for error-state/missing entries (which +/// register no bindings). +fn loaded_node_def<'a>(registry: &'a ProjectRegistry, node: &ProjectedNode) -> Option<&'a NodeDef> { + match ®istry.def(&node.def_location)?.state { + lpc_model::NodeDefState::Loaded(def) => Some(def), + _ => None, + } +} diff --git a/lp-core/lpc-engine/src/engine/engine.rs b/lp-core/lpc-engine/src/engine/engine.rs index bb2f831b8..446682e60 100644 --- a/lp-core/lpc-engine/src/engine/engine.rs +++ b/lp-core/lpc-engine/src/engine/engine.rs @@ -8,17 +8,17 @@ use alloc::sync::Arc; use alloc::vec::Vec; use lp_collection::VecSet; use lpc_model::{ - ChannelName, ControlProduct, NodeDef, NodeDefLocation, NodeDefState, NodeId, Revision, - SlotAccess, SlotAccessor, SlotData, SlotDirection, SlotMerge, SlotPath, SlotPathSegment, - SlotSemantics, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, TreePath, WithRevision, - advance_revision, current_revision, lookup_slot_data_and_shape, + ControlProduct, NodeDef, NodeDefLocation, NodeDefState, NodeId, Revision, SlotAccess, + SlotAccessor, SlotData, SlotDirection, SlotMerge, SlotPath, SlotPathSegment, SlotSemantics, + SlotShapeLookup, SlotShapeRegistry, SlotShapeView, TreePath, WithRevision, advance_revision, + current_revision, lookup_slot_data_and_shape, }; use lpc_registry::ProjectRegistry; use lpc_shared::time::TimeProvider; use lpc_wire::{ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, NodeRuntimeStatus}; use crate::dataflow::binding::{BindingDraft, BindingError, BindingRef}; -use crate::dataflow::bus::Bus; +use crate::dataflow::bus::{Bus, ScopeId, ScopedChannel}; use crate::dataflow::resolver::{ EngineSession, Production, ProductionSource, QueryKey, ResolveHost, ResolveLogLevel, ResolveTrace, Resolver, SessionHostResolver, SessionResolveError, TickResolver, @@ -495,7 +495,10 @@ impl Engine { registry: &ProjectRegistry, channel: &str, ) -> Result { - let key = QueryKey::Bus(lpc_model::ChannelName(channel.to_string())); + let key = QueryKey::Bus(ScopedChannel::new( + ScopeId::Project(self.tree().root()), + lpc_model::ChannelName(channel.to_string()), + )); let fid = self.revision; let mut resolver_tmp = core::mem::replace(&mut self.resolver, Resolver::new()); resolver_tmp.clear_frame_cache(); @@ -583,7 +586,7 @@ impl Engine { pub(crate) fn resolve_bus_channel_value( &mut self, registry: &ProjectRegistry, - channel: &ChannelName, + channel: &ScopedChannel, ) -> Result { let mut resolver = core::mem::replace(&mut self.resolver, Resolver::new()); let mut session = EngineSession::new( @@ -881,7 +884,7 @@ impl ResolveHost for EngineResolveHost<'_> { fn providers_for_bus( &self, - channel: &lpc_model::ChannelName, + channel: &ScopedChannel, ) -> Vec<(BindingRef, crate::dataflow::binding::BindingEntry)> { self.tree .providers_for_bus(channel) @@ -1910,11 +1913,11 @@ pub(super) fn resolve_twice_same_frame_with_engine_host( #[cfg(test)] mod tests { use super::*; - use alloc::string::String; use lps_shared::LpsValueF32; use crate::engine::test_support::{ - EngineTestBuilder, bus, literal, output, path, produced_slot, trace_has_value_origin_path, + EngineTestBuilder, bus, literal, output, path, produced_slot, scoped_channel, + trace_has_value_origin_path, }; use crate::node::test_placeholder_spine; use crate::products::visual::VisualProduct; @@ -2160,7 +2163,7 @@ mod tests { let out = path("outputs[0]"); let (_, trace) = h - .resolve_with_trace(QueryKey::Bus(lpc_model::ChannelName(String::from("video")))) + .resolve_with_trace(QueryKey::Bus(scoped_channel("video"))) .expect("resolve with trace"); assert!(trace_has_value_origin_path( diff --git a/lp-core/lpc-engine/src/engine/mod.rs b/lp-core/lpc-engine/src/engine/mod.rs index 6f1f115d7..54ef79a6f 100644 --- a/lp-core/lpc-engine/src/engine/mod.rs +++ b/lp-core/lpc-engine/src/engine/mod.rs @@ -1,5 +1,6 @@ //! Core runtime owner: [`Engine`] drives frame state, tree, bindings, and resolver. +mod bus_scopes; mod engine; mod engine_error; mod engine_services; diff --git a/lp-core/lpc-engine/src/engine/project_apply.rs b/lp-core/lpc-engine/src/engine/project_apply.rs index 25eaba015..d49cc6619 100644 --- a/lp-core/lpc-engine/src/engine/project_apply.rs +++ b/lp-core/lpc-engine/src/engine/project_apply.rs @@ -13,7 +13,6 @@ use lpc_registry::ProjectRegistry; use lpfs::LpFs; use crate::node::{AssetRefreshContext, AssetRefreshResult, NodeEntryState}; -use crate::nodes::CorePlaceholderNode; use super::{Engine, ProjectLoadError, ProjectLoader}; @@ -139,7 +138,7 @@ impl Engine { if reattach_roots.contains(&location) { self.reattach_runtime_node( self.tree().root(), - alloc::boxed::Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), + alloc::boxed::Box::new(crate::nodes::ProjectNode::new(self.tree().root())), frame, ) .map_err(|e| { diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 4220ae09c..dd235d95f 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -9,9 +9,9 @@ use lp_collection::VecSet; use lpc_model::{ArtifactSpec, NodeInvocation, NodeKind}; use lpc_model::{AssetContentType, AssetLocation, NodeDefLocation, NodeDefState}; use lpc_model::{ - BindingDefs, BindingRef as AuthoredBindingRef, ChannelName, FixtureDef, Kind, LpValue, - MappingConfig, NodeDef, NodeId, NodeName, PlaylistDef, ProjectNodeOrigin, ProjectNodePlacement, - Revision, SlotPath, + BindingDefs, BindingRef as AuthoredBindingRef, FixtureDef, Kind, LpValue, MappingConfig, + NodeDef, NodeId, NodeName, PlaylistDef, ProjectNodeOrigin, ProjectNodePlacement, Revision, + SlotPath, }; use lpc_model::{SlotDirection, SlotPathSegment, SlotShape, StaticSlotShape, well_known_channel}; use lpc_registry::{AssetText, ParseCtx, ProjectRegistry}; @@ -24,11 +24,11 @@ use crate::node::{NodeEntryState, TreeError}; use crate::nodes::FixtureMap2dSource; use crate::nodes::fixture::mapping::mapping_from_map2d_doc; use crate::nodes::{ - ButtonNode, ClockNode, ComputeShaderNode, ControlRadioNode, CorePlaceholderNode, FixtureNode, - FluidNode, OutputNode, PlaylistNode, PlaylistRuntimeEntry, ShaderNode, TextureNode, - playlist_output_path, + ButtonNode, ClockNode, ComputeShaderNode, ControlRadioNode, FixtureNode, FluidNode, OutputNode, + PlaylistNode, PlaylistRuntimeEntry, ProjectNode, ShaderNode, TextureNode, playlist_output_path, }; +use super::bus_scopes::BusScopes; use super::{Engine, EngineServices, LoadedProjectRuntime}; /// Errors loading an authored project into [`Engine`]. @@ -79,12 +79,6 @@ pub(super) enum ProjectedNodeOwnership { PlaylistEntry { playlist: NodeId, entry: u32 }, } -impl ProjectedNodeOwnership { - fn suppress_visual_default_output(self) -> bool { - matches!(self, Self::PlaylistEntry { .. }) - } -} - /// Loads the authored project artifact tree into a core engine-backed runtime. pub struct ProjectLoader; @@ -169,11 +163,7 @@ impl ProjectLoader { } } runtime - .attach_runtime_node( - root, - Box::new(CorePlaceholderNode::new_leaf(NodeKind::Project)), - frame, - ) + .attach_runtime_node(root, Box::new(ProjectNode::new(root)), frame) .map_err(|e| ProjectLoadError::InvalidProjectReference { path: artifact_specifier_label(&project_specifier), reason: format!("attach project runtime: {e}"), @@ -357,6 +347,28 @@ impl ProjectLoader { targets: Option<&VecSet>, frame: Revision, ) -> Result<(), ProjectLoadError> { + // Non-root project nodes get the mirror runtime (scoped-buses ADR + // rule 5); the root's mirror attaches in `build_runtime_spine` / + // incremental root reattach. Error-state defs project with the + // `Project` fallback kind and stay runtime-less as before. + for node in projected_nodes { + if !should_attach_projected_node(node, targets) { + continue; + } + if node.kind != NodeKind::Project || node.ownership == ProjectedNodeOwnership::Root { + continue; + } + let Ok(NodeDef::Project(_)) = projected_node_config(registry, node) else { + continue; + }; + runtime + .attach_runtime_node(node.id, Box::new(ProjectNode::new(node.id)), frame) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("attach project runtime: {e}"), + })?; + } + for node in projected_nodes { if !should_attach_projected_node(node, targets) { continue; @@ -631,8 +643,12 @@ impl ProjectLoader { projected_nodes: &[ProjectedNode], frame: Revision, ) -> Result<(), ProjectLoadError> { + // Pass 1 (scoped-buses ADR): scope assignment over the spine plus + // the per-(scope, channel) writer set; pass 2 below registers + // bindings with consumes resolved against it. + let scopes = BusScopes::compute(registry, projected_nodes, runtime.tree().root()); for node in projected_nodes { - register_node_bindings(registry, runtime, projected_nodes, node, frame)?; + register_node_bindings(registry, runtime, projected_nodes, node, frame, &scopes)?; } Ok(()) } @@ -1041,7 +1057,10 @@ fn binding_source<'a>(bindings: &'a BindingDefs, slot: &str) -> Option(bindings: &'a BindingDefs, slot: &str) -> Option<&'a AuthoredBindingRef> { +pub(super) fn binding_target<'a>( + bindings: &'a BindingDefs, + slot: &str, +) -> Option<&'a AuthoredBindingRef> { bindings.entries().get(slot)?.target_ref() } @@ -1123,11 +1142,19 @@ fn register_node_bindings( projected_nodes: &[ProjectedNode], node: &ProjectedNode, frame: Revision, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { // Unloaded/errored defs project with the `Project` fallback kind and // register nothing — same tolerance the attach arms' kind filters gave // them (the node renders as an error node; the load must not fail). + // Real project defs register no bindings either, but their promoted + // controls are validated here so a bad alias fails the load loudly. if node.kind == NodeKind::Project { + if let Ok(NodeDef::Project(config)) = projected_node_config(registry, node) { + let config = config.clone(); + validate_promoted_controls(projected_nodes, node, &config)?; + } + register_project_mirror_default(runtime, node, frame, scopes)?; return Ok(()); } match projected_node_config(registry, node)?.clone() { @@ -1139,6 +1166,7 @@ fn register_node_bindings( "seconds", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1147,8 +1175,16 @@ fn register_node_bindings( "delta_seconds", &config.bindings, frame, + scopes, + )?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, )?; - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; } NodeDef::Button(config) => { for slot in ["down", "held", "up"] { @@ -1159,6 +1195,7 @@ fn register_node_bindings( slot, &config.bindings, frame, + scopes, )?; } } @@ -1170,6 +1207,7 @@ fn register_node_bindings( "input", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1178,6 +1216,7 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, )?; } NodeDef::Output(config) => { @@ -1206,8 +1245,16 @@ fn register_node_bindings( "input", &config.bindings, frame, + scopes, + )?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, )?; - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; } NodeDef::Shader(config) => { register_target_binding( @@ -1217,6 +1264,7 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, )?; for name in config.consumed_slots.entries.keys() { register_optional_source_binding( @@ -1226,6 +1274,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } for (name, slot) in config.consumed_slots.entries.iter() { @@ -1241,9 +1290,17 @@ fn register_node_bindings( name, SlotDirection::Consumed, &endpoint.value().to_string(), + scopes, )?; } - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, + )?; } NodeDef::ComputeShader(config) => { for name in config.consumed_slots.entries.keys() { @@ -1254,6 +1311,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } for name in config.produced_slots.entries.keys() { @@ -1264,6 +1322,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } } @@ -1275,6 +1334,7 @@ fn register_node_bindings( "time", &config.bindings, frame, + scopes, )?; register_optional_source_binding( runtime, @@ -1283,6 +1343,7 @@ fn register_node_bindings( "emitters", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1291,12 +1352,20 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, + )?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, )?; - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; } NodeDef::Playlist(config) => { if let Some(source) = binding_source(&config.bindings, "time") - .map(|source| binding_source_endpoint(projected_nodes, node, source)) + .map(|source| binding_source_endpoint(projected_nodes, node, source, scopes)) .transpose()? { register_source_binding_at_path( @@ -1310,7 +1379,7 @@ fn register_node_bindings( )?; } if let Some(source) = binding_source(&config.bindings, "trigger") - .map(|source| binding_source_endpoint(projected_nodes, node, source)) + .map(|source| binding_source_endpoint(projected_nodes, node, source, scopes)) .transpose()? { register_source_binding_at_path( @@ -1324,7 +1393,7 @@ fn register_node_bindings( )?; } if let Some(target) = binding_target(&config.bindings, "output") - .map(|target| binding_target_endpoint(projected_nodes, node, target)) + .map(|target| binding_target_endpoint(projected_nodes, node, target, scopes)) .transpose()? { let source = BindingSource::ProducedSlot { @@ -1347,7 +1416,14 @@ fn register_node_bindings( reason: format!("register output target binding: {e}"), })?; } - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, + )?; } NodeDef::Fixture(config) => { register_optional_source_binding( @@ -1357,6 +1433,7 @@ fn register_node_bindings( "input", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1365,17 +1442,122 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, + )?; + register_declared_defaults( + runtime, + projected_nodes, + node, + &config.bindings, + frame, + scopes, )?; - register_declared_defaults(runtime, projected_nodes, node, &config.bindings, frame)?; } NodeDef::Project(_) | NodeDef::Texture(_) => {} } Ok(()) } +/// An embedded project contributes its visual to its host by default: the +/// mirror's produced `output` publishes to the project node's OWN nearest +/// scope's `visual.out` at fallback priority (scoped-buses rule 4 — the +/// project NODE sits in the parent scope, so this is its own scope, not an +/// outward write; only its CHILDREN are isolated inside the scope it +/// introduces). Effects are therefore drop-in: embed one and it lights up +/// with no extra wiring. +/// +/// The ROOT project is skipped: its own scope IS the scope its mirror +/// reads, so a default there would be self-referential (and root's mirror +/// is unread today). +/// +/// Contention is accepted and deliberate (Yona, 2026-07-29): dropping an +/// effect into a project that already drives `visual.out` leaves two +/// equal-priority writers, which resolves as ambiguous until the author +/// picks one. `ProjectDef` carries no `bindings` map yet, so that pick +/// cannot currently be authored on the project node itself. +fn register_project_mirror_default( + engine: &mut Engine, + node: &ProjectedNode, + frame: Revision, + scopes: &BusScopes, +) -> Result<(), ProjectLoadError> { + if node.ownership == ProjectedNodeOwnership::Root { + return Ok(()); + } + let slot = SlotPath::parse("output").expect("project output path"); + let channel = lpc_model::ChannelName(String::from(lpc_model::PRIMARY_VISUAL_CHANNEL)); + let source = BindingSource::ProducedSlot { + node: node.id, + slot, + }; + let target = BindingTarget::BusChannel(scopes.write_channel(node.id, &channel)); + engine + .add_binding( + BindingDraft { + kind: binding_kind(&source, &target, "output"), + source, + target, + priority: BindingPriority::default_fallback(), + owner: node.id, + }, + frame, + ) + .map_err(|e| ProjectLoadError::InvalidProjectReference { + path: node_label(node), + reason: format!("register project output mirror default: {e}"), + })?; + Ok(()) +} + +/// Validate a project node's promoted controls (effects-are-projects ADR): +/// every `controls{}` target must be the node form and resolve to a +/// **direct child** of the project node. Deeper or unresolvable targets are +/// a load error with a path-qualified message — a bad alias fails loudly at +/// load, not as a silently dead knob. (Slot-path existence on the child is +/// not validated here: shader slots are dynamic, so the face layer renders +/// unresolvable slot paths as disabled controls instead.) +fn validate_promoted_controls( + projected_nodes: &[ProjectedNode], + current: &ProjectedNode, + config: &lpc_model::ProjectDef, +) -> Result<(), ProjectLoadError> { + for (name, control) in config.controls.entries.iter() { + let target = control.target.value(); + let node_ref = match target { + AuthoredBindingRef::Node(node_slot) => node_slot, + other => { + return Err(ProjectLoadError::InvalidProjectReference { + path: node_label(current), + reason: format!( + "promoted control `{name}`: target `{other}` must be a node:# ref" + ), + }); + } + }; + let resolved = resolve_relative_node_ref(projected_nodes, current, node_ref.node()) + .ok_or_else(|| ProjectLoadError::InvalidProjectReference { + path: node_label(current), + reason: format!( + "promoted control `{name}`: target node `{}` does not resolve", + node_ref.node() + ), + })?; + if resolved.parent != Some(current.id) { + return Err(ProjectLoadError::InvalidProjectReference { + path: node_label(current), + reason: format!( + "promoted control `{name}`: target `{}` must be a direct child of the project", + node_ref.node() + ), + }); + } + } + Ok(()) +} + /// Slot-declared default bindings for a node kind: (slot name, declared /// direction, `bus:` endpoint) triples from the def and state shapes. -fn declared_default_binds(kind: NodeKind) -> Vec<(String, SlotDirection, String)> { +pub(super) fn declared_default_binds(kind: NodeKind) -> Vec<(String, SlotDirection, String)> { let mut out = Vec::new(); let mut collect = |shape: Option| { if let Some(SlotShape::Record { fields, .. }) = shape { @@ -1408,6 +1590,7 @@ fn register_declared_defaults( current: &ProjectedNode, bindings: &BindingDefs, frame: Revision, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { for (name, direction, endpoint) in declared_default_binds(current.kind) { register_default_bind( @@ -1419,6 +1602,7 @@ fn register_declared_defaults( &name, direction, &endpoint, + scopes, )?; } Ok(()) @@ -1436,6 +1620,7 @@ fn register_default_bind( name: &str, direction: SlotDirection, endpoint: &str, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { let channel = match lpc_model::BindingRef::parse(endpoint) { Ok(lpc_model::BindingRef::Bus(bus)) => bus.channel().clone(), @@ -1451,16 +1636,18 @@ fn register_default_bind( reason: format!("invalid default_bind slot `{name}`: {e}"), })?; let draft = if direction == SlotDirection::Produced { - if current.ownership.suppress_visual_default_output() - || binding_target(bindings, name).is_some() - { + if binding_target(bindings, name).is_some() { return Ok(()); } let source = BindingSource::ProducedSlot { node: current.id, slot, }; - let target = BindingTarget::BusChannel(channel); + // Rule 4: produced defaults write the producer's own scope. For a + // playlist-entry child that scope is its anonymous entry scope — + // which is the general rule the old suppress-entry-visual-defaults + // special case collapsed into. + let target = BindingTarget::BusChannel(scopes.write_channel(current.id, &channel)); BindingDraft { kind: binding_kind(&source, &target, name), source, @@ -1472,7 +1659,9 @@ fn register_default_bind( if binding_source(bindings, name).is_some() { return Ok(()); } - let source = BindingSource::BusChannel(channel); + // Rule 3: consumed defaults resolve to the nearest enclosing scope + // with a writer, else the root scope. + let source = BindingSource::BusChannel(scopes.read_channel(current.id, &channel)); let target = BindingTarget::ConsumedSlot { node: current.id, slot, @@ -1546,6 +1735,7 @@ fn register_source_binding( slot_name: &str, bindings: &BindingDefs, frame: Revision, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { let source = binding_source(bindings, slot_name).ok_or_else(|| { ProjectLoadError::InvalidProjectReference { @@ -1553,7 +1743,7 @@ fn register_source_binding( reason: format!("{slot_name} source binding is missing"), } })?; - let source = binding_source_endpoint(projected_nodes, current, source)?; + let source = binding_source_endpoint(projected_nodes, current, source, scopes)?; let target_slot = SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidProjectReference { path: node_label(current), @@ -1607,11 +1797,20 @@ fn register_optional_source_binding( slot_name: &str, bindings: &BindingDefs, frame: Revision, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { if binding_source(bindings, slot_name).is_none() { return Ok(()); } - register_source_binding(engine, projected_nodes, current, slot_name, bindings, frame) + register_source_binding( + engine, + projected_nodes, + current, + slot_name, + bindings, + frame, + scopes, + ) } fn register_target_binding( @@ -1621,11 +1820,12 @@ fn register_target_binding( slot_name: &str, bindings: &BindingDefs, frame: Revision, + scopes: &BusScopes, ) -> Result<(), ProjectLoadError> { let Some(target) = binding_target(bindings, slot_name) else { return Ok(()); }; - let target = binding_target_endpoint(projected_nodes, current, target)?; + let target = binding_target_endpoint(projected_nodes, current, target, scopes)?; let source_slot = SlotPath::parse(slot_name).map_err(|e| ProjectLoadError::InvalidProjectReference { path: node_label(current), @@ -1665,7 +1865,7 @@ fn binding_kind(source: &BindingSource, target: &BindingTarget, slot_name: &str) (_, BindingTarget::BusChannel(channel)) => Some(channel), _ => None, }; - if let Some(known) = channel.and_then(|channel| well_known_channel(&channel.0)) { + if let Some(known) = channel.and_then(|channel| well_known_channel(&channel.channel.0)) { return known.kind; } match slot_name { @@ -1678,11 +1878,12 @@ fn binding_source_endpoint( projected_nodes: &[ProjectedNode], current: &ProjectedNode, endpoint: AuthoredBindingSource<'_>, + scopes: &BusScopes, ) -> Result { match endpoint { AuthoredBindingSource::Value(value) => Ok(BindingSource::Literal(value.clone())), AuthoredBindingSource::Ref(binding_ref) => { - binding_ref_source(projected_nodes, current, binding_ref) + binding_ref_source(projected_nodes, current, binding_ref, scopes) } } } @@ -1691,15 +1892,18 @@ fn binding_ref_source( projected_nodes: &[ProjectedNode], current: &ProjectedNode, binding_ref: &AuthoredBindingRef, + scopes: &BusScopes, ) -> Result { match binding_ref { AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidProjectReference { path: node_label(current), reason: String::from("binding source cannot be unset"), }), - AuthoredBindingRef::Bus(bus) => Ok(BindingSource::BusChannel(ChannelName( - bus.channel().0.clone(), - ))), + // Rule 3: an authored bus source is a consume — nearest enclosing + // scope with a writer, else root. + AuthoredBindingRef::Bus(bus) => Ok(BindingSource::BusChannel( + scopes.read_channel(current.id, bus.channel()), + )), AuthoredBindingRef::Node(node_slot) => { let node = resolve_node_loc(projected_nodes, current, node_slot.node(), "binding source")?; @@ -1715,15 +1919,18 @@ fn binding_target_endpoint( projected_nodes: &[ProjectedNode], current: &ProjectedNode, endpoint: &AuthoredBindingRef, + scopes: &BusScopes, ) -> Result { match endpoint { AuthoredBindingRef::Unset => Err(ProjectLoadError::InvalidProjectReference { path: node_label(current), reason: String::from("binding target cannot be unset"), }), - AuthoredBindingRef::Bus(bus) => Ok(BindingTarget::BusChannel(ChannelName( - bus.channel().0.clone(), - ))), + // Rule 4: an authored bus target is a produce — always the + // producer's own scope. + AuthoredBindingRef::Bus(bus) => Ok(BindingTarget::BusChannel( + scopes.write_channel(current.id, bus.channel()), + )), AuthoredBindingRef::Node(node_slot) => { let node = resolve_node_loc(projected_nodes, current, node_slot.node(), "binding target")?; @@ -1741,6 +1948,11 @@ mod binding_kind_tests { use super::binding_kind; use crate::dataflow::binding::{BindingSource, BindingTarget}; + use crate::dataflow::bus::{ScopeId, ScopedChannel}; + + fn scoped(name: &str) -> ScopedChannel { + ScopedChannel::new(ScopeId::Project(NodeId::new(0)), ChannelName(name.into())) + } fn consumed(slot: &str) -> BindingTarget { BindingTarget::ConsumedSlot { @@ -1760,12 +1972,12 @@ mod binding_kind_tests { fn well_known_channel_kind_beats_the_slot_name_guess() { // `trigger` is Instant in the registry; the old slot-name guess // stamped it Color (only the time-family names were listed). - let source = BindingSource::BusChannel(ChannelName("trigger".into())); + let source = BindingSource::BusChannel(scoped("trigger")); assert_eq!( binding_kind(&source, &consumed("trigger"), "trigger"), Kind::Instant ); - let target = BindingTarget::BusChannel(ChannelName("visual.out".into())); + let target = BindingTarget::BusChannel(scoped("visual.out")); assert_eq!( binding_kind(&produced("output"), &target, "output"), Kind::Color @@ -1774,7 +1986,7 @@ mod binding_kind_tests { #[test] fn unregistered_channels_fall_back_to_the_slot_name_guess() { - let target = BindingTarget::BusChannel(ChannelName("wobble".into())); + let target = BindingTarget::BusChannel(scoped("wobble")); assert_eq!( binding_kind(&produced("seconds"), &target, "seconds"), Kind::Instant @@ -1787,7 +1999,7 @@ mod binding_kind_tests { } #[cfg(test)] -mod tests { +pub(in crate::engine) mod tests { extern crate std; use core::cell::Cell; @@ -1812,10 +2024,12 @@ mod tests { use super::*; use crate::dataflow::binding::{BindingPriority, BindingSource, BindingTarget}; + use crate::dataflow::bus::{ScopeId, ScopedChannel}; use crate::dataflow::resolver::{Production, QueryKey, ResolveLogLevel}; use crate::engine::test_support::{read_into_view, read_probe_results}; use crate::engine::{ButtonService, RadioService}; use crate::products::visual::RenderTextureRequest; + use lpc_model::FromLpValue; fn node_for_def_path(rt: &Engine, path: &str) -> Option { let location = NodeDefLocation::artifact_root(ArtifactLocation::file(path)); @@ -1932,6 +2146,11 @@ mod tests { )); } + /// Shared with the read-probe tests (entry-scope omission regression). + pub(in crate::engine) fn playlist_project_fs_for_probe() -> LpFsMemory { + playlist_project_fs() + } + fn playlist_project_fs() -> LpFsMemory { let fs = LpFsMemory::new(); fs.write_file( @@ -2285,7 +2504,7 @@ mod tests { rt.tick(1000).expect("first tick"); let first = rt .resolve_with_engine_host( - QueryKey::Bus(ChannelName(String::from("time"))), + QueryKey::Bus(crate::engine::test_support::scoped_channel("time")), ResolveLogLevel::Off, ) .expect("resolve time bus") @@ -2312,7 +2531,7 @@ mod tests { rt.tick(1000).expect("second tick"); let second = rt .resolve_with_engine_host( - QueryKey::Bus(ChannelName(String::from("time"))), + QueryKey::Bus(crate::engine::test_support::scoped_channel("time")), ResolveLogLevel::Off, ) .expect("resolve time bus") @@ -2414,7 +2633,7 @@ mod tests { BindingTarget::BusChannel(channel), ) if *node == shader && slot == &SlotPath::parse("output").expect("output") - && channel.0 == "visual.out" + && channel.channel.0 == "visual.out" && binding.priority == BindingPriority::default_fallback() ) })); @@ -2497,7 +2716,12 @@ mod tests { } #[test] - fn playlist_entry_children_do_not_get_default_visual_output_binding() { + fn playlist_entry_children_write_visual_out_into_their_anonymous_scope() { + // Scoped-buses ADR: the old rule suppressed entry children's + // produced visual defaults outright; the general model registers + // them into a per-entry anonymous scope instead. The root-visible + // behavior is identical — the root `visual.out` channel never sees + // an entry child's writer. let fs = playlist_project_fs(); let services = EngineServices::new(TreePath::parse("/playlist.show").expect("path")); let rt = ProjectLoader::load_from_root(&fs, services).expect("load playlist"); @@ -2511,18 +2735,33 @@ mod tests { .lookup_sibling(playlist, NodeName::parse("active").unwrap()) .expect("active"); - assert!(!rt.tree().bindings().any(|binding| { - matches!( - (&binding.source, &binding.target), + let entry_visual_targets: Vec = rt + .tree() + .bindings() + .filter_map(|binding| match (&binding.source, &binding.target) { ( BindingSource::ProducedSlot { node, slot }, BindingTarget::BusChannel(channel), ) if *node == active && slot == &SlotPath::parse("output").expect("output") - && channel.0 == "visual.out" - && binding.priority == BindingPriority::default_fallback() - ) - })); + && channel.channel.0 == "visual.out" + && binding.priority == BindingPriority::default_fallback() => + { + Some(channel.clone()) + } + _ => None, + }) + .collect(); + + // The default registers exactly once, into the entry child's + // anonymous scope — never the root scope. + assert_eq!( + entry_visual_targets, + alloc::vec![ScopedChannel::new( + ScopeId::Entry(active), + lpc_model::ChannelName(String::from("visual.out")) + )] + ); } #[test] @@ -2555,7 +2794,7 @@ mod tests { ( BindingSource::BusChannel(source), BindingTarget::ConsumedSlot { node, slot }, - ) if source.0 == "trigger" + ) if source.channel.0 == "trigger" && *node == playlist && slot == &SlotPath::parse("trigger").expect("trigger") && binding.priority == BindingPriority::authored() @@ -3303,7 +3542,7 @@ mod tests { ( BindingSource::BusChannel(source), BindingTarget::ConsumedSlot { node, slot }, - ) if source.0 == "trigger" + ) if source.channel.0 == "trigger" && *node == radio && slot == &SlotPath::parse("input").expect("input") ) @@ -3316,7 +3555,7 @@ mod tests { BindingTarget::BusChannel(target), ) if *node == radio && slot == &SlotPath::parse("output").expect("output") - && target.0 == "trigger" + && target.channel.0 == "trigger" ) })); } @@ -3969,7 +4208,7 @@ mod tests { ( BindingSource::ProducedSlot { node: n, slot: s }, BindingTarget::BusChannel(c), - ) if *n == node && s == &SlotPath::parse(slot).expect("slot") && c.0 == channel + ) if *n == node && s == &SlotPath::parse(slot).expect("slot") && c.channel.0 == channel ) }) } @@ -3982,7 +4221,7 @@ mod tests { ( BindingSource::BusChannel(c), BindingTarget::ConsumedSlot { node: n, slot: s }, - ) if *n == node && s == &SlotPath::parse(slot).expect("slot") && c.0 == channel + ) if *n == node && s == &SlotPath::parse(slot).expect("slot") && c.channel.0 == channel ) }) } @@ -4068,7 +4307,7 @@ mod tests { ( BindingSource::ProducedSlot { node, .. }, BindingTarget::BusChannel(c), - ) if *node == clock && c.0 == "custom" + ) if *node == clock && c.channel.0 == "custom" ) })); } @@ -4167,7 +4406,8 @@ mod tests { let fs: &dyn LpFs = &fs; let services = EngineServices::new(TreePath::parse("/fyeah.show").expect("path")); let rt = ProjectLoader::load_from_root(fs, services).expect("load fyeah sign"); - let default_publishers = rt + let root_scope = ScopeId::Project(rt.tree().root()); + let root_default_publishers = rt .tree() .bindings() .filter(|binding| { @@ -4177,14 +4417,701 @@ mod tests { ( BindingSource::ProducedSlot { .. }, BindingTarget::BusChannel(c), - ) if c.0 == "visual.out" + ) if c.channel.0 == "visual.out" && c.scope == root_scope ) }) .count(); assert_eq!( - default_publishers, 1, - "only the playlist itself default-publishes visual.out; entry \ - children are ownership-suppressed" + root_default_publishers, 1, + "only the playlist itself default-publishes the root visual.out; \ + entry children write their own anonymous entry scopes \ + (scoped-buses ADR)" ); } + + /// Memory-fs fixture for the scoped-buses nested-project tests: a host + /// project with a clock and a visual shader, plus an embedded project + /// `fx` (a hand-authored effect — the authoring guard lifts in the + /// composite-effects M2 slice, but the load path is generic already). + fn nested_project_fs(inner_clock: bool) -> LpFsMemory { + let fs = LpFsMemory::new(); + fs.write_file( + "/project.json".as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "clock": { "ref": "./clock.json" }, + "host": { "ref": "./host.json" }, + "fx": { "ref": "./fx/project.json" } + } +} +"#, + ) + .expect("project.json"); + fs.write_file("/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("clock.json"); + fs.write_file( + "/host.json".as_path(), + br#" +{ + "kind": "Shader", + "source": { "path": "host.glsl" }, + "consumed": { + "time": { "kind": "value", "value": "f32", "default": 0.0, "default_bind": "bus:time" } + } +} +"#, + ) + .expect("host.json"); + fs.write_file( + "/host.glsl".as_path(), + b"vec4 render(vec2 pos) { return vec4(time, pos, 1.0); }", + ) + .expect("host.glsl"); + let fx_nodes: &[u8] = if inner_clock { + br#" +{ + "kind": "Project", + "nodes": { + "clock": { "ref": "./clock.json" }, + "shader": { "ref": "./shader.json" } + } +} +"# + } else { + br#" +{ + "kind": "Project", + "nodes": { + "shader": { "ref": "./shader.json" } + } +} +"# + }; + fs.write_file("/fx/project.json".as_path(), fx_nodes) + .expect("fx/project.json"); + if inner_clock { + fs.write_file("/fx/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("fx/clock.json"); + } + fs.write_file( + "/fx/shader.json".as_path(), + br#" +{ + "kind": "Shader", + "source": { "path": "shader.glsl" }, + "consumed": { + "time": { "kind": "value", "value": "f32", "default": 0.0, "default_bind": "bus:time" } + } +} +"#, + ) + .expect("fx/shader.json"); + fs.write_file( + "/fx/shader.glsl".as_path(), + b"vec4 render(vec2 pos) { return vec4(pos, time, 1.0); }", + ) + .expect("fx/shader.glsl"); + fs + } + + fn produced_bus_target(rt: &Engine, node: NodeId, slot: &str) -> Option { + rt.tree() + .bindings() + .find_map(|binding| match (&binding.source, &binding.target) { + ( + BindingSource::ProducedSlot { + node: source, + slot: source_slot, + }, + BindingTarget::BusChannel(channel), + ) if *source == node && source_slot == &SlotPath::parse(slot).expect("slot") => { + Some(channel.clone()) + } + _ => None, + }) + } + + fn consumed_bus_source(rt: &Engine, node: NodeId, slot: &str) -> Option { + rt.tree() + .bindings() + .find_map(|binding| match (&binding.source, &binding.target) { + ( + BindingSource::BusChannel(channel), + BindingTarget::ConsumedSlot { + node: target, + slot: target_slot, + }, + ) if *target == node && target_slot == &SlotPath::parse(slot).expect("slot") => { + Some(channel.clone()) + } + _ => None, + }) + } + + fn scoped_at(scope: ScopeId, name: &str) -> ScopedChannel { + ScopedChannel::new(scope, lpc_model::ChannelName(String::from(name))) + } + + #[test] + fn nested_project_scopes_isolate_visual_and_inherit_time() { + let fs = nested_project_fs(false); + let services = EngineServices::new(TreePath::parse("/nested.show").expect("path")); + let mut rt = ProjectLoader::load_from_root(&fs, services).expect("load nested project"); + let root = rt.tree().root(); + let host = rt + .tree() + .lookup_sibling(root, NodeName::parse("host").unwrap()) + .expect("host shader"); + let fx = rt + .tree() + .lookup_sibling(root, NodeName::parse("fx").unwrap()) + .expect("fx project node"); + let fx_shader = rt + .tree() + .lookup_sibling(fx, NodeName::parse("shader").unwrap()) + .expect("fx shader"); + + // Rule 4: each shader's produced visual default writes its own scope. + assert_eq!( + produced_bus_target(&rt, host, "output"), + Some(scoped_at(ScopeId::Project(root), "visual.out")), + "host visual stays in the root scope" + ); + assert_eq!( + produced_bus_target(&rt, fx_shader, "output"), + Some(scoped_at(ScopeId::Project(fx), "visual.out")), + "embedded shader's visual lands in the fx scope, not the host's" + ); + + // Rule 3: with no inner writer, the embedded shader's time consume + // inherits the host clock's root-scope channel. + assert_eq!( + consumed_bus_source(&rt, fx_shader, "time"), + Some(scoped_at(ScopeId::Project(root), "time")), + "embedded shader inherits host time" + ); + + // Rule 5: the fx project node mirrors its scope's visual as a + // produced `output`. The row carries fx's own renderable handle + // (playlist parity); render dispatch forwards to the inner shader. + rt.tick(16).expect("tick"); + let mirrored = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: fx, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .expect("resolve fx mirror") + .0; + let product = lpc_model::VisualProduct::from_lp_value( + mirrored.value_leaf().expect("mirror value").value(), + ) + .expect("mirror carries a visual product"); + assert_eq!( + product.node(), + fx, + "the mirror's row carries the project node's own handle" + ); + + // Isolation is about the effect's CHILDREN: the inner shader never + // writes root (asserted above). The effect NODE itself does, by + // design — its mirror default-publishes to its own nearest scope so + // effects are drop-in (Yona, 2026-07-29). + let root_visual = scoped_at(ScopeId::Project(root), "visual.out"); + let root_writers: Vec = + rt.tree() + .bindings() + .filter_map(|binding| match (&binding.source, &binding.target) { + ( + BindingSource::ProducedSlot { node, .. }, + BindingTarget::BusChannel(channel), + ) if *channel == root_visual => Some(*node), + _ => None, + }) + .collect(); + assert!( + root_writers.contains(&host), + "the host shader still writes root visual.out" + ); + assert!( + root_writers.contains(&fx), + "the effect's mirror contributes its visual to the host scope" + ); + assert!( + !root_writers.contains(&fx_shader), + "the effect's INNER shader never reaches the host scope" + ); + // Accepted consequence: two equal-priority writers on one channel + // resolve as ambiguous until the author picks one. + assert!(matches!( + rt.resolve_with_engine_host(QueryKey::Bus(root_visual), ResolveLogLevel::Off), + Err(crate::dataflow::resolver::SessionResolveError::AmbiguousBusBinding { .. }) + )); + } + + #[test] + fn nested_project_clock_shadows_time_for_inner_subtree_only() { + let fs = nested_project_fs(true); + let services = EngineServices::new(TreePath::parse("/nested.show").expect("path")); + let rt = ProjectLoader::load_from_root(&fs, services).expect("load nested project"); + let root = rt.tree().root(); + let host = rt + .tree() + .lookup_sibling(root, NodeName::parse("host").unwrap()) + .expect("host shader"); + let fx = rt + .tree() + .lookup_sibling(root, NodeName::parse("fx").unwrap()) + .expect("fx project node"); + let fx_shader = rt + .tree() + .lookup_sibling(fx, NodeName::parse("shader").unwrap()) + .expect("fx shader"); + + assert_eq!( + consumed_bus_source(&rt, fx_shader, "time"), + Some(scoped_at(ScopeId::Project(fx), "time")), + "an inner clock shadows time for the fx subtree" + ); + assert_eq!( + consumed_bus_source(&rt, host, "time"), + Some(scoped_at(ScopeId::Project(root), "time")), + "the host keeps reading root time" + ); + } + + #[test] + fn nested_project_without_visual_writer_renders_cleared() { + let fs = LpFsMemory::new(); + fs.write_file( + "/project.json".as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "fx": { "ref": "./fx/project.json" } + } +} +"#, + ) + .expect("project.json"); + fs.write_file( + "/fx/project.json".as_path(), + br#" +{ + "kind": "Project", + "nodes": { + "clock": { "ref": "./clock.json" } + } +} +"#, + ) + .expect("fx/project.json"); + fs.write_file("/fx/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("fx/clock.json"); + + let services = EngineServices::new(TreePath::parse("/nested.show").expect("path")); + let mut rt = ProjectLoader::load_from_root(&fs, services).expect("load"); + let root = rt.tree().root(); + let fx = rt + .tree() + .lookup_sibling(root, NodeName::parse("fx").unwrap()) + .expect("fx project node"); + + rt.tick(16).expect("tick"); + // No visual writer in the fx scope: the mirror still presents its + // own renderable handle and renders cleared when drawn — a project + // without a visual is a legitimate shape, not an error. + let production = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: fx, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .expect("mirror without a writer still resolves") + .0; + let product = lpc_model::VisualProduct::from_lp_value( + production.value_leaf().expect("mirror value").value(), + ) + .expect("mirror carries a visual product"); + assert_eq!(product.node(), fx); + } + + #[test] + fn playlist_plays_an_embedded_project_through_its_mirror() { + let fs = LpFsMemory::new(); + fs.write_file( + "/project.json".as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "clock": { "ref": "./clock.json" }, + "playlist": { "ref": "./playlist.json" } + } +} +"#, + ) + .expect("project.json"); + fs.write_file("/clock.json".as_path(), br#"{ "kind": "Clock" }"#) + .expect("clock.json"); + fs.write_file( + "/playlist.json".as_path(), + br#" +{ + "kind": "Playlist", + "default_fade": 0.0, + "bindings": { + "time": { "source": "bus:time" } + }, + "entries": { + "1": { + "name": "fx", + "node": { "ref": "./fx/project.json" } + } + } +} +"#, + ) + .expect("playlist.json"); + fs.write_file( + "/fx/project.json".as_path(), + br#" +{ + "kind": "Project", + "nodes": { + "shader": { "ref": "./shader.json" } + } +} +"#, + ) + .expect("fx/project.json"); + fs.write_file( + "/fx/shader.json".as_path(), + br#" +{ + "kind": "Shader", + "source": { "path": "shader.glsl" }, + "consumed": { + "time": { "kind": "value", "value": "f32", "default": 0.0, "default_bind": "bus:time" } + } +} +"#, + ) + .expect("fx/shader.json"); + fs.write_file( + "/fx/shader.glsl".as_path(), + b"vec4 render(vec2 pos) { return vec4(pos, time, 1.0); }", + ) + .expect("fx/shader.glsl"); + + let services = EngineServices::new(TreePath::parse("/nested.show").expect("path")); + let mut rt = ProjectLoader::load_from_root(&fs, services).expect("load"); + let root = rt.tree().root(); + let playlist = rt + .tree() + .lookup_sibling(root, NodeName::parse("playlist").unwrap()) + .expect("playlist"); + let fx = rt + .tree() + .lookup_sibling(playlist, NodeName::parse("fx").unwrap()) + .expect("fx entry child"); + let fx_shader = rt + .tree() + .lookup_sibling(fx, NodeName::parse("shader").unwrap()) + .expect("fx shader"); + + // The entry-owned project sits in an anonymous entry scope; its own + // named scope nests inside it, and its shader's time consume walks + // out past both to the host clock. + assert_eq!( + consumed_bus_source(&rt, fx_shader, "time"), + Some(scoped_at(ScopeId::Project(root), "time")), + "entry effect inherits host time through the anonymous scope" + ); + assert_eq!( + produced_bus_target(&rt, fx_shader, "output"), + Some(scoped_at(ScopeId::Project(fx), "visual.out")), + "entry effect's visual stays in its own named scope" + ); + + // The playlist's blend path reads the entry child's produced + // `output` — for a project child that is the mirror. Resolving the + // playlist's own output proves the whole chain. + rt.tick(16).expect("tick"); + let playlist_output = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: playlist, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .expect("playlist output resolves through the effect mirror") + .0; + assert!(playlist_output.value_leaf().is_some()); + } + + #[test] + fn child_project_format_is_tolerated_and_ignored() { + // Effects-are-projects ADR: `format` is only probed at the root, so + // a vendored effect folder keeps its own `format` (staying + // standalone-openable) and loads unchanged when embedded. + let fs = nested_project_fs(false); + fs.write_file( + "/fx/project.json".as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "shader": { "ref": "./shader.json" } + } +} +"#, + ) + .expect("rewrite fx project with format"); + let services = EngineServices::new(TreePath::parse("/nested.show").expect("path")); + let rt = + ProjectLoader::load_from_root(&fs, services).expect("format on a child is ignored"); + let root = rt.tree().root(); + let fx = rt + .tree() + .lookup_sibling(root, NodeName::parse("fx").unwrap()) + .expect("fx project node"); + assert!( + rt.tree() + .lookup_sibling(fx, NodeName::parse("shader").unwrap()) + .is_some() + ); + } + + #[test] + fn promoted_control_targets_must_resolve_to_direct_children() { + let write_fx_controls = |controls: &str| { + let fs = nested_project_fs(false); + fs.write_file( + "/fx/project.json".as_path(), + alloc::format!( + r#"{{ + "kind": "Project", + "nodes": {{ + "shader": {{ "ref": "./shader.json" }} + }}, + "controls": {{ {controls} }} +}}"# + ) + .as_bytes(), + ) + .expect("rewrite fx project with controls"); + fs + }; + let services = || EngineServices::new(TreePath::parse("/nested.show").expect("path")); + + let load_err = |fs: &LpFsMemory| -> ProjectLoadError { + match ProjectLoader::load_from_root( + fs, + EngineServices::new(TreePath::parse("/nested.show").expect("path")), + ) { + Ok(_) => panic!("load unexpectedly succeeded"), + Err(err) => err, + } + }; + + // A direct-child alias loads. + let fs = write_fx_controls(r#""speed": { "target": "node:shader#speed" }"#); + ProjectLoader::load_from_root(&fs, services()).expect("direct-child alias loads"); + + // An unresolvable node fails loudly with the control's name. + let fs = write_fx_controls(r#""speed": { "target": "node:missing#speed" }"#); + let err = load_err(&fs); + assert!(err.to_string().contains("speed"), "{err}"); + assert!(err.to_string().contains("does not resolve"), "{err}"); + + // A bus target is not an alias. + let fs = write_fx_controls(r#""speed": { "target": "bus:time" }"#); + let err = load_err(&fs); + assert!( + err.to_string() + .contains("must be a node:# ref"), + "{err}" + ); + + // A deeper-than-direct-child target is rejected (host reaching into + // the effect's inner tree from the root project's controls). + let fs = nested_project_fs(false); + fs.write_file( + "/project.json".as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "clock": { "ref": "./clock.json" }, + "host": { "ref": "./host.json" }, + "fx": { "ref": "./fx/project.json" } + }, + "controls": { "inner": { "target": "node:fx/shader#speed" } } +} +"#, + ) + .expect("rewrite root with deep control"); + let err = load_err(&fs); + assert!(err.to_string().contains("direct child"), "{err}"); + } + + /// The shipped effect examples render through the full chain: host + /// clock → effect scope → inner shader(s) → project mirror → forwarded + /// texture. Nonzero pixels prove the GLSL compiles and the mirror's + /// render forwarding works (a compile failure would render black — the + /// swallowed-failure defect class). + #[test] + fn effect_examples_render_through_their_mirrors() { + for (dir, effect_name) in [("plasma", "plasma"), ("meteor", "meteor")] { + let fs = LpFsStd::new( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/effects") + .join(dir), + ); + let services = EngineServices::new(TreePath::parse("/effect.show").expect("path")); + let mut rt = ProjectLoader::load_from_root(&fs, services) + .unwrap_or_else(|e| panic!("load {dir}: {e}")); + rt.set_graphics(Some(Arc::new(lp_gfx_lpvm::TargetLpvmGraphics::new( + lp_shader::ShaderFrontend::LpsGlsl, + )))); + let root = rt.tree().root(); + let effect = rt + .tree() + .lookup_sibling(root, NodeName::parse(effect_name).unwrap()) + .expect("effect node"); + + // Two ticks so meteor's dt integration has a nonzero step. + rt.tick(16).expect("first tick"); + rt.tick(16).expect("second tick"); + + let (mirror, _) = rt + .resolve_with_engine_host( + QueryKey::ProducedSlot { + node: effect, + slot: SlotPath::parse("output").expect("output"), + }, + ResolveLogLevel::Off, + ) + .unwrap_or_else(|e| panic!("{dir}: resolve mirror: {e:?}")); + let LpValue::Product(ProductRef::Visual(product)) = + mirror.value_leaf().expect("mirror value").value() + else { + panic!("{dir}: mirror should carry a visual product"); + }; + let texture = rt + .render_texture_for_test( + *product, + &RenderTextureRequest { + width: 32, + height: 8, + format: TextureStorageFormat::Rgba16Unorm, + time_seconds: 0.5, + }, + ) + .unwrap_or_else(|e| panic!("{dir}: render effect mirror: {e:?}")); + assert!( + texture + .try_raw_bytes() + .expect("bytes") + .chunks_exact(8) + .any(|px| px[..6].iter().any(|byte| *byte != 0)), + "{dir}: the effect's forwarded visual should contain nonzero RGB" + ); + + // Nonzero pixels alone do not prove the nodes are healthy, so + // assert runtime status directly. NOTE this does NOT gate + // shader-lowering support: this test runs the HOST backend, + // which accepts constructs other targets reject (verified + // 2026-07-29 — a shader that failed on 4 of 5 targets still + // passed here, status included). Cross-target example coverage + // is tracked in docs/debt/example-shaders-not-compile-gated.md. + let bad: Vec = rt + .tree() + .entries() + .filter_map(|entry| match entry.status.value() { + NodeRuntimeStatus::Error(message) => { + Some(alloc::format!("{:?}: {message}", entry.path)) + } + _ => None, + }) + .collect(); + assert!( + bad.is_empty(), + "{dir}: nodes reported runtime errors after render: {bad:#?}" + ); + } + } + + /// Scope postconditions over the shipped examples: every consumed bus + /// endpoint either reads a channel some binding writes at exactly that + /// scope, or falls back to the root scope (rule 3's contract); flat + /// projects only ever see the root scope and playlist-entry anonymous + /// scopes. + #[test] + fn example_projects_resolve_bus_endpoints_to_written_or_root_scopes() { + let examples: [(&str, &dyn Fn() -> LpFsStd); 4] = [ + ("fyeah-sign", &examples_fyeah_sign_fs), + ("button-playlist", &examples_button_playlist_fs), + ("events", &examples_events_fs), + ("fluid", &examples_fluid_fs), + ]; + for (name, fs) in examples { + let fs = fs(); + let services = EngineServices::new(TreePath::parse("/check.show").expect("path")); + let rt = ProjectLoader::load_from_root(&fs, services) + .unwrap_or_else(|e| panic!("load {name}: {e}")); + let root_scope = ScopeId::Project(rt.tree().root()); + + let written: alloc::collections::BTreeSet = rt + .tree() + .bindings() + .filter_map(|binding| match &binding.target { + BindingTarget::BusChannel(channel) => Some(channel.clone()), + _ => None, + }) + .collect(); + + for binding in rt.tree().bindings() { + if let BindingSource::BusChannel(channel) = &binding.source { + assert!( + written.contains(channel) || channel.scope == root_scope, + "{name}: consumed endpoint {channel} reads a scope \ + nobody writes and is not the root fallback" + ); + } + } + + // Flat examples: every bus endpoint's scope is the root scope or + // a playlist-entry anonymous scope. (Nesting cannot occur in the + // shipped flat examples, so this pins single-root-scope parity.) + for binding in rt.tree().bindings() { + let check = |channel: &ScopedChannel| { + assert!( + channel.scope == root_scope || matches!(channel.scope, ScopeId::Entry(_)), + "{name}: unexpected scope on {channel}" + ); + }; + if let BindingSource::BusChannel(channel) = &binding.source { + check(channel); + } + if let BindingTarget::BusChannel(channel) = &binding.target { + check(channel); + } + } + } + } } diff --git a/lp-core/lpc-engine/src/engine/project_read_probes.rs b/lp-core/lpc-engine/src/engine/project_read_probes.rs index 12bbe03ba..b91ff139a 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -1,11 +1,12 @@ //! Project probe helpers. use alloc::format; +use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use lp_collection::VecMap; -use lpc_model::{ChannelName, Kind}; +use lpc_model::Kind; use lpc_registry::ProjectRegistry; use lpc_wire::{ BindingGraphProbeRequest, BindingGraphProbeResult, ControlProductProbeRequest, @@ -18,6 +19,7 @@ use lps_shared::TextureStorageFormat; use crate::dataflow::binding::{ BindingEntry, BindingPriority, BindingRef, BindingSource, BindingTarget, }; +use crate::dataflow::bus::{ScopeId, ScopedChannel}; use crate::products::control::{ControlRenderRequest, ControlRenderTarget}; use crate::products::visual::RenderTextureRequest; @@ -93,12 +95,20 @@ impl Engine { let mut wire_index: VecMap = VecMap::new(); for (binding_ref, entry) in self.tree().bindings_with_refs() { wire_index.insert(binding_ref, bindings.len() as u32); - bindings.push(wire_effective_binding(entry)); + bindings.push(wire_effective_binding(self, entry)); } - let channel_names: Vec<(ChannelName, Kind)> = self + // Anonymous ISOLATING scopes (playlist entries) are sinks by + // construction: the playlist reads each entry child's produced + // `output` slot directly, so nothing ever consumes these channels + // (scoped-buses ADR rule 2). Listing them is noise, and resolving + // their values would DEMAND every entry child's shader on each bus + // refresh — rendering inactive entries every frame, which is what + // the retired suppression rule used to prevent (2026-07-30). + let channel_names: Vec<(ScopedChannel, Kind)> = self .tree() .bus_channels() + .filter(|(name, _)| !matches!(name.scope, ScopeId::Entry(_))) .map(|(name, kind)| (name.clone(), kind)) .collect(); @@ -136,7 +146,7 @@ impl Engine { }); channels.push(WireBusChannel { - name: name.0.clone(), + name: scoped_channel_wire_name(self, &name), kind: Some(kind), providers, consumers, @@ -227,27 +237,58 @@ fn linear_unorm16_to_srgb8(value: u16) -> u8 { super::srgb8_lut::LINEAR16_TO_SRGB8[(value >> 4) as usize] } +/// Format a [`ScopedChannel`] for the wire. +/// +/// Root-scope channels keep the bare channel name (today's output exactly). +/// Channels in a nested scope render as `\u{203a}` where the +/// path is the scope node's tree name path relative to root, joined with +/// `/`. Unresolvable scope nodes fall back to the [`ScopedChannel`] Display +/// form. +fn scoped_channel_wire_name(engine: &Engine, scoped: &ScopedChannel) -> String { + use alloc::string::ToString; + + let root = engine.tree().root(); + if scoped.scope == ScopeId::Project(root) { + return scoped.channel.0.clone(); + } + let scope_node = match scoped.scope { + ScopeId::Project(node) | ScopeId::Entry(node) => node, + }; + let (Some(entry), Some(root_entry)) = (engine.tree().get(scope_node), engine.tree().get(root)) + else { + return scoped.to_string(); + }; + let relative: Vec<&str> = entry + .path + .0 + .iter() + .skip(root_entry.path.0.len()) + .map(|segment| segment.name.as_str()) + .collect(); + format!("{}\u{203a}{}", relative.join("/"), scoped.channel) +} + /// Project one runtime binding entry onto the wire. /// /// The anchor is the binding's local slot: the consumed target when one /// exists, otherwise the produced source feeding a bus channel. A binding /// with no local slot (literal or bus-to-bus bridge publishing to a /// channel) anchors to its owner with no slot path. -fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { +fn wire_effective_binding(engine: &Engine, entry: &BindingEntry) -> WireEffectiveBinding { let origin = wire_binding_origin(entry.priority); let (node, slot, direction, endpoint) = match (&entry.source, &entry.target) { (source, BindingTarget::ConsumedSlot { node, slot }) => ( *node, Some(slot.clone()), WireBindingDirection::Consumes, - wire_endpoint_from_source(source), + wire_endpoint_from_source(engine, source), ), (BindingSource::ProducedSlot { node, slot }, BindingTarget::BusChannel(channel)) => ( *node, Some(slot.clone()), WireBindingDirection::Publishes, WireBindingEndpoint::Bus { - channel: channel.0.clone(), + channel: scoped_channel_wire_name(engine, channel), }, ), ( @@ -258,7 +299,7 @@ fn wire_effective_binding(entry: &BindingEntry) -> WireEffectiveBinding { None, WireBindingDirection::Publishes, WireBindingEndpoint::Bus { - channel: channel.0.clone(), + channel: scoped_channel_wire_name(engine, channel), }, ), }; @@ -284,7 +325,7 @@ fn wire_binding_origin(priority: BindingPriority) -> WireBindingOrigin { } } -fn wire_endpoint_from_source(source: &BindingSource) -> WireBindingEndpoint { +fn wire_endpoint_from_source(engine: &Engine, source: &BindingSource) -> WireBindingEndpoint { match source { BindingSource::Literal(value) => WireBindingEndpoint::Literal { value: value.clone(), @@ -294,7 +335,7 @@ fn wire_endpoint_from_source(source: &BindingSource) -> WireBindingEndpoint { slot: slot.clone(), }, BindingSource::BusChannel(channel) => WireBindingEndpoint::Bus { - channel: channel.0.clone(), + channel: scoped_channel_wire_name(engine, channel), }, } } @@ -332,6 +373,55 @@ mod tests { } #[test] + /// A playlist's entry children each own an anonymous-scope `visual.out` + /// (scoped-buses rule 2). Those channels must stay OUT of the bus graph: + /// the probe resolves a value per listed channel, so listing them would + /// demand every entry child's shader on each bus refresh — inactive + /// entries rendering every frame. That is the behavior the retired + /// suppression rule used to prevent; regression found live on + /// examples/fyeah-sign, where the blast entry ran constantly (2026-07-30). + #[test] + fn binding_graph_probe_omits_isolating_entry_scope_channels() { + let fs = crate::engine::project_loader::tests::playlist_project_fs_for_probe(); + let services = crate::engine::EngineServices::new( + lpc_model::TreePath::parse("/playlist.show").expect("path"), + ); + let rt = + crate::engine::ProjectLoader::load_from_root(&fs, services).expect("load playlist"); + let (mut engine, registry) = rt.into_parts(); + + let BindingGraphProbeResult::Graph(graph) = engine.read_project_binding_graph_probe( + ®istry, + BindingGraphProbeRequest { + include_values: true, + }, + ) else { + panic!("expected graph result"); + }; + + // Entry-scope channels render scope-qualified (`idle›visual.out`); + // none may appear. + let scoped: Vec<&str> = graph + .channels + .iter() + .map(|channel| channel.name.as_str()) + .filter(|name| name.contains('\u{203a}')) + .collect(); + assert!( + scoped.is_empty(), + "isolating entry scopes must not reach the bus graph: {scoped:?}" + ); + // The root bus is unaffected. + assert!( + graph + .channels + .iter() + .any(|channel| channel.name == "visual.out"), + "root visual.out still listed: {:?}", + graph.channels.iter().map(|c| &c.name).collect::>() + ); + } + fn binding_graph_probe_reports_bindings_channels_and_values() { let mut h = EngineTestBuilder::new() .shader("writer", output("outputs[0]", 0.5)) diff --git a/lp-core/lpc-engine/src/engine/test_support.rs b/lp-core/lpc-engine/src/engine/test_support.rs index 5abe50921..bf358d08c 100644 --- a/lp-core/lpc-engine/src/engine/test_support.rs +++ b/lp-core/lpc-engine/src/engine/test_support.rs @@ -16,6 +16,7 @@ use lps_shared::LpsValueF32; use crate::dataflow::binding::{ BindingDraft, BindingError, BindingPriority, BindingSource, BindingTarget, }; +use crate::dataflow::bus::{ScopeId, ScopedChannel}; use crate::dataflow::resolver::{ Production, QueryKey, ResolveLogLevel, ResolveTrace, ResolveTraceEvent, SessionResolveError, }; @@ -111,11 +112,12 @@ impl EngineTestBuilder { source: TestBindingSource, priority: i32, ) -> Result { + let root_scope = ScopeId::Project(self.engine.tree().root()); let owner = source.owner(&self.labels); - let source = source.into_binding_source(&self.labels); + let source = source.into_binding_source(&self.labels, root_scope); self.register_binding( source, - BindingTarget::BusChannel(channel_name(channel)), + BindingTarget::BusChannel(ScopedChannel::new(root_scope, channel_name(channel))), priority, owner, )?; @@ -134,8 +136,9 @@ impl EngineTestBuilder { source: TestBindingSource, priority: i32, ) -> Result { + let root_scope = ScopeId::Project(self.engine.tree().root()); let node = self.node_id(label); - let source = source.into_binding_source(&self.labels); + let source = source.into_binding_source(&self.labels, root_scope); self.register_binding( source, BindingTarget::ConsumedSlot { @@ -252,7 +255,11 @@ impl EngineTestHarness { } pub(crate) fn resolve_bus(&mut self, channel: &str) -> Result { - self.resolve(QueryKey::Bus(channel_name(channel))) + let root_scope = ScopeId::Project(self.engine.tree().root()); + self.resolve(QueryKey::Bus(ScopedChannel::new( + root_scope, + channel_name(channel), + ))) } pub(crate) fn resolve(&mut self, query: QueryKey) -> Result { @@ -292,7 +299,11 @@ impl OutputSpec { } impl TestBindingSource { - fn into_binding_source(self, labels: &VecMap) -> BindingSource { + fn into_binding_source( + self, + labels: &VecMap, + root_scope: ScopeId, + ) -> BindingSource { match self { Self::Literal(value) => { BindingSource::Literal(lpc_model::LpValue::F32(f32_value(value))) @@ -301,7 +312,9 @@ impl TestBindingSource { node: *labels.get(&label).expect("produced slot label"), slot, }, - Self::Bus(channel) => BindingSource::BusChannel(channel), + Self::Bus(channel) => { + BindingSource::BusChannel(ScopedChannel::new(root_scope, channel)) + } } } @@ -363,7 +376,7 @@ pub(crate) fn trace_has_value_origin_path( shader: NodeId, output_path: &SlotPath, ) -> bool { - let bus_query = QueryKey::Bus(channel_name(bus_name)); + let bus_query = QueryKey::Bus(scoped_channel(bus_name)); let output_query = QueryKey::ProducedSlot { node: shader, slot: output_path.clone(), @@ -517,6 +530,12 @@ fn channel_name(name: &str) -> ChannelName { ChannelName(String::from(name)) } +/// A [`ScopedChannel`] in the test builders' root scope (the tree root is +/// always `NodeId(0)`). +pub(crate) fn scoped_channel(name: &str) -> ScopedChannel { + ScopedChannel::new(ScopeId::Project(NodeId::new(0)), channel_name(name)) +} + // ---- Project-read streaming test helpers ---- // // The aggregate `ProjectReadResponse`/`ProjectReadCollector` were deleted in diff --git a/lp-core/lpc-engine/src/node/contexts.rs b/lp-core/lpc-engine/src/node/contexts.rs index 5ba875085..2f9540df6 100644 --- a/lp-core/lpc-engine/src/node/contexts.rs +++ b/lp-core/lpc-engine/src/node/contexts.rs @@ -199,6 +199,15 @@ impl<'r> TickContext<'r> { self.resolver.resolve(query) } + /// Resolve a bus channel this frame; `None` when the channel has no + /// writer (the project-node mirror's absent case). + pub fn resolve_bus( + &mut self, + channel: crate::dataflow::bus::ScopedChannel, + ) -> Result, ResolveError> { + self.resolver.resolve_bus(channel) + } + pub fn publish_runtime_slot( &mut self, state_root: &dyn SlotAccess, @@ -675,6 +684,13 @@ mod tests { pub value: ValueSlot, } + fn scoped(name: &str) -> crate::dataflow::bus::ScopedChannel { + crate::dataflow::bus::ScopedChannel::new( + crate::dataflow::bus::ScopeId::Project(NodeId::new(0)), + ChannelName(String::from(name)), + ) + } + #[derive(Default)] struct TestBindings { entries: Vec<(BindingRef, BindingEntry)>, @@ -712,7 +728,7 @@ mod tests { fn providers_for_bus( &self, - channel: &lpc_model::ChannelName, + channel: &crate::dataflow::bus::ScopedChannel, ) -> Vec<(BindingRef, BindingEntry)> { self.entries .iter() @@ -750,7 +766,7 @@ mod tests { fn providers_for_bus( &self, - channel: &lpc_model::ChannelName, + channel: &crate::dataflow::bus::ScopedChannel, ) -> Vec<(BindingRef, BindingEntry)> { self.bindings.providers_for_bus(channel) } @@ -787,7 +803,7 @@ mod tests { fn tick_context_resolve_bus_query() { let mut bindings = TestBindings::default(); let frame = Revision::new(10); - let channel = lpc_model::ChannelName(String::from("level_bus")); + let channel = scoped("level_bus"); bindings.add( BindingDraft { source: BindingSource::Literal(lpc_model::LpValue::F32(7.8)), @@ -967,7 +983,7 @@ mod tests { fn dummy_node_can_resolve_bus_query_from_produce() { let mut bindings = TestBindings::default(); let frame = Revision::new(10); - let channel = lpc_model::ChannelName(String::from("in")); + let channel = scoped("in"); bindings.add( BindingDraft { source: BindingSource::Literal(lpc_model::LpValue::F32(8.8)), diff --git a/lp-core/lpc-engine/src/node/node_binding_index.rs b/lp-core/lpc-engine/src/node/node_binding_index.rs index 1c72e19f9..3ac3c0da9 100644 --- a/lp-core/lpc-engine/src/node/node_binding_index.rs +++ b/lp-core/lpc-engine/src/node/node_binding_index.rs @@ -3,20 +3,21 @@ use alloc::vec::Vec; use lp_collection::VecMap; -use lpc_model::{ChannelName, Kind, NodeId, SlotPath}; +use lpc_model::{Kind, NodeId, SlotPath}; use crate::dataflow::binding::{ BindingEntry, BindingError, BindingRef, BindingSource, BindingTarget, channels_touched, }; +use crate::dataflow::bus::ScopedChannel; use super::RuntimeNodeEntry; #[derive(Clone, Debug, Default)] pub(super) struct NodeBindingIndex { consumed_targets: VecMap<(NodeId, SlotPath), Vec>, - bus_targets: VecMap>, - bus_sources: VecMap>, - channel_kinds: VecMap, + bus_targets: VecMap>, + bus_sources: VecMap>, + channel_kinds: VecMap, } impl NodeBindingIndex { @@ -89,17 +90,17 @@ impl NodeBindingIndex { .map_or(&[], Vec::as_slice) } - pub(super) fn bus_targets(&self, channel: &ChannelName) -> &[BindingRef] { + pub(super) fn bus_targets(&self, channel: &ScopedChannel) -> &[BindingRef] { self.bus_targets.get(channel).map_or(&[], Vec::as_slice) } - pub(super) fn bus_sources(&self, channel: &ChannelName) -> &[BindingRef] { + pub(super) fn bus_sources(&self, channel: &ScopedChannel) -> &[BindingRef] { self.bus_sources.get(channel).map_or(&[], Vec::as_slice) } /// Every channel referenced by at least one binding, with its established /// kind. - pub(super) fn channels(&self) -> impl Iterator { + pub(super) fn channels(&self) -> impl Iterator { self.channel_kinds.iter().map(|(name, kind)| (name, *kind)) } } diff --git a/lp-core/lpc-engine/src/node/node_tree.rs b/lp-core/lpc-engine/src/node/node_tree.rs index 43b7e378a..4c348ec7a 100644 --- a/lp-core/lpc-engine/src/node/node_tree.rs +++ b/lp-core/lpc-engine/src/node/node_tree.rs @@ -4,12 +4,11 @@ use alloc::vec::Vec; use lp_collection::VecMap; -use lpc_model::{ - ChannelName, NodeId, NodeInvocation, NodeName, NodePathSegment, Revision, SlotPath, TreePath, -}; +use lpc_model::{NodeId, NodeInvocation, NodeName, NodePathSegment, Revision, SlotPath, TreePath}; use lpc_wire::WireChildKind; use crate::dataflow::binding::{BindingDraft, BindingEntry, BindingError, BindingRef}; +use crate::dataflow::bus::ScopedChannel; use crate::node::node_binding_index::{NodeBindingIndex, binding_by_ref}; use crate::node::{RuntimeNodeEntry, TreeError}; @@ -289,7 +288,7 @@ impl RuntimeNodeTree { /// Resolve all consumers of a bus channel (bindings whose source is the /// channel). - pub fn consumers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, &BindingEntry)> { + pub fn consumers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, &BindingEntry)> { self.binding_index .bus_sources(channel) .iter() @@ -302,7 +301,7 @@ impl RuntimeNodeTree { /// Every bus channel referenced by at least one binding, with its /// established kind. - pub fn bus_channels(&self) -> impl Iterator { + pub fn bus_channels(&self) -> impl Iterator { self.binding_index.channels() } @@ -356,7 +355,7 @@ impl RuntimeNodeTree { } /// Resolve all providers for a bus channel. - pub fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, &BindingEntry)> { + pub fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, &BindingEntry)> { self.binding_index .bus_targets(channel) .iter() @@ -392,6 +391,7 @@ impl RuntimeNodeTree { mod tests { use super::RuntimeNodeTree; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; + use crate::dataflow::bus::{ScopeId, ScopedChannel}; use crate::node::test_placeholder_spine; use alloc::string::String; use alloc::vec::Vec; @@ -399,6 +399,13 @@ mod tests { use lpc_model::{ChannelName, Kind, LpValue, NodeId, NodeName, Revision, SlotPath, TreePath}; use lpc_wire::{WireChildKind, WireSlotIndex}; + fn scoped(name: &str) -> ScopedChannel { + ScopedChannel::new( + ScopeId::Project(NodeId::new(0)), + ChannelName(String::from(name)), + ) + } + fn make_tree() -> RuntimeNodeTree<()> { RuntimeNodeTree::new(TreePath::parse("/root.show").unwrap(), Revision::new(0)) } @@ -509,7 +516,7 @@ mod tests { let mut tree = make_tree(); let shader = add_test_child(&mut tree, "shader"); let fixture = add_test_child(&mut tree, "fixture"); - let channel = ChannelName(String::from("visual")); + let channel = scoped("visual"); let out = SlotPath::parse("output").unwrap(); let input = SlotPath::parse("input").unwrap(); @@ -556,7 +563,7 @@ mod tests { let mut tree = make_tree(); let a = add_test_child(&mut tree, "a"); let b = add_test_child(&mut tree, "b"); - let channel = ChannelName(String::from("visual")); + let channel = scoped("visual"); let draft = |owner| BindingDraft { source: BindingSource::Literal(LpValue::F32(1.0)), diff --git a/lp-core/lpc-engine/src/nodes/mod.rs b/lp-core/lpc-engine/src/nodes/mod.rs index efda2ae05..be0af5c25 100644 --- a/lp-core/lpc-engine/src/nodes/mod.rs +++ b/lp-core/lpc-engine/src/nodes/mod.rs @@ -5,6 +5,7 @@ pub mod fluid; pub mod output; mod placeholder; pub mod playlist; +pub mod project; pub mod radio; pub mod shader; pub mod texture; @@ -16,6 +17,7 @@ pub use fluid::{FluidNode, MsaFluidSolver, fluid_emitters_path, fluid_output_pat pub use output::output_node::{OutputNode, output_input_path}; pub use placeholder::CorePlaceholderNode; pub use playlist::{PlaylistNode, PlaylistRuntimeEntry, playlist_output_path}; +pub use project::ProjectNode; pub use radio::{ControlRadioNode, control_radio_input_path, control_radio_output_path}; pub use shader::compute_shader_node::ComputeShaderNode; pub use shader::shader_node::{ShaderNode, shader_output_path}; diff --git a/lp-core/lpc-engine/src/nodes/project/mod.rs b/lp-core/lpc-engine/src/nodes/project/mod.rs new file mode 100644 index 000000000..ad1384597 --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/project/mod.rs @@ -0,0 +1,7 @@ +//! Project runtime node — the bus-scope output mirror (scoped-buses ADR). + +mod project_mirror_state; +mod project_node; + +pub use project_mirror_state::ProjectMirrorState; +pub use project_node::ProjectNode; diff --git a/lp-core/lpc-engine/src/nodes/project/project_mirror_state.rs b/lp-core/lpc-engine/src/nodes/project/project_mirror_state.rs new file mode 100644 index 000000000..d1762e423 --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/project/project_mirror_state.rs @@ -0,0 +1,23 @@ +//! Runtime state root for [`super::ProjectNode`]. + +use lpc_model::{Slotted, VisualProductSlot}; + +/// Runtime state exposed by a project node: the scope's `visual.out` +/// mirrored as a produced `output` (scoped-buses ADR, rule 5). The row +/// carries the project node's own product handle (playlist parity); render +/// dispatch forwards to the scope's actual producer, and a scope with no +/// visual writer renders cleared (not an error). +/// +/// Engine-side on purpose — the mirror is a runtime convention, not an +/// authored artifact surface, so it stays out of the model's static shape +/// catalog (and therefore out of `schemas/`). +#[derive(Default, Slotted)] +#[slot(default_policy = "read_only_transient")] +pub struct ProjectMirrorState { + /// This project node's own renderable handle. No `default_bind`: the + /// mirror reads its scope's channel directly and never writes a bus + /// channel of its own (that would make it a writer of the channel it + /// mirrors). + #[slot(produced)] + pub output: VisualProductSlot, +} diff --git a/lp-core/lpc-engine/src/nodes/project/project_node.rs b/lp-core/lpc-engine/src/nodes/project/project_node.rs new file mode 100644 index 000000000..98d5e36e9 --- /dev/null +++ b/lp-core/lpc-engine/src/nodes/project/project_node.rs @@ -0,0 +1,186 @@ +//! The project node's runtime: mirror the scope's `visual.out` as produced +//! `output` (scoped-buses ADR, rule 5). + +use alloc::format; +use alloc::string::String; + +use lp_gfx::TextureHandle; +use lpc_model::{ChannelName, FromLpValue, NodeId, SlotPath, VisualProduct, VisualProductSlot}; +use lps_shared::TextureStorageFormat; + +use crate::dataflow::bus::{ScopeId, ScopedChannel}; +use crate::node::{ + DestroyCtx, MemPressureCtx, NodeError, NodeRuntime, PressureLevel, ProduceResult, + RenderContext, RenderNode, RuntimeStateShape, TickContext, err_ctx, +}; +use crate::products::visual::{ + RenderTextureRequest, TextureRenderProduct, VisualSampleBufferRequest, VisualSampleTarget, +}; +use lpc_model::{SlotAccess, SlotShapeRegistry, SlotShapeRegistryError}; + +use super::ProjectMirrorState; + +/// Runtime node attached to every project node, root included (uniformity +/// is the point — the root's mirror is simply unread today). +/// +/// The playlist pattern, minus the blending: `produce` resolves the +/// project's own scope's `visual.out` channel and remembers the producing +/// node's [`VisualProduct`] handle, while the published `output` row always +/// carries the project node's *own* handle — render dispatch then forwards +/// to the remembered producer. A scope with no visual writer forwards +/// nothing and renders cleared (a project without a visual is a legitimate +/// shape, not an error). No bindings are registered for the mirror — it has +/// zero binding-graph footprint. +pub struct ProjectNode { + /// The scope this project node introduces, pinned to `visual.out`. + channel: ScopedChannel, + /// The scope's current visual producer, refreshed each `produce`. + mirrored: Option, + state: ProjectMirrorState, +} + +impl ProjectNode { + pub fn new(node_id: NodeId) -> Self { + Self { + channel: ScopedChannel::new( + ScopeId::Project(node_id), + ChannelName(String::from(lpc_model::PRIMARY_VISUAL_CHANNEL)), + ), + mirrored: None, + state: ProjectMirrorState { + output: VisualProductSlot::new(VisualProduct::new(node_id, 0)), + }, + } + } + + fn output_path() -> SlotPath { + SlotPath::parse("output").expect("project output path") + } +} + +impl NodeRuntime for ProjectNode { + fn produce( + &mut self, + _slot: &SlotPath, + ctx: &mut TickContext<'_>, + ) -> Result { + // `None` = the scope has no `visual.out` writer: nothing to + // forward; renders clear below. + self.mirrored = match ctx + .resolve_bus(self.channel.clone()) + .map_err(|e| NodeError::msg(format!("resolve scope visual channel: {}", e.message)))? + { + Some(production) => { + let value = production + .value_leaf() + .ok_or_else(|| NodeError::msg("scope visual channel is not a value"))?; + Some( + VisualProduct::from_lp_value(value.value()) + .map_err(|e| NodeError::msg(format!("scope visual channel: {e}")))?, + ) + } + None => None, + }; + self.state + .output + .set_with_version(ctx.revision(), *self.state.output.value()); + ctx.publish_runtime_slot(&self.state, Self::output_path())?; + Ok(ProduceResult::Produced) + } + + fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { + Ok(()) + } + + fn handle_memory_pressure( + &mut self, + _level: PressureLevel, + _ctx: &mut MemPressureCtx<'_>, + ) -> Result<(), NodeError> { + Ok(()) + } + + fn runtime_state_slots(&self) -> Option<&dyn SlotAccess> { + Some(&self.state) + } + + fn register_runtime_state_shapes( + &self, + registry: &mut SlotShapeRegistry, + ) -> Result<(), SlotShapeRegistryError> { + ProjectMirrorState::register_runtime_state_shape(registry).map(|_| ()) + } + + fn render_node(&mut self) -> Option<&mut dyn RenderNode> { + Some(self) + } +} + +impl RenderNode for ProjectNode { + fn render_texture( + &mut self, + product: VisualProduct, + request: &RenderTextureRequest, + ctx: &mut RenderContext<'_>, + ) -> Result { + if request.format != TextureStorageFormat::Rgba16Unorm { + return Err(NodeError::msg( + "project mirror texture render only supports RGBA16 unorm", + )); + } + let mut texture = { + let graphics = ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))?; + graphics + .create_render_target(request.width, request.height) + .map_err(err_ctx("project mirror texture"))? + }; + self.render_texture_into(product, request, &mut texture, ctx)?; + let graphics = ctx.graphics().expect("graphics checked above"); + if !graphics.supports_read_back() { + return TextureRenderProduct::gpu_resident(texture) + .map_err(err_ctx("project mirror gpu texture product")); + } + let bytes = graphics + .read_back(&texture) + .map_err(err_ctx("project mirror read back"))? + .into_bytes(); + TextureRenderProduct::rgba16_unorm(request.width, request.height, bytes) + .map_err(err_ctx("project mirror texture product")) + } + + fn render_texture_into( + &mut self, + _product: VisualProduct, + request: &RenderTextureRequest, + target: &mut TextureHandle, + ctx: &mut RenderContext<'_>, + ) -> Result<(), NodeError> { + let Some(mirrored) = self.mirrored else { + return ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))? + .clear_texture(target) + .map_err(err_ctx("project mirror clear target")); + }; + ctx.render_texture_into(mirrored, request, target) + } + + fn sample_visual_into( + &mut self, + _product: VisualProduct, + request: VisualSampleBufferRequest<'_>, + target: VisualSampleTarget<'_>, + ctx: &mut RenderContext<'_>, + ) -> Result<(), NodeError> { + let Some(mirrored) = self.mirrored else { + return ctx + .graphics() + .ok_or_else(|| NodeError::msg("missing graphics backend"))? + .clear_sample_out(target.samples) + .map_err(err_ctx("project mirror clear samples")); + }; + ctx.sample_visual_into(mirrored, request, target) + } +} diff --git a/lp-core/lpc-engine/tests/runtime_spine.rs b/lp-core/lpc-engine/tests/runtime_spine.rs index b722e7075..ea467d143 100644 --- a/lp-core/lpc-engine/tests/runtime_spine.rs +++ b/lp-core/lpc-engine/tests/runtime_spine.rs @@ -8,6 +8,7 @@ use alloc::vec::Vec; use lpc_engine::dataflow::binding::{ BindingEntry, BindingPriority, BindingRef, BindingSource, BindingTarget, }; +use lpc_engine::dataflow::bus::{ScopeId, ScopedChannel}; use lpc_engine::dataflow::resolver::{ Production, QueryKey, ResolveHost, ResolveLogLevel, ResolveSession, ResolveTrace, Resolver, SessionHostResolver, SessionResolveError, TickResolver, @@ -26,9 +27,16 @@ use lps_shared::LpsValueF32; // --- Tests (concise scenarios; helpers below) --- +fn scoped(name: &str) -> ScopedChannel { + ScopedChannel::new( + ScopeId::Project(NodeId::new(0)), + ChannelName(String::from(name)), + ) +} + #[test] fn runtime_spine_tick_context_resolve_bus_query() { - let channel = ChannelName(String::from("live")); + let channel = scoped("live"); let frame = Revision::new(99); let binding = BindingEntry { source: BindingSource::Literal(LpValue::F32(2.0)), @@ -47,7 +55,7 @@ fn runtime_spine_tick_context_resolve_bus_query() { ); struct NoProduceHost { - channel: ChannelName, + channel: ScopedChannel, binding: BindingEntry, } @@ -60,7 +68,7 @@ fn runtime_spine_tick_context_resolve_bus_query() { Err(SessionResolveError::other("unexpected produce")) } - fn providers_for_bus(&self, channel: &ChannelName) -> Vec<(BindingRef, BindingEntry)> { + fn providers_for_bus(&self, channel: &ScopedChannel) -> Vec<(BindingRef, BindingEntry)> { if channel == &self.channel { Vec::from([(BindingRef::new(self.binding.owner, 0), self.binding.clone())]) } else { @@ -587,7 +595,7 @@ fn published_channels(engine: &lpc_engine::Engine) -> Vec<(String, bool)> { .bindings() .filter_map(|binding| match &binding.target { BindingTarget::BusChannel(channel) => Some(( - channel.0.clone(), + channel.channel.0.clone(), binding.priority == BindingPriority::authored(), )), _ => None, diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index f3e930483..1527f4d0a 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -118,9 +118,9 @@ pub use nodes::{ ShaderDef, ShaderDefView, ShaderHeaderGenError, ShaderMapKeyDef, ShaderParamDef, ShaderParamDefView, ShaderSlotDef, ShaderSlotKind, ShaderSlotMappingDef, ShaderSlotMappingKind, ShaderState, ShaderStateView, ShaderValueShapeRef, TextureDef, TextureDefView, TextureFormat, - TextureState, TextureStateView, generate_compute_shader_header, glsl_type_for_lp_type, - read_project_format_json, resolve_artifact_specifier, starter_def_for_kind, starter_for_kind, - starter_project_files, + TextureState, TextureStateView, effect_starter_files, generate_compute_shader_header, + glsl_type_for_lp_type, read_project_format_json, resolve_artifact_specifier, + starter_def_for_kind, starter_for_kind, starter_project_files, }; pub use product::{ ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlPathSpan2d, diff --git a/lp-core/lpc-model/src/nodes/mod.rs b/lp-core/lpc-model/src/nodes/mod.rs index 973e05a0c..a66d16991 100644 --- a/lp-core/lpc-model/src/nodes/mod.rs +++ b/lp-core/lpc-model/src/nodes/mod.rs @@ -43,5 +43,5 @@ pub use starter::{ NodeStarter, STARTER_SHADER_GLSL, STARTER_STEM_PLACEHOLDER, starter_def_for_kind, starter_for_kind, }; -pub use starter_project::starter_project_files; +pub use starter_project::{effect_starter_files, starter_project_files}; pub use texture::{TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView}; diff --git a/lp-core/lpc-model/src/nodes/project/mod.rs b/lp-core/lpc-model/src/nodes/project/mod.rs index a50900716..5e7aff9c8 100644 --- a/lp-core/lpc-model/src/nodes/project/mod.rs +++ b/lp-core/lpc-model/src/nodes/project/mod.rs @@ -1,4 +1,6 @@ pub mod project_def; +pub mod promoted_control_def; pub use crate::slot_views::ProjectDefView; pub use project_def::{PROJECT_FORMAT_VERSION, ProjectDef}; +pub use promoted_control_def::PromotedControlDef; diff --git a/lp-core/lpc-model/src/nodes/project/project_def.rs b/lp-core/lpc-model/src/nodes/project/project_def.rs index 2f2efb7ff..432763b16 100644 --- a/lp-core/lpc-model/src/nodes/project/project_def.rs +++ b/lp-core/lpc-model/src/nodes/project/project_def.rs @@ -2,6 +2,8 @@ use alloc::string::String; use crate::{MapSlot, NodeInvocationSlot, OptionSlot, Slotted, ValueSlot}; +use super::PromotedControlDef; + /// Monotonic format version of authored `project.json` artifacts. /// /// The project root carries this as its top-level `format` key; child node @@ -36,6 +38,17 @@ pub struct ProjectDef { /// slot edits under this map. #[slot(policy = "read_only_persisted")] pub nodes: MapSlot, + /// Promoted controls — the project's curated public knobs, keyed by a + /// stable user-facing name (effects-are-projects ADR). Each entry + /// aliases a slot on a direct child; values live on the target slot. + pub controls: MapSlot, + /// Provenance: effect author attribution (plain string, optional). + pub author: OptionSlot>, + /// Provenance: authored version string (no semver semantics yet). + pub version: OptionSlot>, + /// Provenance: license identifier (e.g. `"CC0-1.0"`). Repo samples are + /// CC0 unless otherwise noted. + pub license: OptionSlot>, } impl ProjectDef { @@ -164,6 +177,69 @@ mod tests { assert_eq!(policy("name"), SlotPolicy::writable_persisted()); } + #[test] + fn project_def_without_new_fields_writes_byte_identically() { + // Effects-are-projects ADR: `controls`/`author`/`version`/`license` + // are additive and serialize skip-if-default — existing artifacts + // must stay byte-identical. + let json = "{\n \"kind\": \"Project\",\n \"format\": 1,\n \"name\": \"basic\",\n \"nodes\": {\n \"shader\": {\n \"ref\": \"./shader.json\"\n }\n }\n}\n"; + let def = NodeDef::read_json(®istry(), json).unwrap(); + let rewritten = def.write_json(®istry()).unwrap(); + assert_eq!(rewritten, json); + assert!(!rewritten.contains("controls"), "{rewritten}"); + assert!(!rewritten.contains("author"), "{rewritten}"); + assert!(!rewritten.contains("version"), "{rewritten}"); + assert!(!rewritten.contains("license"), "{rewritten}"); + } + + #[test] + fn project_def_controls_and_provenance_round_trip_byte_stably() { + use crate::nodes::project::PromotedControlDef; + use crate::{BindingRef, MapSlot, OptionSlot, ValueSlot}; + use lp_collection::VecMap; + + let mut controls = VecMap::new(); + let mut speed = + PromotedControlDef::to_target(BindingRef::parse("node:./shader#speed").unwrap()); + speed.label = OptionSlot::some(ValueSlot::new("Speed".to_string())); + speed.min = OptionSlot::some(ValueSlot::new(0.0)); + speed.max = OptionSlot::some(ValueSlot::new(4.0)); + controls.insert("speed".to_string(), speed); + + let def = crate::ProjectDef { + format: crate::ProjectDef::current_format_slot(), + name: OptionSlot::some(ValueSlot::new("glow".to_string())), + controls: MapSlot::new(controls), + author: OptionSlot::some(ValueSlot::new("photomancer".to_string())), + version: OptionSlot::some(ValueSlot::new("1".to_string())), + license: OptionSlot::some(ValueSlot::new("CC0-1.0".to_string())), + ..crate::ProjectDef::default() + }; + let first = NodeDef::Project(def).write_json(®istry()).unwrap(); + assert!(first.contains("\"controls\""), "{first}"); + assert!( + first.contains("\"target\": \"node:shader#speed\""), + "{first}" + ); + assert!(first.contains("\"license\": \"CC0-1.0\""), "{first}"); + + let read = NodeDef::read_json(®istry(), &first).unwrap(); + let NodeDef::Project(project) = &read else { + panic!("expected project"); + }; + let control = project.controls.entries.get("speed").expect("speed"); + assert_eq!( + control.target.value(), + &crate::BindingRef::parse("node:./shader#speed").unwrap() + ); + assert_eq!( + control.min.data.as_ref().map(|slot| *slot.value()), + Some(0.0) + ); + let second = read.write_json(®istry()).unwrap(); + assert_eq!(first, second); + } + fn registry() -> SlotShapeRegistry { SlotShapeRegistry::default() } diff --git a/lp-core/lpc-model/src/nodes/project/promoted_control_def.rs b/lp-core/lpc-model/src/nodes/project/promoted_control_def.rs new file mode 100644 index 000000000..f2587e65e --- /dev/null +++ b/lp-core/lpc-model/src/nodes/project/promoted_control_def.rs @@ -0,0 +1,53 @@ +//! Promoted controls: a project's curated public knobs +//! (effects-are-projects ADR, `docs/adr/2026-07-28-effects-are-projects.md`). + +use alloc::string::String; + +use crate::{BindingRef, OptionSlot, Slotted, ValueSlot}; + +/// One promoted control on a project node: an **alias** to a slot on a +/// direct child, plus optional display overrides. +/// +/// An alias carries **no value** — values live on the target slot, so +/// overlay dirty state, transient edits, and binding state all observe the +/// one real slot. (This deliberately differs from +/// [`crate::nodes::shader::ShaderSlotDef`], which owns defaults; an alias +/// with its own default would be a second source of truth.) +#[derive(Clone, Debug, PartialEq, Slotted)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct PromotedControlDef { + /// Alias target: a node-slot ref relative to this project node, e.g. + /// `node:./sim#speed`. Must be the node form ([`BindingRef::Node`]) + /// and resolve to a **direct child** of the project node — the loader + /// rejects bus/unset forms and deeper or unresolvable targets with a + /// path-qualified error. + pub target: ValueSlot, + /// Display label override; the target slot's own label applies when + /// absent. + pub label: OptionSlot>, + /// Display unit override (e.g. `"Hz"`). + pub unit: OptionSlot>, + /// Control-range override for the rendered widget. + pub min: OptionSlot>, + /// Control-range override for the rendered widget. + pub max: OptionSlot>, +} + +impl Default for PromotedControlDef { + fn default() -> Self { + Self::to_target(BindingRef::Unset) + } +} + +impl PromotedControlDef { + /// A bare alias to `target` with no display overrides. + pub fn to_target(target: BindingRef) -> Self { + Self { + target: ValueSlot::new(target), + label: OptionSlot::none(), + unit: OptionSlot::none(), + min: OptionSlot::none(), + max: OptionSlot::none(), + } + } +} diff --git a/lp-core/lpc-model/src/nodes/starter_project.rs b/lp-core/lpc-model/src/nodes/starter_project.rs index e791c011d..ab0153684 100644 --- a/lp-core/lpc-model/src/nodes/starter_project.rs +++ b/lp-core/lpc-model/src/nodes/starter_project.rs @@ -91,6 +91,81 @@ pub fn starter_project_files( Ok(files) } +/// A minimal effect folder as `(relative path, bytes)` pairs — folder- +/// relative, ready to rebase under `effects//` in a host project or +/// to open standalone (effects-are-projects ADR). One working promoted +/// control (`speed`) out of the box; consumed `time` inherits the host +/// clock through the scoped bus. +pub fn effect_starter_files( + name: &str, + registry: &SlotShapeRegistry, +) -> Result)>, NodeDefWriteError> { + use crate::AssetSlot; + use crate::nodes::project::PromotedControlDef; + use crate::nodes::shader::{ShaderDef, ShaderSlotDef}; + use crate::nodes::starter::{starter_glsl_opts, starter_time_consumed_slots}; + + let mut files = Vec::new(); + + let mut nodes = VecMap::new(); + nodes.insert( + String::from("shader"), + NodeInvocationSlot::new(NodeInvocation::path(ArtifactSpec::path("./shader.json"))), + ); + let mut controls = VecMap::new(); + controls.insert( + String::from("speed"), + PromotedControlDef::to_target( + BindingRef::parse("node:shader#speed").expect("starter control target"), + ), + ); + let project = ProjectDef { + format: ProjectDef::current_format_slot(), + name: OptionSlot::some(ValueSlot::new(String::from(name))), + nodes: MapSlot::new(nodes), + controls: MapSlot::new(controls), + ..ProjectDef::default() + }; + files.push(( + String::from("project.json"), + node_json(&NodeDef::Project(project), registry)?, + )); + + let mut consumed = starter_time_consumed_slots(); + let mut speed = ShaderSlotDef::value_f32("Speed", "Pulse rate multiplier", 1.0, Some(0.0)); + speed.max = OptionSlot::some(ValueSlot::new(4.0)); + speed.panel = OptionSlot::some(ValueSlot::new(true)); + consumed.entries.insert(String::from("speed"), speed); + let shader = ShaderDef { + source: AssetSlot::path("./main.glsl"), + glsl_opts: starter_glsl_opts(), + consumed_slots: consumed, + ..ShaderDef::default() + }; + files.push(( + String::from("shader.json"), + node_json(&NodeDef::Shader(shader), registry)?, + )); + + files.push(( + String::from("main.glsl"), + EFFECT_STARTER_GLSL.as_bytes().to_vec(), + )); + + Ok(files) +} + +/// The effect starter's scaffold body: the red-pulse starter driven by the +/// promoted `speed` control. +const EFFECT_STARTER_GLSL: &str = "layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; +layout(binding = 2) uniform float speed; + +vec4 render(vec2 pos) { + return vec4(mod(time * speed, 1.0), 0.0, 0.0, 1.0); +} +"; + fn starter_project_def(name: &str) -> NodeDef { let mut nodes = VecMap::new(); for node in ["output", "clock", "texture", "shader", "fixture"] { @@ -103,9 +178,9 @@ fn starter_project_def(name: &str) -> NodeDef { } NodeDef::Project(ProjectDef { format: ProjectDef::current_format_slot(), - uid: OptionSlot::none(), name: OptionSlot::some(ValueSlot::new(String::from(name))), nodes: MapSlot::new(nodes), + ..ProjectDef::default() }) } @@ -214,6 +289,61 @@ mod tests { use crate::{MappingConfig, PROJECT_FORMAT_VERSION}; use alloc::string::ToString; + #[test] + fn effect_starter_files_compose_a_working_folder() { + let registry = SlotShapeRegistry::default(); + let files = effect_starter_files("glow", ®istry).expect("compose"); + let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["project.json", "shader.json", "main.glsl"]); + + for (name, bytes) in &files { + if !name.ends_with(".json") { + continue; + } + let text = core::str::from_utf8(bytes).expect("utf-8"); + let def = NodeDef::read_json(®istry, text).expect("artifact parses"); + let rewritten = def.write_json(®istry).expect("re-write"); + assert_eq!( + rewritten, text, + "{name} must be canonical write_json output" + ); + } + + let (_, project) = files + .iter() + .find(|(name, _)| name == "project.json") + .unwrap(); + let project = + NodeDef::read_json(®istry, core::str::from_utf8(project).unwrap()).unwrap(); + let project = project.as_project().expect("project def"); + assert_eq!(project.format(), Some(PROJECT_FORMAT_VERSION)); + let control = project + .controls + .entries + .get("speed") + .expect("speed control"); + assert_eq!(control.target.value().to_string(), "node:shader#speed"); + + let (_, shader) = files + .iter() + .find(|(name, _)| name == "shader.json") + .unwrap(); + let shader = NodeDef::read_json(®istry, core::str::from_utf8(shader).unwrap()).unwrap(); + let NodeDef::Shader(shader) = shader else { + panic!("expected shader"); + }; + let speed = shader + .consumed_slots + .entries + .get("speed") + .expect("speed slot"); + assert_eq!( + speed.panel.data.as_ref().map(|slot| *slot.value()), + Some(true) + ); + assert!(shader.consumed_slots.entries.get("time").is_some()); + } + #[test] fn starter_project_files_are_complete_and_parse() { let registry = SlotShapeRegistry::default(); diff --git a/lp-core/lpc-registry/src/registry/node_authoring.rs b/lp-core/lpc-registry/src/registry/node_authoring.rs index 921155ab7..7db3c60a6 100644 --- a/lp-core/lpc-registry/src/registry/node_authoring.rs +++ b/lp-core/lpc-registry/src/registry/node_authoring.rs @@ -125,26 +125,23 @@ impl ProjectRegistry { .unwrap_or_else(|| LpPath::new("/")) .to_path_buf(); - // Body parses as a node definition; `Project` cannot be created as a - // child node (nested sub-projects are future work). + // Body parses as a node definition. A `Project` def IS creatable as + // a child node (effects-are-projects ADR: an effect is a project + // embedded as a node); the path-safety and occupancy checks below + // still apply, and the occupancy check keeps the project root path + // itself uncreatable. let text = core::str::from_utf8(body).map_err(|_| { reject( MutationRejectionReason::InvalidBody, "node body is not valid UTF-8".into(), ) })?; - let def = NodeDef::read_json(ctx.shapes, text).map_err(|err| { + NodeDef::read_json(ctx.shapes, text).map_err(|err| { reject( MutationRejectionReason::InvalidBody, format!("node body does not parse as a node definition: {err}"), ) })?; - if matches!(def, NodeDef::Project(_)) { - return Err(reject( - MutationRejectionReason::InvalidBody, - "kind Project cannot be created as a child node".into(), - )); - } // Def and asset paths: project-relative, path-safe, unoccupied on // disk and in the effective inventory, and unique within the request. @@ -1075,7 +1072,7 @@ mod tests { } #[test] - fn create_rejects_unparsable_and_project_kind_bodies() { + fn create_rejects_unparsable_bodies_and_accepts_project_kind() { let shapes = SlotShapeRegistry::default(); let (fs, mut registry) = clock_project(&shapes); @@ -1092,8 +1089,11 @@ mod tests { assert_eq!(rejection.reason, MutationRejectionReason::InvalidBody); assert_nothing_written(&fs, "/new.json"); + // The former "kind Project cannot be created as a child node" guard + // is lifted (effects-are-projects ADR): a bare child project is a + // valid create. let project_body = br#"{ "kind": "Project", "format": 1, "nodes": {} }"#; - let rejection = create( + create( &fs, &mut registry, &shapes, @@ -1102,9 +1102,7 @@ mod tests { &[], &project_nodes("new"), ) - .expect_err("Project kind body rejects"); - assert_eq!(rejection.reason, MutationRejectionReason::InvalidBody); - assert_nothing_written(&fs, "/new.json"); + .expect("Project kind body is creatable as a child node"); } #[test] @@ -1575,6 +1573,151 @@ mod tests { // --- Helpers --- + /// Canonical bytes for a minimal effect folder: a child project def + /// with one promoted control plus its shader def and GLSL source. + fn effect_folder_bodies(shapes: &SlotShapeRegistry) -> (String, String, Vec) { + use lpc_model::{BindingRef, MapSlot, OptionSlot, ProjectDef, ValueSlot}; + let mut controls = lp_collection::VecMap::new(); + controls.insert( + alloc::string::String::from("speed"), + lpc_model::nodes::project::PromotedControlDef::to_target( + BindingRef::parse("node:shader#speed").unwrap(), + ), + ); + let mut nodes = lp_collection::VecMap::new(); + nodes.insert( + alloc::string::String::from("shader"), + lpc_model::NodeInvocationSlot::new(lpc_model::NodeInvocation::path( + lpc_model::ArtifactSpec::path("./shader.json"), + )), + ); + let project = NodeDef::Project(ProjectDef { + format: ProjectDef::current_format_slot(), + name: OptionSlot::some(ValueSlot::new(alloc::string::String::from("glow"))), + nodes: MapSlot::new(nodes), + controls: MapSlot::new(controls), + ..ProjectDef::default() + }) + .write_json(shapes) + .expect("effect project body"); + let shader = NodeDef::from_json_str( + r#"{ "kind": "Shader", "source": { "path": "./main.glsl" }, + "consumed": { "speed": { "kind": "value", "value": "f32", "default": 1.0, "panel": true } } }"#, + ) + .expect("shader def") + .write_json(shapes) + .expect("canonical shader body"); + let glsl = + b"vec4 render(vec2 pos) { return vec4(mod(speed, 1.0), 0.0, 0.0, 1.0); }".to_vec(); + (project, shader, glsl) + } + + #[test] + fn create_project_child_effect_folder_at_project_nodes() { + let shapes = SlotShapeRegistry::default(); + let (fs, mut registry) = clock_project(&shapes); + let (project_body, shader_body, glsl) = effect_folder_bodies(&shapes); + + let outcome = create( + &fs, + &mut registry, + &shapes, + "./effects/glow/project.json", + project_body.as_bytes(), + &[ + ( + LpPathBuf::from("./effects/glow/shader.json"), + shader_body.into_bytes(), + ), + (LpPathBuf::from("./effects/glow/main.glsl"), glsl), + ], + &project_nodes("glow"), + ) + .expect("a Project def is creatable as a child node (guard lifted)"); + + // The effect's own def loads and its inner shader is discovered as a + // node def through the folder-relative ref. + let effect_def = root_def("/effects/glow/project.json"); + assert!(matches!( + registry.def(&effect_def).expect("effect def").state, + NodeDefState::Loaded(NodeDef::Project(_)) + )); + let shader_def = root_def("/effects/glow/shader.json"); + assert!(matches!( + registry.def(&shader_def).expect("inner shader def").state, + NodeDefState::Loaded(NodeDef::Shader(_)) + )); + assert!(outcome.changes.defs.added.contains(&effect_def)); + let glow_use = NodeUseLocation::root().child(SlotPath::parse("nodes[glow]").unwrap()); + assert!(outcome.changes.uses.added.contains(&glow_use)); + let shader_use = glow_use.child(SlotPath::parse("nodes[shader]").unwrap()); + assert!(outcome.changes.uses.added.contains(&shader_use)); + } + + #[test] + fn create_project_child_effect_folder_at_playlist_entry() { + let shapes = SlotShapeRegistry::default(); + let (fs, mut registry) = playlist_project(&shapes); + let (project_body, shader_body, glsl) = effect_folder_bodies(&shapes); + let attach = NodeAttachSite::Slot { + artifact: ArtifactLocation::file("/playlist.json"), + path: SlotPath::parse("entries[2].node").unwrap(), + }; + + let outcome = create( + &fs, + &mut registry, + &shapes, + "./effects/glow/project.json", + project_body.as_bytes(), + &[ + ( + LpPathBuf::from("./effects/glow/shader.json"), + shader_body.into_bytes(), + ), + (LpPathBuf::from("./effects/glow/main.glsl"), glsl), + ], + &attach, + ) + .expect("effect folder attaches at a playlist entry"); + + let entry_use = NodeUseLocation::root() + .child(SlotPath::parse("nodes[playlist]").unwrap()) + .child(SlotPath::parse("entries[2].node").unwrap()); + assert!(outcome.changes.uses.added.contains(&entry_use)); + assert!(matches!( + registry + .def(&root_def("/effects/glow/project.json")) + .expect("effect def") + .state, + NodeDefState::Loaded(NodeDef::Project(_)) + )); + } + + #[test] + fn create_project_def_at_root_path_stays_rejected() { + let shapes = SlotShapeRegistry::default(); + let (fs, mut registry) = clock_project(&shapes); + let (project_body, _, _) = effect_folder_bodies(&shapes); + + let rejection = create( + &fs, + &mut registry, + &shapes, + "./project.json", + project_body.as_bytes(), + &[], + &project_nodes("self"), + ) + .expect_err("the project root path is occupied"); + assert!( + rejection.message.contains("project.json") + || rejection.reason == MutationRejectionReason::InvalidPath + || rejection.reason == MutationRejectionReason::TargetOccupied, + "{rejection:?}" + ); + } + fn clock_project(shapes: &SlotShapeRegistry) -> (LpFsMemory, ProjectRegistry) { let mut fs = LpFsMemory::new(); crate::test::fixtures::write_file( diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index 22ce7550d..5b070006d 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -211,9 +211,9 @@ impl ProjectBuilder { } let project = ProjectDef { format: ProjectDef::current_format_slot(), - uid: OptionSlot::none(), name: OptionSlot::some(ValueSlot::new(self.name.clone())), nodes: MapSlot::new(nodes), + ..ProjectDef::default() }; let project_json = authored_node_json(®istry, &NodeDef::Project(project)); self.write_file_helper("/project.json", project_json.as_bytes()) diff --git a/lp-shader/lps-filetests/filetests/uniform/array-of-struct.glsl b/lp-shader/lps-filetests/filetests/uniform/array-of-struct.glsl new file mode 100644 index 000000000..0a6bea1e7 --- /dev/null +++ b/lp-shader/lps-filetests/filetests/uniform/array-of-struct.glsl @@ -0,0 +1,51 @@ +// test run + +// Uniform ARRAY OF STRUCTS. Distinct from `uniform/array.glsl` (uniform +// arrays of scalars/vectors, dynamically indexable) and from +// `array/of-struct/*` (function-local struct arrays): a uniform struct +// array's element address must be resolvable at lower time, so members are +// read through CONSTANT indices — the idiom `examples/events/shader.glsl` +// and `examples/effects/meteor` both carry. +// +// Regression origin (2026-07-29): the meteor effect shipped a helper that +// indexed this array with a runtime parameter. It compiled on every +// Naga-frontend target and failed only on `Frontend::Lp` lowering +// ("AccessIndex: struct value behind Load: Access base has no uniform +// element addr"), so both the engine render test and CI passed while the +// browser sim failed. + +struct Particle { + uint id; + vec2 pos; + vec3 color; + float intensity; +}; + +layout(binding = 0) uniform Particle particles[4]; + +// Members pass into the helper as scalars/vectors; the helper never +// indexes the uniform array itself. +vec3 accumulate(vec3 accum, uint id, vec2 pos, vec3 color, float intensity) { + if (id == 0u) { + return accum; + } + return accum + color * intensity + vec3(pos.x, pos.y, 0.0); +} + +vec3 test_uniform_aos_constant_index_through_helper() { + vec3 accum = vec3(0.0, 0.0, 0.0); + accum = accumulate(accum, particles[0].id, particles[0].pos, particles[0].color, particles[0].intensity); + accum = accumulate(accum, particles[1].id, particles[1].pos, particles[1].color, particles[1].intensity); + accum = accumulate(accum, particles[2].id, particles[2].pos, particles[2].color, particles[2].intensity); + accum = accumulate(accum, particles[3].id, particles[3].pos, particles[3].color, particles[3].intensity); + return accum; +} + +// run: test_uniform_aos_constant_index_through_helper() ~= vec3(0.0, 0.0, 0.0) + +// Direct constant-index member reads at the top level. +float test_uniform_aos_direct_member_read() { + return particles[0].intensity + particles[3].pos.x + float(particles[2].id); +} + +// run: test_uniform_aos_direct_member_read() ~= 0.0 diff --git a/schemas/README.md b/schemas/README.md index b8936bad9..923c6926a 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -48,6 +48,14 @@ Two more guards keep the schemas honest: appears in an RV32 firmware graph — schema generation is host-only tooling behind the non-default `schema-gen` features. +## Additive fields + +Optional fields that serialize skip-if-default are **additive** — they grow +the schemas without a format bump, and existing artifacts stay +byte-identical (precedent: `ProjectDef`'s `controls`/`author`/`version`/ +`license`, effects-are-projects ADR). A bump is only for changes that break +reading existing artifacts. + ## Format version and the bump procedure `project.json` carries `"format": N` (`PROJECT_FORMAT_VERSION` in diff --git a/schemas/node.schema.json b/schemas/node.schema.json index c04333a90..c459770ee 100644 --- a/schemas/node.schema.json +++ b/schemas/node.schema.json @@ -189,6 +189,27 @@ }, "type": "object" }, + "lpc_model::nodes::project::promoted_control_def::PromotedControlDef": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "target": { + "type": "string" + }, + "unit": { + "type": "string" + } + }, + "type": "object" + }, "lpc_model::nodes::shader::glsl_opts::GlslOpts": { "additionalProperties": false, "properties": { @@ -307,6 +328,15 @@ { "additionalProperties": false, "properties": { + "author": { + "type": "string" + }, + "controls": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::project::promoted_control_def::PromotedControlDef" + }, + "type": "object" + }, "format": { "maximum": 4294967295, "minimum": 0, @@ -315,6 +345,9 @@ "kind": { "const": "Project" }, + "license": { + "type": "string" + }, "name": { "type": "string" }, @@ -352,6 +385,9 @@ }, "uid": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ diff --git a/schemas/project.schema.json b/schemas/project.schema.json index ed289f6d1..22b6f0d03 100644 --- a/schemas/project.schema.json +++ b/schemas/project.schema.json @@ -1,14 +1,49 @@ { + "$defs": { + "lpc_model::nodes::project::promoted_control_def::PromotedControlDef": { + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + }, + "target": { + "type": "string" + }, + "unit": { + "type": "string" + } + }, + "type": "object" + } + }, "$id": "https://lightplayer.dev/schemas/project.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { + "author": { + "type": "string" + }, + "controls": { + "additionalProperties": { + "$ref": "#/$defs/lpc_model::nodes::project::promoted_control_def::PromotedControlDef" + }, + "type": "object" + }, "format": { "const": 1 }, "kind": { "const": "Project" }, + "license": { + "type": "string" + }, "name": { "type": "string" }, @@ -46,6 +81,9 @@ }, "uid": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ diff --git a/schemas/shapes/_index.json b/schemas/shapes/_index.json index 4731e118e..9a9ce5cb1 100644 --- a/schemas/shapes/_index.json +++ b/schemas/shapes/_index.json @@ -24,6 +24,7 @@ "lpc_model::nodes::playlist::playlist_entry::PlaylistEntry": 899201012, "lpc_model::nodes::playlist::playlist_state::PlaylistState": 237636858, "lpc_model::nodes::project::project_def::ProjectDef": 2328756067, + "lpc_model::nodes::project::promoted_control_def::PromotedControlDef": 2602397900, "lpc_model::nodes::radio::control_radio_def::ControlRadioDef": 4099574392, "lpc_model::nodes::radio::control_radio_def::ControlRadioState": 1057824914, "lpc_model::nodes::shader::compute_shader_def::ComputeShaderDef": 3622644300, diff --git a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json index c0213c3ad..f76d7cfb1 100644 --- a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json +++ b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json @@ -98,6 +98,74 @@ } } } + }, + { + "name": "controls", + "shape": { + "map": { + "key": "string", + "meta": {}, + "value": { + "ref": { + "id": 2602397900 + } + } + } + } + }, + { + "name": "author", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "version", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "license", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } } ], "meta": {} diff --git a/schemas/shapes/lpc_model.nodes.project.promoted_control_def.PromotedControlDef.json b/schemas/shapes/lpc_model.nodes.project.promoted_control_def.PromotedControlDef.json new file mode 100644 index 000000000..367385231 --- /dev/null +++ b/schemas/shapes/lpc_model.nodes.project.promoted_control_def.PromotedControlDef.json @@ -0,0 +1,92 @@ +{ + "record": { + "fields": [ + { + "name": "target", + "shape": { + "value": { + "shape": { + "editor": "path", + "id": 1364391883, + "meta": {}, + "ty": "string" + } + } + } + }, + { + "name": "label", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "unit", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + }, + { + "name": "max", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2605450937, + "meta": {}, + "ty": "f32" + } + } + } + } + } + } + ], + "meta": {} + } +}