diff --git a/.gitignore b/.gitignore index 1cf3745..bd70a57 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ renders/ # user reference material (large videos, third-party content — never publish) refs/ +# confidential design doc (keep locally, never commit) +docs/research/2026-06-20-core-ir-design.md + # build artifact copies in example projects (regenerate: stereoframe update) examples/*/assets/stereoframe.js diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ad42ae1..47e390e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,6 +23,11 @@ import { FINISH_ATTRS, STAGE_PRESETS, GEN_PROVIDERS, + IR_DRIVER_KINDS, + IR_BEHAVIOR_KINDS, + IR_TIMELINE_KINDS, + IR_CHANNELS, + IR_VERB_CHANNEL, } from "stereoframe-runtime/vocab"; import { addBlock, listBlocks } from "./blocks"; import { @@ -828,6 +833,13 @@ async function main(): Promise { stagePresets: STAGE_PRESETS, genProviders: GEN_PROVIDERS, }, + ir: { + driverKinds: IR_DRIVER_KINDS, + behaviorKinds: IR_BEHAVIOR_KINDS, + timelineKinds: IR_TIMELINE_KINDS, + channels: IR_CHANNELS, + verbChannel: IR_VERB_CHANNEL, + }, }; console.log(JSON.stringify(spec, null, 2)); return; diff --git a/packages/cli/src/lint.ts b/packages/cli/src/lint.ts index d0605df..171db8b 100644 --- a/packages/cli/src/lint.ts +++ b/packages/cli/src/lint.ts @@ -5,7 +5,15 @@ * free; `lintHtml` is pure (filesystem access injected) so every rule is * unit-testable against fixture strings. */ -import { ASSET_ATTRS, EASE_NAMES, ELEMENT_NAMES, VERB_NAMES } from "stereoframe-runtime/vocab"; +import { + ASSET_ATTRS, + EASE_NAMES, + ELEMENT_NAMES, + IR_VERB_CHANNEL, + VERB_DEFAULT_DURATION, + VERB_NAMES, + VERB_REF_ATTR, +} from "stereoframe-runtime/vocab"; export interface Finding { rule: string; @@ -251,5 +259,113 @@ export function lintHtml(html: string, opts: LintOptions): Finding[] { } } + // ── IR structural/temporal checks (the core="ir" model; render-free) ── + // Built from the shared IR verb tables, scoped per shot, so they hold for any + // composition. These catch authoring mistakes a render would only reveal late. + for (const block of sceneBlocks) { + const sceneAttrs = block.match(/]*)>/i)?.[1] ?? ""; + const durAttr = readAttr(sceneAttrs, "duration"); + const sceneDur = durAttr != null ? Number(durAttr) : null; + const channelStarts = new Map(); // "target.channel" → starts + + for (const el of sfTags(block).filter((t) => t.name === "sf-animate")) { + const verb = (readAttr(el.attrs, "verb") ?? "").toLowerCase(); + const target = readAttr(el.attrs, "target"); + const start = Number(readAttr(el.attrs, "start") ?? 0) || 0; + const durationAttr = readAttr(el.attrs, "duration"); + + // ir_dangling_ref — around/toward/subject pointing at a missing #id. + const refAttr = VERB_REF_ATTR[verb]; + if (refAttr) { + const ref = readAttr(el.attrs, refAttr); + if (ref?.startsWith("#") && !new RegExp(`\\bid\\s*=\\s*"${ref.slice(1)}"`).test(html)) { + findings.push({ + rule: "ir_dangling_ref", + severity: "warning", + message: `sf-animate verb="${verb}" ${refAttr}="${ref}" references no element id — it resolves to the origin.`, + fixHint: `Point ${refAttr} at an existing #id, or use explicit "x y z" coordinates.`, + }); + } + } + + // ir_zero_duration — a windowed verb given a non-positive duration snaps. + const windowed = verb in VERB_DEFAULT_DURATION; + if (windowed && durationAttr != null && Number(durationAttr) <= 0) { + findings.push({ + rule: "ir_zero_duration", + severity: "warning", + message: `sf-animate verb="${verb}" duration="${durationAttr}" is not positive — it snaps instantly.`, + fixHint: "Give the windowed verb a positive duration (seconds).", + }); + } + + // ir_unreachable — a verb that starts at/after the shot ends never runs. + if (sceneDur != null && sceneDur > 0 && start >= sceneDur) { + findings.push({ + rule: "ir_unreachable", + severity: "warning", + message: `sf-animate verb="${verb}" start="${start}" is at/after the scene duration (${sceneDur}s) — it never runs.`, + fixHint: "Lower start, or extend the sf-scene duration.", + }); + } + + const channel = IR_VERB_CHANNEL[verb]; + if (channel && target) { + const key = `${target}.${channel}`; + const list = channelStarts.get(key); + if (list) list.push(start); + else channelStarts.set(key, [start]); + } + } + + // ir_channel_conflict — two drivers starting together on one channel are + // order-ambiguous (the later-declared one silently wins each frame). + for (const [key, starts] of channelStarts) { + const seen = new Set(); + const clash = starts.some((s) => (seen.has(s) ? true : (seen.add(s), false))); + if (clash) { + findings.push({ + rule: "ir_channel_conflict", + severity: "warning", + message: `two sf-animate drivers start at the same time on ${key} — their order is ambiguous.`, + fixHint: "Stagger the starts, or target different channels/nodes.", + }); + } + } + } + + // ── IR named-state checks ── + const stateNames = new Set(); + for (const m of markup.matchAll(/]*)>/gi)) { + const name = readAttr(m[1] ?? "", "name"); + if (name) stateNames.add(name); + } + // ir_state_requires_core — is inert unless the scene opts into core="ir". + for (const block of sceneBlocks) { + if (!/]*)>/i)?.[1] ?? ""; + if (readAttr(sceneAttrs, "core") !== "ir") { + findings.push({ + rule: "ir_state_requires_core", + severity: "warning", + message: ' is ignored by the legacy path — its needs core="ir".', + fixHint: 'Add core="ir" to the .', + }); + } + } + // ir_unknown_state — `to` targeting a state that was never declared. + for (const el of animates) { + if ((readAttr(el.attrs, "verb") ?? "").toLowerCase() !== "to") continue; + const state = readAttr(el.attrs, "state"); + if (state && !stateNames.has(state)) { + findings.push({ + rule: "ir_unknown_state", + severity: "warning", + message: `sf-animate verb="to" state="${state}" has no matching .`, + fixHint: "Define the state, or fix the name.", + }); + } + } + return findings; } diff --git a/packages/cli/tests/lint.test.ts b/packages/cli/tests/lint.test.ts index cff1bf7..9b1fdf8 100644 --- a/packages/cli/tests/lint.test.ts +++ b/packages/cli/tests/lint.test.ts @@ -51,6 +51,91 @@ describe("lint", () => { expect(r).toContain("remote_asset"); }); + test("sf-group is a recognized element + grouped/animated content is clean", () => { + const html = ` + + + + + + + + + ${RUNTIME}`; + expect(rules(html)).toEqual([]); + }); + + test("named states: clean when core=ir + matching state; flags unknown state", () => { + const ok = ` + + + + + + ${RUNTIME}`; + expect(rules(ok)).toEqual([]); + const bad = ok.replace('state="open"', 'state="ajar"'); + expect(rules(bad)).toContain("ir_unknown_state"); + }); + + test("ir_state_requires_core flags on a non-IR scene", () => { + const html = ` + + + + ${RUNTIME}`; + expect(rules(html)).toContain("ir_state_requires_core"); + }); + + test("ir_dangling_ref flags an orbit around a missing #id; valid ref is clean", () => { + const bad = ` + + + + ${RUNTIME}`; + expect(rules(bad)).toContain("ir_dangling_ref"); + const good = bad.replace("#heor", "#hero"); + expect(rules(good)).not.toContain("ir_dangling_ref"); + }); + + test("ir_zero_duration flags a windowed verb with non-positive duration", () => { + const html = ` + + + + ${RUNTIME}`; + expect(rules(html)).toContain("ir_zero_duration"); + }); + + test("ir_unreachable flags a verb starting at/after the scene duration", () => { + const html = ` + + + + ${RUNTIME}`; + expect(rules(html)).toContain("ir_unreachable"); + }); + + test("ir_channel_conflict flags two same-start drivers on one channel", () => { + const html = ` + + + + + ${RUNTIME}`; + expect(rules(html)).toContain("ir_channel_conflict"); + }); + + test("sequential drivers on one channel (different starts) are NOT a conflict", () => { + const html = ` + + + + + ${RUNTIME}`; + expect(rules(html)).not.toContain("ir_channel_conflict"); + }); + test("time_impurity catches wall-clock and RNG in inline scripts", () => { const html = `${OK}`; expect(rules(html)).toContain("time_impurity"); diff --git a/packages/cli/tests/schema.test.ts b/packages/cli/tests/schema.test.ts index 65ca7db..3e3b641 100644 --- a/packages/cli/tests/schema.test.ts +++ b/packages/cli/tests/schema.test.ts @@ -7,11 +7,16 @@ import { GEOMETRY_KINDS, MATERIAL_KINDS, STAGE_PRESETS, + IR_DRIVER_KINDS, + IR_BEHAVIOR_KINDS, + IR_BEHAVIOR_VERBS, + IR_VERB_CHANNEL, } from "stereoframe-runtime/vocab"; import { PRESETS } from "../src/stage"; const sceneSrc = readFileSync(new URL("../../runtime/src/scene.ts", import.meta.url), "utf8"); const cliSrc = readFileSync(new URL("../src/cli.ts", import.meta.url), "utf8"); +const irTypesSrc = readFileSync(new URL("../../runtime/src/ir/types.ts", import.meta.url), "utf8"); describe("schema vocab consistency", () => { test("VERB_PARAMS has an entry for every verb (and no extras)", () => { @@ -52,6 +57,23 @@ describe("schema ↔ source drift guards", () => { for (const n of names) expect(cliSrc).toContain(`stereoframe ${n}`); }); + test("IR driver/behavior kinds match the unions in ir/types.ts", () => { + for (const k of IR_DRIVER_KINDS) expect(irTypesSrc).toContain(`kind: "${k}"`); + for (const k of IR_BEHAVIOR_KINDS) expect(irTypesSrc).toContain(`kind: "${k}"`); + }); + + test("IR verb tables reference only real verbs", () => { + const verbs = new Set(VERB_NAMES); + for (const v of Object.keys(IR_VERB_CHANNEL)) expect(verbs.has(v)).toBe(true); + for (const v of IR_BEHAVIOR_VERBS) expect(verbs.has(v)).toBe(true); + }); + + test("schema command emits the ir block", () => { + for (const key of ["driverKinds", "behaviorKinds", "timelineKinds", "channels", "verbChannel"]) { + expect(cliSrc).toContain(`${key}:`); + } + }); + test("gen schema advertises implemented generation flags", () => { for (const flag of [ "input", diff --git a/packages/runtime/src/animate.ts b/packages/runtime/src/animate.ts index 93e7a6a..d5abaaf 100644 --- a/packages/runtime/src/animate.ts +++ b/packages/runtime/src/animate.ts @@ -118,10 +118,29 @@ function makeOpacitySetter(obj: Object3D): (factor: number) => void { }; } -export function compileAnimations(compiled: CompiledScene): void { +/** + * @param opts.skip verbs already handled elsewhere (the IR core owns + * turntable/orbit/etc); legacy writers are built only for the rest. This is the + * `core="ir"` hybrid: the IR drives the modeled verbs, legacy handles the + * long-tail (path/explode/variant/…) byte-identically until they migrate. + */ +export function compileAnimations( + compiled: CompiledScene, + opts: { skip?: Set; skip3d?: Set } = {}, +): void { + const skip = opts.skip; + const skip3d = opts.skip3d; const variantEls: Array<{ el: Element; timing: verbs.VerbTiming }> = []; for (const el of Array.from(compiled.host.querySelectorAll("sf-animate"))) { const verb = (el.getAttribute("verb") ?? "").toLowerCase(); + if (skip?.has(verb)) continue; + // skip3d: the IR handled this verb only for 3D targets (camera or a #id that + // resolves to a scene object); DOM-target instances still fall through here. + if (skip3d?.has(verb)) { + const t = el.getAttribute("target"); + const is3d = t === "camera" || (!!t && t.startsWith("#") && compiled.objectsById.has(t.slice(1))); + if (is3d) continue; + } // Window verbs get a sensible default duration; continuous verbs // (turntable, float, follow) stay duration-less. diff --git a/packages/runtime/src/boot.ts b/packages/runtime/src/boot.ts index cf038c7..861d2ca 100644 --- a/packages/runtime/src/boot.ts +++ b/packages/runtime/src/boot.ts @@ -11,6 +11,7 @@ import * as THREE from "three"; import { compileAnimations } from "./animate"; import { compileCallouts } from "./callout"; +import { installIR } from "./ir/backend"; import { parseSeconds } from "./parse"; import { compileScene, sceneDuration } from "./scene"; import { installHfGate, installSeekListener, isStandalone, startPreviewLoop } from "./seek"; @@ -18,7 +19,7 @@ import { installHfGate, installSeekListener, isStandalone, startPreviewLoop } fr const STYLES = ` sf-scene { display: block; line-height: 0; } sf-scene > canvas { display: block; } -sf-camera, sf-model, sf-mesh, sf-light, sf-env, sf-animate, sf-particles, sf-sky, sf-ocean, sf-swarm, sf-metaball, sf-scatter, sf-baked, sf-shader, sf-callout { display: none; } +sf-camera, sf-group, sf-state, sf-set, sf-model, sf-mesh, sf-light, sf-env, sf-animate, sf-particles, sf-sky, sf-ocean, sf-swarm, sf-metaball, sf-scatter, sf-baked, sf-shader, sf-callout { display: none; } `; function injectStyles(): void { @@ -59,7 +60,10 @@ export async function boot(): Promise { await Promise.all(scenes.map((s) => s.ready)); for (const s of scenes) { - compileAnimations(s); + // Opt-in IR core (core="ir" on ): lower to the IR and drive seeks + // via evaluate(t) instead of the legacy verb writers. Same seek protocol. + if (s.host.getAttribute("core") === "ir") installIR(s); + else compileAnimations(s); compileCallouts(s); } runEscapeHatchScripts(scenes); diff --git a/packages/runtime/src/ir/backend.ts b/packages/runtime/src/ir/backend.ts new file mode 100644 index 0000000..6439a3e --- /dev/null +++ b/packages/runtime/src/ir/backend.ts @@ -0,0 +1,127 @@ +/** + * three.js backend for the IR path. + * + * `installIR` replaces the legacy animation layer (compileAnimations): it lowers + * the scene to SceneIR, compiles it, and registers ONE seek writer that runs + * `evaluate(t)` and applies the resulting local transforms + material state to the + * three.js objects. World matrices, camera look-at, post-fx, fingerprint, + * diagnostics and the whole `window.__stereoframe` protocol are reused unchanged. + */ +import { Color, type Material, Mesh, MeshStandardMaterial } from "three"; +import { compileAnimations } from "../animate"; +import type { CompiledScene } from "../scene"; +import { compile } from "./compile"; +import { evaluate } from "./evaluate"; +import { lowerScene } from "./from-html"; +import type { MaterialFrame } from "./types"; + +/** Verbs the IR models for ANY target — legacy never builds them. */ +export const IR_VERBS = new Set(["turntable", "float", "sway", "orbit", "move", "dolly", "zoom", "follow", "variant", "to"]); +/** Verbs the IR models only for 3D targets — DOM-target instances stay legacy. */ +export const IR_VERBS_3D = new Set(["bounce-in", "fade-in"]); + +interface MatState { + mat: Material; + baseOpacity: number; + baseTransparent: boolean; + std: MeshStandardMaterial | null; + baseColor: Color | null; + baseRoughness: number; + baseMetalness: number; + name: string; +} + +/** Build per-node material appliers for nodes touched by an opacity/material + * channel. Each applier captures the base state once and, every seek, resets to + * base then applies the frame's material state — so nothing goes stale. */ +function buildMaterialAppliers( + ir: ReturnType, + objectMap: Map, +): Map void> { + const ids = new Set(); + for (const key of ir.segments.keys()) { + const ch = key.slice(key.lastIndexOf(".") + 1); + if (ch === "opacity" || ch === "material") ids.add(key.slice(0, key.lastIndexOf("."))); + } + + const appliers = new Map void>(); + const c1 = new Color(); + const c2 = new Color(); + for (const id of ids) { + const obj = objectMap.get(id); + if (!obj) continue; + const states: MatState[] = []; + const seen = new Set(); + obj.traverse((child) => { + const m = (child as Mesh).material; + for (const mat of Array.isArray(m) ? m : m ? [m] : []) { + if (seen.has(mat)) continue; + seen.add(mat); + const std = mat instanceof MeshStandardMaterial ? mat : null; + states.push({ + mat, + baseOpacity: mat.opacity, + baseTransparent: mat.transparent, + std, + baseColor: std ? std.color.clone() : null, + baseRoughness: std ? std.roughness : 0, + baseMetalness: std ? std.metalness : 0, + name: mat.name ?? "", + }); + } + }); + appliers.set(id, (f) => { + for (const s of states) { + // opacity (every material under the node) + if (f?.opacity != null) { + s.mat.opacity = s.baseOpacity * f.opacity; + s.mat.transparent = f.opacity < 1 ? true : s.baseTransparent; + } else { + s.mat.opacity = s.baseOpacity; + s.mat.transparent = s.baseTransparent; + } + // variant color/roughness/metalness (name-filtered, standard materials) + if (s.std && s.baseColor) { + const matches = !f?.materialName || s.name === f.materialName; + if (matches && f?.color) s.std.color.copy(c1.set(f.color.from)).lerp(c2.set(f.color.to), f.color.mix); + else s.std.color.copy(s.baseColor); + s.std.roughness = matches && f?.roughness != null ? f.roughness : s.baseRoughness; + s.std.metalness = matches && f?.metalness != null ? f.metalness : s.baseMetalness; + } + } + }); + } + return appliers; +} + +export function installIR(compiled: CompiledScene): void { + const { scene, objectMap } = lowerScene(compiled); + const ir = compile(scene); + const materialAppliers = buildMaterialAppliers(ir, objectMap); + + // IR writer runs first (seekFns[0]); legacy long-tail writers are appended + // after, so where they share a node/channel (e.g. DOM fades) legacy wins. + compiled.seekFns.push((t: number) => { + const fs = evaluate(ir, t); + for (const [id, tr] of fs.nodes) { + if (id === "camera") continue; // camera applied via fs.camera below + const obj = objectMap.get(id); + if (!obj) continue; + obj.position.set(tr.position[0], tr.position[1], tr.position[2]); + obj.rotation.set(tr.rotation[0], tr.rotation[1], tr.rotation[2]); + obj.scale.set(tr.scale[0], tr.scale[1], tr.scale[2]); + } + if (fs.camera.position) { + compiled.camera.position.set(fs.camera.position[0], fs.camera.position[1], fs.camera.position[2]); + } + if (fs.camera.fov != null && fs.camera.fov !== compiled.camera.fov) { + compiled.camera.fov = fs.camera.fov; + compiled.camera.updateProjectionMatrix(); + } + for (const [id, applier] of materialAppliers) applier(fs.materials.get(id)); + }); + + // Long-tail verbs the IR doesn't model yet (path/camera-path/explode/isolate/ + // morph/deform/crossfade-clip + DOM-target bounce-in/fade-in): legacy, byte-identical. + compileAnimations(compiled, { skip: IR_VERBS, skip3d: IR_VERBS_3D }); +} diff --git a/packages/runtime/src/ir/compile.ts b/packages/runtime/src/ir/compile.ts new file mode 100644 index 0000000..d104884 --- /dev/null +++ b/packages/runtime/src/ir/compile.ts @@ -0,0 +1,86 @@ +/** + * compile(SceneIR) → CompiledIR. + * + * Pure, three.js-free, DOM-free. Walks the timeline tree once, resolving the + * `seq`/`par`/`stagger`/`beat` algebra into flat per-channel segments with + * absolute times, inferring total duration bottom-up, and recording + * `labelTimes`/`beatTimes`. Each segment is registered under every channel its + * driver writes, so `evaluate` can hold the active segment per channel. + */ +import { driverChannels, type CompiledIR, type SceneIR, type Segment, type Span, type TimelineIR } from "./types"; + +const DEFAULT_EASE = "power1.out"; + +interface Acc { + byChannel: Map; + labels: Map; + beats: Map; +} + +function addSegment(acc: Acc, seg: Segment): void { + for (const ch of driverChannels(seg.driver)) { + const key = `${seg.driver.target}.${ch}`; + const list = acc.byChannel.get(key); + if (list) list.push(seg); + else acc.byChannel.set(key, [seg]); + } +} + +/** Place a timeline node at absolute `t0`, durations scaled by `scale` (beat + * stretch). Emits into `acc`. Returns the node's end time. */ +function place(node: TimelineIR, t0: number, scale: number, acc: Acc): number { + switch (node.kind) { + case "clip": { + const duration = node.duration * scale; + addSegment(acc, { driver: node.driver, start: t0, duration, ease: node.ease ?? DEFAULT_EASE, label: node.label }); + if (node.label) acc.labels.set(node.label, { t0, t1: t0 + duration }); + return t0 + duration; + } + case "wait": { + const end = t0 + node.duration * scale; + if (node.label) acc.labels.set(node.label, { t0, t1: end }); + return end; + } + case "seq": { + let cur = t0; + for (const child of node.children) cur = place(child, cur, scale, acc); + return cur; + } + case "par": { + let end = t0; + for (const child of node.children) end = Math.max(end, place(child, t0, scale, acc)); + return end; + } + case "stagger": { + let end = t0; + node.children.forEach((child, i) => { + end = Math.max(end, place(child, t0 + i * node.interval * scale, scale, acc)); + }); + return end; + } + case "beat": { + const start = node.at != null ? node.at : t0 + (node.gap ?? 0) * scale; + const innerScale = scale * (node.scale ?? 1); + let cur = start; + for (const child of node.children) cur = place(child, cur, innerScale, acc); + acc.beats.set(node.name, { t0: start, t1: cur }); + return cur; + } + } +} + +export function compile(scene: SceneIR): CompiledIR { + const acc: Acc = { byChannel: new Map(), labels: new Map(), beats: new Map() }; + const timelineEnd = scene.timeline ? place(scene.timeline, 0, 1, acc) : 0; + + for (const list of acc.byChannel.values()) list.sort((a, b) => a.start - b.start); + + return { + nodes: new Map(scene.nodes.map((n) => [n.id, n])), + segments: acc.byChannel, + behaviors: scene.behaviors, + duration: Math.max(scene.duration ?? 0, timelineEnd), + labelTimes: acc.labels, + beatTimes: acc.beats, + }; +} diff --git a/packages/runtime/src/ir/evaluate.ts b/packages/runtime/src/ir/evaluate.ts new file mode 100644 index 0000000..47a4ec4 --- /dev/null +++ b/packages/runtime/src/ir/evaluate.ts @@ -0,0 +1,268 @@ +/** + * evaluate(CompiledIR, t) → FrameState. + * + * Pure, three.js-free, DOM-free — the determinism boundary. The frame is a + * function of `t` alone: each call starts from the rest poses, so seeking any `t` + * twice (or out of order) is identical. + * + * Per channel (".") we hold the segment active at `t` — actually the + * last segment that has started; before the first, the channel keeps its rest + * value. (This fixes a legacy quirk where a not-yet-started driver could clobber + * an active one — see design doc / plan, HYBRID parity.) + * + * Two passes: (1) independent drivers + additive behaviors resolve every node's + * pose; (2) reference drivers (orbit/dolly/follow) read those evaluated poses, so + * the camera can orbit/track a moving subject (dynamic centers). + */ +import { getEase } from "../ease"; +import { + isEntranceDriver, + isReferenceDriver, + type Behavior, + type Channel, + type CompiledIR, + type Driver, + type FrameState, + type MaterialFrame, + type NodeBase, + type NodeTransform, + type Segment, + type Vec3, +} from "./types"; + +const TWO_PI = Math.PI * 2; +const DEG = Math.PI / 180; +const AXIS_INDEX = { x: 0, y: 1, z: 2 } as const; + +function transformFrom(n: NodeBase): NodeTransform { + return { position: [...n.position] as Vec3, rotation: [...n.rotation] as Vec3, scale: [...n.scale] as Vec3 }; +} + +/** Eased progress within a window, clamped to [0,1]. duration<=0 → step at start. */ +function progress(t: number, seg: Segment): number { + if (seg.duration <= 0) return t < seg.start ? 0 : 1; + const p = (t - seg.start) / seg.duration; + return getEase(seg.ease)(Math.min(1, Math.max(0, p))); +} + +/** The last segment that has started by `t` (rest pose before the first). */ +function held(list: Segment[], t: number): Segment | null { + let found: Segment | null = null; + for (const seg of list) { + if (seg.start <= t) found = seg; + else break; // sorted by start + } + return found; +} + +/** Held segment, or — before the first starts — the earliest segment if it's an + * entrance (so it holds its `from`-state, e.g. scale 0 / opacity 0). */ +function heldOrEntrance(list: Segment[], t: number): Segment | null { + const h = held(list, t); + if (h) return h; + const first = list[0]; + return first && isEntranceDriver(first.driver) ? first : null; +} + +interface EvalCtx { + nodes: Map; + transforms: Map; + materials: Map; + cameraFov: number | undefined; +} + +function materialOf(ctx: EvalCtx, id: string): MaterialFrame { + let m = ctx.materials.get(id); + if (!m) ctx.materials.set(id, (m = {})); + return m; +} + +/** Resolve an `around`/`toward`/`subject` reference to a point: a literal, or a + * node's evaluated (pass-1) position so centers track moving subjects. */ +function resolveRef(ref: string | Vec3, ctx: EvalCtx): Vec3 { + if (Array.isArray(ref)) return ref; + const tr = ctx.transforms.get(ref); + if (tr) return tr.position; + return ctx.nodes.get(ref)?.position ?? [0, 0, 0]; +} + +function applyDriver(driver: Driver, channel: Channel, t: number, seg: Segment, ctx: EvalCtx): void { + const tr = ctx.transforms.get(driver.target); + const p = progress(t, seg); + + switch (driver.kind) { + case "orbit": { + if (!tr || channel !== "position") return; + const c = resolveRef(driver.around, ctx); + const az = driver.fromDeg * DEG + (driver.toDeg - driver.fromDeg) * DEG * p; + tr.position[0] = c[0] + driver.radius * Math.sin(az); + tr.position[1] = c[1] + driver.height; + tr.position[2] = c[2] + driver.radius * Math.cos(az); + return; + } + case "move": { + if (!tr || channel !== "position") return; + const base = ctx.nodes.get(driver.target)?.position ?? [0, 0, 0]; + const from = driver.from ?? base; + tr.position[0] = from[0] + (driver.to[0] - from[0]) * p; + tr.position[1] = from[1] + (driver.to[1] - from[1]) * p; + tr.position[2] = from[2] + (driver.to[2] - from[2]) * p; + return; + } + case "dolly": { + if (!tr || channel !== "position") return; + const base = ctx.nodes.get(driver.target)?.position ?? [0, 0, 0]; + const c = resolveRef(driver.toward, ctx); + const dx = c[0] - base[0]; + const dy = c[1] - base[1]; + const dz = c[2] - base[2]; + const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; + const d = driver.distance * p; + tr.position[0] = base[0] + (dx / len) * d; + tr.position[1] = base[1] + (dy / len) * d; + tr.position[2] = base[2] + (dz / len) * d; + return; + } + case "zoom": { + if (channel === "fov") ctx.cameraFov = driver.from + (driver.to - driver.from) * p; + return; + } + case "follow": { + if (!tr || channel !== "position") return; + const s = resolveRef(driver.subject, ctx); + tr.position[0] = s[0] + driver.offset[0]; + tr.position[1] = s[1] + driver.offset[1]; + tr.position[2] = s[2] + driver.offset[2]; + return; + } + case "bounce-in": { + // Scale-in entrance: rest × eased progress (p clamps to 0 before the window). + if (!tr || channel !== "scale") return; + const base = ctx.nodes.get(driver.target)?.scale ?? [1, 1, 1]; + tr.scale[0] = base[0] * p; + tr.scale[1] = base[1] * p; + tr.scale[2] = base[2] * p; + return; + } + case "fade-in": { + // Opacity factor 0→1 (entrance). Backend multiplies each base opacity by it. + if (channel !== "opacity") return; + materialOf(ctx, driver.target).opacity = p; + return; + } + case "variant": { + // Material colorway link: scalars pre-lerped here; color left as from/to/mix + // for the backend to lerp with THREE.Color (byte-identical to legacy). + if (channel !== "material") return; + const m = materialOf(ctx, driver.target); + if (driver.materialName != null) m.materialName = driver.materialName; + if (driver.to.color != null && driver.from.color != null) { + m.color = { from: driver.from.color, to: driver.to.color, mix: p }; + } + if (driver.to.roughness != null && driver.from.roughness != null) { + m.roughness = driver.from.roughness + (driver.to.roughness - driver.from.roughness) * p; + } + if (driver.to.metalness != null && driver.from.metalness != null) { + m.metalness = driver.from.metalness + (driver.to.metalness - driver.from.metalness) * p; + } + return; + } + case "tween": { + // Generic transform tween (named-state transitions). Componentwise lerp. + if (!tr) return; + const v = tr[driver.channel]; + v[0] = driver.from[0] + (driver.to[0] - driver.from[0]) * p; + v[1] = driver.from[1] + (driver.to[1] - driver.from[1]) * p; + v[2] = driver.from[2] + (driver.to[2] - driver.from[2]) * p; + return; + } + default: + return; // path lowers in a later phase + } +} + +function applyBehavior(b: Behavior, t: number, ctx: EvalCtx): void { + const tr = ctx.transforms.get(b.target); + if (!tr) return; + const at = Math.max(0, t); + + switch (b.kind) { + case "turntable": { + const i = AXIS_INDEX[b.axis]; + const ang = (b.rpm / 60) * TWO_PI * at; + tr.rotation[i] += ang; + if (b.pivot) { + const c = b.pivot; + const dx = tr.position[0] - c[0]; + const dy = tr.position[1] - c[1]; + const dz = tr.position[2] - c[2]; + const s = Math.sin(ang); + const co = Math.cos(ang); + if (b.axis === "x") { + tr.position[1] = c[1] + dy * co - dz * s; + tr.position[2] = c[2] + dy * s + dz * co; + } else if (b.axis === "y") { + tr.position[0] = c[0] + dx * co + dz * s; + tr.position[2] = c[2] - dx * s + dz * co; + } else { + tr.position[0] = c[0] + dx * co - dy * s; + tr.position[1] = c[1] + dx * s + dy * co; + } + } + return; + } + case "float": { + tr.position[1] += b.amplitude * Math.sin((TWO_PI / Math.max(1e-4, b.period)) * at); + return; + } + case "sway": { + const w = TWO_PI / Math.max(1e-4, b.period); + const a = b.amount; + tr.rotation[2] += a * Math.sin(w * at); + tr.rotation[0] += a * 0.6 * Math.sin(w * 0.73 * at + 1.7); + tr.rotation[1] += a * 0.4 * Math.sin(w * 0.51 * at + 0.4); + tr.position[0] += a * 0.25 * Math.sin(w * 0.62 * at + 2.1); + tr.position[1] += a * 0.2 * Math.sin(w * 0.83 * at); + return; + } + } +} + +function channelOf(key: string): Channel { + return key.slice(key.lastIndexOf(".") + 1) as Channel; +} + +export function evaluate(compiled: CompiledIR, t: number): FrameState { + const time = Math.max(0, Number(t) || 0); + + const transforms = new Map(); + const materials = new Map(); + let cameraFov: number | undefined; + for (const [id, n] of compiled.nodes) { + transforms.set(id, transformFrom(n)); + if (n.kind === "camera") cameraFov = n.fov; + } + const ctx: EvalCtx = { nodes: compiled.nodes, transforms, materials, cameraFov }; + + // Pass 1: independent drivers (held/entrance per channel) then additive behaviors. + for (const [key, list] of compiled.segments) { + const seg = heldOrEntrance(list, time); + if (!seg || isReferenceDriver(seg.driver)) continue; + applyDriver(seg.driver, channelOf(key), time, seg, ctx); + } + for (const b of compiled.behaviors) applyBehavior(b, time, ctx); + + // Pass 2: reference drivers read pass-1 poses (dynamic centers). + for (const [key, list] of compiled.segments) { + const seg = held(list, time); + if (!seg || !isReferenceDriver(seg.driver)) continue; + applyDriver(seg.driver, channelOf(key), time, seg, ctx); + } + + const cam = transforms.get("camera"); + return { + nodes: transforms, + camera: { position: cam ? cam.position : undefined, fov: ctx.cameraFov }, + materials, + }; +} diff --git a/packages/runtime/src/ir/from-html.ts b/packages/runtime/src/ir/from-html.ts new file mode 100644 index 0000000..fc5339a --- /dev/null +++ b/packages/runtime/src/ir/from-html.ts @@ -0,0 +1,394 @@ +/** + * Lowering front-end: a compiled three.js scene + its markup → SceneIR. + * + * Browser-side (reads three.js objects to resolve base poses and part pivots), + * but emits pure-data SceneIR. Rest poses are read from the objects that + * `compileScene` already built, so the IR base matches the static scene exactly + * (including model `fit`). Part pivots are baked to plain numbers here, keeping + * `evaluate` three.js-free. + * + * Phase 1 covers the verbs the de-risk example needs: turntable/float/sway + * (behaviors) and orbit/move/dolly/zoom (timeline drivers), incl. per-part spin. + */ +import { Box3, Mesh, MeshStandardMaterial, Vector3, type Object3D } from "three"; +import { collectParts, resolvePartIndex } from "../animate"; +import { parseAngleDeg, parseColorString, parseNumber, parseRotationRad, parseScale, parseSeconds, parseVec3 } from "../parse"; +import type { CompiledScene } from "../scene"; +import { VERB_DEFAULT_DURATION } from "../vocab"; +import type { Behavior, Driver, MaterialState, NodeBase, SceneIR, StateOverride, TimelineIR, Vec3 } from "./types"; + +/** First standard material under an object → its base color/roughness/metalness. */ +function baseMaterialOf(obj: Object3D, nameFilter?: string): MaterialState | undefined { + let found: MeshStandardMaterial | undefined; + obj.traverse((child) => { + if (found) return; + const m = (child as Mesh).material; + for (const mat of Array.isArray(m) ? m : m ? [m] : []) { + if (mat instanceof MeshStandardMaterial && (!nameFilter || mat.name === nameFilter)) { + found = mat; + break; + } + } + }); + return found ? { color: `#${found.color.getHexString()}`, roughness: found.roughness, metalness: found.metalness } : undefined; +} + +/** Window duration: the `duration` attr, else the verb's shared default. */ +function durOf(el: Element, verb: string): number { + const a = el.getAttribute("duration"); + const def = VERB_DEFAULT_DURATION[verb] ?? 0; + return a != null && a !== "" ? parseSeconds(a, def) : def; +} + +function trsOf(obj: Object3D): Pick { + return { + position: [obj.position.x, obj.position.y, obj.position.z], + rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z], + scale: [obj.scale.x, obj.scale.y, obj.scale.z], + }; +} + +/** Center as concrete numbers (for computing orbit/dolly defaults). */ +function centerVec(spec: string | null, compiled: CompiledScene): Vec3 { + if (spec && spec.startsWith("#")) { + const obj = compiled.objectsById.get(spec.slice(1)); + if (obj) return [obj.position.x, obj.position.y, obj.position.z]; + } + const v = parseVec3(spec, [0, 0, 0]); + return [v[0], v[1], v[2]]; +} + +/** Center as an IR reference (node id for tracking, or a literal point). */ +function centerRef(spec: string | null): string | Vec3 { + if (spec && spec.startsWith("#")) return spec.slice(1); + const v = parseVec3(spec, [0, 0, 0]); + return [v[0], v[1], v[2]]; +} + +/** Place a windowed clip at its authored start: lead with a wait when start>0. */ +function placeAt(start: number, clip: TimelineIR): TimelineIR { + return start > 0 ? { kind: "seq", children: [{ kind: "wait", duration: start }, clip] } : clip; +} + +export interface Lowered { + scene: SceneIR; + /** id → three.js object the backend writes (camera handled separately). */ + objectMap: Map; +} + +export function lowerScene(compiled: CompiledScene): Lowered { + const nodes: NodeBase[] = []; + const objectMap = new Map(); + + nodes.push({ id: "camera", kind: "camera", ...trsOf(compiled.camera), fov: compiled.camera.fov }); + for (const [id, obj] of compiled.objectsById) { + nodes.push({ id, kind: "model", ...trsOf(obj) }); + objectMap.set(id, obj); + } + + const behaviors: Behavior[] = []; + const clips: TimelineIR[] = []; + const variantEls: Element[] = []; + let partCounter = 0; + + for (const el of Array.from(compiled.host.querySelectorAll("sf-animate"))) { + const verb = (el.getAttribute("verb") ?? "").toLowerCase(); + const targetSpec = el.getAttribute("target"); + + let targetId: string | null = null; + let targetObj: Object3D | null = null; + if (targetSpec === "camera") { + targetId = "camera"; + targetObj = compiled.camera; + } else if (targetSpec && targetSpec.startsWith("#")) { + const id = targetSpec.slice(1); + const obj = compiled.objectsById.get(id); + if (obj) { + targetId = id; + targetObj = obj; + } + } + if (!targetId || !targetObj) continue; // DOM-target verbs (titles) are Phase 2 + + const start = parseSeconds(el.getAttribute("start"), 0); + const ease = el.getAttribute("ease") ?? undefined; + + // Per-part: redirect motion to a synthetic node bound to a child object. + let motionId = targetId; + let pivot: Vec3 | undefined; + const partAttr = el.getAttribute("part"); + if (partAttr !== null && (verb === "turntable" || verb === "sway" || verb === "float" || verb === "move")) { + const parts = collectParts(targetObj); + const partObj = parts[resolvePartIndex(parts, partAttr, 0)] ?? targetObj; + if (partObj !== targetObj) { + const pid = `${targetId}::part${partCounter++}`; + nodes.push({ id: pid, kind: "mesh", ...trsOf(partObj) }); + objectMap.set(pid, partObj); + motionId = pid; + if (verb === "turntable") { + partObj.updateWorldMatrix(true, true); + const box = new Box3().setFromObject(partObj); + if (!box.isEmpty()) { + const cw = box.getCenter(new Vector3()); + if (partObj.parent) partObj.parent.worldToLocal(cw); + pivot = [cw.x, cw.y, cw.z]; + } + } + } + } + + switch (verb) { + case "turntable": + behaviors.push({ + kind: "turntable", + target: motionId, + rpm: parseNumber(el.getAttribute("rpm"), 6), + axis: (el.getAttribute("axis") ?? "y") as "x" | "y" | "z", + pivot, + }); + break; + case "float": + behaviors.push({ + kind: "float", + target: motionId, + amplitude: parseNumber(el.getAttribute("amplitude"), 0.1), + period: parseNumber(el.getAttribute("period"), 4), + }); + break; + case "sway": + behaviors.push({ + kind: "sway", + target: motionId, + amount: (parseNumber(el.getAttribute("amount"), 6) * Math.PI) / 180, + period: parseNumber(el.getAttribute("period"), 5), + }); + break; + case "orbit": { + const duration = durOf(el, verb); + const c = centerVec(el.getAttribute("around"), compiled); + const cam = compiled.camera.position; + const dx = cam.x - c[0]; + const dz = cam.z - c[2]; + const initialRadius = Math.sqrt(dx * dx + dz * dz); + const fromDeg = parseAngleDeg(el.getAttribute("from"), (Math.atan2(dx, dz) * 180) / Math.PI); + const driver: Driver = { + kind: "orbit", + target: targetId, + around: centerRef(el.getAttribute("around")), + radius: parseNumber(el.getAttribute("radius"), initialRadius || 5), + fromDeg, + toDeg: parseAngleDeg(el.getAttribute("to"), fromDeg + 360), + height: parseNumber(el.getAttribute("height"), cam.y - c[1]), + }; + clips.push(placeAt(start, { kind: "clip", driver, duration, ease })); + break; + } + case "dolly": { + const duration = durOf(el, verb); + const driver: Driver = { + kind: "dolly", + target: targetId, + toward: centerRef(el.getAttribute("toward")), + distance: parseNumber(el.getAttribute("distance"), 1), + }; + clips.push(placeAt(start, { kind: "clip", driver, duration, ease })); + break; + } + case "zoom": { + if (targetId !== "camera") break; + const duration = durOf(el, verb); + const fov = compiled.camera.fov; + const driver: Driver = { + kind: "zoom", + target: "camera", + from: parseAngleDeg(el.getAttribute("from"), fov), + to: parseAngleDeg(el.getAttribute("to"), fov), + }; + clips.push(placeAt(start, { kind: "clip", driver, duration, ease })); + break; + } + case "move": { + const toAttr = el.getAttribute("to"); + if (!toAttr) break; + const to = parseVec3(toAttr, [0, 0, 0]); + const fromAttr = el.getAttribute("from"); + const from = fromAttr ? parseVec3(fromAttr, [0, 0, 0]) : null; + const duration = durOf(el, verb); + const driver: Driver = { + kind: "move", + target: motionId, + to: [to[0], to[1], to[2]], + ...(from ? { from: [from[0], from[1], from[2]] as Vec3 } : {}), + }; + clips.push(placeAt(start, { kind: "clip", driver, duration, ease })); + break; + } + case "bounce-in": { + // Scale-in entrance (whole object). Default ease back.out, like legacy. + const ease2 = el.getAttribute("ease") ?? "back.out"; + clips.push(placeAt(start, { kind: "clip", driver: { kind: "bounce-in", target: targetId }, duration: durOf(el, verb), ease: ease2 })); + break; + } + case "fade-in": { + // Opacity 0→1 entrance on a 3D object (DOM-target fade-in stays in legacy). + clips.push(placeAt(start, { kind: "clip", driver: { kind: "fade-in", target: targetId }, duration: durOf(el, verb), ease })); + break; + } + case "variant": + variantEls.push(el); // chained after the loop (from = previous link's to) + break; + case "follow": { + const subjSpec = el.getAttribute("subject"); + if (!subjSpec || !subjSpec.startsWith("#")) break; + const subject = subjSpec.slice(1); + const offAttr = el.getAttribute("offset"); + let offset: Vec3; + if (offAttr) { + const o = parseVec3(offAttr, [0, 2, 5]); + offset = [o[0], o[1], o[2]]; + } else { + // Default to the target's current offset from the subject (rest poses). + const s = centerVec(subjSpec, compiled); + offset = [targetObj.position.x - s[0], targetObj.position.y - s[1], targetObj.position.z - s[2]]; + } + // Continuous: held from start for the whole shot (progress is ignored). + clips.push(placeAt(start, { kind: "clip", driver: { kind: "follow", target: targetId, subject, offset }, duration: compiled.shot.duration })); + break; + } + default: + break; // bounce-in / path / material+clip verbs lower in Phase 2b + } + } + + // Variant chains: group by target + material filter, capture the base material + // from three.js, then thread links so each one's `from` is the previous `to` + // (continuous configurator transitions). The held model gives "latest active + // wins, base before the first" for free. + const variantGroups = new Map(); + for (const el of variantEls) { + const key = `${el.getAttribute("target") ?? ""}|${el.getAttribute("material") ?? "*"}`; + const list = variantGroups.get(key); + if (list) list.push(el); + else variantGroups.set(key, [el]); + } + for (const els of variantGroups.values()) { + const targetSpec = els[0]!.getAttribute("target"); + if (!targetSpec || !targetSpec.startsWith("#")) continue; + const targetId = targetSpec.slice(1); + const obj = compiled.objectsById.get(targetId); + if (!obj) continue; + const nameFilter = els[0]!.getAttribute("material") ?? undefined; + let baseMat: MeshStandardMaterial | undefined; + obj.traverse((child) => { + if (baseMat) return; + const m = (child as Mesh).material; + for (const mat of Array.isArray(m) ? m : m ? [m] : []) { + if (mat instanceof MeshStandardMaterial && (!nameFilter || mat.name === nameFilter)) { + baseMat = mat; + break; + } + } + }); + if (!baseMat) continue; + let from: MaterialState = { + color: `#${baseMat.color.getHexString()}`, + roughness: baseMat.roughness, + metalness: baseMat.metalness, + }; + const sorted = [...els].sort( + (a, b) => parseSeconds(a.getAttribute("start"), 0) - parseSeconds(b.getAttribute("start"), 0), + ); + for (const el of sorted) { + const to: MaterialState = { + color: el.getAttribute("color") ? parseColorString(el.getAttribute("color"), "#ffffff") : from.color, + roughness: el.getAttribute("roughness") != null ? parseNumber(el.getAttribute("roughness"), 0.5) : from.roughness, + metalness: el.getAttribute("metalness") != null ? parseNumber(el.getAttribute("metalness"), 0) : from.metalness, + }; + const driver: Driver = { kind: "variant", target: targetId, ...(nameFilter ? { materialName: nameFilter } : {}), from, to }; + clips.push( + placeAt(parseSeconds(el.getAttribute("start"), 0), { + kind: "clip", + driver, + duration: durOf(el, "variant"), + ease: el.getAttribute("ease") ?? undefined, + }), + ); + from = to; + } + } + + // Named states: declare sparse + // per-node overrides; `initial` + each `to` transition chain (from = previous + // state's value), lowered to per-channel tween (transform) + variant (material) + // segments that the held model resolves ("latest active state wins"). + const stateDefs = new Map>(); + for (const stateEl of Array.from(compiled.host.querySelectorAll("sf-state"))) { + const name = stateEl.getAttribute("name"); + if (!name) continue; + const overrides = new Map(); + for (const setEl of Array.from(stateEl.querySelectorAll("sf-set"))) { + const t = setEl.getAttribute("target"); + if (!t || !t.startsWith("#")) continue; + const o: StateOverride = overrides.get(t.slice(1)) ?? {}; + if (setEl.getAttribute("position")) o.position = parseVec3(setEl.getAttribute("position"), [0, 0, 0]); + if (setEl.getAttribute("rotation")) o.rotation = parseRotationRad(setEl.getAttribute("rotation")); + if (setEl.getAttribute("scale")) o.scale = parseScale(setEl.getAttribute("scale")); + if (setEl.getAttribute("color")) o.color = parseColorString(setEl.getAttribute("color"), "#ffffff"); + if (setEl.getAttribute("roughness")) o.roughness = parseNumber(setEl.getAttribute("roughness"), 0.5); + if (setEl.getAttribute("metalness")) o.metalness = parseNumber(setEl.getAttribute("metalness"), 0); + overrides.set(t.slice(1), o); + } + stateDefs.set(name, overrides); + } + + if (stateDefs.size) { + // Transition sequence: an instant set to `initial` at t=0 (if any), then each + // `to` transition sorted by start. + const transitions: Array<{ t: number; dur: number; ease?: string; state: string }> = []; + const initial = compiled.host.getAttribute("initial"); + if (initial && stateDefs.has(initial)) transitions.push({ t: 0, dur: 0, state: initial }); + for (const el of Array.from(compiled.host.querySelectorAll('sf-animate[verb="to"]'))) { + const state = el.getAttribute("state"); + if (!state || !stateDefs.has(state)) continue; + transitions.push({ t: parseSeconds(el.getAttribute("start"), 0), dur: durOf(el, "to"), ease: el.getAttribute("ease") ?? undefined, state }); + } + transitions.sort((a, b) => a.t - b.t); + + // Per-node current value of each channel (rest until a state sets it). + const cur = new Map(); + for (const tr of transitions) { + const ov = stateDefs.get(tr.state)!; + for (const [id, o] of ov) { + const obj = objectMap.get(id); + if (!obj) continue; + const c = cur.get(id) ?? {}; + const rest = trsOf(obj); + for (const ch of ["position", "rotation", "scale"] as const) { + const to = o[ch]; + if (!to) continue; + const from = c[ch] ?? rest[ch]; + clips.push(placeAt(tr.t, { kind: "clip", driver: { kind: "tween", target: id, channel: ch, from, to }, duration: tr.dur, ease: tr.ease })); + c[ch] = to; + } + if (o.color != null || o.roughness != null || o.metalness != null) { + const baseMat = baseMaterialOf(obj) ?? {}; + const from: MaterialState = { color: c.color ?? baseMat.color, roughness: c.roughness ?? baseMat.roughness, metalness: c.metalness ?? baseMat.metalness }; + const to: MaterialState = { color: o.color ?? from.color, roughness: o.roughness ?? from.roughness, metalness: o.metalness ?? from.metalness }; + clips.push(placeAt(tr.t, { kind: "clip", driver: { kind: "variant", target: id, from, to }, duration: tr.dur, ease: tr.ease })); + c.color = to.color; + c.roughness = to.roughness; + c.metalness = to.metalness; + } + cur.set(id, c); + } + } + } + + const scene: SceneIR = { + nodes, + behaviors, + duration: compiled.shot.duration, + ...(clips.length ? { timeline: { kind: "par", children: clips } as TimelineIR } : {}), + }; + return { scene, objectMap }; +} diff --git a/packages/runtime/src/ir/types.ts b/packages/runtime/src/ir/types.ts new file mode 100644 index 0000000..8e0964b --- /dev/null +++ b/packages/runtime/src/ir/types.ts @@ -0,0 +1,182 @@ +/** + * Stereoframe IR — the intermediate representation between the surface + * and the three.js backend. + * + * INVARIANT: every value here is plain JSON data. No functions, ever. The IR is + * fully serializable (`JSON.stringify(scene)`), so it can be inspected, linted, + * diffed, and round-tripped. All motion math lives in `evaluate.ts`; the IR only + * names *what* moves, *how*, and *when* — never *the computation*. + * + * Spatial half = `nodes` (rest poses + identity). Temporal half = `timeline` + * (windowed parametric drivers, composed) + `behaviors` (continuous, additive). + * `compile()` flattens the timeline to per-channel segments; `evaluate(t)` is a + * pure function that, per channel, holds the segment active at `t`. + */ + +export type Vec3 = [number, number, number]; + +/** The channel a driver writes. Transform channels (position/rotation/scale/fov) + * feed node transforms; opacity/material feed FrameState.materials. One driver + * may own more than one (a path with orient writes position + rotation). */ +export type Channel = "position" | "rotation" | "scale" | "fov" | "opacity" | "material"; + +/** A variant's material endpoint — only the props it changes are set. */ +export interface MaterialState { + color?: string; + roughness?: number; + metalness?: number; +} + +/** A named state's sparse per-node override (transform + material). */ +export interface StateOverride { + position?: Vec3; + rotation?: Vec3; + scale?: Vec3; + color?: string; + roughness?: number; + metalness?: number; +} + +/** A node's rest pose + identity in the spatial graph. + * rotation is Euler radians; orientation tweens (path aim) are converted to a + * quaternion internally then back to a compatible Euler for the backend. */ +export interface NodeBase { + id: string; + kind: "group" | "model" | "mesh" | "camera" | "light"; + position: Vec3; + rotation: Vec3; + scale: Vec3; + /** Camera only — vertical FOV in degrees. */ + fov?: number; +} + +/** Windowed parametric motions. Driver params only — start/duration/ease are + * assigned by the timeline that *places* the driver. A `target`/`around`/ + * `toward`/`subject` of "camera" addresses the camera node. */ +export type Driver = + | { kind: "orbit"; target: string; around: string | Vec3; radius: number; fromDeg: number; toDeg: number; height: number } + | { kind: "move"; target: string; from?: Vec3; to: Vec3 } + | { kind: "dolly"; target: string; toward: string | Vec3; distance: number } + | { kind: "zoom"; target: string; from: number; to: number } + | { kind: "bounce-in"; target: string } + | { kind: "fade-in"; target: string; rise?: number } + /** Generic transform tween (used by named-state transitions). */ + | { kind: "tween"; target: string; channel: "position" | "rotation" | "scale"; from: Vec3; to: Vec3 } + /** One link of a variant chain (sorted by start; from = previous link's to). */ + | { kind: "variant"; target: string; materialName?: string; from: MaterialState; to: MaterialState } + | { kind: "follow"; target: string; subject: string; offset: Vec3 } + | { kind: "path"; target: string; points: Vec3[]; closed: boolean; orient: "ahead" | "none" }; + +/** Continuous, additive motions (no window) — composed on top of the rest pose + * and any timeline drivers. `pivot` (a point in the target's parent space) spins + * the geometry about that point so an off-center part (a wheel) turns in place. */ +export type Behavior = + | { kind: "turntable"; target: string; rpm: number; axis: "x" | "y" | "z"; pivot?: Vec3 } + | { kind: "float"; target: string; amplitude: number; period: number } + | { kind: "sway"; target: string; amount: number; period: number }; + +/** Composable timeline. Leaves (`clip`/`wait`) carry duration; combinators + * (`seq`/`par`/`stagger`/`beat`) place them in time. Durations are inferred + * bottom-up at compile, so authors never hand-compute absolute `start`s. */ +export type TimelineIR = + | { kind: "seq"; children: TimelineIR[] } + | { kind: "par"; children: TimelineIR[] } + | { kind: "stagger"; interval: number; children: TimelineIR[] } + | { kind: "beat"; name: string; at?: number; gap?: number; scale?: number; children: TimelineIR[] } + | { kind: "wait"; duration: number; label?: string } + | { kind: "clip"; driver: Driver; duration: number; ease?: string; label?: string }; + +export interface SceneIR { + nodes: NodeBase[]; + timeline?: TimelineIR; + behaviors: Behavior[]; + /** Explicit total duration (seconds); compile uses max(this, timeline end). */ + duration?: number; +} + +// ── compiled form ─────────────────────────────────────────────────────────── + +/** A driver with resolved absolute timing. */ +export interface Segment { + driver: Driver; + start: number; + duration: number; + ease: string; + label?: string; +} + +export interface Span { + t0: number; + t1: number; +} + +export interface CompiledIR { + nodes: Map; + /** Per-channel segments, key = `"."`, sorted by start. At any + * `t` the channel holds the last segment that has started (rest pose before + * the first) — so sequential moves don't clobber each other. */ + segments: Map; + behaviors: Behavior[]; + duration: number; + labelTimes: Map; + beatTimes: Map; +} + +/** The channels a driver writes (registered under each at compile). */ +export function driverChannels(driver: Driver): Channel[] { + switch (driver.kind) { + case "zoom": + return ["fov"]; + case "bounce-in": + return ["scale"]; + case "fade-in": + return ["opacity"]; + case "variant": + return ["material"]; + case "tween": + return [driver.channel]; + case "path": + return driver.orient === "ahead" ? ["position", "rotation"] : ["position"]; + default: + return ["position"]; + } +} + +/** Reference drivers read other nodes' evaluated poses → resolved in pass 2. */ +export function isReferenceDriver(driver: Driver): boolean { + return driver.kind === "orbit" || driver.kind === "dolly" || driver.kind === "follow"; +} + +/** Entrance drivers hold their `from`-state before their window (scale 0 / opacity + * 0), unlike a mid-timeline tween which rests until it starts. */ +export function isEntranceDriver(driver: Driver): boolean { + return driver.kind === "bounce-in" || driver.kind === "fade-in"; +} + +// ── evaluated form ─────────────────────────────────────────────────────────── + +export interface NodeTransform { + position: Vec3; + rotation: Vec3; + scale: Vec3; +} + +/** Per-node material state at `t`. Color is left as from/to/mix so the backend + * lerps with THREE.Color (byte-identical to legacy); scalars are pre-lerped. */ +export interface MaterialFrame { + /** Fade factor 0..1 (fade-in) applied to every material's opacity under the node. */ + opacity?: number; + /** Restrict variant writes to this material name (else all). */ + materialName?: string; + roughness?: number; + metalness?: number; + color?: { from: string; to: string; mix: number }; +} + +/** The frame at time `t` — a pure function of `t`. The backend applies this to + * three.js objects (`nodes` by id) + the camera, then composes world matrices. */ +export interface FrameState { + nodes: Map; + camera: { position?: Vec3; fov?: number }; + materials: Map; +} diff --git a/packages/runtime/src/scene.ts b/packages/runtime/src/scene.ts index 9a895fe..1fb634b 100644 --- a/packages/runtime/src/scene.ts +++ b/packages/runtime/src/scene.ts @@ -243,7 +243,7 @@ function buildMesh(el: Element, specularAA: boolean, maxAniso: number): THREE.Me return mesh; } -function buildLights(el: Element, scene: THREE.Scene): void { +function buildLights(el: Element, scene: THREE.Object3D): void { const preset = (el.getAttribute("preset") ?? "").toLowerCase(); if (preset === "studio") { scene.add(new THREE.AmbientLight(0xffffff, 0.35)); @@ -439,7 +439,10 @@ export function compileScene(host: HTMLElement): CompiledScene { ); } - for (const el of Array.from(host.children)) { + // Build one element into `parent` (the scene, or an Group). Recursive + // so nests content; three.js composes world matrices (parent ∘ local), + // so animating a group carries its children. Closes over camera/objectsById/etc. + const addElement = (el: Element, parent: THREE.Object3D): void => { const tag = el.tagName.toLowerCase(); if (tag === "sf-camera") { camera.fov = parseAngleDeg(el.getAttribute("fov"), 35); @@ -452,14 +455,14 @@ export function compileScene(host: HTMLElement): CompiledScene { else if (la) lookAt = { point: parseVec3(la, [0, 0, 0]) }; } else if (tag === "sf-mesh") { const mesh = buildMesh(el, specularAA, maxAniso); - scene.add(mesh); + parent.add(mesh); if (el.id) objectsById.set(el.id, mesh); } else if (tag === "sf-model") { const group = new THREE.Group(); // Position/scale on the group (the handle verbs animate); the base pose // rotation goes on the inner gltf.scene below, before `fit` measures it. applyTransform(group, el, { rotation: false }); - scene.add(group); + parent.add(group); if (el.id) objectsById.set(el.id, group); const src = el.getAttribute("src"); const clipAttr = el.getAttribute("clip"); @@ -517,42 +520,42 @@ export function compileScene(host: HTMLElement): CompiledScene { ); } } else if (tag === "sf-light") { - buildLights(el, scene); + buildLights(el, parent); } else if (tag === "sf-particles") { const { points, timeUniform } = buildParticles(el); - scene.add(points); + parent.add(points); if (el.id) objectsById.set(el.id, points); timeUniforms.push(timeUniform); } else if (tag === "sf-sky") { const { sky, sunDirection } = buildSky(el); - scene.add(sky); + parent.add(sky); if (el.id) objectsById.set(el.id, sky); skySunDirection = sunDirection; } else if (tag === "sf-ocean") { const ocean = buildOcean(el); - scene.add(ocean.water); + parent.add(ocean.water); if (el.id) objectsById.set(el.id, ocean.water); timeUniforms.push(ocean.timeProxy); pending.push(ocean.pending); oceans.push(ocean); } else if (tag === "sf-swarm") { const swarm = buildSwarm(el); - scene.add(swarm.mesh); + parent.add(swarm.mesh); if (el.id) objectsById.set(el.id, swarm.mesh); seekFns.push(swarm.writer); } else if (tag === "sf-metaball") { const metaball = buildMetaball(el, buildMeshMaterial(el)); - scene.add(metaball.mesh); + parent.add(metaball.mesh); if (el.id) objectsById.set(el.id, metaball.mesh); seekFns.push(metaball.writer); } else if (tag === "sf-scatter") { const scatter = buildScatter(el, buildGeometry(el), buildMeshMaterial(el)); - scene.add(scatter.mesh); + parent.add(scatter.mesh); if (el.id) objectsById.set(el.id, scatter.mesh); seekFns.push(scatter.writer); } else if (tag === "sf-baked") { const baked = buildBaked(el, buildGeometry(el), buildMeshMaterial(el)); - scene.add(baked.group); + parent.add(baked.group); if (el.id) objectsById.set(el.id, baked.group); pending.push(baked.pending); seekFns.push(baked.writer); @@ -561,11 +564,21 @@ export function compileScene(host: HTMLElement): CompiledScene { const geometry = fullscreen ? new THREE.PlaneGeometry(2, 2) : buildGeometry(el); const { mesh, timeUniform } = buildShader(el, { width, height, geometry, fullscreen }); if (!fullscreen) applyTransform(mesh, el); - scene.add(mesh); + parent.add(mesh); if (el.id) objectsById.set(el.id, mesh); timeUniforms.push(timeUniform); + } else if (tag === "sf-group") { + // Declarative grouping: a transform container. Its content is parented under + // the Group, so a verb on the group's id moves/rotates the whole subtree. + const group = new THREE.Group(); + applyTransform(group, el); + parent.add(group); + if (el.id) objectsById.set(el.id, group); + for (const child of Array.from(el.children)) addElement(child, group); } - } + }; + + for (const el of Array.from(host.children)) addElement(el, scene); // An sun drives specular highlights automatically. if (skySunDirection) { diff --git a/packages/runtime/src/vocab.ts b/packages/runtime/src/vocab.ts index 28d3656..76a3c7d 100644 --- a/packages/runtime/src/vocab.ts +++ b/packages/runtime/src/vocab.ts @@ -10,6 +10,9 @@ export { EASE_NAMES } from "./ease"; export const ELEMENT_NAMES = [ "sf-scene", "sf-camera", + "sf-group", // transform container: groups content so a verb on it moves the subtree + "sf-state", // named state: a set of per-node overrides (core="ir") + "sf-set", // one per-node override inside "sf-model", "sf-mesh", "sf-light", @@ -46,6 +49,7 @@ export const VERB_NAMES = [ "explode", "isolate", "variant", + "to", // transition to a named (core="ir") ] as const; /** sf-* attributes whose value is a local asset path the renderer fetches. */ @@ -101,6 +105,7 @@ export const VERB_PARAMS: Record = { explode: ["distance"], isolate: ["part", "dim"], variant: [], + to: ["state"], }; /** `stereoframe stage --preset` values. */ @@ -110,3 +115,57 @@ export const STAGE_PRESETS = [ /** `stereoframe gen --provider` backends. */ export const GEN_PROVIDERS = ["meshy", "fal"] as const; + +// ── IR (intermediate representation) vocabulary ────────────────────────────── +// The `core="ir"` pipeline (see ir/). Node-safe so it's shared by the runtime +// lowering (ir/from-html.ts) and the render-free IR lint (cli/src/lint.ts), and +// emitted by `stereoframe schema` so agents see the IR surface. + +export const IR_DRIVER_KINDS = ["orbit", "move", "dolly", "zoom", "bounce-in", "fade-in", "variant", "tween", "follow", "path"] as const; +export const IR_BEHAVIOR_KINDS = ["turntable", "float", "sway"] as const; +export const IR_TIMELINE_KINDS = ["seq", "par", "stagger", "beat", "wait", "clip"] as const; +export const IR_CHANNELS = ["position", "rotation", "scale", "fov", "opacity", "material"] as const; + +/** Verbs lowered to a continuous, additive IR behavior. */ +export const IR_BEHAVIOR_VERBS = ["turntable", "float", "sway"] as const; + +/** Windowed verbs the IR models → the transform channel each writes. (Verbs not + * here are handled by the legacy fallthrough — see ir/backend.ts IR_VERBS.) */ +export const IR_VERB_CHANNEL: Record = { + orbit: "position", + move: "position", + dolly: "position", + follow: "position", + "camera-path": "position", + path: "position", + zoom: "fov", + "bounce-in": "scale", + "fade-in": "opacity", + variant: "material", +}; + +/** The node-reference attribute each verb resolves (for dangling-ref checks). */ +export const VERB_REF_ATTR: Record = { + orbit: "around", + dolly: "toward", + follow: "subject", +}; + +/** Default window durations (seconds) for windowed verbs — the single source for + * the runtime lowering and the IR lint (continuous verbs are absent by design). */ +export const VERB_DEFAULT_DURATION: Record = { + orbit: 4, + dolly: 1.5, + zoom: 2, + move: 2, + "bounce-in": 0.6, + "fade-in": 0.6, + "crossfade-clip": 0.5, + "camera-path": 8, + path: 8, + morph: 1, + variant: 0.8, + explode: 2.5, + isolate: 0.8, + to: 1, +}; diff --git a/packages/runtime/tests/ir/compile.test.ts b/packages/runtime/tests/ir/compile.test.ts new file mode 100644 index 0000000..e921426 --- /dev/null +++ b/packages/runtime/tests/ir/compile.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { compile } from "../../src/ir/compile"; +import type { Driver, SceneIR, Segment, TimelineIR } from "../../src/ir/types"; + +const orbitDriver: Driver = { kind: "orbit", target: "camera", around: "product", radius: 4, fromDeg: 0, toDeg: 35, height: 0 }; +const moveDriver: Driver = { kind: "move", target: "a", to: [1, 0, 0] }; + +function scene(timeline: TimelineIR, extra: Partial = {}): SceneIR { + return { nodes: [], behaviors: [], timeline, ...extra }; +} +function starts(segs: Segment[] | undefined): number[] { + return (segs ?? []).map((s) => s.start); +} + +describe("compile — per-channel segments + duration inference", () => { + test("seq sums durations; segments land on the right channel key", () => { + const c = compile( + scene({ + kind: "seq", + children: [ + { kind: "clip", driver: moveDriver, duration: 2 }, + { kind: "wait", duration: 1 }, + { kind: "clip", driver: moveDriver, duration: 3 }, + ], + }), + ); + expect(c.duration).toBe(6); + expect(starts(c.segments.get("a.position"))).toEqual([0, 3]); // wait pushes the 2nd clip + }); + + test("par: each driver keyed by its own target.channel; max span wins", () => { + const c = compile( + scene({ + kind: "par", + children: [ + { kind: "clip", driver: moveDriver, duration: 8 }, + { kind: "clip", driver: orbitDriver, duration: 3 }, + ], + }), + ); + expect(c.duration).toBe(8); + expect(starts(c.segments.get("a.position"))).toEqual([0]); + expect(starts(c.segments.get("camera.position"))).toEqual([0]); + }); + + test("stagger offsets each child by interval (same channel → sorted)", () => { + const c = compile( + scene({ + kind: "stagger", + interval: 0.5, + children: [ + { kind: "clip", driver: moveDriver, duration: 1 }, + { kind: "clip", driver: moveDriver, duration: 1 }, + { kind: "clip", driver: moveDriver, duration: 1 }, + ], + }), + ); + expect(starts(c.segments.get("a.position"))).toEqual([0, 0.5, 1]); + expect(c.duration).toBe(2); + }); + + test("zoom keys the fov channel; explicit scene.duration extends the total", () => { + const c = compile( + scene({ kind: "clip", driver: { kind: "zoom", target: "camera", from: 30, to: 60 }, duration: 2 }, { duration: 10 }), + ); + expect(starts(c.segments.get("camera.fov"))).toEqual([0]); + expect(c.duration).toBe(10); + }); +}); + +describe("compile — beat (rigid span)", () => { + test("scale stretches interior durations; beatTimes/labelTimes recorded", () => { + const c = compile( + scene({ + kind: "beat", + name: "intro", + scale: 2, + children: [ + { kind: "clip", driver: moveDriver, duration: 1, label: "a" }, + { kind: "clip", driver: moveDriver, duration: 1, label: "b" }, + ], + }), + ); + expect((c.segments.get("a.position") ?? []).map((s) => [s.start, s.duration])).toEqual([ + [0, 2], + [2, 2], + ]); + expect(c.beatTimes.get("intro")).toEqual({ t0: 0, t1: 4 }); + expect(c.labelTimes.get("b")).toEqual({ t0: 2, t1: 4 }); + }); + + test("absolute `at` places the beat rigidly", () => { + const c = compile( + scene({ + kind: "seq", + children: [ + { kind: "clip", driver: moveDriver, duration: 2 }, + { kind: "beat", name: "b", at: 5, children: [{ kind: "clip", driver: moveDriver, duration: 1 }] }, + ], + }), + ); + expect(c.beatTimes.get("b")).toEqual({ t0: 5, t1: 6 }); + }); +}); + +describe("compile — ease defaults", () => { + test("clip ease defaults to power1.out; explicit ease is kept per channel", () => { + const c = compile( + scene({ + kind: "par", + children: [ + { kind: "clip", driver: moveDriver, duration: 1 }, + { kind: "clip", driver: orbitDriver, duration: 1, ease: "sine.inOut" }, + ], + }), + ); + expect(c.segments.get("a.position")![0]!.ease).toBe("power1.out"); + expect(c.segments.get("camera.position")![0]!.ease).toBe("sine.inOut"); + }); +}); diff --git a/packages/runtime/tests/ir/evaluate.test.ts b/packages/runtime/tests/ir/evaluate.test.ts new file mode 100644 index 0000000..69310e1 --- /dev/null +++ b/packages/runtime/tests/ir/evaluate.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test"; +import { compile } from "../../src/ir/compile"; +import { evaluate } from "../../src/ir/evaluate"; +import type { NodeBase, SceneIR } from "../../src/ir/types"; + +const camera: NodeBase = { + id: "camera", + kind: "camera", + position: [0, 0.35, 4.4], + rotation: [0, 0, 0], + scale: [1, 1, 1], + fov: 33, +}; +const product: NodeBase = { + id: "product", + kind: "model", + position: [0, 0.15, 0], + rotation: [0, 0, 0], + scale: [6, 6, 6], +}; + +describe("evaluate — drivers", () => { + test("orbit places the camera on the arc around the subject", () => { + const scene: SceneIR = { + nodes: [camera, product], + behaviors: [], + timeline: { + kind: "par", + children: [ + { + kind: "clip", + ease: "linear", + duration: 8, + driver: { kind: "orbit", target: "camera", around: "product", radius: 4, fromDeg: 0, toDeg: 90, height: 0 }, + }, + ], + }, + }; + const c = compile(scene); + + const a = evaluate(c, 0); + expect(a.camera.position![0]).toBeCloseTo(0); + expect(a.camera.position![1]).toBeCloseTo(0.15); // subject y + height + expect(a.camera.position![2]).toBeCloseTo(4); + + const b = evaluate(c, 8); + expect(b.camera.position![0]).toBeCloseTo(4); // 90° → on +X + expect(b.camera.position![2]).toBeCloseTo(0); + }); + + test("zoom ramps camera fov", () => { + const scene: SceneIR = { + nodes: [camera], + behaviors: [], + timeline: { kind: "clip", ease: "linear", duration: 2, driver: { kind: "zoom", target: "camera", from: 30, to: 60 } }, + }; + const c = compile(scene); + expect(evaluate(c, 1).camera.fov).toBeCloseTo(45); + expect(evaluate(c, 2).camera.fov).toBeCloseTo(60); + }); +}); + +describe("evaluate — behaviors", () => { + test("turntable spins about the axis as f(t)", () => { + const scene: SceneIR = { + nodes: [product], + behaviors: [{ kind: "turntable", target: "product", rpm: 5, axis: "y" }], + }; + const c = compile(scene); + // 5 rpm → 0.5 rev at 6s → π + expect(evaluate(c, 6).nodes.get("product")!.rotation[1]).toBeCloseTo(Math.PI); + }); + + test("per-part turntable spins an off-center part about its pivot", () => { + const wheel: NodeBase = { id: "wheel", kind: "mesh", position: [1, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { + nodes: [wheel], + behaviors: [{ kind: "turntable", target: "wheel", rpm: 60, axis: "y", pivot: [0, 0, 0] }], + }; + const c = compile(scene); + const p = evaluate(c, 0.25).nodes.get("wheel")!.position; // 60rpm → quarter rev at 0.25s + expect(p[0]).toBeCloseTo(0); + expect(p[2]).toBeCloseTo(-1); + }); + + test("turntable + sway compose additively on Y (IR fix vs legacy clobber)", () => { + // Legacy `sway` ASSIGNS rotation.y, overwriting turntable's Y spin when both + // run on one object (type-poster). IR adds both — the spin is preserved. + const node: NodeBase = { id: "f", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { + nodes: [node], + behaviors: [ + { kind: "turntable", target: "f", rpm: 6, axis: "y" }, + { kind: "sway", target: "f", amount: 0.15, period: 6 }, + ], + }; + const c = compile(scene); + const spinOnly = (6 / 60) * Math.PI * 2 * 5; // turntable y at t=5 + const y = evaluate(c, 5).nodes.get("f")!.rotation[1]; + expect(y).toBeGreaterThan(spinOnly - 0.2); // spin retained (sway only nudges it) + expect(y).not.toBe(spinOnly); // sway's Y term is added on top + }); + + test("float bobs additively on Y", () => { + const node: NodeBase = { id: "a", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { nodes: [node], behaviors: [{ kind: "float", target: "a", amplitude: 1, period: 4 }] }; + const c = compile(scene); + expect(evaluate(c, 1).nodes.get("a")!.position[1]).toBeCloseTo(1); // quarter period → peak + }); +}); + +describe("evaluate — per-channel hold + dynamic refs", () => { + const a: NodeBase = { id: "a", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + + test("a not-yet-started later move does NOT clobber the active one (hold fix)", () => { + const scene: SceneIR = { + nodes: [a], + behaviors: [], + timeline: { + kind: "seq", + children: [ + { kind: "clip", ease: "linear", duration: 2, driver: { kind: "move", target: "a", to: [10, 0, 0] } }, + { kind: "clip", ease: "linear", duration: 2, driver: { kind: "move", target: "a", from: [10, 0, 0], to: [10, 5, 0] } }, + ], + }, + }; + const c = compile(scene); + expect(evaluate(c, 1).nodes.get("a")!.position).toEqual([5, 0, 0]); // seg1 active, seg2 ignored + expect(evaluate(c, 2).nodes.get("a")!.position).toEqual([10, 0, 0]); // seg2 just held, p=0 + expect(evaluate(c, 4).nodes.get("a")!.position).toEqual([10, 5, 0]); // seg2 done + }); + + test("orbit center tracks a moving subject (pass-2 dynamic ref)", () => { + const cam: NodeBase = { id: "camera", kind: "camera", position: [0, 0, 2], rotation: [0, 0, 0], scale: [1, 1, 1], fov: 35 }; + const s: NodeBase = { id: "s", kind: "model", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { + nodes: [cam, s], + behaviors: [], + timeline: { + kind: "par", + children: [ + { kind: "clip", ease: "linear", duration: 4, driver: { kind: "move", target: "s", to: [10, 0, 0] } }, + { kind: "clip", ease: "linear", duration: 4, driver: { kind: "orbit", target: "camera", around: "s", radius: 2, fromDeg: 0, toDeg: 0, height: 0 } }, + ], + }, + }; + const c = compile(scene); + const cp = evaluate(c, 4).camera.position!; + expect(cp[0]).toBeCloseTo(10); // subject moved to x=10, camera orbits around it + expect(cp[2]).toBeCloseTo(2); + }); + + test("follow tracks a moving subject at a fixed offset", () => { + const cam: NodeBase = { id: "camera", kind: "camera", position: [0, 1, 5], rotation: [0, 0, 0], scale: [1, 1, 1], fov: 35 }; + const s: NodeBase = { id: "s", kind: "model", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { + nodes: [cam, s], + behaviors: [], + timeline: { + kind: "par", + children: [ + { kind: "clip", ease: "linear", duration: 4, driver: { kind: "move", target: "s", to: [10, 0, 0] } }, + { kind: "clip", ease: "linear", duration: 4, driver: { kind: "follow", target: "camera", subject: "s", offset: [0, 1, 5] } }, + ], + }, + }; + const c = compile(scene); + expect(evaluate(c, 4).camera.position).toEqual([10, 1, 5]); + }); +}); + +describe("evaluate — entrance + material channels", () => { + const a: NodeBase = { id: "a", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [2, 2, 2] }; + + test("bounce-in holds scale 0 before its window, then scales to rest", () => { + const scene: SceneIR = { + nodes: [a], + behaviors: [], + timeline: { kind: "seq", children: [{ kind: "wait", duration: 1 }, { kind: "clip", ease: "linear", duration: 0.6, driver: { kind: "bounce-in", target: "a" } }] }, + }; + const c = compile(scene); + expect(evaluate(c, 0.5).nodes.get("a")!.scale).toEqual([0, 0, 0]); // before start = from(0) + expect(evaluate(c, 1.3).nodes.get("a")!.scale[0]).toBeCloseTo(1); // half → rest×0.5 + expect(evaluate(c, 2).nodes.get("a")!.scale).toEqual([2, 2, 2]); // done = rest + }); + + test("fade-in holds opacity 0 before its window, then ramps to 1", () => { + const scene: SceneIR = { + nodes: [a], + behaviors: [], + timeline: { kind: "seq", children: [{ kind: "wait", duration: 1 }, { kind: "clip", ease: "linear", duration: 0.6, driver: { kind: "fade-in", target: "a" } }] }, + }; + const c = compile(scene); + expect(evaluate(c, 0.5).materials.get("a")!.opacity).toBe(0); + expect(evaluate(c, 1.3).materials.get("a")!.opacity).toBeCloseTo(0.5); + expect(evaluate(c, 2).materials.get("a")!.opacity).toBe(1); + }); + + test("variant chain: base before first, latest-active-wins, from=prev.to", () => { + const g: NodeBase = { id: "g", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const v1: SceneIR["timeline"] = { kind: "clip", ease: "linear", duration: 1, driver: { kind: "variant", target: "g", from: { color: "#111111" }, to: { color: "#ff0000" } } }; + const v2: SceneIR["timeline"] = { kind: "clip", ease: "linear", duration: 1, driver: { kind: "variant", target: "g", from: { color: "#ff0000" }, to: { color: "#00ff00" } } }; + const scene: SceneIR = { + nodes: [g], + behaviors: [], + timeline: { kind: "seq", children: [{ kind: "wait", duration: 1 }, v1, { kind: "wait", duration: 1 }, v2] }, + }; + const c = compile(scene); + expect(evaluate(c, 0.5).materials.get("g")).toBeUndefined(); // before first variant (not an entrance) + expect(evaluate(c, 1).materials.get("g")!.color).toEqual({ from: "#111111", to: "#ff0000", mix: 0 }); + expect(evaluate(c, 1.5).materials.get("g")!.color!.mix).toBeCloseTo(0.5); + expect(evaluate(c, 2.5).materials.get("g")!.color!.mix).toBe(1); // between v1 end and v2 start → v1 held at p=1 + expect(evaluate(c, 3.5).materials.get("g")!.color).toEqual({ from: "#ff0000", to: "#00ff00", mix: 0.5 }); + }); + + test("tween drives an arbitrary transform channel; state chain via held", () => { + const node: NodeBase = { id: "a", kind: "mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; + const scene: SceneIR = { + nodes: [node], + behaviors: [], + timeline: { + kind: "seq", + children: [ + { kind: "clip", ease: "linear", duration: 1, driver: { kind: "tween", target: "a", channel: "rotation", from: [0, 0, 0], to: [0, Math.PI, 0] } }, + { kind: "clip", ease: "linear", duration: 1, driver: { kind: "tween", target: "a", channel: "rotation", from: [0, Math.PI, 0], to: [0, 0, 0] } }, + ], + }, + }; + const c = compile(scene); + expect(evaluate(c, 0.5).nodes.get("a")!.rotation[1]).toBeCloseTo(Math.PI / 2); + expect(evaluate(c, 1).nodes.get("a")!.rotation[1]).toBeCloseTo(Math.PI); // state A reached + expect(evaluate(c, 1.5).nodes.get("a")!.rotation[1]).toBeCloseTo(Math.PI / 2); + expect(evaluate(c, 2).nodes.get("a")!.rotation[1]).toBeCloseTo(0); // back to initial + }); +}); + +describe("evaluate — determinism", () => { + const scene: SceneIR = { + nodes: [camera, product], + behaviors: [{ kind: "turntable", target: "product", rpm: 5, axis: "y" }], + timeline: { + kind: "par", + children: [ + { + kind: "clip", + ease: "sine.inOut", + duration: 8, + driver: { kind: "orbit", target: "camera", around: "product", radius: 4, fromDeg: 0, toDeg: 35, height: 0 }, + }, + ], + }, + }; + const c = compile(scene); + + function snap(t: number): string { + const fs = evaluate(c, t); + return JSON.stringify({ cam: fs.camera, prod: fs.nodes.get("product") }); + } + + test("evaluating the same t twice is identical", () => { + expect(snap(3.21)).toBe(snap(3.21)); + }); + + test("out-of-order seeks match in-order (no cross-t state)", () => { + const ref = snap(3.21); + snap(7); + snap(0); + snap(5.5); + expect(snap(3.21)).toBe(ref); + }); +});