@jgengine/editor 0.11.0 → 0.13.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.
Files changed (171) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +12 -0
  3. package/dist/AssetBrowser.d.ts +33 -1
  4. package/dist/AssetBrowser.js +64 -2
  5. package/dist/AssetDropZone.d.ts +9 -0
  6. package/dist/AssetDropZone.js +65 -0
  7. package/dist/CatalogsPanel.d.ts +4 -2
  8. package/dist/CatalogsPanel.js +162 -9
  9. package/dist/CityPreview.d.ts +11 -0
  10. package/dist/CityPreview.js +45 -0
  11. package/dist/CollectionsPanel.js +5 -5
  12. package/dist/DebugDraw.d.ts +3 -1
  13. package/dist/DebugDraw.js +37 -19
  14. package/dist/EditorApp.d.ts +15 -1
  15. package/dist/EditorApp.js +147 -35
  16. package/dist/EditorChrome.d.ts +27 -6
  17. package/dist/EditorChrome.js +314 -130
  18. package/dist/InspectorPanel.d.ts +17 -7
  19. package/dist/InspectorPanel.js +173 -27
  20. package/dist/LightingPanel.d.ts +11 -0
  21. package/dist/LightingPanel.js +99 -0
  22. package/dist/MaterialsWorkspacePanel.d.ts +12 -0
  23. package/dist/MaterialsWorkspacePanel.js +86 -0
  24. package/dist/ParentPickerMenu.d.ts +13 -0
  25. package/dist/ParentPickerMenu.js +33 -0
  26. package/dist/PerfProbe.js +33 -0
  27. package/dist/PrefabsPanel.js +5 -5
  28. package/dist/RuntimePlayBridge.d.ts +5 -3
  29. package/dist/RuntimePlayBridge.js +6 -13
  30. package/dist/ScatterPreview.d.ts +3 -1
  31. package/dist/ScatterPreview.js +10 -7
  32. package/dist/SchemaInspector.js +21 -8
  33. package/dist/ScriptingPanel.d.ts +15 -0
  34. package/dist/ScriptingPanel.js +46 -0
  35. package/dist/SelectionGizmo.js +114 -30
  36. package/dist/StandaloneEditor.d.ts +16 -9
  37. package/dist/StandaloneEditor.js +19 -20
  38. package/dist/TerrainPanel.d.ts +3 -2
  39. package/dist/TerrainPanel.js +16 -5
  40. package/dist/TerrainSculpt.js +98 -59
  41. package/dist/agent/AgentPanel.d.ts +5 -2
  42. package/dist/agent/AgentPanel.js +11 -6
  43. package/dist/agent/toolBridge.js +5 -0
  44. package/dist/assetImport.d.ts +36 -0
  45. package/dist/assetImport.js +66 -0
  46. package/dist/authoredComponentMeta.d.ts +25 -0
  47. package/dist/authoredComponentMeta.js +65 -0
  48. package/dist/gizmoPivot.d.ts +4 -0
  49. package/dist/gizmoPivot.js +34 -0
  50. package/dist/handlers/catalogs.d.ts +3 -0
  51. package/dist/handlers/catalogs.js +137 -0
  52. package/dist/handlers/collections.d.ts +3 -0
  53. package/dist/handlers/collections.js +57 -0
  54. package/dist/handlers/context.d.ts +36 -0
  55. package/dist/handlers/context.js +1 -0
  56. package/dist/handlers/document.d.ts +3 -0
  57. package/dist/handlers/document.js +350 -0
  58. package/dist/handlers/grids.d.ts +3 -0
  59. package/dist/handlers/grids.js +142 -0
  60. package/dist/handlers/hierarchy.d.ts +3 -0
  61. package/dist/handlers/hierarchy.js +55 -0
  62. package/dist/handlers/index.d.ts +8 -0
  63. package/dist/handlers/index.js +25 -0
  64. package/dist/handlers/minimap.d.ts +9 -0
  65. package/dist/handlers/minimap.js +32 -0
  66. package/dist/handlers/runtime.d.ts +3 -0
  67. package/dist/handlers/runtime.js +191 -0
  68. package/dist/handlers/streets.d.ts +3 -0
  69. package/dist/handlers/streets.js +159 -0
  70. package/dist/handlers/terrain.d.ts +3 -0
  71. package/dist/handlers/terrain.js +204 -0
  72. package/dist/index.d.ts +3 -2
  73. package/dist/index.js +3 -2
  74. package/dist/listDocumentTriggers.d.ts +42 -0
  75. package/dist/listDocumentTriggers.js +49 -0
  76. package/dist/materialAssignments.d.ts +63 -0
  77. package/dist/materialAssignments.js +84 -0
  78. package/dist/mcp/fieldSpec.d.ts +32 -0
  79. package/dist/mcp/fieldSpec.js +174 -0
  80. package/dist/mcp/rpcRequest.d.ts +8 -5
  81. package/dist/mcp/rpcRequest.js +72 -93
  82. package/dist/mcp/tools.js +105 -912
  83. package/dist/networkSnapshot.d.ts +82 -0
  84. package/dist/networkSnapshot.js +73 -0
  85. package/dist/parentCandidates.d.ts +12 -0
  86. package/dist/parentCandidates.js +25 -0
  87. package/dist/session.d.ts +91 -4
  88. package/dist/session.js +36 -1017
  89. package/dist/shell/AnimationPanel.d.ts +17 -0
  90. package/dist/shell/AnimationPanel.js +134 -0
  91. package/dist/shell/AssetThumbnail.d.ts +12 -0
  92. package/dist/shell/AssetThumbnail.js +23 -0
  93. package/dist/shell/BottomDock.d.ts +25 -0
  94. package/dist/shell/BottomDock.js +40 -0
  95. package/dist/shell/CommandPalette.d.ts +12 -0
  96. package/dist/shell/CommandPalette.js +56 -0
  97. package/dist/shell/ConsolePanel.d.ts +8 -0
  98. package/dist/shell/ConsolePanel.js +45 -0
  99. package/dist/shell/ContentBrowser.d.ts +21 -0
  100. package/dist/shell/ContentBrowser.js +130 -0
  101. package/dist/shell/HierarchyPanel.d.ts +26 -0
  102. package/dist/shell/HierarchyPanel.js +387 -0
  103. package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
  104. package/dist/shell/NetworkWorkspacePanel.js +49 -0
  105. package/dist/shell/PlayModeBar.d.ts +12 -0
  106. package/dist/shell/PlayModeBar.js +27 -0
  107. package/dist/shell/ProfilerPanel.d.ts +10 -0
  108. package/dist/shell/ProfilerPanel.js +41 -0
  109. package/dist/shell/SceneToolbar.d.ts +43 -0
  110. package/dist/shell/SceneToolbar.js +122 -0
  111. package/dist/shell/StatusBar.d.ts +18 -0
  112. package/dist/shell/StatusBar.js +41 -0
  113. package/dist/shell/TopAppBar.d.ts +35 -0
  114. package/dist/shell/TopAppBar.js +64 -0
  115. package/dist/shell/ViewportOverlays.d.ts +23 -0
  116. package/dist/shell/ViewportOverlays.js +136 -0
  117. package/dist/shell/WorkspaceRail.d.ts +20 -0
  118. package/dist/shell/WorkspaceRail.js +31 -0
  119. package/dist/shell/cameraTelemetry.d.ts +27 -0
  120. package/dist/shell/cameraTelemetry.js +24 -0
  121. package/dist/shell/commandRegistry.d.ts +58 -0
  122. package/dist/shell/commandRegistry.js +133 -0
  123. package/dist/shell/consoleSink.d.ts +11 -0
  124. package/dist/shell/consoleSink.js +17 -0
  125. package/dist/shell/consoleStore.d.ts +27 -0
  126. package/dist/shell/consoleStore.js +43 -0
  127. package/dist/shell/fields.d.ts +39 -0
  128. package/dist/shell/fields.js +104 -0
  129. package/dist/shell/formatSavedRelative.d.ts +2 -0
  130. package/dist/shell/formatSavedRelative.js +14 -0
  131. package/dist/shell/glbThumbnailCache.d.ts +45 -0
  132. package/dist/shell/glbThumbnailCache.js +241 -0
  133. package/dist/shell/hierarchyA11y.d.ts +21 -0
  134. package/dist/shell/hierarchyA11y.js +35 -0
  135. package/dist/shell/icons.d.ts +13 -0
  136. package/dist/shell/icons.js +85 -0
  137. package/dist/shell/layoutStore.d.ts +68 -0
  138. package/dist/shell/layoutStore.js +156 -0
  139. package/dist/shell/pathFlythrough.d.ts +37 -0
  140. package/dist/shell/pathFlythrough.js +54 -0
  141. package/dist/shell/perfHistory.d.ts +70 -0
  142. package/dist/shell/perfHistory.js +130 -0
  143. package/dist/shell/renameObject.d.ts +3 -0
  144. package/dist/shell/renameObject.js +25 -0
  145. package/dist/shell/theme.d.ts +36 -0
  146. package/dist/shell/theme.js +37 -0
  147. package/dist/shell/ui.d.ts +84 -0
  148. package/dist/shell/ui.js +90 -0
  149. package/dist/shell/useGlbThumbnail.d.ts +8 -0
  150. package/dist/shell/useGlbThumbnail.js +19 -0
  151. package/dist/uiStore.d.ts +27 -0
  152. package/dist/uiStore.js +10 -0
  153. package/dist/viewportContextMenu.d.ts +4 -2
  154. package/dist/viewportContextMenu.js +5 -1
  155. package/package.json +4 -4
  156. package/skills/jgengine/SKILL.md +8 -4
  157. package/skills/jgengine/api.md +182 -81
  158. package/skills/jgengine/capabilities.md +104 -2
  159. package/skills/jgengine/recipes/minimal-game.md +88 -0
  160. package/skills/jgengine-editor/SKILL.md +18 -3
  161. package/skills/jgengine-editor/api.md +403 -20
  162. package/skills/jgengine-editor/capabilities.md +46 -0
  163. package/skills/jgengine-editor/reference.md +87 -0
  164. package/skills/level-design/SKILL.md +54 -0
  165. package/skills/level-design/agents/openai.yaml +4 -0
  166. package/skills/level-design/references/application-playbook.md +192 -0
  167. package/skills/level-design/references/field-guide.md +337 -0
  168. package/skills/level-design/references/live-editor-loop.md +179 -0
  169. package/dist/OutlinerPanel.d.ts +0 -14
  170. package/dist/OutlinerPanel.js +0 -84
  171. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,54 @@
