From b25353d0a5dfda827b88dc3f5f154a98cbacb5c7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Tue, 28 Jul 2026 13:17:14 -0700 Subject: [PATCH 1/8] feat(engine): scoped buses with writer-shadowing + project output mirror Channels are now keyed (scope, name): every project node introduces a named bus scope, playlist entries wrap children in anonymous scopes (retiring the suppress-entry-visual-defaults rule), consumes resolve to the nearest enclosing scope with a writer (else root), produces always write their own scope. Every project node's runtime mirrors its scope's visual.out as produced output (playlist-pattern render forwarding), so an embedded project is playlist-playable with zero playlist changes. ADR: docs/adr/2026-07-28-scoped-buses.md (+ effects-are-projects ADR covering the M2 model slice landing next). Co-Authored-By: Claude Fable 5 --- docs/adr/2026-07-28-effects-are-projects.md | 120 +++ docs/adr/2026-07-28-scoped-buses.md | 162 ++++ lp-core/lpc-engine/README.md | 10 + .../src/dataflow/binding/binding_entry.rs | 14 +- .../src/dataflow/binding/binding_error.rs | 4 +- lp-core/lpc-engine/src/dataflow/bus/mod.rs | 2 + lp-core/lpc-engine/src/dataflow/bus/scope.rs | 64 ++ .../src/dataflow/resolver/query_key.rs | 18 +- .../src/dataflow/resolver/resolve_error.rs | 6 +- .../src/dataflow/resolver/resolve_host.rs | 5 +- .../src/dataflow/resolver/resolve_session.rs | 29 +- .../src/dataflow/resolver/resolver_cache.rs | 6 +- .../src/dataflow/resolver/tick_resolver.rs | 24 + lp-core/lpc-engine/src/engine/bus_scopes.rs | 205 +++++ lp-core/lpc-engine/src/engine/engine.rs | 25 +- lp-core/lpc-engine/src/engine/mod.rs | 1 + .../lpc-engine/src/engine/project_apply.rs | 3 +- .../lpc-engine/src/engine/project_loader.rs | 744 ++++++++++++++++-- .../src/engine/project_read_probes.rs | 53 +- lp-core/lpc-engine/src/engine/test_support.rs | 33 +- lp-core/lpc-engine/src/node/contexts.rs | 24 +- .../lpc-engine/src/node/node_binding_index.rs | 15 +- lp-core/lpc-engine/src/node/node_tree.rs | 23 +- lp-core/lpc-engine/src/nodes/mod.rs | 2 + lp-core/lpc-engine/src/nodes/project/mod.rs | 7 + .../src/nodes/project/project_mirror_state.rs | 23 + .../src/nodes/project/project_node.rs | 186 +++++ lp-core/lpc-engine/tests/runtime_spine.rs | 16 +- 28 files changed, 1672 insertions(+), 152 deletions(-) create mode 100644 docs/adr/2026-07-28-effects-are-projects.md create mode 100644 docs/adr/2026-07-28-scoped-buses.md create mode 100644 lp-core/lpc-engine/src/dataflow/bus/scope.rs create mode 100644 lp-core/lpc-engine/src/engine/bus_scopes.rs create mode 100644 lp-core/lpc-engine/src/nodes/project/mod.rs create mode 100644 lp-core/lpc-engine/src/nodes/project/project_mirror_state.rs create mode 100644 lp-core/lpc-engine/src/nodes/project/project_node.rs 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..cb802c295 --- /dev/null +++ b/docs/adr/2026-07-28-scoped-buses.md @@ -0,0 +1,162 @@ +# 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. + +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). No bindings are registered for the + mirror, so the binding graph and bus pane of existing projects 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/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 c8ae09bed..682254033 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}; @@ -23,11 +23,11 @@ use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, Bin use crate::node::{NodeEntryState, TreeError}; use crate::nodes::fixture::mapping::resolve_svg_path_mapping; 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`]. @@ -78,12 +78,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; @@ -168,11 +162,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}"), @@ -356,6 +346,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; @@ -629,8 +641,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(()) } @@ -1030,7 +1046,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() } @@ -1112,6 +1131,7 @@ 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 @@ -1128,6 +1148,7 @@ fn register_node_bindings( "seconds", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1136,8 +1157,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"] { @@ -1148,6 +1177,7 @@ fn register_node_bindings( slot, &config.bindings, frame, + scopes, )?; } } @@ -1159,6 +1189,7 @@ fn register_node_bindings( "input", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1167,6 +1198,7 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, )?; } NodeDef::Output(config) => { @@ -1195,8 +1227,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( @@ -1206,6 +1246,7 @@ fn register_node_bindings( "output", &config.bindings, frame, + scopes, )?; for name in config.consumed_slots.entries.keys() { register_optional_source_binding( @@ -1215,6 +1256,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } for (name, slot) in config.consumed_slots.entries.iter() { @@ -1230,9 +1272,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() { @@ -1243,6 +1293,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } for name in config.produced_slots.entries.keys() { @@ -1253,6 +1304,7 @@ fn register_node_bindings( name.as_str(), &config.bindings, frame, + scopes, )?; } } @@ -1264,6 +1316,7 @@ fn register_node_bindings( "time", &config.bindings, frame, + scopes, )?; register_optional_source_binding( runtime, @@ -1272,6 +1325,7 @@ fn register_node_bindings( "emitters", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1280,12 +1334,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( @@ -1299,7 +1361,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( @@ -1313,7 +1375,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 { @@ -1336,7 +1398,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( @@ -1346,6 +1415,7 @@ fn register_node_bindings( "input", &config.bindings, frame, + scopes, )?; register_target_binding( runtime, @@ -1354,8 +1424,16 @@ 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(_) => {} } @@ -1364,7 +1442,7 @@ fn register_node_bindings( /// 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 { @@ -1397,6 +1475,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( @@ -1408,6 +1487,7 @@ fn register_declared_defaults( &name, direction, &endpoint, + scopes, )?; } Ok(()) @@ -1425,6 +1505,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(), @@ -1440,16 +1521,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, @@ -1461,7 +1544,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, @@ -1535,6 +1620,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 { @@ -1542,7 +1628,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), @@ -1596,11 +1682,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( @@ -1610,11 +1705,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), @@ -1654,7 +1750,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 { @@ -1667,11 +1763,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) } } } @@ -1680,15 +1777,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")?; @@ -1704,15 +1804,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")?; @@ -1730,6 +1833,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 { @@ -1749,12 +1857,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 @@ -1763,7 +1871,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 @@ -1801,10 +1909,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)); @@ -2286,7 +2396,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") @@ -2313,7 +2423,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") @@ -2415,7 +2525,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() ) })); @@ -2503,7 +2613,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"); @@ -2517,18 +2632,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] @@ -2561,7 +2691,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() @@ -3319,7 +3449,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") ) @@ -3332,7 +3462,7 @@ mod tests { BindingTarget::BusChannel(target), ) if *node == radio && slot == &SlotPath::parse("output").expect("output") - && target.0 == "trigger" + && target.channel.0 == "trigger" ) })); } @@ -3990,7 +4120,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 ) }) } @@ -4003,7 +4133,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 ) }) } @@ -4089,7 +4219,7 @@ mod tests { ( BindingSource::ProducedSlot { node, .. }, BindingTarget::BusChannel(c), - ) if *node == clock && c.0 == "custom" + ) if *node == clock && c.channel.0 == "custom" ) })); } @@ -4188,7 +4318,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| { @@ -4198,14 +4329,487 @@ 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" + ); + let _ = fx_shader; + + // The host's root visual channel resolves to the host shader — + // never clobbered by the embedded project. + let root_visual = rt + .resolve_with_engine_host( + QueryKey::Bus(scoped_at(ScopeId::Project(root), "visual.out")), + ResolveLogLevel::Off, + ) + .expect("resolve root visual") + .0; + let root_product = lpc_model::VisualProduct::from_lp_value( + root_visual.value_leaf().expect("root visual value").value(), + ) + .expect("root visual is a product"); + assert_eq!(root_product.node(), host, "host visual.out untouched"); + } + + #[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()); + } + + /// 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 mut 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..67126c01d 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,10 +95,10 @@ 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 + let channel_names: Vec<(ScopedChannel, Kind)> = self .tree() .bus_channels() .map(|(name, kind)| (name.clone(), kind)) @@ -136,7 +138,7 @@ impl Engine { }); channels.push(WireBusChannel { - name: name.0.clone(), + name: scoped_channel_wire_name(self, &name), kind: Some(kind), providers, consumers, @@ -227,27 +229,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 +291,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 +317,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 +327,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), }, } } 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 96b5edaf4..5b9d112ff 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 f5363bcf8..31e6cb7cd 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 { @@ -488,7 +496,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, From 816bdc9f0272b11cc1bf28ca859c642008e70f77 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Tue, 28 Jul 2026 13:32:40 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(model):=20embeddable=20projects=20?= =?UTF-8?q?=E2=80=94=20promoted=20controls,=20provenance,=20guard=20lift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectDef grows optional controls{name: PromotedControlDef} (alias to a direct child's slot, no value of its own) plus author/version/license; all skip-if-default so existing artifacts are byte-identical, schemas additive with no format bump. The 'kind Project cannot be created as a child node' authoring guard is lifted (effects-are-projects ADR); child project.json format fields are tolerated and ignored (root-only probe); effect_starter_files composes the New Effect folder starter. Co-Authored-By: Claude Fable 5 --- .../lpc-engine/src/engine/project_loader.rs | 161 ++++++++++++++++- lp-core/lpc-model/src/lib.rs | 6 +- lp-core/lpc-model/src/nodes/mod.rs | 2 +- lp-core/lpc-model/src/nodes/project/mod.rs | 2 + .../src/nodes/project/project_def.rs | 76 ++++++++ .../src/nodes/project/promoted_control_def.rs | 53 ++++++ .../lpc-model/src/nodes/starter_project.rs | 132 +++++++++++++- .../src/registry/node_authoring.rs | 171 ++++++++++++++++-- lp-core/lpc-shared/src/project/builder.rs | 2 +- schemas/README.md | 8 + schemas/node.schema.json | 36 ++++ schemas/project.schema.json | 38 ++++ schemas/shapes/_index.json | 1 + ....nodes.project.project_def.ProjectDef.json | 68 +++++++ ...omoted_control_def.PromotedControlDef.json | 92 ++++++++++ 15 files changed, 827 insertions(+), 21 deletions(-) create mode 100644 lp-core/lpc-model/src/nodes/project/promoted_control_def.rs create mode 100644 schemas/shapes/lpc_model.nodes.project.promoted_control_def.PromotedControlDef.json diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 682254033..2a97bef90 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -1136,7 +1136,12 @@ fn register_node_bindings( // 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) { + validate_promoted_controls(projected_nodes, node, config)?; + } return Ok(()); } match projected_node_config(registry, node)?.clone() { @@ -1440,6 +1445,52 @@ fn register_node_bindings( 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. pub(super) fn declared_default_binds(kind: NodeKind) -> Vec<(String, SlotDirection, String)> { @@ -4754,6 +4805,114 @@ mod tests { 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}"); + } + /// 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 @@ -4797,7 +4956,7 @@ mod tests { // 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 mut check = |channel: &ScopedChannel| { + let check = |channel: &ScopedChannel| { assert!( channel.scope == root_scope || matches!(channel.scope, ScopeId::Entry(_)), "{name}: unexpected scope on {channel}" diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index d2fcd6c23..328619db8 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -118,9 +118,9 @@ pub use nodes::{ ScalarHintView, 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, + TextureDefView, TextureFormat, 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, ControlProduct, diff --git a/lp-core/lpc-model/src/nodes/mod.rs b/lp-core/lpc-model/src/nodes/mod.rs index 04b41bd65..64a41bb68 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 af482aee0..0e67c8c09 100644 --- a/lp-core/lpc-model/src/nodes/starter_project.rs +++ b/lp-core/lpc-model/src/nodes/starter_project.rs @@ -85,6 +85,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"] { @@ -97,9 +172,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() }) } @@ -208,6 +283,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 1af6f6831..957848ace 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/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 7bccff5ab..af328fc9a 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": {} + } +} From 5315864a13781b17baab071f95444c4be28f53b5 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Tue, 28 Jul 2026 13:51:30 -0700 Subject: [PATCH 3/8] feat(studio): effect card face with promoted-control aliasing + New Effect source An embedded project renders as an effect card: output-mirror hero, promoted-control knobs whose addresses are the inner child's slots (the standard address-routed write path carries edits, dirty, bound-violet), broken aliases render disabled with Invalid, provenance line below. The add picker grows an Effect source that expands the effect folder starter at effects// for both attach sites. Co-Authored-By: Claude Fable 5 --- .../lpa-studio-core/src/app/node/face/mod.rs | 5 +- .../src/app/node/face/ui_effect_face.rs | 24 ++ .../src/app/node/face/ui_node_face.rs | 5 +- lp-app/lpa-studio-core/src/app/node/mod.rs | 4 +- .../src/app/project/node/node_create_op.rs | 6 +- .../src/app/project/node/node_face_builder.rs | 394 +++++++++++++++++- .../src/app/project/node/ui_add_node_menu.rs | 73 ++-- .../src/app/project/project_controller.rs | 124 ++++-- lp-app/lpa-studio-core/src/lib.rs | 18 +- lp-app/lpa-studio-web/assets/tailwind.css | 9 +- .../src/app/node/effect_face_stories.rs | 50 +++ .../src/app/node/face/effect_face.rs | 60 +++ .../lpa-studio-web/src/app/node/face/mod.rs | 2 + .../src/app/node/face/node_face_body.rs | 18 +- .../src/app/node/face_story_fixtures.rs | 83 +++- lp-app/lpa-studio-web/src/app/node/mod.rs | 2 + lp-app/lpa-studio-web/src/base/icon.rs | 2 +- 17 files changed, 790 insertions(+), 89 deletions(-) create mode 100644 lp-app/lpa-studio-core/src/app/node/face/ui_effect_face.rs create mode 100644 lp-app/lpa-studio-web/src/app/node/effect_face_stories.rs create mode 100644 lp-app/lpa-studio-web/src/app/node/face/effect_face.rs 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/node/node_create_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_create_op.rs index 2275d5482..785832314 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, 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 42f5e1902..3117054bc 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 { @@ -862,6 +1087,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..3b933801f 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 @@ -45,31 +45,47 @@ 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."), + }); + UiAddNodeMenu { entries } } #[cfg(test)] @@ -77,14 +93,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..c91d0ee34 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,33 +2839,64 @@ 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)| { - ( - lpc_model::LpPathBuf::from(format!("./{file}").as_str()), - bytes, - ) - }) - .collect::>(); - let request = WireCreateNodeRequest::new( - lpc_model::LpPathBuf::from(format!("./{name}.json").as_str()), - body.into_bytes(), - assets, - site, - ); + 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, + ) + }; let outcome = server.project_create_node(handle_id, request).await?; let mut logs = outcome.logs; @@ -3198,7 +3238,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 +3302,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 @@ -8072,7 +8136,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/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 664e705e9..458091114 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}; diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 2bc7cface..13d3c1c20 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -1157,12 +1157,6 @@ .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); } @@ -1647,6 +1641,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); } 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 57db7ef4d..1d5bea6be 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::control_preview_product; @@ -358,6 +358,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 fadc18f71..5cd2792f0 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 e7aa439a6..0f483313a 100644 --- a/lp-app/lpa-studio-web/src/base/icon.rs +++ b/lp-app/lpa-studio-web/src/base/icon.rs @@ -208,7 +208,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, From d13203a16df280e8a32f5e40676a03d74e0e0b41 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Tue, 28 Jul 2026 14:09:46 -0700 Subject: [PATCH 4/8] feat(examples,studio): plasma + meteor effects, Effects gallery, playlist import Two CC0 effect examples ship as workbench projects (preview rig wrapping a vendorable effect folder); an engine test renders both through the LpsGlsl compiler via their output mirrors. The gallery's kind chips are real (Projects | Effects), and the add picker's Effects section vendors a shipped effect into effects// and attaches it in one gesture. Co-Authored-By: Claude Fable 5 --- examples/effects/README.md | 32 +++ examples/effects/meteor/clock.json | 3 + examples/effects/meteor/fixture.json | 270 ++++++++++++++++++ examples/effects/meteor/meteor/project.json | 27 ++ examples/effects/meteor/meteor/render.glsl | 41 +++ examples/effects/meteor/meteor/render.json | 38 +++ examples/effects/meteor/meteor/sim.glsl | 54 ++++ examples/effects/meteor/meteor/sim.json | 53 ++++ examples/effects/meteor/output.json | 20 ++ examples/effects/meteor/project.json | 19 ++ examples/effects/plasma/clock.json | 3 + examples/effects/plasma/fixture.json | 47 +++ examples/effects/plasma/output.json | 20 ++ examples/effects/plasma/plasma/main.glsl | 17 ++ examples/effects/plasma/plasma/project.json | 21 ++ examples/effects/plasma/plasma/shader.json | 36 +++ examples/effects/plasma/project.json | 19 ++ .../src/app/home/embedded_example.rs | 110 ++++++- .../src/app/home/home_view_builder.rs | 2 +- lp-app/lpa-studio-core/src/app/project/mod.rs | 2 +- .../src/app/project/node/mod.rs | 2 +- .../src/app/project/node/node_create_op.rs | 45 +++ .../src/app/project/node/ui_add_node_menu.rs | 31 +- .../src/app/project/project_controller.rs | 149 ++++++++++ .../src/app/studio/studio_controller.rs | 27 +- lp-app/lpa-studio-core/src/lib.rs | 8 +- .../src/app/home/home_gallery.rs | 59 +++- .../src/app/node/add_node_picker.rs | 11 + .../lpc-engine/src/engine/project_loader.rs | 65 +++++ 29 files changed, 1203 insertions(+), 28 deletions(-) create mode 100644 examples/effects/README.md create mode 100644 examples/effects/meteor/clock.json create mode 100644 examples/effects/meteor/fixture.json create mode 100644 examples/effects/meteor/meteor/project.json create mode 100644 examples/effects/meteor/meteor/render.glsl create mode 100644 examples/effects/meteor/meteor/render.json create mode 100644 examples/effects/meteor/meteor/sim.glsl create mode 100644 examples/effects/meteor/meteor/sim.json create mode 100644 examples/effects/meteor/output.json create mode 100644 examples/effects/meteor/project.json create mode 100644 examples/effects/plasma/clock.json create mode 100644 examples/effects/plasma/fixture.json create mode 100644 examples/effects/plasma/output.json create mode 100644 examples/effects/plasma/plasma/main.glsl create mode 100644 examples/effects/plasma/plasma/project.json create mode 100644 examples/effects/plasma/plasma/shader.json create mode 100644 examples/effects/plasma/project.json 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..3521cbead --- /dev/null +++ b/examples/effects/meteor/fixture.json @@ -0,0 +1,270 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 6 + }, + "bindings": { + "input": { + "source": "node:../meteor#output" + }, + "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..069568145 --- /dev/null +++ b/examples/effects/meteor/meteor/render.glsl @@ -0,0 +1,41 @@ +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). +vec3 drawMeteor(vec3 accum, int slot, vec2 uv) { + if (meteors[slot].id == 0u) { + return accum; + } + if (meteors[slot].intensity <= 0.0) { + return accum; + } + float behind = meteors[slot].pos.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 - meteors[slot].pos.y)); + return accum + meteors[slot].color * tail * lane * meteors[slot].intensity; +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + vec3 accum = vec3(0.0, 0.0, 0.0); + accum = drawMeteor(accum, 0, uv); + accum = drawMeteor(accum, 1, uv); + accum = drawMeteor(accum, 2, uv); + accum = drawMeteor(accum, 3, 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..9a1f014b9 --- /dev/null +++ b/examples/effects/meteor/meteor/sim.glsl @@ -0,0 +1,54 @@ +// 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 dt = clamp(time - prev_time, 0.0, 0.1); + prev_time = time; + float active = clamp(count, 1.0, 4.0); + + if (meteors[0].id == 0u) { + 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; + } + + 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..64f314c46 --- /dev/null +++ b/examples/effects/plasma/fixture.json @@ -0,0 +1,47 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 32, + "height": 32 + }, + "bindings": { + "input": { + "source": "node:../plasma#output" + }, + "output": { + "target": "bus:control.out" + } + }, + "sampling": "direct", + "mapping": { + "kind": "PathPoints", + "paths": { + "0": { + "kind": "RingArray", + "center": [ + 0.5, + 0.5 + ], + "diameter": 1, + "start_ring_inclusive": 0, + "end_ring_exclusive": 9, + "ring_lamp_counts": { + "0": 1, + "1": 8, + "2": 12, + "3": 16, + "4": 24, + "5": 32, + "6": 40, + "7": 48, + "8": 60 + }, + "offset_angle": 0, + "order": "inner_first" + } + }, + "sample_diameter": 2 + }, + "color_order": "rgb", + "brightness": 255 +} 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..6e060a576 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,67 @@ 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", + "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 +90,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 +134,27 @@ 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" + ); + } + } + #[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 9e4e53049..3a8685133 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/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 785832314..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 @@ -78,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/ui_add_node_menu.rs b/lp-app/lpa-studio-core/src/app/project/node/ui_add_node_menu.rs index 3b933801f..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: @@ -85,7 +91,28 @@ pub fn add_node_menu(attach: &UiAttachTarget) -> UiAddNodeMenu { .with_label("New Effect") .with_summary("Create a new effect: a small embedded project with promoted controls."), }); - UiAddNodeMenu { entries } + 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)] 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 c91d0ee34..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 @@ -2898,6 +2898,127 @@ impl ProjectController { ) }; + 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", + ))); + }; + ( + NodeAttachSite::ProjectNodes { key: name.clone() }, + root.address().clone(), + name.clone(), + ) + } + 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!("./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 { @@ -3967,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) 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 4b35a589f..65154e4bc 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 458091114..f379df678 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -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/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-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 2a97bef90..9848fa1af 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -4913,6 +4913,71 @@ mod tests { 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" + ); + } + } + /// 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 From 2a9c5234a204215d8945e06938f433abd6797359 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 29 Jul 2026 00:26:40 -0700 Subject: [PATCH 5/8] chore(studio): regenerate tailwind for the gallery chip's selected fill Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/assets/tailwind.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 13d3c1c20..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); @@ -1160,6 +1161,12 @@ .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); } @@ -1659,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); } From 6f3775fa00ea23efe603e3395cba94069bcde5ae Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 29 Jul 2026 00:58:55 -0700 Subject: [PATCH 6/8] fix(examples): meteor render reads its uniform struct array by constant index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawMeteor indexed meteors[slot] with a runtime parameter; a uniform array of structs has no element address resolvable at lower time, so members must be read with constant indices at the call site and passed in as scalars (the shape examples/events/shader.glsl already carries). Measured: the runtime-indexed form compile-fails on 4 of 5 targets. Every automated gate stayed green on the broken shader. The engine render test runs the host backend, which accepts the construct — adding a node-status assertion did not catch it either, so its comment now says plainly what it cannot gate. Adds a filetest pinning the supported idiom on all five targets, plus defect + debt entries for the missing cross-target example gate. Co-Authored-By: Claude Fable 5 --- .../debt/example-shaders-not-compile-gated.md | 47 +++++++++++++++ ...7-29-uniform-struct-array-runtime-index.md | 60 +++++++++++++++++++ examples/effects/meteor/meteor/render.glsl | 25 ++++---- .../lpc-engine/src/engine/project_loader.rs | 22 +++++++ .../filetests/uniform/array-of-struct.glsl | 51 ++++++++++++++++ 5 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 docs/debt/example-shaders-not-compile-gated.md create mode 100644 docs/defects/2026-07-29-uniform-struct-array-runtime-index.md create mode 100644 lp-shader/lps-filetests/filetests/uniform/array-of-struct.glsl 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/meteor/meteor/render.glsl b/examples/effects/meteor/meteor/render.glsl index 069568145..119b08155 100644 --- a/examples/effects/meteor/meteor/render.glsl +++ b/examples/effects/meteor/meteor/render.glsl @@ -14,28 +14,33 @@ 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). -vec3 drawMeteor(vec3 accum, int slot, vec2 uv) { - if (meteors[slot].id == 0u) { +// +// 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 (meteors[slot].intensity <= 0.0) { + if (intensity <= 0.0) { return accum; } - float behind = meteors[slot].pos.x - uv.x; + 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 - meteors[slot].pos.y)); - return accum + meteors[slot].color * tail * lane * meteors[slot].intensity; + 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, 0, uv); - accum = drawMeteor(accum, 1, uv); - accum = drawMeteor(accum, 2, uv); - accum = drawMeteor(accum, 3, uv); + 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/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 018c6755e..bb586a20c 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -4954,6 +4954,28 @@ mod tests { .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:#?}" + ); } } 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 From 2e79523c5f5e81a4cce747b217443155a39a69b9 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 29 Jul 2026 08:12:07 -0700 Subject: [PATCH 7/8] feat(engine): an embedded project contributes its visual to its host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-root project node's output mirror now 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) — the node sits there; only its children are inside the scope it introduces — so an effect is drop-in while its interior stays isolated. Root is excluded: its containing scope is the one its mirror reads. Accepted consequence: embedding an effect where visual.out is already driven leaves two equal-priority writers and resolves ambiguous until the author picks one. The nested-project test now pins the writer set and that ambiguity rather than a winner. The workbench fixtures drop their explicit node: refs and ride the default, which is also what makes the effect gallery thumbnails preview. Co-Authored-By: Claude Fable 5 --- docs/adr/2026-07-28-scoped-buses.md | 24 +++- examples/effects/meteor/fixture.json | 3 - examples/effects/plasma/fixture.json | 3 - .../lpc-engine/src/engine/project_loader.rs | 104 +++++++++++++++--- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/docs/adr/2026-07-28-scoped-buses.md b/docs/adr/2026-07-28-scoped-buses.md index cb802c295..78ed84efa 100644 --- a/docs/adr/2026-07-28-scoped-buses.md +++ b/docs/adr/2026-07-28-scoped-buses.md @@ -81,6 +81,25 @@ at load time from the projected node spine: 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 @@ -111,8 +130,9 @@ writes stay local (that is the encapsulation guarantee). 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). No bindings are registered for the - mirror, so the binding graph and bus pane of existing projects are + (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 diff --git a/examples/effects/meteor/fixture.json b/examples/effects/meteor/fixture.json index 3521cbead..867ce0167 100644 --- a/examples/effects/meteor/fixture.json +++ b/examples/effects/meteor/fixture.json @@ -5,9 +5,6 @@ "height": 6 }, "bindings": { - "input": { - "source": "node:../meteor#output" - }, "output": { "target": "bus:control.out" } diff --git a/examples/effects/plasma/fixture.json b/examples/effects/plasma/fixture.json index 647734ee8..6da84596c 100644 --- a/examples/effects/plasma/fixture.json +++ b/examples/effects/plasma/fixture.json @@ -5,9 +5,6 @@ "height": 32 }, "bindings": { - "input": { - "source": "node:../plasma#output" - }, "output": { "target": "bus:control.out" } diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index bb586a20c..b67321d23 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -1151,8 +1151,10 @@ fn register_node_bindings( // 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) { - validate_promoted_controls(projected_nodes, node, config)?; + 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() { @@ -1456,6 +1458,57 @@ fn register_node_bindings( 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 @@ -4559,22 +4612,41 @@ mod tests { fx, "the mirror's row carries the project node's own handle" ); - let _ = fx_shader; - // The host's root visual channel resolves to the host shader — - // never clobbered by the embedded project. - let root_visual = rt - .resolve_with_engine_host( - QueryKey::Bus(scoped_at(ScopeId::Project(root), "visual.out")), - ResolveLogLevel::Off, - ) - .expect("resolve root visual") - .0; - let root_product = lpc_model::VisualProduct::from_lp_value( - root_visual.value_leaf().expect("root visual value").value(), - ) - .expect("root visual is a product"); - assert_eq!(root_product.node(), host, "host visual.out untouched"); + // 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] From 7014d3897031e02869a04130c8cbea2d214bd966 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Thu, 30 Jul 2026 08:50:22 -0700 Subject: [PATCH 8/8] fix(engine): isolating entry scopes stay out of the bus graph probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bus-graph probe resolves a value for every listed channel. Retiring the playlist suppression rule gave each entry child its own anonymous Entry-scope visual.out, so those channels started appearing — and the probe then DEMANDED every entry child's shader on each bus refresh, rendering inactive entries every frame. Found live on examples/fyeah-sign, where the blast entry ran constantly instead of only while active. Entry scopes are sinks by construction (the playlist reads each child's produced output slot directly, bus-independent), so they are filtered out of the graph entirely — restoring pre-change bus-pane parity for playlists while named project scopes stay visible. Regression test pinned and verified to fail without the filter. Co-Authored-By: Claude Fable 5 --- examples/effects/meteor/meteor/sim.glsl | 15 ++++- .../lpc-engine/src/engine/project_loader.rs | 7 ++- .../src/engine/project_read_probes.rs | 57 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/examples/effects/meteor/meteor/sim.glsl b/examples/effects/meteor/meteor/sim.glsl index 9a1f014b9..ce8f0f8f7 100644 --- a/examples/effects/meteor/meteor/sim.glsl +++ b/examples/effects/meteor/meteor/sim.glsl @@ -3,11 +3,13 @@ float prev_time; void tick() { - float dt = clamp(time - prev_time, 0.0, 0.1); - prev_time = time; 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; @@ -33,6 +35,15 @@ void tick() { 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); diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index b67321d23..dd235d95f 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -1999,7 +1999,7 @@ mod binding_kind_tests { } #[cfg(test)] -mod tests { +pub(in crate::engine) mod tests { extern crate std; use core::cell::Cell; @@ -2146,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( 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 67126c01d..b91ff139a 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -98,9 +98,17 @@ impl Engine { bindings.push(wire_effective_binding(self, entry)); } + // 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(); @@ -365,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))