@jgengine/editor 0.13.0 → 0.15.0-next.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.
- package/CHANGELOG.md +529 -1
- package/dist/ClipPreviewLayer.d.ts +15 -0
- package/dist/ClipPreviewLayer.js +60 -0
- package/dist/EditorApp.js +4 -3
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +4 -6
- package/dist/InspectorPanel.d.ts +4 -1
- package/dist/InspectorPanel.js +47 -5
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.d.ts +3 -1
- package/dist/SchemaInspector.js +63 -32
- package/dist/TerrainPanel.js +3 -4
- package/dist/TerrainReadout.js +1 -3
- package/dist/agent/AgentPanel.js +7 -11
- package/dist/camera/orbitFraming.d.ts +62 -0
- package/dist/camera/orbitFraming.js +69 -0
- package/dist/chromeFields.js +9 -4
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +78 -9
- package/dist/mcp/fieldSpec.js +4 -2
- package/dist/mcp/tools.js +4 -2
- package/dist/modelAnimationAuthoring.d.ts +75 -0
- package/dist/modelAnimationAuthoring.js +164 -0
- package/dist/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.d.ts +10 -8
- package/dist/shell/AnimationPanel.js +120 -26
- package/dist/shell/BottomDock.d.ts +3 -1
- package/dist/shell/BottomDock.js +2 -2
- package/dist/shell/CommandPalette.js +1 -5
- package/dist/shell/HierarchyPanel.js +17 -26
- package/dist/shell/PlayModeBar.d.ts +7 -0
- package/dist/shell/PlayModeBar.js +12 -3
- package/dist/shell/SceneToolbar.js +9 -5
- package/dist/shell/clipPreview.d.ts +85 -0
- package/dist/shell/clipPreview.js +82 -0
- package/dist/shell/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.d.ts +22 -0
- package/dist/shell/scatterCoverage.js +40 -0
- package/dist/uiStore.d.ts +6 -0
- package/dist/uiStore.js +1 -0
- package/dist/useStoreSelector.d.ts +12 -0
- package/dist/useStoreSelector.js +23 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-editor/api.md +56 -4
- package/skills/jgengine-editor/reference.md +44 -0
|
@@ -44,10 +44,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
44
44
|
const [query, setQuery] = useState("");
|
|
45
45
|
const [collapsedKinds, setCollapsedKinds] = useState({});
|
|
46
46
|
const [collapsedNodes, setCollapsedNodes] = useState({});
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
void visibilityTick;
|
|
50
|
-
const visibility = api.getVisibility();
|
|
47
|
+
const visibilityStore = useMemo(() => ({ getState: api.getVisibility, subscribe: api.subscribeVisibility }), [api]);
|
|
48
|
+
const visibility = useStoreSelector(visibilityStore, (v) => v);
|
|
51
49
|
const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
|
|
52
50
|
const total = document.markers.length + document.volumes.length + document.paths.length + document.annotations.length;
|
|
53
51
|
const scrollRef = useRef(null);
|
|
@@ -79,15 +77,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
79
77
|
const [activeId, setActiveId] = useState(null);
|
|
80
78
|
const [renamingId, setRenamingId] = useState(null);
|
|
81
79
|
const [renameDraft, setRenameDraft] = useState("");
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (activeId !== null && !navigableIds.includes(activeId))
|
|
85
|
-
setActiveId(navigableIds[0] ?? null);
|
|
86
|
-
}, [activeId, navigableIds]);
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (renamingId !== null)
|
|
89
|
-
renameInputRef.current?.focus();
|
|
90
|
-
}, [renamingId]);
|
|
80
|
+
// Raw intent clamped at read time: a filtered-away active row falls back to the first row.
|
|
81
|
+
const effectiveActiveId = activeId === null ? null : navigableIds.includes(activeId) ? activeId : (navigableIds[0] ?? null);
|
|
91
82
|
const focusTree = () => {
|
|
92
83
|
scrollRef.current?.focus({ preventScroll: true });
|
|
93
84
|
};
|
|
@@ -129,7 +120,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
129
120
|
const onListFocus = () => {
|
|
130
121
|
if (renamingId !== null)
|
|
131
122
|
return;
|
|
132
|
-
if (
|
|
123
|
+
if (effectiveActiveId !== null)
|
|
133
124
|
return;
|
|
134
125
|
const seed = selectedId !== undefined && navigableIds.includes(selectedId)
|
|
135
126
|
? selectedId
|
|
@@ -142,7 +133,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
142
133
|
return;
|
|
143
134
|
if (navigableIds.length === 0)
|
|
144
135
|
return;
|
|
145
|
-
const currentIndex =
|
|
136
|
+
const currentIndex = effectiveActiveId === null ? -1 : navigableIds.indexOf(effectiveActiveId);
|
|
146
137
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
147
138
|
event.preventDefault();
|
|
148
139
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
@@ -164,17 +155,17 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
164
155
|
activateId(navigableIds[navigableIds.length - 1], event.shiftKey);
|
|
165
156
|
return;
|
|
166
157
|
}
|
|
167
|
-
if (event.key === "Enter" &&
|
|
158
|
+
if (event.key === "Enter" && effectiveActiveId !== null) {
|
|
168
159
|
event.preventDefault();
|
|
169
|
-
api.handle({ method: "camera_goto", id:
|
|
160
|
+
api.handle({ method: "camera_goto", id: effectiveActiveId });
|
|
170
161
|
return;
|
|
171
162
|
}
|
|
172
|
-
if (event.key === "F2" &&
|
|
163
|
+
if (event.key === "F2" && effectiveActiveId !== null) {
|
|
173
164
|
event.preventDefault();
|
|
174
|
-
const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] ===
|
|
175
|
-
(entry.type === "treeItem" && entry.id ===
|
|
165
|
+
const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === effectiveActiveId) ||
|
|
166
|
+
(entry.type === "treeItem" && entry.id === effectiveActiveId));
|
|
176
167
|
if (row !== undefined && (row.type === "kindItem" || row.type === "treeItem")) {
|
|
177
|
-
beginRename(
|
|
168
|
+
beginRename(effectiveActiveId, row.label);
|
|
178
169
|
}
|
|
179
170
|
}
|
|
180
171
|
};
|
|
@@ -245,7 +236,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
245
236
|
return;
|
|
246
237
|
session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: true } });
|
|
247
238
|
};
|
|
248
|
-
const activeDescendant = hierarchyActiveDescendantId(
|
|
239
|
+
const activeDescendant = hierarchyActiveDescendantId(effectiveActiveId, navigableIds);
|
|
249
240
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.06] p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx(Icon, { name: "search", size: 12, className: "pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search\u2026", "aria-label": "Search scene objects", className: `h-7 w-full pl-7 pr-2 ${INPUT_CLS}` })] }), _jsx(IconButton, { icon: "plus", label: "Add object\u2026", onClick: onAdd })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Segmented, { ariaLabel: "Outliner grouping", value: view, onChange: setView, options: [
|
|
250
241
|
{ value: "kind", label: "By kind", icon: "layers" },
|
|
251
242
|
{ value: "tree", label: "Tree", icon: "folder" },
|
|
@@ -273,7 +264,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
273
264
|
const hidden = rowHidden(row.ids);
|
|
274
265
|
const collectionOnlyLock = locked &&
|
|
275
266
|
row.ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
|
|
276
|
-
const active =
|
|
267
|
+
const active = effectiveActiveId === primaryId;
|
|
277
268
|
const renaming = renamingId === primaryId && row.ids.length === 1;
|
|
278
269
|
return (_jsxs("div", { id: hierarchyRowDomId(primaryId), role: "treeitem", "aria-selected": rowSelected, "aria-level": 2, style: { height: ROW_HEIGHT }, className: `group flex w-full items-center gap-0.5 rounded-[5px] pl-6 pr-0.5 text-[11px] transition-colors ${dropActive
|
|
279
270
|
? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
|
|
@@ -294,7 +285,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
294
285
|
event.preventDefault();
|
|
295
286
|
setActiveId(primaryId);
|
|
296
287
|
onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, primaryId);
|
|
297
|
-
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", {
|
|
288
|
+
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
|
|
298
289
|
event.stopPropagation();
|
|
299
290
|
if (event.key === "Enter") {
|
|
300
291
|
event.preventDefault();
|
|
@@ -331,7 +322,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
331
322
|
const hidden = isEditorObjectHidden(document, row.id);
|
|
332
323
|
const collectionOnlyLock = isEditorObjectCollectionLocked(document, row.id) && !isEditorObjectSelfLocked(document, row.id);
|
|
333
324
|
const dragging = draggingObjectId === row.id;
|
|
334
|
-
const active =
|
|
325
|
+
const active = effectiveActiveId === row.id;
|
|
335
326
|
const renaming = renamingId === row.id;
|
|
336
327
|
const expanded = row.hasChildren ? collapsedNodes[row.id] !== true : undefined;
|
|
337
328
|
return (_jsxs("div", { id: hierarchyRowDomId(row.id), role: "treeitem", "aria-selected": rowSelected, "aria-level": row.depth + 1, "aria-expanded": expanded, style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: `group flex items-center ${hidden ? "opacity-55" : ""}`, children: [row.hasChildren ? (_jsx("button", { type: "button", tabIndex: -1, "aria-label": collapsedNodes[row.id] === true ? "Expand children" : "Collapse children", className: `flex h-4 w-4 shrink-0 items-center justify-center text-neutral-500 transition-colors hover:text-neutral-200 ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: _jsx(Icon, { name: collapsedNodes[row.id] === true ? "chevronRight" : "chevronDown", size: 10 }) })) : (_jsx("span", { className: "w-4 shrink-0", "aria-hidden": "true" })), _jsxs("button", { type: "button", tabIndex: -1, draggable: true, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] px-1.5 text-left text-[11px] transition-colors ${FOCUS_RING} ${materialDrop || reparentDrop
|
|
@@ -364,7 +355,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
364
355
|
if (commitMaterialDrop(event, [row.id]))
|
|
365
356
|
return;
|
|
366
357
|
commitReparentDrop(event, row.id);
|
|
367
|
-
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", {
|
|
358
|
+
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
|
|
368
359
|
event.stopPropagation();
|
|
369
360
|
if (event.key === "Enter") {
|
|
370
361
|
event.preventDefault();
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { RuntimePlayControl } from "@jgengine/core/editor/index";
|
|
1
2
|
import type { EditorHostApi } from "../session.js";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to the host runtime play-control (pause / pending steps) as an external store.
|
|
5
|
+
* Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
|
|
2
9
|
/**
|
|
3
10
|
* Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
|
|
4
11
|
* does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { useStoreSelector } from "../useStoreSelector.js";
|
|
3
4
|
import { Icon } from "./icons.js";
|
|
4
5
|
import { BORDER, FOCUS_RING } from "./theme.js";
|
|
5
6
|
import { IconButton, ToolbarDivider } from "./ui.js";
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe to the host runtime play-control (pause / pending steps) as an external store.
|
|
9
|
+
* Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function usePlayControl(api) {
|
|
13
|
+
const store = useMemo(() => ({ getState: api.getPlayControl, subscribe: api.subscribePlayControl }), [api]);
|
|
14
|
+
return useStoreSelector(store, (play) => play);
|
|
15
|
+
}
|
|
6
16
|
/**
|
|
7
17
|
* Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
|
|
8
18
|
* does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
|
|
@@ -10,8 +20,7 @@ import { IconButton, ToolbarDivider } from "./ui.js";
|
|
|
10
20
|
* @internal
|
|
11
21
|
*/
|
|
12
22
|
export function PlayModeBar({ gameId, api, onExit, }) {
|
|
13
|
-
const
|
|
14
|
-
useEffect(() => api.subscribePlayControl(setPlay), [api]);
|
|
23
|
+
const play = usePlayControl(api);
|
|
15
24
|
const pause = () => {
|
|
16
25
|
api.handle({ method: "runtime_pause" });
|
|
17
26
|
};
|
|
@@ -112,11 +112,15 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
|
|
|
112
112
|
? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
|
|
113
113
|
: "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
|
|
114
114
|
? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
|
|
115
|
-
: "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))),
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
: "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
|
|
116
|
+
const category = definition.addCategory ?? "Studios";
|
|
117
|
+
(groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
|
|
118
|
+
return groups;
|
|
119
|
+
}, new Map())].map(([category, defs]) => (_jsxs("div", { children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: category }), defs.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
|
|
120
|
+
? { tool: "path", kind: definition.kind }
|
|
121
|
+
: definition.target === "volume"
|
|
122
|
+
? { tool: "volume", kind: definition.kind, shape: "box" }
|
|
123
|
+
: { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
|
|
120
124
|
? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
|
|
121
125
|
: "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
|
|
122
126
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ClipRole } from "@jgengine/core/game/clipRoles";
|
|
2
|
+
import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
/**
|
|
4
|
+
* Editor viewport clip-preview model — pure logic, no JSX / R3F. Mirrors how `pathFlythrough.ts`
|
|
5
|
+
* splits the flythrough math out of {@link AnimationPanel}: derives a rigged asset's playable clip
|
|
6
|
+
* list and models a scrub/loop/speed playback driver that the viewport layer and the dock panel
|
|
7
|
+
* both consume. The actual mixer is the shell's `useModelAnimation`; this module only shapes the
|
|
8
|
+
* config it plays and the transitions the controls dispatch.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
/** A rigged asset carrying animation clip names — the browser entry / catalog `ModelAssetRef`. */
|
|
13
|
+
export interface ClipSourceRef {
|
|
14
|
+
readonly clips?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
/** One previewable clip with its detected semantic role (null when unclassified). */
|
|
17
|
+
export interface ClipEntry {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly role: ClipRole | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
|
|
23
|
+
* the ref carries no clips (not rigged, or clip metadata absent).
|
|
24
|
+
*/
|
|
25
|
+
export declare function clipEntriesFromRef(ref: ClipSourceRef | null | undefined): ClipEntry[];
|
|
26
|
+
/** A rigged asset resolved for viewport preview: its catalog id, label, GLB url, and clip names. */
|
|
27
|
+
export interface ClipPreviewSource {
|
|
28
|
+
readonly assetId: string;
|
|
29
|
+
readonly label: string;
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly clips: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The live clip-preview session shared across panels via the editor UI store: which asset is being
|
|
35
|
+
* previewed, the playback driver, and the selected clip's measured duration (published by the
|
|
36
|
+
* viewport layer once its GLB loads; 0 while unknown).
|
|
37
|
+
*/
|
|
38
|
+
export interface ClipPreviewSession {
|
|
39
|
+
readonly source: ClipPreviewSource;
|
|
40
|
+
readonly driver: ClipPreviewState;
|
|
41
|
+
readonly duration: number;
|
|
42
|
+
}
|
|
43
|
+
/** Playback state of the clip preview driver. `time` is seconds into the clip. */
|
|
44
|
+
export interface ClipPreviewState {
|
|
45
|
+
/** Clip currently loaded into the preview, or null when nothing is selected. */
|
|
46
|
+
clipName: string | null;
|
|
47
|
+
playing: boolean;
|
|
48
|
+
loop: boolean;
|
|
49
|
+
/** Playback rate multiplier; clamped to a sane preview range. */
|
|
50
|
+
speed: number;
|
|
51
|
+
/** Playhead position in seconds. Meaningful while paused/scrubbing. */
|
|
52
|
+
time: number;
|
|
53
|
+
}
|
|
54
|
+
/** Lowest / highest preview playback rate the speed control allows. */
|
|
55
|
+
/** Lower bound for the clip-preview playback rate. */
|
|
56
|
+
export declare const MIN_PREVIEW_SPEED = 0.1;
|
|
57
|
+
/** Upper bound for the clip-preview playback rate. */
|
|
58
|
+
export declare const MAX_PREVIEW_SPEED = 4;
|
|
59
|
+
/** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
|
|
60
|
+
export declare function initialClipPreviewState(clipName?: string | null): ClipPreviewState;
|
|
61
|
+
/** Switch to a different clip: resets the playhead and starts playing it. */
|
|
62
|
+
export declare function selectPreviewClip(state: ClipPreviewState, clipName: string): ClipPreviewState;
|
|
63
|
+
/** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
|
|
64
|
+
export declare function setPreviewPlaying(state: ClipPreviewState, playing: boolean): ClipPreviewState;
|
|
65
|
+
/** Toggles play/pause of the previewed clip without losing the scrub position. */
|
|
66
|
+
export declare function togglePreviewPlaying(state: ClipPreviewState): ClipPreviewState;
|
|
67
|
+
/** Sets whether the previewed clip loops when it reaches its end. */
|
|
68
|
+
export declare function setPreviewLoop(state: ClipPreviewState, loop: boolean): ClipPreviewState;
|
|
69
|
+
/** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
|
|
70
|
+
export declare function setPreviewSpeed(state: ClipPreviewState, speed: number): ClipPreviewState;
|
|
71
|
+
/** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
|
|
72
|
+
export declare function scrubPreview(state: ClipPreviewState, time: number): ClipPreviewState;
|
|
73
|
+
/**
|
|
74
|
+
* Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
|
|
75
|
+
* (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
|
|
76
|
+
* unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
|
|
77
|
+
* this in lockstep with the freerunning viewport mixer stays in sync.
|
|
78
|
+
*/
|
|
79
|
+
export declare function advancePreview(state: ClipPreviewState, dt: number, duration: number): ClipPreviewState;
|
|
80
|
+
/**
|
|
81
|
+
* The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
|
|
82
|
+
* a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
|
|
83
|
+
* `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
|
|
84
|
+
*/
|
|
85
|
+
export declare function previewAnimationConfig(state: ClipPreviewState): ModelAnimationConfig | undefined;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { classifyClip } from "@jgengine/core/game/clipRoles";
|
|
2
|
+
/**
|
|
3
|
+
* Lists a rigged asset's clips with their classified roles, preserving catalog order. Empty when
|
|
4
|
+
* the ref carries no clips (not rigged, or clip metadata absent).
|
|
5
|
+
*/
|
|
6
|
+
export function clipEntriesFromRef(ref) {
|
|
7
|
+
const clips = ref?.clips;
|
|
8
|
+
if (clips === undefined || clips.length === 0)
|
|
9
|
+
return [];
|
|
10
|
+
return clips.map((name) => ({ name, role: classifyClip(name) }));
|
|
11
|
+
}
|
|
12
|
+
/** Lowest / highest preview playback rate the speed control allows. */
|
|
13
|
+
/** Lower bound for the clip-preview playback rate. */
|
|
14
|
+
export const MIN_PREVIEW_SPEED = 0.1;
|
|
15
|
+
/** Upper bound for the clip-preview playback rate. */
|
|
16
|
+
export const MAX_PREVIEW_SPEED = 4;
|
|
17
|
+
function clampSpeed(speed) {
|
|
18
|
+
if (!Number.isFinite(speed))
|
|
19
|
+
return 1;
|
|
20
|
+
return Math.max(MIN_PREVIEW_SPEED, Math.min(MAX_PREVIEW_SPEED, speed));
|
|
21
|
+
}
|
|
22
|
+
/** Fresh driver state for a clip (auto-plays when a clip is given, like the browser badge implies). */
|
|
23
|
+
export function initialClipPreviewState(clipName = null) {
|
|
24
|
+
return { clipName, playing: clipName !== null, loop: true, speed: 1, time: 0 };
|
|
25
|
+
}
|
|
26
|
+
/** Switch to a different clip: resets the playhead and starts playing it. */
|
|
27
|
+
export function selectPreviewClip(state, clipName) {
|
|
28
|
+
return { ...state, clipName, time: 0, playing: true };
|
|
29
|
+
}
|
|
30
|
+
/** Sets whether the previewed clip is playing; pausing keeps the current scrub time. */
|
|
31
|
+
export function setPreviewPlaying(state, playing) {
|
|
32
|
+
if (state.clipName === null)
|
|
33
|
+
return { ...state, playing: false };
|
|
34
|
+
return { ...state, playing };
|
|
35
|
+
}
|
|
36
|
+
/** Toggles play/pause of the previewed clip without losing the scrub position. */
|
|
37
|
+
export function togglePreviewPlaying(state) {
|
|
38
|
+
return setPreviewPlaying(state, !state.playing);
|
|
39
|
+
}
|
|
40
|
+
/** Sets whether the previewed clip loops when it reaches its end. */
|
|
41
|
+
export function setPreviewLoop(state, loop) {
|
|
42
|
+
return { ...state, loop };
|
|
43
|
+
}
|
|
44
|
+
/** Sets the preview playback rate, clamped to [MIN_PREVIEW_SPEED, MAX_PREVIEW_SPEED]. */
|
|
45
|
+
export function setPreviewSpeed(state, speed) {
|
|
46
|
+
return { ...state, speed: clampSpeed(speed) };
|
|
47
|
+
}
|
|
48
|
+
/** Scrub to an absolute time (seconds). Pauses playback, matching the path-flythrough scrubber. */
|
|
49
|
+
export function scrubPreview(state, time) {
|
|
50
|
+
const next = Number.isFinite(time) ? Math.max(0, time) : 0;
|
|
51
|
+
return { ...state, time: next, playing: false };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Advances the playhead by `dt` seconds at the current speed, wrapping when looping and clamping
|
|
55
|
+
* (then stopping) at `duration` otherwise. No-op while paused, clip-less, or the duration is
|
|
56
|
+
* unknown (<= 0). Uses the same time += speed·dt formula the shell mixer runs, so a panel that ticks
|
|
57
|
+
* this in lockstep with the freerunning viewport mixer stays in sync.
|
|
58
|
+
*/
|
|
59
|
+
export function advancePreview(state, dt, duration) {
|
|
60
|
+
if (!state.playing || state.clipName === null || duration <= 0 || !Number.isFinite(dt) || dt <= 0) {
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
let next = state.time + state.speed * dt;
|
|
64
|
+
if (next >= duration) {
|
|
65
|
+
if (state.loop)
|
|
66
|
+
next %= duration;
|
|
67
|
+
else
|
|
68
|
+
return { ...state, time: duration, playing: false };
|
|
69
|
+
}
|
|
70
|
+
return { ...state, time: next };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The `ModelConfig.animation` config the preview layer feeds to `useModelAnimation`. Playing yields
|
|
74
|
+
* a freerunning single-clip config (mixer owns the clock, no per-frame churn); paused yields a held
|
|
75
|
+
* `paused`+`time` pose so a scrub renders the exact frame. Returns undefined with no clip (bind pose).
|
|
76
|
+
*/
|
|
77
|
+
export function previewAnimationConfig(state) {
|
|
78
|
+
if (state.clipName === null)
|
|
79
|
+
return undefined;
|
|
80
|
+
const base = { clip: state.clipName, loop: state.loop, timeScale: state.speed };
|
|
81
|
+
return state.playing ? base : { ...base, paused: true, time: state.time };
|
|
82
|
+
}
|
package/dist/shell/fields.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
3
|
import { AXIS_COLORS, FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
|
|
4
4
|
/** Pixels of pointer drag per `step` of value change when scrubbing an axis chip. */
|
|
5
5
|
const DRAG_PX_PER_STEP = 4;
|
|
@@ -15,14 +15,12 @@ function formatValue(value, precision) {
|
|
|
15
15
|
* values on change and re-normalizes its text on blur. Invalid text never commits.
|
|
16
16
|
*/
|
|
17
17
|
export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, precision = 3, disabled = false, mixed = false, }) {
|
|
18
|
-
|
|
18
|
+
// `text` only exists while the input has focus; otherwise the shown value derives from props.
|
|
19
|
+
const [text, setText] = useState("");
|
|
19
20
|
const [editing, setEditing] = useState(false);
|
|
20
21
|
const inputRef = useRef(null);
|
|
21
22
|
const dragRef = useRef(null);
|
|
22
|
-
|
|
23
|
-
if (!editing)
|
|
24
|
-
setText(mixed ? "—" : formatValue(value, precision));
|
|
25
|
-
}, [value, precision, editing, mixed]);
|
|
23
|
+
const formatted = mixed ? "—" : formatValue(value, precision);
|
|
26
24
|
const commitText = (raw) => {
|
|
27
25
|
if (raw === "—" || raw.trim().length === 0)
|
|
28
26
|
return;
|
|
@@ -69,7 +67,10 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
|
|
|
69
67
|
}
|
|
70
68
|
};
|
|
71
69
|
const colors = axis !== undefined ? AXIS_COLORS[axis] : null;
|
|
72
|
-
return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: text, disabled: disabled, "aria-label": label, onFocus: () =>
|
|
70
|
+
return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: editing ? text : formatted, disabled: disabled, "aria-label": label, onFocus: () => {
|
|
71
|
+
setText(formatted);
|
|
72
|
+
setEditing(true);
|
|
73
|
+
}, onDoubleClick: (event) => {
|
|
73
74
|
event.stopPropagation();
|
|
74
75
|
event.currentTarget.select();
|
|
75
76
|
}, onChange: (event) => {
|
|
@@ -78,7 +79,6 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
|
|
|
78
79
|
}, onBlur: (event) => {
|
|
79
80
|
setEditing(false);
|
|
80
81
|
commitText(event.target.value);
|
|
81
|
-
setText(formatValue(Number.isFinite(Number(event.target.value)) ? Number(event.target.value) : value, precision));
|
|
82
82
|
}, onKeyDown: (event) => {
|
|
83
83
|
if (event.key === "Enter")
|
|
84
84
|
event.currentTarget.blur();
|
|
@@ -15,6 +15,16 @@ const MAX_CONCURRENT = 1;
|
|
|
15
15
|
const cache = new Map();
|
|
16
16
|
const queue = [];
|
|
17
17
|
let active = 0;
|
|
18
|
+
/** Shared idle snapshot — one stable identity for every "no thumbnail yet" case. */
|
|
19
|
+
const IDLE_STATE = { status: "idle", dataUrl: null };
|
|
20
|
+
/**
|
|
21
|
+
* Last snapshot handed out per URL. `getGlbThumbnailState` is the `getSnapshot`
|
|
22
|
+
* for `useSyncExternalStore`, which requires a referentially stable result while
|
|
23
|
+
* the underlying state is unchanged: returning a fresh object literal every call
|
|
24
|
+
* makes React's post-commit consistency check ("Maximum update depth exceeded")
|
|
25
|
+
* fire forever. We reuse the prior object until status/dataUrl actually changes.
|
|
26
|
+
*/
|
|
27
|
+
const snapshots = new Map();
|
|
18
28
|
let renderer = null;
|
|
19
29
|
let scene = null;
|
|
20
30
|
let camera = null;
|
|
@@ -32,6 +42,7 @@ export function resetGlbThumbnailLoader() {
|
|
|
32
42
|
/** @internal Clear cache + dispose GPU resources (tests / hot dispose). */
|
|
33
43
|
export function clearGlbThumbnailCache() {
|
|
34
44
|
cache.clear();
|
|
45
|
+
snapshots.clear();
|
|
35
46
|
queue.length = 0;
|
|
36
47
|
active = 0;
|
|
37
48
|
disposeRenderer();
|
|
@@ -132,6 +143,7 @@ function evictIfNeeded() {
|
|
|
132
143
|
if (entry.status !== "ready")
|
|
133
144
|
continue;
|
|
134
145
|
cache.delete(key);
|
|
146
|
+
snapshots.delete(key);
|
|
135
147
|
if (cache.size <= MAX_CACHE)
|
|
136
148
|
return;
|
|
137
149
|
}
|
|
@@ -193,15 +205,34 @@ function pump() {
|
|
|
193
205
|
*/
|
|
194
206
|
export function getGlbThumbnailState(url) {
|
|
195
207
|
if (url === undefined || url.length === 0)
|
|
196
|
-
return
|
|
208
|
+
return IDLE_STATE;
|
|
197
209
|
const entry = cache.get(url);
|
|
198
|
-
if (entry === undefined)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
if (entry === undefined) {
|
|
211
|
+
snapshots.delete(url);
|
|
212
|
+
return IDLE_STATE;
|
|
213
|
+
}
|
|
214
|
+
const prev = snapshots.get(url);
|
|
215
|
+
// Reuse the prior snapshot object whenever the observable state is unchanged so
|
|
216
|
+
// useSyncExternalStore sees a stable reference (no infinite re-render loop).
|
|
217
|
+
if (entry.status === "ready") {
|
|
218
|
+
if (prev !== undefined && prev.status === "ready" && prev.dataUrl === entry.dataUrl)
|
|
219
|
+
return prev;
|
|
220
|
+
const next = { status: "ready", dataUrl: entry.dataUrl };
|
|
221
|
+
snapshots.set(url, next);
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
if (entry.status === "error") {
|
|
225
|
+
if (prev !== undefined && prev.status === "error")
|
|
226
|
+
return prev;
|
|
227
|
+
const next = { status: "error", dataUrl: null };
|
|
228
|
+
snapshots.set(url, next);
|
|
229
|
+
return next;
|
|
230
|
+
}
|
|
231
|
+
if (prev !== undefined && prev.status === "loading")
|
|
232
|
+
return prev;
|
|
233
|
+
const next = { status: "loading", dataUrl: null };
|
|
234
|
+
snapshots.set(url, next);
|
|
235
|
+
return next;
|
|
205
236
|
}
|
|
206
237
|
/**
|
|
207
238
|
* Ensures a thumbnail render is in flight (or already cached) for `url`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ParamSchema, ParsedParams, SceneKindCoverage, SceneKindObject } from "@jgengine/core/scene/sceneKinds";
|
|
2
|
+
/** A patch to a single meta key, coalesced under a stable undo key (matches `SchemaInspector`). */
|
|
3
|
+
type MetaPatch = (patch: Record<string, unknown>, coalesce: string) => void;
|
|
4
|
+
/**
|
|
5
|
+
* The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
|
|
6
|
+
* consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
|
|
7
|
+
* of what fills it (the weighted palette, when the kind declares one), the single Density slider
|
|
8
|
+
* hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
|
|
9
|
+
* parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
|
|
10
|
+
* scatter, and city never re-derive the "how much stuff grows here" affordance.
|
|
11
|
+
* @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }: {
|
|
14
|
+
schema: ParamSchema;
|
|
15
|
+
coverage: SceneKindCoverage;
|
|
16
|
+
object: SceneKindObject;
|
|
17
|
+
params: ParsedParams;
|
|
18
|
+
note?: string;
|
|
19
|
+
accent: string;
|
|
20
|
+
onMeta: MetaPatch;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { scatterFootprintArea } from "@jgengine/core/world/scatterCoverage";
|
|
3
|
+
import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
|
|
4
|
+
import { MICRO_LABEL } from "./theme.js";
|
|
5
|
+
const fmt = (n) => Math.round(n).toLocaleString();
|
|
6
|
+
/**
|
|
7
|
+
* The hoisted density slider: live-mirrored thumb + readout, debounced commit flushed on release
|
|
8
|
+
* (#1372). Scatter/grass/city regen is heavy, so one write per pause instead of ~50 per drag.
|
|
9
|
+
*/
|
|
10
|
+
function CoverageDensitySlider({ field, density, densityKey, onMeta, }) {
|
|
11
|
+
const { value: local, onInput, flush } = useDebouncedCommit(density, (next) => onMeta({ [densityKey]: next }, `kind:${densityKey}`));
|
|
12
|
+
return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: field.label ?? field.key }), _jsxs("span", { className: "text-cyan-200", children: [local.toFixed(field.step !== undefined && field.step >= 1 ? 0 : 2), field.unit ?? ""] })] }), _jsx("input", { type: "range", min: field.min, max: field.max, step: field.step ?? (field.max - field.min) / 100, value: local, className: "w-full accent-emerald-400", "aria-label": `${field.label ?? field.key} density`, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
|
|
13
|
+
}
|
|
14
|
+
/** One-line summary of a weighted "what fills it" palette: count + the leading item ids. */
|
|
15
|
+
function assetsSummary(entries) {
|
|
16
|
+
if (entries === undefined || entries.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
const ids = entries.map((entry) => entry.item).filter((item) => item.length > 0);
|
|
19
|
+
if (ids.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const head = ids.slice(0, 4).join(", ");
|
|
22
|
+
const rest = ids.length > 4 ? ` +${ids.length - 4}` : "";
|
|
23
|
+
return `${ids.length} ${ids.length === 1 ? "type" : "types"} · ${head}${rest}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The shared leading block for every scatterable kind (`grass_field` / `scatter` / `city`): one
|
|
27
|
+
* consistent Area → Assets → Density gesture. Renders the footprint Area readout, a compact summary
|
|
28
|
+
* of what fills it (the weighted palette, when the kind declares one), the single Density slider
|
|
29
|
+
* hoisted out of the schema body, and the kind's budget clamp-and-warn `note`. The rest of the kind's
|
|
30
|
+
* parameters render below via `SchemaInspector` (with the density key hidden). One module so grass,
|
|
31
|
+
* scatter, and city never re-derive the "how much stuff grows here" affordance.
|
|
32
|
+
* @internal — mounted by `InspectorPanel`'s `KindInspector` when a kind declares `coverage`.
|
|
33
|
+
*/
|
|
34
|
+
export function CoverageSection({ schema, coverage, object, params, note, accent, onMeta, }) {
|
|
35
|
+
const area = scatterFootprintArea(object);
|
|
36
|
+
const densityField = schema.fields.find((field) => field.key === coverage.densityKey);
|
|
37
|
+
const density = typeof params[coverage.densityKey] === "number" ? params[coverage.densityKey] : 0;
|
|
38
|
+
const assets = coverage.assetsKey === undefined ? null : assetsSummary(params[coverage.assetsKey]);
|
|
39
|
+
return (_jsxs("div", { className: "space-y-2 rounded-lg border p-2.5", style: { borderColor: `${accent}26`, backgroundColor: `${accent}10` }, children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em]", style: { color: accent }, children: "Coverage" }), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "area" }), _jsx("span", { className: "text-cyan-200", children: area > 0 ? `${fmt(area)} m²` : "—" })] }), assets !== null ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO_LABEL, children: "fills with" }), _jsx("span", { className: "min-w-0 truncate text-right text-[10px] text-neutral-300", title: assets, children: assets })] })) : null, densityField !== undefined && densityField.type === "range" ? (_jsx(CoverageDensitySlider, { field: densityField, density: density, densityKey: coverage.densityKey, onMeta: onMeta })) : null, note !== undefined && note.length > 0 ? _jsx("div", { className: "text-[10px] text-neutral-500", children: note }) : null] }));
|
|
40
|
+
}
|
package/dist/uiStore.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EditorSession, EditorVec3, EditorVolumeShape } from "@jgengine/core/editor/index";
|
|
2
2
|
import type { TerraformFalloff, TerraformShape } from "@jgengine/core/world/terraform";
|
|
3
|
+
import type { ClipPreviewSession } from "./shell/clipPreview.js";
|
|
3
4
|
/** Which top-level editor tool is active: object placement/selection, or terrain sculpting. */
|
|
4
5
|
export type EditorTool = "select" | "terrain";
|
|
5
6
|
/** A heightfield sculpt brush the terrain tool can apply. */
|
|
@@ -120,6 +121,11 @@ export interface EditorUiState {
|
|
|
120
121
|
sculpt: SculptSettings;
|
|
121
122
|
paint: PaintSettings;
|
|
122
123
|
contextMenu: EditorContextMenuState | null;
|
|
124
|
+
/**
|
|
125
|
+
* Active viewport clip-preview session (rigged asset + playback driver), or null when nothing is
|
|
126
|
+
* previewing. Driven by the Animation dock's clip mode; rendered by `ClipPreviewLayer`.
|
|
127
|
+
*/
|
|
128
|
+
clipPreview: ClipPreviewSession | null;
|
|
123
129
|
}
|
|
124
130
|
/** Subscribable store for the editor's transient UI state (gizmo, snapping, placement). */
|
|
125
131
|
export interface EditorUiStore {
|
package/dist/uiStore.js
CHANGED
|
@@ -10,6 +10,18 @@ export interface SubscribableStore<S> {
|
|
|
10
10
|
* slice. The selected value is memoized so an equal slice keeps its reference (no render churn).
|
|
11
11
|
*/
|
|
12
12
|
export declare function useStoreSelector<S, T>(store: SubscribableStore<S>, selector: (state: S) => T, isEqual?: (a: T, b: T) => boolean): T;
|
|
13
|
+
/** A notification-only source — `subscribe` is all a coarse invalidation needs. */
|
|
14
|
+
export interface SubscribableSource {
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe a component to **every** notification from one or more sources, returning a monotonic
|
|
19
|
+
* version number via `useSyncExternalStore`. For components that read `store.getState()` directly
|
|
20
|
+
* in render (whole-store chrome) or fan several sources into one coarse invalidation — the version
|
|
21
|
+
* is a `useMemo` cache key, not data.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare function useStoreVersion(...sources: readonly SubscribableSource[]): number;
|
|
13
25
|
/** Shallow array equality — for selectors that return id lists (`selection`) or small tuples. */
|
|
14
26
|
export declare function shallowArrayEqual<T>(a: readonly T[], b: readonly T[]): boolean;
|
|
15
27
|
/** The visible slice of a fixed-row-height list: which rows to mount and the spacer geometry. */
|