1
+ import { pathFollowSeek, pathLength, } from "@jgengine/core/nav/pathFollow";
2
+ /**
3
+ * Builds the path-follow config used by the flythrough scrubber. Pure; no document mutation.
4
+ * Returns null when the path cannot be sampled (fewer than two points).
5
+ *
6
+ * @internal
7
+ */
8
+ export function pathFollowConfigFromEditorPath(path) {
9
+ if (path.points.length < 2)
10
+ return null;
11
+ const waypoints = path.points.map((point) => [point.x, point.y, point.z]);
12
+ return { waypoints, speed: 1 };
13
+ }
14
+ /**
15
+ * Lists document paths that can be scrubbed, with length metadata for the dock UI.
16
+ *
17
+ * @internal
18
+ */
19
+ export function listScrubbablePaths(paths) {
20
+ return paths
21
+ .map((path) => {
22
+ const config = pathFollowConfigFromEditorPath(path);
23
+ const length = config === null ? 0 : pathLength(config);
24
+ return {
25
+ id: path.id,
26
+ kind: path.kind,
27
+ label: path.label?.trim() || path.id,
28
+ pointCount: path.points.length,
29
+ length,
30
+ };
31
+ })
32
+ .filter((entry) => entry.pointCount >= 2);
33
+ }
34
+ /**
35
+ * Samples a world position along an authored path at normalized progress `t` in `[0, 1]`.
36
+ * Clamps `t`. Returns null when the path has fewer than two points.
37
+ *
38
+ * @internal
39
+ */
40
+ export function samplePathAt(path, t) {
41
+ const config = pathFollowConfigFromEditorPath(path);
42
+ if (config === null)
43
+ return null;
44
+ const length = pathLength(config);
45
+ const clamped = Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : 0;
46
+ const state = pathFollowSeek(config, { kind: "normalized", value: clamped });
47
+ return {
48
+ x: state.position[0],
49
+ y: state.position[1],
50
+ z: state.position[2],
51
+ distance: length * clamped,
52
+ length,
53
+ };
54
+ }
@@ -0,0 +1,70 @@
1
+ import type { EditorPerfSample } from "../session.js";
2
+ /**
3
+ * Rolling history of real {@link EditorPerfSample}s for the Profiler dock tab's frame-time graph.
4
+ * Fed by the same 500ms host poll the toolbar pill uses; never fabricates values. Kept outside
5
+ * React state so recording continues while the profiler tab is closed without rerendering anything.
6
+ */
7
+ export interface PerfHistoryStore {
8
+ push(sample: EditorPerfSample): void;
9
+ getSamples(): readonly EditorPerfSample[];
10
+ clear(): void;
11
+ setPaused(paused: boolean): void;
12
+ isPaused(): boolean;
13
+ subscribe(listener: () => void): () => void;
14
+ }
15
+ /** Samples retained (~2 minutes at the 500ms poll). */
16
+ export declare const PERF_HISTORY_CAPACITY = 240;
17
+ /** Creates the rolling perf-sample history store. */
18
+ export declare function createPerfHistoryStore(): PerfHistoryStore;
19
+ /** Mean of a numeric series, 0 for empty input. */
20
+ export declare function seriesAverage(values: readonly number[]): number;
21
+ /**
22
+ * Mean of defined values only — used for optional series (`simMs` / `outsideMs` / `memoryMb`)
23
+ * so zeros from absent samples do not drag the average down.
24
+ *
25
+ * @internal
26
+ */
27
+ export declare function seriesAverageDefined(values: readonly (number | undefined)[]): number;
28
+ /** Builds an SVG polyline `points` string for a series scaled into a width×height box. */
29
+ export declare function sparklinePoints(values: readonly number[], width: number, height: number, maxValue: number): string;
30
+ /** Minimal frame-stats shape consumed when folding core `devtools.frame.stats()` into a sample. */
31
+ export interface FrameBudgetStatsInput {
32
+ avgSimMs: number;
33
+ avgOutsideMs: number;
34
+ phases: readonly {
35
+ name: string;
36
+ avgMs: number;
37
+ }[];
38
+ }
39
+ /** Optional sim / outside / phase fields derived from real frame-tracker stats (never invented). */
40
+ export interface FrameBudgetFields {
41
+ simMs: number;
42
+ outsideMs: number;
43
+ phases?: readonly {
44
+ name: string;
45
+ avgMs: number;
46
+ }[];
47
+ }
48
+ /**
49
+ * Map core `devtools.frame.stats()` into optional editor sample fields.
50
+ * Returns `null` when the frame tracker has no samples yet — callers must omit the series entirely.
51
+ *
52
+ * @internal
53
+ */
54
+ export declare function frameBudgetFromStats(stats: FrameBudgetStatsInput | null): FrameBudgetFields | null;
55
+ /**
56
+ * True when any sample carries real sim/outside budget fields from the frame tracker.
57
+ *
58
+ * @internal
59
+ */
60
+ export declare function samplesHaveFrameBudget(samples: readonly EditorPerfSample[]): boolean;
61
+ /**
62
+ * Latest non-empty phase list from the history (newest sample that reported phases).
63
+ * Empty when no named `measure()` marks have been recorded.
64
+ *
65
+ * @internal
66
+ */
67
+ export declare function latestPhases(samples: readonly EditorPerfSample[]): readonly {
68
+ name: string;
69
+ avgMs: number;
70
+ }[];
@@ -0,0 +1,130 @@
1
+ /** Samples retained (~2 minutes at the 500ms poll). */
2
+ export const PERF_HISTORY_CAPACITY = 240;
3
+ /** Creates the rolling perf-sample history store. */
4
+ export function createPerfHistoryStore() {
5
+ let samples = [];
6
+ let paused = false;
7
+ let lastSampledAt = -1;
8
+ const listeners = new Set();
9
+ const emit = () => {
10
+ for (const listener of listeners)
11
+ listener();
12
+ };
13
+ return {
14
+ push(sample) {
15
+ if (paused)
16
+ return;
17
+ // The host poll re-reads the same sample between probe windows; only record fresh ones.
18
+ if (sample.sampledAt === lastSampledAt)
19
+ return;
20
+ lastSampledAt = sample.sampledAt;
21
+ samples = [...samples, sample];
22
+ if (samples.length > PERF_HISTORY_CAPACITY)
23
+ samples = samples.slice(samples.length - PERF_HISTORY_CAPACITY);
24
+ emit();
25
+ },
26
+ getSamples: () => samples,
27
+ clear() {
28
+ samples = [];
29
+ lastSampledAt = -1;
30
+ emit();
31
+ },
32
+ setPaused(next) {
33
+ if (paused === next)
34
+ return;
35
+ paused = next;
36
+ emit();
37
+ },
38
+ isPaused: () => paused,
39
+ subscribe(listener) {
40
+ listeners.add(listener);
41
+ return () => {
42
+ listeners.delete(listener);
43
+ };
44
+ },
45
+ };
46
+ }
47
+ /** Mean of a numeric series, 0 for empty input. */
48
+ export function seriesAverage(values) {
49
+ if (values.length === 0)
50
+ return 0;
51
+ let sum = 0;
52
+ for (const value of values)
53
+ sum += value;
54
+ return sum / values.length;
55
+ }
56
+ /**
57
+ * Mean of defined values only — used for optional series (`simMs` / `outsideMs` / `memoryMb`)
58
+ * so zeros from absent samples do not drag the average down.
59
+ *
60
+ * @internal
61
+ */
62
+ export function seriesAverageDefined(values) {
63
+ let sum = 0;
64
+ let count = 0;
65
+ for (const value of values) {
66
+ if (value === undefined)
67
+ continue;
68
+ sum += value;
69
+ count += 1;
70
+ }
71
+ return count === 0 ? 0 : sum / count;
72
+ }
73
+ /** Builds an SVG polyline `points` string for a series scaled into a width×height box. */
74
+ export function sparklinePoints(values, width, height, maxValue) {
75
+ if (values.length === 0 || maxValue <= 0)
76
+ return "";
77
+ const step = values.length > 1 ? width / (values.length - 1) : 0;
78
+ return values
79
+ .map((value, index) => {
80
+ const x = values.length > 1 ? index * step : width / 2;
81
+ const y = height - Math.min(1, value / maxValue) * height;
82
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
83
+ })
84
+ .join(" ");
85
+ }
86
+ const MAX_PHASES_IN_SAMPLE = 6;
87
+ function roundMs(value) {
88
+ return Math.round(value * 100) / 100;
89
+ }
90
+ /**
91
+ * Map core `devtools.frame.stats()` into optional editor sample fields.
92
+ * Returns `null` when the frame tracker has no samples yet — callers must omit the series entirely.
93
+ *
94
+ * @internal
95
+ */
96
+ export function frameBudgetFromStats(stats) {
97
+ if (stats === null)
98
+ return null;
99
+ const phases = stats.phases
100
+ .filter((phase) => Number.isFinite(phase.avgMs) && phase.avgMs > 0 && phase.name.length > 0)
101
+ .slice(0, MAX_PHASES_IN_SAMPLE)
102
+ .map((phase) => ({ name: phase.name, avgMs: roundMs(phase.avgMs) }));
103
+ return {
104
+ simMs: roundMs(stats.avgSimMs),
105
+ outsideMs: roundMs(stats.avgOutsideMs),
106
+ ...(phases.length > 0 ? { phases } : {}),
107
+ };
108
+ }
109
+ /**
110
+ * True when any sample carries real sim/outside budget fields from the frame tracker.
111
+ *
112
+ * @internal
113
+ */
114
+ export function samplesHaveFrameBudget(samples) {
115
+ return samples.some((sample) => sample.simMs !== undefined && sample.outsideMs !== undefined);
116
+ }
117
+ /**
118
+ * Latest non-empty phase list from the history (newest sample that reported phases).
119
+ * Empty when no named `measure()` marks have been recorded.
120
+ *
121
+ * @internal
122
+ */
123
+ export function latestPhases(samples) {
124
+ for (let index = samples.length - 1; index >= 0; index -= 1) {
125
+ const phases = samples[index]?.phases;
126
+ if (phases !== undefined && phases.length > 0)
127
+ return phases;
128
+ }
129
+ return [];
130
+ }
@@ -0,0 +1,3 @@
1
+ import { type EditorSession } from "@jgengine/core/editor/index";
2
+ /** Renames a placeable object by writing its label (notes use `text`). Returns false if unknown. @internal */
3
+ export declare function renameEditorObject(session: EditorSession, id: string, nextLabel: string): boolean;
@@ -0,0 +1,25 @@
1
+ import { findEditorMarker, findEditorNote, findEditorPath, findEditorVolume, } from "@jgengine/core/editor/index";
2
+ /** Renames a placeable object by writing its label (notes use `text`). Returns false if unknown. @internal */
3
+ export function renameEditorObject(session, id, nextLabel) {
4
+ const label = nextLabel.trim();
5
+ if (label.length === 0)
6
+ return false;
7
+ const doc = session.getState().document;
8
+ if (findEditorMarker(doc, id) !== undefined) {
9
+ session.dispatch({ type: "setMarker", id, patch: { label } });
10
+ return true;
11
+ }
12
+ if (findEditorVolume(doc, id) !== undefined) {
13
+ session.dispatch({ type: "setVolume", id, patch: { label } });
14
+ return true;
15
+ }
16
+ if (findEditorPath(doc, id) !== undefined) {
17
+ session.dispatch({ type: "setPath", id, patch: { label } });
18
+ return true;
19
+ }
20
+ if (findEditorNote(doc, id) !== undefined) {
21
+ session.dispatch({ type: "setNote", id, patch: { text: label } });
22
+ return true;
23
+ }
24
+ return false;
25
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Editor shell design tokens as Tailwind class constants. One place owns the layered dark theme
3
+ * (app < panel < control elevation), text scale, and interaction states so every shell component
4
+ * reads identically. Class strings are complete literals so the host app's Tailwind scan keeps them.
5
+ */
6
+ /** Application background — the near-black base everything sits on. */
7
+ export declare const APP_BG = "bg-[#0b0d10]";
8
+ /** Panel surface — one elevation step above the app background. */
9
+ export declare const PANEL_BG = "bg-[#111318]";
10
+ /** Elevated control surface — inputs, chips, dropdowns. */
11
+ export declare const CONTROL_BG = "bg-[#191d24]";
12
+ /** Floating layer surface — menus, palettes, popovers. */
13
+ export declare const FLOAT_BG = "bg-[#14171d]";
14
+ /** Low-contrast neutral border used between shell regions. */
15
+ export declare const BORDER = "border-white/[0.07]";
16
+ /** Standard control text row. */
17
+ export declare const TEXT = "text-[12px] text-neutral-300";
18
+ /** Muted metadata text. */
19
+ export declare const TEXT_MUTED = "text-[11px] text-neutral-500";
20
+ /** Micro uppercase section label. */
21
+ export declare const MICRO_LABEL = "text-[10px] font-semibold uppercase tracking-[0.12em] text-neutral-500";
22
+ /** Shared focus ring for keyboard navigation on interactive controls. */
23
+ export declare const FOCUS_RING = "outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-0";
24
+ /** Base interactive control: compact, subtle border, hover elevation. */
25
+ export declare const CONTROL = "rounded-[5px] border border-white/[0.07] bg-[#191d24] text-neutral-300 transition-colors hover:bg-[#1f242d] hover:text-neutral-100 outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-0";
26
+ /** Active/selected control state — restrained cyan. */
27
+ export declare const CONTROL_ACTIVE = "border-cyan-400/40 bg-cyan-500/15 text-cyan-200 hover:bg-cyan-500/20";
28
+ /** Text input styling shared across shell panels. */
29
+ export declare const INPUT_CLS = "rounded-[5px] border border-white/[0.08] bg-black/40 text-[12px] text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/50 focus:bg-black/60 outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-0";
30
+ /** Tabular numerals for transforms, metrics, and timing readouts. */
31
+ export declare const NUMERIC = "tabular-nums";
32
+ /** Per-axis accent colors for transform fields (X red, Y green, Z blue — gizmo-matched). */
33
+ export declare const AXIS_COLORS: Record<"x" | "y" | "z", {
34
+ text: string;
35
+ bar: string;
36
+ }>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Editor shell design tokens as Tailwind class constants. One place owns the layered dark theme
3
+ * (app < panel < control elevation), text scale, and interaction states so every shell component
4
+ * reads identically. Class strings are complete literals so the host app's Tailwind scan keeps them.
5
+ */
6
+ /** Application background — the near-black base everything sits on. */
7
+ export const APP_BG = "bg-[#0b0d10]";
8
+ /** Panel surface — one elevation step above the app background. */
9
+ export const PANEL_BG = "bg-[#111318]";
10
+ /** Elevated control surface — inputs, chips, dropdowns. */
11
+ export const CONTROL_BG = "bg-[#191d24]";
12
+ /** Floating layer surface — menus, palettes, popovers. */
13
+ export const FLOAT_BG = "bg-[#14171d]";
14
+ /** Low-contrast neutral border used between shell regions. */
15
+ export const BORDER = "border-white/[0.07]";
16
+ /** Standard control text row. */
17
+ export const TEXT = "text-[12px] text-neutral-300";
18
+ /** Muted metadata text. */
19
+ export const TEXT_MUTED = "text-[11px] text-neutral-500";
20
+ /** Micro uppercase section label. */
21
+ export const MICRO_LABEL = "text-[10px] font-semibold uppercase tracking-[0.12em] text-neutral-500";
22
+ /** Shared focus ring for keyboard navigation on interactive controls. */
23
+ export const FOCUS_RING = "outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-offset-0";
24
+ /** Base interactive control: compact, subtle border, hover elevation. */
25
+ export const CONTROL = `rounded-[5px] border border-white/[0.07] bg-[#191d24] text-neutral-300 transition-colors hover:bg-[#1f242d] hover:text-neutral-100 ${FOCUS_RING}`;
26
+ /** Active/selected control state — restrained cyan. */
27
+ export const CONTROL_ACTIVE = "border-cyan-400/40 bg-cyan-500/15 text-cyan-200 hover:bg-cyan-500/20";
28
+ /** Text input styling shared across shell panels. */
29
+ export const INPUT_CLS = `rounded-[5px] border border-white/[0.08] bg-black/40 text-[12px] text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/50 focus:bg-black/60 ${FOCUS_RING}`;
30
+ /** Tabular numerals for transforms, metrics, and timing readouts. */
31
+ export const NUMERIC = "tabular-nums";
32
+ /** Per-axis accent colors for transform fields (X red, Y green, Z blue — gizmo-matched). */
33
+ export const AXIS_COLORS = {
34
+ x: { text: "text-[#f87171]", bar: "bg-[#f87171]" },
35
+ y: { text: "text-[#4ade80]", bar: "bg-[#4ade80]" },
36
+ z: { text: "text-[#60a5fa]", bar: "bg-[#60a5fa]" },
37
+ };
@@ -0,0 +1,84 @@
1
+ import { type ReactNode } from "react";
2
+ import { type IconName } from "./icons.js";
3
+ /** Icon-only control with a mandatory accessible name (tooltip + aria-label). */
4
+ export declare function IconButton({ icon, label, onClick, active, disabled, size, className, tone, }: {
5
+ icon: IconName;
6
+ label: string;
7
+ onClick?: () => void;
8
+ active?: boolean;
9
+ disabled?: boolean;
10
+ size?: number;
11
+ className?: string;
12
+ tone?: "neutral" | "ghost";
13
+ }): import("react").JSX.Element;
14
+ /** Thin vertical divider between toolbar clusters. */
15
+ export declare function ToolbarDivider(): import("react").JSX.Element;
16
+ /** Keyboard-shortcut chip rendered inside buttons and menus. */
17
+ export declare function Kbd({ children }: {
18
+ children: ReactNode;
19
+ }): import("react").JSX.Element;
20
+ /** One choice within a {@link Segmented} control. */
21
+ export interface SegmentedOption<T extends string> {
22
+ value: T;
23
+ label: string;
24
+ icon?: IconName;
25
+ /** Keyboard hint shown after the label. */
26
+ kbd?: string;
27
+ /** Hide the text label and render icon-only (label becomes the tooltip). */
28
+ iconOnly?: boolean;
29
+ }
30
+ /** Compact segmented control on the elevated-control surface with a cyan active segment. */
31
+ export declare function Segmented<T extends string>({ options, value, onChange, ariaLabel, disabled, }: {
32
+ options: readonly SegmentedOption<T>[];
33
+ value: T;
34
+ onChange: (value: T) => void;
35
+ ariaLabel: string;
36
+ disabled?: boolean;
37
+ }): import("react").JSX.Element;
38
+ /** Horizontal tab strip for dock panels (bottom dock, inspector tabs). */
39
+ export declare function PanelTabs<T extends string>({ tabs, active, onSelect, trailing, ariaLabel, }: {
40
+ tabs: readonly {
41
+ id: T;
42
+ label: string;
43
+ icon?: IconName;
44
+ badge?: string | number;
45
+ }[];
46
+ active: T;
47
+ onSelect: (id: T) => void;
48
+ trailing?: ReactNode;
49
+ ariaLabel: string;
50
+ }): import("react").JSX.Element;
51
+ /** Collapsible component card for the inspector and side panels. */
52
+ export declare function CollapsibleSection({ title, icon, open, onToggle, children, trailing, tone, }: {
53
+ title: string;
54
+ icon?: IconName;
55
+ open: boolean;
56
+ onToggle: () => void;
57
+ children: ReactNode;
58
+ trailing?: ReactNode;
59
+ /** Optional accent color for the title icon. */
60
+ tone?: string;
61
+ }): import("react").JSX.Element;
62
+ /** Polished empty state used by unsupported workspaces and empty docks. */
63
+ export declare function EmptyState({ icon, title, description, badge, children, }: {
64
+ icon: IconName;
65
+ title: string;
66
+ description: string;
67
+ badge?: string;
68
+ children?: ReactNode;
69
+ }): import("react").JSX.Element;
70
+ /** Micro uppercase heading used inside panels. */
71
+ export declare function PanelHeading({ children, trailing }: {
72
+ children: ReactNode;
73
+ trailing?: ReactNode;
74
+ }): import("react").JSX.Element;
75
+ /**
76
+ * Drag handle between shell regions. Pointer-drag resizes continuously; arrow keys resize in
77
+ * 16px steps for keyboard access. `sign` maps a positive pointer delta to a size increase.
78
+ */
79
+ export declare function PanelResizer({ orientation, label, onResize, sign, }: {
80
+ orientation: "vertical" | "horizontal";
81
+ label: string;
82
+ onResize: (delta: number) => void;
83
+ sign?: 1 | -1;
84
+ }): import("react").JSX.Element;
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useRef } from "react";
3
+ import { Icon } from "./icons.js";
4
+ import { CONTROL, CONTROL_ACTIVE, FOCUS_RING, MICRO_LABEL } from "./theme.js";
5
+ /** Icon-only control with a mandatory accessible name (tooltip + aria-label). */
6
+ export function IconButton({ icon, label, onClick, active = false, disabled = false, size = 14, className = "", tone = "neutral", }) {
7
+ const base = tone === "ghost"
8
+ ? `rounded-[5px] border border-transparent text-neutral-400 transition-colors hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`
9
+ : CONTROL;
10
+ return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": active, disabled: disabled, onClick: onClick, className: `flex h-7 w-7 shrink-0 items-center justify-center ${base} ${active ? CONTROL_ACTIVE : ""} disabled:pointer-events-none disabled:opacity-35 ${className}`, children: _jsx(Icon, { name: icon, size: size }) }));
11
+ }
12
+ /** Thin vertical divider between toolbar clusters. */
13
+ export function ToolbarDivider() {
14
+ return _jsx("div", { className: "mx-1 h-5 w-px shrink-0 bg-white/[0.07]", "aria-hidden": "true" });
15
+ }
16
+ /** Keyboard-shortcut chip rendered inside buttons and menus. */
17
+ export function Kbd({ children }) {
18
+ return (_jsx("kbd", { className: "rounded-[3px] bg-white/[0.07] px-1 py-px font-sans text-[9px] leading-none text-neutral-400 ring-1 ring-inset ring-white/[0.08]", children: children }));
19
+ }
20
+ /** Compact segmented control on the elevated-control surface with a cyan active segment. */
21
+ export function Segmented({ options, value, onChange, ariaLabel, disabled = false, }) {
22
+ return (_jsx("div", { role: "group", "aria-label": ariaLabel, className: `flex shrink-0 items-center gap-0.5 rounded-[6px] border border-white/[0.07] bg-black/30 p-0.5 ${disabled ? "pointer-events-none opacity-40" : ""}`, children: options.map((option) => {
23
+ const active = option.value === value;
24
+ return (_jsxs("button", { type: "button", title: option.kbd !== undefined ? `${option.label} (${option.kbd})` : option.label, "aria-label": option.label, "aria-pressed": active, onClick: () => onChange(option.value), className: `flex h-6 items-center gap-1.5 rounded-[4px] px-2 text-[11px] transition-colors ${FOCUS_RING} ${active
25
+ ? "bg-cyan-500/20 text-cyan-100 shadow-[inset_0_0_0_1px_rgba(34,211,238,0.25)]"
26
+ : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, children: [option.icon !== undefined ? _jsx(Icon, { name: option.icon, size: 13 }) : null, option.iconOnly === true ? null : _jsx("span", { children: option.label }), option.kbd !== undefined && option.iconOnly !== true ? (_jsx("kbd", { className: `font-sans text-[9px] ${active ? "text-cyan-200/70" : "text-neutral-600"}`, children: option.kbd })) : null] }, option.value));
27
+ }) }));
28
+ }
29
+ /** Horizontal tab strip for dock panels (bottom dock, inspector tabs). */
30
+ export function PanelTabs({ tabs, active, onSelect, trailing, ariaLabel, }) {
31
+ return (_jsxs("div", { role: "tablist", "aria-label": ariaLabel, className: "flex h-8 shrink-0 items-center gap-0.5 border-b border-white/[0.07] px-1.5", children: [tabs.map((tab) => {
32
+ const selected = tab.id === active;
33
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": selected, onClick: () => onSelect(tab.id), className: `flex h-6.5 items-center gap-1.5 rounded-[5px] px-2.5 text-[11px] transition-colors ${FOCUS_RING} ${selected ? "bg-white/[0.08] text-neutral-100" : "text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300"}`, children: [tab.icon !== undefined ? _jsx(Icon, { name: tab.icon, size: 13 }) : null, _jsx("span", { children: tab.label }), tab.badge !== undefined ? (_jsx("span", { className: `rounded-full px-1.5 py-px text-[9px] tabular-nums ${selected ? "bg-cyan-500/20 text-cyan-200" : "bg-white/[0.06] text-neutral-500"}`, children: tab.badge })) : null] }, tab.id));
34
+ }), trailing !== undefined ? _jsx("div", { className: "ml-auto flex items-center gap-1", children: trailing }) : null] }));
35
+ }
36
+ /** Collapsible component card for the inspector and side panels. */
37
+ export function CollapsibleSection({ title, icon, open, onToggle, children, trailing, tone, }) {
38
+ return (_jsxs("section", { className: "overflow-hidden rounded-[6px] border border-white/[0.07] bg-white/[0.02]", children: [_jsxs("div", { className: "flex h-7.5 items-center gap-1.5 px-1.5", children: [_jsxs("button", { type: "button", "aria-expanded": open, onClick: onToggle, className: `flex min-w-0 flex-1 items-center gap-1.5 rounded-[4px] px-1 py-0.5 text-left ${FOCUS_RING}`, children: [_jsx(Icon, { name: open ? "chevronDown" : "chevronRight", size: 11, className: "shrink-0 text-neutral-500" }), icon !== undefined ? (_jsx(Icon, { name: icon, size: 13, className: "shrink-0 text-neutral-400", style: tone !== undefined ? { color: tone } : undefined })) : null, _jsx("span", { className: "truncate text-[12px] font-medium text-neutral-200", children: title })] }), trailing] }), open ? _jsx("div", { className: "border-t border-white/[0.05] p-2.5", children: children }) : null] }));
39
+ }
40
+ /** Polished empty state used by unsupported workspaces and empty docks. */
41
+ export function EmptyState({ icon, title, description, badge, children, }) {
42
+ return (_jsxs("div", { className: "flex h-full min-h-0 flex-col items-center justify-center gap-2 p-6 text-center", children: [_jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded-[8px] border border-white/[0.07] bg-white/[0.03] text-neutral-500", children: _jsx(Icon, { name: icon, size: 18 }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "text-[12px] font-medium text-neutral-300", children: title }), badge !== undefined ? (_jsx("span", { className: "rounded-full bg-white/[0.06] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-neutral-500 ring-1 ring-inset ring-white/[0.08]", children: badge })) : null] }), _jsx("p", { className: "max-w-[32ch] text-[11px] leading-relaxed text-neutral-500", children: description }), children] }));
43
+ }
44
+ /** Micro uppercase heading used inside panels. */
45
+ export function PanelHeading({ children, trailing }) {
46
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: MICRO_LABEL, children: children }), trailing !== undefined ? _jsx("div", { className: "ml-auto flex items-center gap-1", children: trailing }) : null] }));
47
+ }
48
+ const RESIZE_KEY_STEP = 16;
49
+ /**
50
+ * Drag handle between shell regions. Pointer-drag resizes continuously; arrow keys resize in
51
+ * 16px steps for keyboard access. `sign` maps a positive pointer delta to a size increase.
52
+ */
53
+ export function PanelResizer({ orientation, label, onResize, sign = 1, }) {
54
+ const dragState = useRef(null);
55
+ const onPointerDown = useCallback((event) => {
56
+ event.preventDefault();
57
+ event.currentTarget.setPointerCapture(event.pointerId);
58
+ dragState.current = {
59
+ pointerId: event.pointerId,
60
+ last: orientation === "vertical" ? event.clientX : event.clientY,
61
+ };
62
+ }, [orientation]);
63
+ const onPointerMove = useCallback((event) => {
64
+ const drag = dragState.current;
65
+ if (drag === null || drag.pointerId !== event.pointerId)
66
+ return;
67
+ const position = orientation === "vertical" ? event.clientX : event.clientY;
68
+ const delta = (position - drag.last) * sign;
69
+ drag.last = position;
70
+ if (delta !== 0)
71
+ onResize(delta);
72
+ }, [onResize, orientation, sign]);
73
+ const onPointerUp = useCallback((event) => {
74
+ if (dragState.current?.pointerId === event.pointerId)
75
+ dragState.current = null;
76
+ }, []);
77
+ const onKeyDown = useCallback((event) => {
78
+ const grow = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
79
+ const shrink = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
80
+ if (event.key === grow) {
81
+ event.preventDefault();
82
+ onResize(RESIZE_KEY_STEP * sign);
83
+ }
84
+ else if (event.key === shrink) {
85
+ event.preventDefault();
86
+ onResize(-RESIZE_KEY_STEP * sign);
87
+ }
88
+ }, [onResize, orientation, sign]);
89
+ return (_jsx("div", { role: "separator", "aria-label": label, "aria-orientation": orientation, tabIndex: 0, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onKeyDown: onKeyDown, className: `group relative z-10 shrink-0 ${FOCUS_RING} ${orientation === "vertical" ? "w-1 cursor-col-resize" : "h-1 cursor-row-resize"}`, children: _jsx("div", { className: `absolute bg-transparent transition-colors group-hover:bg-cyan-400/40 group-focus-visible:bg-cyan-400/50 ${orientation === "vertical" ? "inset-y-0 left-0 w-1" : "inset-x-0 top-0 h-1"}` }) }));
90
+ }
@@ -0,0 +1,8 @@
1
+ import { type ThumbnailState } from "./glbThumbnailCache.js";
2
+ /**
3
+ * Subscribes to the offscreen GLB thumbnail cache for a model URL.
4
+ * Without a URL (catalog markers, generators with no mesh) stays idle so the
5
+ * caller can keep a typed glyph — never invents imagery.
6
+ * @internal
7
+ */
8
+ export declare function useGlbThumbnail(url: string | undefined): ThumbnailState;
@@ -0,0 +1,19 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { getGlbThumbnailState, requestGlbThumbnail, subscribeGlbThumbnail, } from "./glbThumbnailCache.js";
3
+ const IDLE = { status: "idle", dataUrl: null };
4
+ /**
5
+ * Subscribes to the offscreen GLB thumbnail cache for a model URL.
6
+ * Without a URL (catalog markers, generators with no mesh) stays idle so the
7
+ * caller can keep a typed glyph — never invents imagery.
8
+ * @internal
9
+ */
10
+ export function useGlbThumbnail(url) {
11
+ const subscribe = (onStoreChange) => {
12
+ if (url === undefined || url.length === 0)
13
+ return () => { };
14
+ requestGlbThumbnail(url);
15
+ return subscribeGlbThumbnail(url, onStoreChange);
16
+ };
17
+ const getSnapshot = () => getGlbThumbnailState(url);
18
+ return useSyncExternalStore(subscribe, getSnapshot, () => IDLE);
19
+ }
package/dist/uiStore.d.ts CHANGED
@@ -47,8 +47,23 @@ export interface SculptSettings {
47
47
  export declare const DEFAULT_SCULPT_SETTINGS: SculptSettings;
48
48
  /** Which transform gizmo is active for the current selection. */
49
49
  export type GizmoMode = "translate" | "rotate" | "scale";
50
+ /** Gizmo handle orientation: world axes, or the selection's local (yaw-rotated) axes. */
51
+ export type GizmoSpace = "world" | "local";
52
+ /**
53
+ * Where multi-select gizmo handles sit:
54
+ * - `origin` — primary selection (first selected id)
55
+ * - `center` — arithmetic mean of selected object positions
56
+ * - `median` — per-axis median of selected object positions
57
+ */
58
+ export type GizmoPivot = "origin" | "center" | "median";
50
59
  /** How gizmo drags land: stick to terrain height, quantize to a grid, or free. */
51
60
  export type SnapMode = "ground" | "grid" | "off";
61
+ /** Editor viewport camera projection — perspective (default) or orthographic top-down-friendly view. */
62
+ export type CameraProjectionMode = "perspective" | "orthographic";
63
+ /** Rotation snap increments (degrees) offered by the toolbar snap menu. */
64
+ export declare const ROTATION_SNAP_CHOICES_DEG: readonly number[];
65
+ /** Scale snap increments offered by the toolbar snap menu. */
66
+ export declare const SCALE_SNAP_CHOICES: readonly number[];
52
67
  /** The active creation tool — what a viewport click places next. */
53
68
  export type PlacementTool = {
54
69
  tool: "marker";
@@ -73,8 +88,18 @@ export interface EditorContextMenuState {
73
88
  /** Transient editor UI state shared between chrome, viewport, and gizmos. */
74
89
  export interface EditorUiState {
75
90
  gizmoMode: GizmoMode;
91
+ /** Whether gizmo handles align to world axes or the selection's yaw. */
92
+ gizmoSpace: GizmoSpace;
93
+ /** Multi-select gizmo pivot policy. */
94
+ gizmoPivot: GizmoPivot;
76
95
  snapMode: SnapMode;
77
96
  gridSize: number;
97
+ /** Rotation snap increment in degrees; `null` rotates freely. */
98
+ rotationSnapDeg: number | null;
99
+ /** Scale snap increment; `null` scales freely. */
100
+ scaleSnap: number | null;
101
+ /** Viewport camera projection (perspective vs orthographic). */
102
+ cameraProjection: CameraProjectionMode;
78
103
  showGrid: boolean;
79
104
  /** Surface-following iso-elevation contour overlay (terrain readability). */
80
105
  showContours: boolean;
@@ -88,6 +113,8 @@ export interface EditorUiState {
88
113
  pathId: string;
89
114
  index: number;
90
115
  } | null;
116
+ /** Viewport pre-selection hover (object under cursor); null when nothing is hovered. */
117
+ hoverId: string | null;
91
118
  tool: EditorTool;
92
119
  terrainMode: TerrainMode;
93
120
  sculpt: SculptSettings;
package/dist/uiStore.js CHANGED
@@ -30,6 +30,10 @@ export const DEFAULT_SCULPT_SETTINGS = {
30
30
  invert: false,
31
31
  heightLimit: { min: null, max: null },
32
32
  };
33
+ /** Rotation snap increments (degrees) offered by the toolbar snap menu. */
34
+ export const ROTATION_SNAP_CHOICES_DEG = [5, 15, 45, 90];
35
+ /** Scale snap increments offered by the toolbar snap menu. */
36
+ export const SCALE_SNAP_CHOICES = [0.1, 0.25, 0.5, 1];
33
37
  function placementId(prefix) {
34
38
  return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1296).toString(36)}`;
35
39
  }
@@ -37,8 +41,13 @@ function placementId(prefix) {
37
41
  export function createEditorUiStore() {
38
42
  let state = {
39
43
  gizmoMode: "translate",
44
+ gizmoSpace: "world",
45
+ gizmoPivot: "center",
40
46
  snapMode: "ground",
41
47
  gridSize: 1,
48
+ rotationSnapDeg: 15,
49
+ scaleSnap: null,
50
+ cameraProjection: "perspective",
42
51
  showGrid: true,
43
52
  showContours: false,
44
53
  showSurfaceGrid: false,
@@ -46,6 +55,7 @@ export function createEditorUiStore() {
46
55
  placement: null,
47
56
  pathDraft: [],
48
57
  pathPoint: null,
58
+ hoverId: null,
49
59
  tool: "select",
50
60
  terrainMode: "sculpt",
51
61
  sculpt: { ...DEFAULT_SCULPT_SETTINGS },