@jgengine/editor 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ import { generateStreets } from "@jgengine/core/world/streetGenerator";
2
+ /** Fully-defaulted slider preset for an open street net (resolves to `net` in `streetNetworkMode`). */
3
+ const NET_DEFAULTS = {
4
+ gridness: 0.9,
5
+ loopiness: 0.2,
6
+ connectivity: 0.35,
7
+ branching: 0.3,
8
+ deadEnds: 0.5,
9
+ segmentLength: 48,
10
+ aspect: 1,
11
+ winding: 0.2,
12
+ minCurveRadius: 30,
13
+ minTurnAngle: 0,
14
+ maxTurnAngle: 120,
15
+ width: 7,
16
+ boulevards: 0.3,
17
+ };
18
+ /** Fully-defaulted slider preset for a closed race circuit (resolves to `circuit` in `streetNetworkMode`). */
19
+ const CIRCUIT_DEFAULTS = {
20
+ gridness: 0.1,
21
+ loopiness: 1,
22
+ connectivity: 0,
23
+ branching: 0,
24
+ deadEnds: 0,
25
+ segmentLength: 60,
26
+ aspect: 1,
27
+ winding: 0.5,
28
+ minCurveRadius: 24,
29
+ minTurnAngle: 0,
30
+ maxTurnAngle: 70,
31
+ width: 12,
32
+ boulevards: 0,
33
+ };
34
+ /** The numeric slider keys a caller may overlay onto the mode preset via `params`. */
35
+ const RULE_KEYS = [
36
+ "gridness",
37
+ "loopiness",
38
+ "connectivity",
39
+ "branching",
40
+ "deadEnds",
41
+ "segmentLength",
42
+ "aspect",
43
+ "winding",
44
+ "minCurveRadius",
45
+ "minTurnAngle",
46
+ "maxTurnAngle",
47
+ "width",
48
+ "boulevards",
49
+ ];
50
+ /** The tag every baked path carries in `meta.generator`, so a re-run can find and replace its own output. */
51
+ const GENERATOR_TAG = "streetGenerator";
52
+ /** Stable id prefix for a seed's baked paths — the idempotency key for replace-on-re-run. */
53
+ function genPrefix(seed) {
54
+ return `gen-${seed}-`;
55
+ }
56
+ /** True when a path was baked by this generator for `seed` (matches id prefix OR the meta tag). */
57
+ function isGeneratedForSeed(path, seed) {
58
+ if (path.id.startsWith(genPrefix(seed)))
59
+ return true;
60
+ const meta = path.meta;
61
+ return meta !== undefined && meta.generator === GENERATOR_TAG && meta.seed === seed;
62
+ }
63
+ /** Half-extents (hx, hz) and world origin a volume fills, so the generator grows inside its footprint. */
64
+ function volumeFootprint(volume) {
65
+ if (volume.shape === "box" && volume.halfExtents !== undefined) {
66
+ return { hx: Math.max(1, volume.halfExtents.x), hz: Math.max(1, volume.halfExtents.z), origin: volume.center };
67
+ }
68
+ // sphere / cylinder (or a box missing halfExtents): a radius is symmetric in x/z.
69
+ const r = Math.max(1, volume.radius ?? 0);
70
+ return { hx: r, hz: r, origin: volume.center };
71
+ }
72
+ /** Document, selection, camera, mode, asset placement, and status verbs. */
73
+ export const streetsHandlers = {
74
+ generate_streets: (ctx, request) => {
75
+ const doc = ctx.session.getState().document;
76
+ const seed = request.seed ?? "path";
77
+ const requestedMode = request.mode === "circuit" ? "circuit" : "net";
78
+ // Resolve the footprint + world origin from a named volume or from explicit bounds.
79
+ let hx;
80
+ let hz;
81
+ let origin;
82
+ if (request.volumeId !== undefined) {
83
+ const volume = doc.volumes.find((v) => v.id === request.volumeId);
84
+ if (volume === undefined)
85
+ return { ok: false, error: `volume not found: ${request.volumeId}` };
86
+ ({ hx, hz, origin } = volumeFootprint(volume));
87
+ }
88
+ else {
89
+ const center = request.center ?? { x: 0, y: 0, z: 0 };
90
+ if (request.halfX === undefined || request.halfZ === undefined) {
91
+ return { ok: false, error: "generate_streets requires volumeId, or center + halfX + halfZ" };
92
+ }
93
+ hx = Math.max(1, request.halfX);
94
+ hz = Math.max(1, request.halfZ);
95
+ origin = center;
96
+ }
97
+ // Build rules: mode preset, overlaid with any numeric slider overrides from `params`.
98
+ const preset = requestedMode === "circuit" ? CIRCUIT_DEFAULTS : NET_DEFAULTS;
99
+ const rules = { seed, ...preset };
100
+ const params = request.params;
101
+ if (params !== undefined) {
102
+ for (const key of RULE_KEYS) {
103
+ const value = params[key];
104
+ if (typeof value === "number" && Number.isFinite(value)) {
105
+ rules[key] = value;
106
+ }
107
+ }
108
+ }
109
+ const network = generateStreets(rules, hx, hz);
110
+ // The generator resolves the ACTUAL topology from the rules; use it for the default path kind so a
111
+ // circuit bakes `route`s and a net bakes `road`s even when the caller's sliders cross the boundary.
112
+ const kind = request.kind ?? (network.mode === "circuit" ? "route" : "road");
113
+ const baseY = origin.y;
114
+ const toWorld = (street) => street.points.map(([lx, lz]) => ({ x: origin.x + lx, y: baseY, z: origin.z + lz }));
115
+ // Through-streets → one path each (a circuit's ring is a single closed loop street: first ≈ last).
116
+ const newPaths = [];
117
+ network.streets.forEach((street, index) => {
118
+ const points = toWorld(street);
119
+ if (points.length < 2)
120
+ return;
121
+ newPaths.push({
122
+ id: `${genPrefix(seed)}${index}`,
123
+ kind,
124
+ points,
125
+ width: street.width,
126
+ label: `${kind} ${index}`,
127
+ meta: {
128
+ generator: GENERATOR_TAG,
129
+ seed,
130
+ mode: network.mode,
131
+ level: street.level,
132
+ loop: street.loop,
133
+ },
134
+ });
135
+ });
136
+ // Idempotent replace: drop this seed's prior baked paths, keep everything else, append the fresh set —
137
+ // all as ONE undoable structural edit via replaceDocument.
138
+ const kept = doc.paths.filter((p) => !isGeneratedForSeed(p, seed));
139
+ const nextDoc = { ...doc, paths: [...kept, ...newPaths] };
140
+ const { applied } = ctx.dispatchGuarded({ type: "replaceDocument", document: nextDoc });
141
+ if (!applied)
142
+ return { ok: false, error: "generate_streets: document replace had no effect" };
143
+ // replaceDocument clears selection; re-select the freshly baked paths for the caller.
144
+ ctx.session.dispatch({ type: "select", ids: newPaths.map((p) => p.id) });
145
+ return {
146
+ ok: true,
147
+ result: {
148
+ seed,
149
+ requestedMode,
150
+ mode: network.mode,
151
+ kind,
152
+ pathCount: newPaths.length,
153
+ removed: doc.paths.length - kept.length,
154
+ ids: newPaths.map((p) => p.id),
155
+ footprint: { hx, hz, origin },
156
+ },
157
+ };
158
+ },
159
+ };
@@ -71,6 +71,7 @@ export const RPC_FIELD_SCHEMAS = {
71
71
  set_terrain_layers: [req(oa("layers", [req(s("id")), req(s("surface")), n("roughness"), n("tiling"), b("triplanar"), s("tint"), n("opacity")]))],
72
72
  blend_terrain: [req(s("surface")), req(n("x")), req(n("z")), n("radius"), n("strength"), s("shape", SHAPE)],
73
73
  convert_scatter: [req(s("pathId"))],
74
+ generate_streets: [s("volumeId"), v3("center"), n("halfX"), n("halfZ"), s("seed"), s("mode", ["net", "circuit"]), s("kind"), o("params")],
74
75
  bake_minimap: [n("padding"), n("resolution"), n("waterLevel")],
75
76
  add_foliage: [req(oa("points", [req(n("x")), req(n("z"))])), n("density"), s("item"), s("seed"), n("minSpacing")],
76
77
  scatter_summary: [],
package/dist/mcp/tools.js CHANGED
@@ -64,6 +64,7 @@ const TOOL_DESCRIPTIONS = [
64
64
  { name: "set_terrain_layers", description: "Replace the reorderable terrain material layer stack; a params-only edit keeps painted blends." },
65
65
  { name: "blend_terrain", description: "Blend-paint a material layer's weight at x/z (weighted multi-layer blend); auto-adds the layer." },
66
66
  { name: "convert_scatter", description: "Detach a foliage/scatter region into individually-editable placed prop markers, removing the region." },
67
+ { name: "generate_streets", description: "Bake the seed-driven street generator into the scene as authorable road/route paths inside a volume (or explicit center+halfX/halfZ). mode net→roads, circuit→closed route; params overlays sliders (gridness/loopiness/connectivity/winding/...). Idempotent: re-running the same seed replaces its own prior output. One undoable edit; round-trips --save." },
67
68
  { name: "bake_minimap", description: "Rasterize the authored terrain into a stored minimap PNG on the document (runtime reads it as the Minimap background). Live-editor only — needs the mounted viewport's terrain sampler, not a headless CLI." },
68
69
  { name: "add_foliage", description: "Add a foliage/scatter region from a closed polygon (≥3 x/z points) with density and item." },
69
70
  { name: "scatter_summary", description: "Count foliage/scatter regions and their total deterministic instance placements." },
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
3
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
4
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
5
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
6
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
7
+ *
8
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
9
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
10
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
11
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
12
+ *
13
+ * @internal
14
+ */
15
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
16
+ export declare const LOCOMOTION_ROLES: readonly ["idle", "walk", "run"];
17
+ /** Locomotion clip role a placement can override (idle/walk/run). */
18
+ export type LocomotionRole = (typeof LOCOMOTION_ROLES)[number];
19
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
20
+ export declare const LOCOMOTION_NUMBERS: readonly ["walkSpeed", "runSpeed", "fadeSec"];
21
+ /** Numeric locomotion tuning field on an authored animation config (walkSpeed/runSpeed/fadeSec). */
22
+ export type LocomotionNumber = (typeof LOCOMOTION_NUMBERS)[number];
23
+ /**
24
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
25
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
26
+ */
27
+ export declare const ONE_SHOT_EVENTS: readonly ["attack", "hit", "death", "jump", "interact", "cheer"];
28
+ /** One-shot animation event a placement can bind a clip to (attack/hit/death/...). */
29
+ export type OneShotEvent = (typeof ONE_SHOT_EVENTS)[number];
30
+ /** Role->clip override map persisted inside an authored animation config. */
31
+ export interface AuthoredAnimationStates {
32
+ idle?: string;
33
+ walk?: string;
34
+ run?: string;
35
+ walkSpeed?: number;
36
+ runSpeed?: number;
37
+ fadeSec?: number;
38
+ }
39
+ /** Structurally compatible with `ModelAnimationConfig`, but every field optional for authoring. */
40
+ export interface AuthoredAnimationConfig {
41
+ clip?: string;
42
+ loop?: boolean;
43
+ timeScale?: number;
44
+ states?: AuthoredAnimationStates;
45
+ oneShots?: Record<string, string>;
46
+ }
47
+ /** The value stored at `meta.animation`. */
48
+ export type AnimationSetting = AuthoredAnimationConfig | "auto" | "none";
49
+ /** Which authoring mode a stored setting represents. `default` = no override key. */
50
+ export type AnimationMode = "default" | "auto" | "none" | "custom";
51
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
52
+ export declare function readAnimationSetting(meta: Record<string, unknown> | undefined): AnimationSetting | undefined;
53
+ /** The authoring mode a stored setting maps to. */
54
+ export declare function animationMode(setting: AnimationSetting | undefined): AnimationMode;
55
+ /**
56
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
57
+ * a working config, then overrides) when there is no existing object config; the other modes are the
58
+ * bare string/undefined values.
59
+ */
60
+ export declare function setAnimationMode(setting: AnimationSetting | undefined, mode: AnimationMode, clips?: readonly string[]): AnimationSetting | undefined;
61
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
62
+ export declare function defaultCustomConfig(clips: readonly string[]): AuthoredAnimationConfig;
63
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
64
+ export declare function setLocomotionClip(setting: AnimationSetting | undefined, role: LocomotionRole, clipName: string | null): AuthoredAnimationConfig;
65
+ /** Sets or clears (null) a numeric locomotion tuning. */
66
+ export declare function setLocomotionNumber(setting: AnimationSetting | undefined, key: LocomotionNumber, value: number | null): AuthoredAnimationConfig;
67
+ /** Binds or clears (null) a one-shot event to a clip. */
68
+ export declare function setOneShotClip(setting: AnimationSetting | undefined, event: string, clipName: string | null): AuthoredAnimationConfig;
69
+ /**
70
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
71
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
72
+ */
73
+ export declare function animationMetaPatch(setting: AnimationSetting | undefined): {
74
+ animation: AnimationSetting | undefined;
75
+ };
@@ -0,0 +1,164 @@
1
+ import { rolesFromClips } from "@jgengine/core/game/clipRoles";
2
+ /**
3
+ * Authoring model for a placement's `ModelConfig.animation`, persisted on a marker's `meta.animation`
4
+ * and consumed by the shell exactly like a game-authored config. Pure data + reducers, no JSX — the
5
+ * inspector section wires these to `session.dispatch({ type: "setMarker", ... })` so every edit is
6
+ * undoable like any other inspector change. Splitting this out mirrors how `pathFlythrough.ts` /
7
+ * `renameObject.ts` keep inspector logic testable apart from the panel.
8
+ *
9
+ * The stored value is either the string modes `"auto"` / `"none"` (see `resolveAnimationConfig`) or a
10
+ * concrete config object. `undefined` means "no override" — a catalog-resolved rigged asset still
11
+ * auto-animates from its clip roles. Authored types here are permissive supersets of core's
12
+ * `ModelAnimationConfig` so an in-progress edit (idle picked, walk not yet) is still storable.
13
+ *
14
+ * @internal
15
+ */
16
+ /** Locomotion state roles the speed-driven idle/walk/run crossfade reads. */
17
+ export const LOCOMOTION_ROLES = ["idle", "walk", "run"];
18
+ /** Numeric locomotion tunings (`ModelAnimationStates`). */
19
+ export const LOCOMOTION_NUMBERS = ["walkSpeed", "runSpeed", "fadeSec"];
20
+ /**
21
+ * One-shot event slots offered in the inspector. `hit` / `death` auto-fire on this entity's
22
+ * `combat.hitReaction` / `entity.died`; the rest fire when the game calls `playEntityAnimation`.
23
+ */
24
+ export const ONE_SHOT_EVENTS = ["attack", "hit", "death", "jump", "interact", "cheer"];
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ /** Reads and shape-validates the animation override from a marker's meta; undefined when absent/invalid. */
29
+ export function readAnimationSetting(meta) {
30
+ const value = meta?.["animation"];
31
+ if (value === "auto" || value === "none")
32
+ return value;
33
+ if (!isRecord(value))
34
+ return undefined;
35
+ const config = {};
36
+ if (typeof value["clip"] === "string")
37
+ config.clip = value["clip"];
38
+ if (typeof value["loop"] === "boolean")
39
+ config.loop = value["loop"];
40
+ if (typeof value["timeScale"] === "number")
41
+ config.timeScale = value["timeScale"];
42
+ if (isRecord(value["states"])) {
43
+ const raw = value["states"];
44
+ const states = {};
45
+ for (const role of LOCOMOTION_ROLES)
46
+ if (typeof raw[role] === "string")
47
+ states[role] = raw[role];
48
+ for (const key of LOCOMOTION_NUMBERS)
49
+ if (typeof raw[key] === "number")
50
+ states[key] = raw[key];
51
+ if (Object.keys(states).length > 0)
52
+ config.states = states;
53
+ }
54
+ if (isRecord(value["oneShots"])) {
55
+ const raw = value["oneShots"];
56
+ const oneShots = {};
57
+ for (const [event, clip] of Object.entries(raw)) {
58
+ if (typeof clip === "string")
59
+ oneShots[event] = clip;
60
+ else if (Array.isArray(clip) && typeof clip[0] === "string")
61
+ oneShots[event] = clip[0];
62
+ }
63
+ if (Object.keys(oneShots).length > 0)
64
+ config.oneShots = oneShots;
65
+ }
66
+ return config;
67
+ }
68
+ /** The authoring mode a stored setting maps to. */
69
+ export function animationMode(setting) {
70
+ if (setting === undefined)
71
+ return "default";
72
+ if (setting === "auto")
73
+ return "auto";
74
+ if (setting === "none")
75
+ return "none";
76
+ return "custom";
77
+ }
78
+ function asConfig(setting) {
79
+ return setting !== undefined && setting !== "auto" && setting !== "none" ? setting : {};
80
+ }
81
+ function normalizeConfig(config) {
82
+ const next = { ...config };
83
+ if (next.states !== undefined && Object.keys(next.states).length === 0)
84
+ delete next.states;
85
+ if (next.oneShots !== undefined && Object.keys(next.oneShots).length === 0)
86
+ delete next.oneShots;
87
+ return next;
88
+ }
89
+ /**
90
+ * Switches authoring mode. `custom` seeds from the asset's clip-role defaults (so the user starts from
91
+ * a working config, then overrides) when there is no existing object config; the other modes are the
92
+ * bare string/undefined values.
93
+ */
94
+ export function setAnimationMode(setting, mode, clips = []) {
95
+ if (mode === "default")
96
+ return undefined;
97
+ if (mode === "auto")
98
+ return "auto";
99
+ if (mode === "none")
100
+ return "none";
101
+ if (setting !== undefined && setting !== "auto" && setting !== "none")
102
+ return setting;
103
+ return normalizeConfig(defaultCustomConfig(clips));
104
+ }
105
+ /** A concrete custom config derived from a rigged asset's clip roles — the `custom` mode seed. */
106
+ export function defaultCustomConfig(clips) {
107
+ const roles = rolesFromClips(clips);
108
+ const states = {};
109
+ if (roles.idle?.[0] !== undefined)
110
+ states.idle = roles.idle[0];
111
+ if (roles.walk?.[0] !== undefined)
112
+ states.walk = roles.walk[0];
113
+ if (roles.run?.[0] !== undefined)
114
+ states.run = roles.run[0];
115
+ const oneShots = {};
116
+ for (const event of ONE_SHOT_EVENTS) {
117
+ const variant = roles[event]?.[0];
118
+ if (variant !== undefined)
119
+ oneShots[event] = variant;
120
+ }
121
+ const config = {};
122
+ if (Object.keys(states).length > 0)
123
+ config.states = states;
124
+ if (Object.keys(oneShots).length > 0)
125
+ config.oneShots = oneShots;
126
+ return config;
127
+ }
128
+ /** Sets or clears (null) a locomotion state clip; forces the setting into `custom`. */
129
+ export function setLocomotionClip(setting, role, clipName) {
130
+ const config = asConfig(setting);
131
+ const states = { ...config.states };
132
+ if (clipName === null)
133
+ delete states[role];
134
+ else
135
+ states[role] = clipName;
136
+ return normalizeConfig({ ...config, states });
137
+ }
138
+ /** Sets or clears (null) a numeric locomotion tuning. */
139
+ export function setLocomotionNumber(setting, key, value) {
140
+ const config = asConfig(setting);
141
+ const states = { ...config.states };
142
+ if (value === null || !Number.isFinite(value))
143
+ delete states[key];
144
+ else
145
+ states[key] = value;
146
+ return normalizeConfig({ ...config, states });
147
+ }
148
+ /** Binds or clears (null) a one-shot event to a clip. */
149
+ export function setOneShotClip(setting, event, clipName) {
150
+ const config = asConfig(setting);
151
+ const oneShots = { ...config.oneShots };
152
+ if (clipName === null)
153
+ delete oneShots[event];
154
+ else
155
+ oneShots[event] = clipName;
156
+ return normalizeConfig({ ...config, oneShots });
157
+ }
158
+ /**
159
+ * The meta patch that persists a setting (or clears the override when undefined). Shallow-merges onto
160
+ * `marker.meta` via `setMarker`; an undefined value drops out of the saved JSON document.
161
+ */
162
+ export function animationMetaPatch(setting) {
163
+ return { animation: setting };
164
+ }
package/dist/session.d.ts CHANGED
@@ -195,6 +195,20 @@ export type EditorBridgeRequest = {
195
195
  } | {
196
196
  method: "convert_scatter";
197
197
  pathId: string;
198
+ } | {
199
+ method: "generate_streets";
200
+ volumeId?: string;
201
+ center?: {
202
+ x: number;
203
+ y: number;
204
+ z: number;
205
+ };
206
+ halfX?: number;
207
+ halfZ?: number;
208
+ seed?: string;
209
+ mode?: "net" | "circuit";
210
+ kind?: string;
211
+ params?: Record<string, unknown>;
198
212
  } | {
199
213
  method: "bake_minimap";
200
214
  padding?: number;
@@ -1,17 +1,19 @@
1
1
  import type { EditorSession } from "@jgengine/core/editor/index";
2
+ import type { EditorAssetEntry } from "../AssetBrowser.js";
2
3
  import type { EditorHostApi } from "../session.js";
4
+ import type { EditorUiStore } from "../uiStore.js";
3
5
  /**
4
- * Animation dock: path flythrough scrubber over authored scene polylines.
5
- *
6
- * Scope (honest, no fake NLE):
7
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
8
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
9
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
10
- * stored outside the scene document.
6
+ * Animation dock with two modes:
7
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
8
+ * no fabricated keyframes).
9
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
10
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
11
11
  *
12
12
  * @internal
13
13
  */
14
- export declare function AnimationPanel({ session, api }: {
14
+ export declare function AnimationPanel({ session, api, ui, assets, }: {
15
15
  session: EditorSession;
16
16
  api: EditorHostApi;
17
+ ui: EditorUiStore;
18
+ assets: readonly EditorAssetEntry[];
17
19
  }): import("react").JSX.Element;
@@ -1,23 +1,123 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { useStoreSelector } from "../useStoreSelector.js";
3
+ import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
4
+ import { shallowArrayEqual, useStoreSelector } from "../useStoreSelector.js";
5
+ import { advancePreview, clipEntriesFromRef, initialClipPreviewState, MAX_PREVIEW_SPEED, MIN_PREVIEW_SPEED, scrubPreview, selectPreviewClip, setPreviewLoop, setPreviewSpeed, togglePreviewPlaying, } from "./clipPreview.js";
4
6
  import { listScrubbablePaths, samplePathAt } from "./pathFlythrough.js";
5
7
  import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
6
8
  import { EmptyState, IconButton } from "./ui.js";
7
9
  /** Default world-units/sec when playing a path flythrough preview. */
8
10
  const DEFAULT_PLAY_SPEED = 8;
11
+ /** Rigged asset entries (carry animation clips) for the clip-preview picker. */
12
+ function riggedAssets(assets) {
13
+ return assets.filter((entry) => entry.url !== undefined && entry.clips !== undefined && entry.clips.length > 0);
14
+ }
9
15
  /**
10
- * Animation dock: path flythrough scrubber over authored scene polylines.
11
- *
12
- * Scope (honest, no fake NLE):
13
- * - Scrub / play the editor orbit camera along document paths via {@link EditorHostApi.setFocusTarget}.
14
- * - Uses real path points + `@jgengine/core` path-follow sampling — not invented keyframes.
15
- * - Out of scope: clip authoring, keyframe curves, scene-kind param animation, director cinematics
16
- * stored outside the scene document.
16
+ * Animation dock with two modes:
17
+ * - **Path** — scrub/play the editor orbit camera along authored scene polylines (real path points,
18
+ * no fabricated keyframes).
19
+ * - **Clips** — preview a rigged asset's animation clips in the viewport (scrub, loop, speed) via the
20
+ * shell mixer, either a placed instance's asset or any rigged catalog asset.
17
21
  *
18
22
  * @internal
19
23
  */
20
- export function AnimationPanel({ session, api }) {
24
+ export function AnimationPanel({ session, api, ui, assets, }) {
25
+ const rigged = useMemo(() => riggedAssets(assets), [assets]);
26
+ // Default to clips only when the scene has rigged assets but no scrubbable path yet.
27
+ const paths = useStoreSelector(session, (state) => state.document.paths);
28
+ const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
29
+ // Tear down the viewport preview whenever the panel leaves clip mode or unmounts.
30
+ useEffect(() => {
31
+ if (mode !== "clips" && ui.getState().clipPreview !== null)
32
+ ui.patch({ clipPreview: null });
33
+ }, [mode, ui]);
34
+ useEffect(() => () => {
35
+ if (ui.getState().clipPreview !== null)
36
+ ui.patch({ clipPreview: null });
37
+ }, [ui]);
38
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: _jsx("div", { className: "flex items-center gap-0.5", role: "tablist", "aria-label": "Animation mode", children: [
39
+ ["path", "Path flythrough"],
40
+ ["clips", "Clip preview"],
41
+ ].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === id, className: `rounded-[5px] px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${mode === id ? "bg-cyan-500/15 text-cyan-200" : "text-neutral-500 hover:text-neutral-300"}`, onClick: () => setMode(id), children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
42
+ }
43
+ /** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
44
+ function ClipPreview({ session, ui, rigged, }) {
45
+ const preview = useStoreSelector(ui, (state) => state.clipPreview);
46
+ const selection = useStoreSelector(session, (state) => state.selection, shallowArrayEqual);
47
+ // A placed marker in the selection that resolves to a rigged catalog asset — offer to preview it.
48
+ const selectedRigged = useMemo(() => {
49
+ const document = session.getState().document;
50
+ for (const id of selection) {
51
+ const marker = document.markers.find((entry) => entry.id === id);
52
+ if (marker === undefined)
53
+ continue;
54
+ const catalogId = markerCatalogId(marker);
55
+ if (catalogId === null)
56
+ continue;
57
+ const asset = rigged.find((entry) => entry.id === catalogId);
58
+ if (asset !== undefined)
59
+ return asset;
60
+ }
61
+ return null;
62
+ }, [selection, rigged, session]);
63
+ const startPreview = useCallback((asset) => {
64
+ if (asset.url === undefined || asset.clips === undefined)
65
+ return;
66
+ const firstClip = asset.clips[0] ?? null;
67
+ ui.patch({
68
+ clipPreview: {
69
+ source: { assetId: asset.id, label: asset.label, url: asset.url, clips: asset.clips },
70
+ driver: initialClipPreviewState(firstClip),
71
+ duration: 0,
72
+ },
73
+ });
74
+ }, [ui]);
75
+ const updateDriver = useCallback((next) => {
76
+ const current = ui.getState().clipPreview;
77
+ if (current === null)
78
+ return;
79
+ ui.patch({ clipPreview: { ...current, driver: next } });
80
+ }, [ui]);
81
+ // While playing with a known clip duration, advance the driver each frame (freerunning mixer + slider in sync).
82
+ const previewRef = useRef(preview);
83
+ previewRef.current = preview;
84
+ const playing = preview?.driver.playing ?? false;
85
+ useEffect(() => {
86
+ if (!playing)
87
+ return;
88
+ let frame = 0;
89
+ let last = performance.now();
90
+ const tick = (now) => {
91
+ const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
92
+ last = now;
93
+ const current = previewRef.current;
94
+ if (current === null || !current.driver.playing)
95
+ return;
96
+ const next = advancePreview(current.driver, dt, current.duration);
97
+ if (next !== current.driver)
98
+ ui.patch({ clipPreview: { ...current, driver: next } });
99
+ frame = requestAnimationFrame(tick);
100
+ };
101
+ frame = requestAnimationFrame(tick);
102
+ return () => cancelAnimationFrame(frame);
103
+ }, [playing, ui, preview?.duration, preview?.source.assetId]);
104
+ if (rigged.length === 0) {
105
+ return (_jsx(EmptyState, { icon: "film", title: "Clip preview", badge: "Rigged assets", description: "Preview animation clips in the viewport. No rigged assets in this game's catalog \u2014 imported/catalog models carrying animation clips (\u25B6 badge in the Content Browser) show up here." }));
106
+ }
107
+ const source = preview?.source ?? null;
108
+ const driver = preview?.driver ?? null;
109
+ const duration = preview?.duration ?? 0;
110
+ const clipEntries = clipEntriesFromRef(source);
111
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-7 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Clip preview" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [rigged.length, " rigged asset", rigged.length === 1 ? "" : "s", " \u00B7 viewport playback"] }), selectedRigged !== null && selectedRigged.id !== source?.assetId ? (_jsx("button", { type: "button", className: `ml-auto rounded-[5px] border border-cyan-400/30 bg-cyan-500/15 px-2 py-0.5 text-[10px] text-cyan-200 transition-colors hover:bg-cyan-500/25 ${FOCUS_RING}`, onClick: () => startPreview(selectedRigged), title: `Preview the selected placement's rig (${selectedRigged.label})`, children: "Preview selection" })) : null] }), _jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-3", children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Rigged asset" }), _jsxs("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: source?.assetId ?? "", "aria-label": "Preview asset", onChange: (event) => {
112
+ const asset = rigged.find((entry) => entry.id === event.target.value);
113
+ if (asset !== undefined)
114
+ startPreview(asset);
115
+ }, children: [_jsx("option", { value: "", disabled: true, children: "Select a rigged asset\u2026" }), rigged.map((asset) => (_jsxs("option", { value: asset.id, children: [asset.label, " \u00B7 ", asset.clips?.length ?? 0, " clips"] }, asset.id)))] })] }), source === null || driver === null ? (_jsx("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: "Pick a rigged asset above (or select a placed instance and press \u201CPreview selection\u201D) to play its clips at the camera focus point." })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Clip" }), _jsx("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: driver.clipName ?? "", "aria-label": "Preview clip", onChange: (event) => updateDriver(selectPreviewClip(driver, event.target.value)), children: clipEntries.map((entry) => (_jsxs("option", { value: entry.name, children: [entry.name, entry.role === null ? "" : ` · ${entry.role}`] }, entry.name))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(IconButton, { icon: driver.playing ? "pause" : "play", label: driver.playing ? "Pause clip" : "Play clip", size: 14, active: driver.playing, disabled: driver.clipName === null, onClick: () => updateDriver(togglePreviewPlaying(driver)) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${driver.loop
116
+ ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
117
+ : "border-white/[0.07] bg-white/[0.02] text-neutral-500 hover:text-neutral-300"}`, "aria-pressed": driver.loop, "aria-label": driver.loop ? "Loop on" : "Loop off", onClick: () => updateDriver(setPreviewLoop(driver, !driver.loop)), children: "Loop" }), _jsxs("div", { className: "ml-auto flex items-center gap-1.5", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Speed" }), _jsx("input", { type: "range", min: MIN_PREVIEW_SPEED, max: MAX_PREVIEW_SPEED, step: 0.05, value: driver.speed, "aria-label": "Playback speed", className: `h-2 w-24 accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => updateDriver(setPreviewSpeed(driver, Number(event.target.value))) }), _jsxs("span", { className: `w-8 text-right text-[10px] text-neutral-400 ${NUMERIC}`, children: [driver.speed.toFixed(2), "\u00D7"] })] })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between text-[9px] uppercase tracking-wider text-neutral-600", children: [_jsx("span", { children: "Scrub" }), _jsxs("span", { className: NUMERIC, children: [driver.time.toFixed(2), duration > 0 ? ` / ${duration.toFixed(2)}` : "", " s"] })] }), _jsx("input", { type: "range", min: 0, max: duration > 0 ? duration : 1, step: 0.01, value: Math.min(driver.time, duration > 0 ? duration : 1), "aria-label": "Clip time", disabled: driver.clipName === null || duration <= 0, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => updateDriver(scrubPreview(driver, Number(event.target.value))) })] }), _jsxs("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: ["Plays the selected clip on ", source.label, " at the camera focus point. Same mixer the game runs at play time. Authoring lives in the inspector\u2019s Animation section on a placed instance."] })] }))] })] }));
118
+ }
119
+ /** Path flythrough mode: scrub the orbit camera along authored scene polylines. */
120
+ function PathFlythrough({ session, api }) {
21
121
  const paths = useStoreSelector(session, (state) => state.document.paths);
22
122
  const scrubbable = useMemo(() => listScrubbablePaths(paths), [paths]);
23
123
  const [selectedId, setSelectedId] = useState(null);
@@ -114,11 +214,11 @@ export function AnimationPanel({ session, api }) {
114
214
  };
115
215
  }, [playing, selected, applyProgress, session]);
116
216
  if (scrubbable.length === 0) {
117
- return (_jsx(EmptyState, { icon: "film", title: "Path flythrough", badge: "Scoped", description: "Scope: scrub the orbit camera along authored scene paths (routes, roads, patrols). Draw a path with 2+ points via Add \u2192 path/road, then open this tab. Keyframe clip authoring and scene-kind param animation are not in scope." }));
217
+ return (_jsx(EmptyState, { icon: "film", title: "Path flythrough", badge: "Scoped", description: "Scope: scrub the orbit camera along authored scene paths (routes, roads, patrols). Draw a path with 2+ points via Add \u2192 path/road, then open this tab. Switch to Clip preview above to play a rigged asset's animations." }));
118
218
  }
119
219
  const lengthLabel = selected === null ? "—" : selected.length < 10 ? `${selected.length.toFixed(1)} m` : `${Math.round(selected.length)} m`;
120
220
  const distance = selected === null || selected.length <= 0 ? 0 : progress * selected.length;
121
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Path flythrough" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [scrubbable.length, " path", scrubbable.length === 1 ? "" : "s", " \u00B7 camera follows scrub"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause flythrough" : "Play flythrough", size: 12, active: playing, disabled: selected === null || selected.length <= 0, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${loop
221
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex h-7 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: [_jsx("span", { className: "text-[10px] uppercase tracking-wider text-neutral-500", children: "Path flythrough" }), _jsxs("span", { className: `text-[10px] text-neutral-600 ${NUMERIC}`, children: [scrubbable.length, " path", scrubbable.length === 1 ? "" : "s", " \u00B7 camera follows scrub"] }), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [_jsx(IconButton, { icon: playing ? "pause" : "play", label: playing ? "Pause flythrough" : "Play flythrough", size: 12, active: playing, disabled: selected === null || selected.length <= 0, onClick: () => setPlaying((value) => !value) }), _jsx("button", { type: "button", className: `rounded-[5px] border px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${loop
122
222
  ? "border-cyan-400/30 bg-cyan-500/15 text-cyan-200"
123
223
  : "border-white/[0.07] bg-white/[0.02] text-neutral-500 hover:text-neutral-300"}`, "aria-pressed": loop, "aria-label": loop ? "Loop on" : "Loop off", onClick: () => setLoop((value) => !value), children: "Loop" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1 gap-3 overflow-auto p-3", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsxs("label", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-600", children: "Path" }), _jsx("select", { className: `${INPUT_CLS} h-8 px-2 text-[12px]`, value: selected?.id ?? "", "aria-label": "Flythrough path", onChange: (event) => onSelectPath(event.target.value), children: scrubbable.map((entry) => (_jsxs("option", { value: entry.id, children: [entry.label, " \u00B7 ", entry.kind, " \u00B7 ", entry.pointCount, " pts"] }, entry.id))) })] }), _jsxs("label", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between text-[9px] uppercase tracking-wider text-neutral-600", children: [_jsx("span", { children: "Progress" }), _jsxs("span", { className: NUMERIC, children: [(progress * 100).toFixed(0), "% \u00B7 ", distance.toFixed(1), " / ", lengthLabel.replace(" m", ""), " m"] })] }), _jsx("input", { type: "range", min: 0, max: 1, step: 0.001, value: progress, "aria-label": "Flythrough progress", disabled: selected === null || selected.length <= 0, className: `h-2 w-full accent-cyan-400 ${FOCUS_RING}`, onChange: (event) => onScrub(Number(event.target.value)) })] }), _jsxs("p", { className: "text-[10px] leading-relaxed text-neutral-600", children: ["Scope: real path points only \u2014 no fabricated keyframes. Play moves the orbit focus along the polyline at", " ", DEFAULT_PLAY_SPEED, " u/s. Edit points in the viewport or inspector."] })] }), _jsxs("div", { className: "flex w-40 shrink-0 flex-col gap-1.5", children: [_jsx(Meta, { label: "Kind", value: selected?.kind ?? "—" }), _jsx(Meta, { label: "Points", value: selected === null ? "—" : String(selected.pointCount) }), _jsx(Meta, { label: "Length", value: lengthLabel }), _jsx(Meta, { label: "At", value: selectedPath === undefined
124
224
  ? "—"