@jgengine/editor 0.14.0 → 0.15.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 +500 -1
- package/dist/EditorApp.js +2 -2
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +2 -4
- package/dist/InspectorPanel.js +12 -1
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.js +55 -29
- 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/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.js +14 -20
- 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/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.js +10 -1
- 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 +3 -1
- 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 +16 -3
- package/skills/jgengine-editor/reference.md +44 -0
package/dist/EditorApp.js
CHANGED
|
@@ -261,7 +261,6 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
|
|
|
261
261
|
if (readoutStoreRef.current === null)
|
|
262
262
|
readoutStoreRef.current = createTerrainReadoutStore();
|
|
263
263
|
const readout = readoutStoreRef.current;
|
|
264
|
-
const [mode, setModeState] = useState("edit");
|
|
265
264
|
const catalogAssets = useMemo(() => {
|
|
266
265
|
try {
|
|
267
266
|
const ids = playable.game.assets.ids();
|
|
@@ -286,7 +285,8 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
|
|
|
286
285
|
};
|
|
287
286
|
}, [gameId, resolvedLayers, catalogs, catalogAssets]);
|
|
288
287
|
useEffect(() => host.dispose, [host]);
|
|
289
|
-
|
|
288
|
+
const modeStore = useMemo(() => ({ getState: host.api.getMode, subscribe: host.api.subscribeMode }), [host]);
|
|
289
|
+
const mode = useStoreSelector(modeStore, (m) => m);
|
|
290
290
|
const [pendingDraft, setPendingDraft] = useState(null);
|
|
291
291
|
useEffect(() => {
|
|
292
292
|
const draft = readDraft(gameId);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { EditorHostApi } from "./session.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Drives the editor orbit camera from the host's focus target. When the target carries only a
|
|
4
|
+
* point, the camera pans to keep it centered (the historical behavior). When it also carries
|
|
5
|
+
* placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
|
|
6
|
+
* how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
|
|
7
|
+
*/
|
|
3
8
|
export declare const EditorCameraDriver: import("react").MemoExoticComponent<({ api }: {
|
|
4
9
|
api: EditorHostApi;
|
|
5
10
|
}) => null>;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useThree } from "@react-three/fiber";
|
|
2
2
|
import { memo, useEffect } from "react";
|
|
3
|
-
|
|
3
|
+
import { orbitCameraPosition } from "./camera/orbitFraming.js";
|
|
4
|
+
/**
|
|
5
|
+
* Drives the editor orbit camera from the host's focus target. When the target carries only a
|
|
6
|
+
* point, the camera pans to keep it centered (the historical behavior). When it also carries
|
|
7
|
+
* placement (`distance`/`pitch`/`yaw`/`height`), the camera is repositioned to that orbit pose —
|
|
8
|
+
* how `camera_goto`/`camera_frame` compose an aerial without the KeyF-buries-in-terrain problem.
|
|
9
|
+
*/
|
|
4
10
|
export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
|
|
5
11
|
const controls = useThree((state) => state.controls);
|
|
6
12
|
const camera = useThree((state) => state.camera);
|
|
@@ -8,6 +14,24 @@ export const EditorCameraDriver = memo(function EditorCameraDriver({ api }) {
|
|
|
8
14
|
return api.subscribeFocus((target) => {
|
|
9
15
|
if (target === null || controls === null || controls.target === undefined)
|
|
10
16
|
return;
|
|
17
|
+
const hasPlacement = target.distance !== undefined ||
|
|
18
|
+
target.pitch !== undefined ||
|
|
19
|
+
target.yaw !== undefined ||
|
|
20
|
+
target.height !== undefined;
|
|
21
|
+
if (hasPlacement) {
|
|
22
|
+
controls.target.set(target.x, target.y, target.z);
|
|
23
|
+
const distance = target.distance ?? camera.position.distanceTo(controls.target);
|
|
24
|
+
const pose = orbitCameraPosition({
|
|
25
|
+
target,
|
|
26
|
+
distance,
|
|
27
|
+
pitchDeg: target.pitch ?? 45,
|
|
28
|
+
yawDeg: target.yaw,
|
|
29
|
+
height: target.height,
|
|
30
|
+
});
|
|
31
|
+
camera.position.set(pose.x, pose.y, pose.z);
|
|
32
|
+
controls.update();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
11
35
|
const previous = controls.target.clone();
|
|
12
36
|
controls.target.set(target.x, target.y, target.z);
|
|
13
37
|
camera.position.add(controls.target.clone().sub(previous));
|
package/dist/EditorChrome.js
CHANGED
|
@@ -15,6 +15,7 @@ import { buildOutlinerGroups } from "./outlinerModel.js";
|
|
|
15
15
|
import { importAssetToHost, loadDroppedAssets, } from "./assetImport.js";
|
|
16
16
|
import { newPlacementId } from "./uiStore.js";
|
|
17
17
|
import { useF2Chord } from "./useF2Chord.js";
|
|
18
|
+
import { useStoreVersion } from "./useStoreSelector.js";
|
|
18
19
|
import { TerrainPanel } from "./TerrainPanel.js";
|
|
19
20
|
import { LightingPanel } from "./LightingPanel.js";
|
|
20
21
|
import { InspectorPanel } from "./InspectorPanel.js";
|
|
@@ -112,10 +113,10 @@ const LEFT_PAGES = [
|
|
|
112
113
|
* and host RPC. Mounted by `EditorApp`; not a game-author entry point.
|
|
113
114
|
*/
|
|
114
115
|
export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
|
|
115
|
-
const [, setTick] = useState(0);
|
|
116
116
|
const layoutRef = useRef(null);
|
|
117
117
|
layoutRef.current ??= createShellLayoutStore(gameId);
|
|
118
118
|
const layout = layoutRef.current;
|
|
119
|
+
useStoreVersion(session, ui, layout);
|
|
119
120
|
const consoleRef = useRef(null);
|
|
120
121
|
consoleRef.current ??= createEditorConsoleStore();
|
|
121
122
|
const consoleStore = consoleRef.current;
|
|
@@ -171,9 +172,6 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
|
|
|
171
172
|
useF2Chord("KeyE", () => api.setMode("play"));
|
|
172
173
|
// F2+Q leaves the editor entirely (back to the game) — only meaningful when summoned over one.
|
|
173
174
|
useF2Chord("KeyQ", useCallback(() => onExitEditor?.(), [onExitEditor]));
|
|
174
|
-
useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
|
|
175
|
-
useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
|
|
176
|
-
useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
|
|
177
175
|
const copySelection = useCallback(() => {
|
|
178
176
|
const current = session.getState();
|
|
179
177
|
if (current.selection.length === 0)
|
package/dist/InspectorPanel.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getSceneKind, isSceneKind, parseParams, } from "@jgengine/core/scene/sc
|
|
|
6
6
|
import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
|
|
7
7
|
import { markerCatalogId } from "@jgengine/core/world/authoredObjects";
|
|
8
8
|
import { useGameContext } from "@jgengine/react/provider";
|
|
9
|
+
import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
|
|
9
10
|
import { canAuthorTrigger, clearMaterialAssignmentPatch, clearTriggerInstallPatch, defaultTriggerInstallPatch, hasAuthoredTrigger, hasMaterialAssignment, } from "./authoredComponentMeta.js";
|
|
10
11
|
import { SchemaInspector } from "./SchemaInspector.js";
|
|
11
12
|
import { TriggerInspector } from "./TriggerInspector.js";
|
|
@@ -21,6 +22,16 @@ import { Icon, kindIcon } from "./shell/icons.js";
|
|
|
21
22
|
import { CoverageSection } from "./shell/scatterCoverage.js";
|
|
22
23
|
import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
|
|
23
24
|
import { CollapsibleSection, EmptyState, IconButton, PanelTabs } from "./shell/ui.js";
|
|
25
|
+
/** Live-mirrored, debounced density slider — grass regen only fires on pause/release, not per step (#1372). */
|
|
26
|
+
function VegetationDensitySlider({ density, sliderMax, onMeta, }) {
|
|
27
|
+
const { value: local, onInput, flush } = useDebouncedCommit(density, (next) => onMeta({ density: next }, "veg:density"));
|
|
28
|
+
return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "density /m\u00B2" }), _jsx("span", { className: "text-cyan-200", children: local.toFixed(2) })] }), _jsx("input", { type: "range", min: 0, max: sliderMax, step: sliderMax / 200, className: "w-full accent-emerald-400", value: Math.min(local, sliderMax), onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
|
|
29
|
+
}
|
|
30
|
+
/** Live-mirrored, debounced item/seed text — avoids a scene patch (and grass regen) per keystroke (#1372). */
|
|
31
|
+
function VegetationTextField({ label, field, value, placeholder, onMeta, }) {
|
|
32
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field]: next }, `veg:${field}`));
|
|
33
|
+
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { className: `w-32 ${INPUT}`, value: local, placeholder: placeholder, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
|
|
34
|
+
}
|
|
24
35
|
function VegetationFields({ volume, onMeta, }) {
|
|
25
36
|
const settings = readVegetationSettings(volume);
|
|
26
37
|
if (settings === null)
|
|
@@ -29,7 +40,7 @@ function VegetationFields({ volume, onMeta, }) {
|
|
|
29
40
|
const areaM2 = (footprint.maxX - footprint.minX) * (footprint.maxZ - footprint.minZ);
|
|
30
41
|
const sliderMax = settings.item === "grass" ? 12 : 1;
|
|
31
42
|
const estimated = Math.floor(areaM2 * settings.density);
|
|
32
|
-
return (_jsxs("div", { className: "space-y-2", children: [
|
|
43
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(VegetationTextField, { label: "item", field: "item", value: settings.item, placeholder: "grass", onMeta: onMeta }), _jsx(VegetationDensitySlider, { density: settings.density, sliderMax: sliderMax, onMeta: onMeta }), _jsx(NumberField, { label: "density", step: 0.01, value: settings.density, onCommit: (value) => onMeta({ density: Math.max(0, value) }, "veg:density") }), _jsx(NumberField, { label: "min scale", step: 0.05, value: settings.minScale, onCommit: (value) => onMeta({ minScale: value }, "veg:minScale") }), _jsx(NumberField, { label: "max scale", step: 0.05, value: settings.maxScale, onCommit: (value) => onMeta({ maxScale: value }, "veg:maxScale") }), _jsx(NumberField, { label: "spacing", step: 0.25, value: settings.minDistance, onCommit: (value) => onMeta({ minDistance: Math.max(0, value) }, "veg:minDistance") }), _jsx(VegetationTextField, { label: "seed", field: "seed", value: settings.seed, placeholder: "reroll\u2026", onMeta: onMeta }), _jsxs("div", { className: "text-[10px] text-neutral-500", children: ["\u2248 ", estimated.toLocaleString(), " ", settings.item === "grass" ? "blades" : "placements", " over ", Math.round(areaM2).toLocaleString(), " m\u00B2"] })] }));
|
|
33
44
|
}
|
|
34
45
|
/** Builds the resolver-facing view of a document object for a registered scene kind's inspector. */
|
|
35
46
|
function markerObject(marker) {
|
package/dist/ParentPickerMenu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
3
|
import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
|
|
4
4
|
/**
|
|
5
5
|
* Searchable parent-target menu for hierarchy / viewport "Parent to…".
|
|
@@ -8,17 +8,13 @@ import { FOCUS_RING, INPUT_CLS, NUMERIC } from "./shell/theme.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export function ParentPickerMenu({ x, y, candidates, onPick, onClose, }) {
|
|
10
10
|
const [query, setQuery] = useState("");
|
|
11
|
-
const inputRef = useRef(null);
|
|
12
|
-
useEffect(() => {
|
|
13
|
-
inputRef.current?.focus();
|
|
14
|
-
}, []);
|
|
15
11
|
const filtered = useMemo(() => {
|
|
16
12
|
const q = query.trim().toLowerCase();
|
|
17
13
|
if (q.length === 0)
|
|
18
14
|
return candidates;
|
|
19
15
|
return candidates.filter((entry) => entry.label.toLowerCase().includes(q) || entry.id.toLowerCase().includes(q));
|
|
20
16
|
}, [candidates, query]);
|
|
21
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", {
|
|
17
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pointer-events-auto fixed inset-0 z-[60]", onPointerDown: onClose, onContextMenu: (event) => event.preventDefault() }), _jsxs("div", { role: "menu", "aria-label": "Parent to object", className: "pointer-events-auto fixed z-[61] flex max-h-72 w-64 flex-col overflow-hidden rounded-md border border-white/15 bg-neutral-900/95 shadow-2xl backdrop-blur-sm", style: { left: x, top: y }, onContextMenu: (event) => event.preventDefault(), children: [_jsx("div", { className: "border-b border-white/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/45", children: "Parent to\u2026" }), _jsx("div", { className: "border-b border-white/10 p-1.5", children: _jsx("input", { autoFocus: true, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
|
|
22
18
|
if (event.key === "Escape") {
|
|
23
19
|
event.preventDefault();
|
|
24
20
|
onClose();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { useFrame } from "@react-three/fiber";
|
|
4
4
|
import { useGameContext } from "@jgengine/react/provider";
|
|
5
5
|
import { MICRO } from "./chromeStyles.js";
|
|
6
|
-
import { PlayModeBar } from "./shell/PlayModeBar.js";
|
|
6
|
+
import { PlayModeBar, usePlayControl } from "./shell/PlayModeBar.js";
|
|
7
7
|
const PUBLISH_MS = 100;
|
|
8
8
|
function metaValues(meta) {
|
|
9
9
|
if (meta === null || typeof meta !== "object" || Array.isArray(meta))
|
|
@@ -83,11 +83,9 @@ export function RuntimePlayPublisher({ api }) {
|
|
|
83
83
|
* @internal
|
|
84
84
|
*/
|
|
85
85
|
export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
|
|
86
|
-
const
|
|
86
|
+
const play = usePlayControl(api);
|
|
87
87
|
const [summary, setSummary] = useState(null);
|
|
88
88
|
const [selectedId, setSelectedId] = useState(null);
|
|
89
|
-
const [detail, setDetail] = useState("");
|
|
90
|
-
useEffect(() => api.subscribePlayControl(setPlay), [api]);
|
|
91
89
|
useEffect(() => {
|
|
92
90
|
const tick = () => {
|
|
93
91
|
const result = api.handle({ method: "runtime_summary" });
|
|
@@ -99,17 +97,14 @@ export function RuntimePlayInspectorChrome({ gameId, api, onExit, }) {
|
|
|
99
97
|
const timer = setInterval(tick, PUBLISH_MS);
|
|
100
98
|
return () => clearInterval(timer);
|
|
101
99
|
}, [api]);
|
|
102
|
-
|
|
103
|
-
if (selectedId === null)
|
|
104
|
-
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
100
|
+
const detail = useMemo(() => {
|
|
101
|
+
if (selectedId === null)
|
|
102
|
+
return "";
|
|
107
103
|
const got = api.handle({ method: "runtime_get", id: selectedId });
|
|
108
|
-
if (!got.ok)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
setDetail(JSON.stringify(got.result, null, 2));
|
|
104
|
+
if (!got.ok)
|
|
105
|
+
return got.error ?? "missing";
|
|
106
|
+
return JSON.stringify(got.result, null, 2);
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- entityCount/play are poll-tick cache keys
|
|
113
108
|
}, [api, selectedId, summary?.entityCount, play.pendingSteps, play.paused]);
|
|
114
109
|
const nudgeSelected = (axis, delta) => {
|
|
115
110
|
if (selectedId === null)
|
package/dist/SchemaInspector.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { parseParams, randomizeGroupParams, resetGroupParams, } from "@jgengine/core/scene/sceneKinds";
|
|
4
|
+
import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
|
|
4
5
|
import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
|
|
5
6
|
const INPUT = `px-2 py-1 ${INPUT_CLS}`;
|
|
6
7
|
const MICRO = MICRO_LABEL;
|
|
@@ -19,22 +20,56 @@ function PresetRow({ schema, onMeta }) {
|
|
|
19
20
|
function labelOf(field) {
|
|
20
21
|
return field.label ?? field.key;
|
|
21
22
|
}
|
|
23
|
+
/** Range field: live-mirrored thumb + readout, debounced commit, flush on release/blur/keyup (#1372). */
|
|
24
|
+
function RangeField({ field, value, coalesce, onMeta, }) {
|
|
25
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
26
|
+
return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _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", onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush })] }));
|
|
27
|
+
}
|
|
28
|
+
/** Number field: debounced commit, flush on blur (#1372). */
|
|
29
|
+
function NumberInputField({ field, value, coalesce, onMeta, }) {
|
|
30
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
31
|
+
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "number", step: field.step ?? 1, className: `w-32 ${INPUT}`, value: local, onChange: (event) => {
|
|
32
|
+
const next = Number(event.target.value);
|
|
33
|
+
if (Number.isFinite(next))
|
|
34
|
+
onInput(next);
|
|
35
|
+
}, onBlur: flush })] }));
|
|
36
|
+
}
|
|
37
|
+
/** Color field: debounced commit while the picker drags, flush on blur (#1372). */
|
|
38
|
+
function ColorField({ field, value, coalesce, onMeta, }) {
|
|
39
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
40
|
+
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "color", className: `h-7 w-14 cursor-pointer rounded-[5px] border border-white/10 bg-black/40 ${FOCUS_RING}`, value: local, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
|
|
41
|
+
}
|
|
42
|
+
/** Text field: debounced commit while typing, flush on blur (#1372). */
|
|
43
|
+
function TextField({ field, value, coalesce, onMeta, }) {
|
|
44
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
45
|
+
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-32 min-w-0 ${INPUT}`, value: local, onChange: (event) => onInput(event.target.value), onBlur: flush })] }));
|
|
46
|
+
}
|
|
47
|
+
/** Seed field: debounced text commit while typing (reroll button commits immediately) (#1372). */
|
|
48
|
+
function SeedField({ field, value, coalesce, onMeta, }) {
|
|
49
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
50
|
+
return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-full min-w-0 ${INPUT}`, value: local, placeholder: "reroll\u2026", onChange: (event) => onInput(event.target.value), onBlur: flush })] }), _jsx("button", { type: "button", className: BTN, title: "Reroll seed", onClick: () => {
|
|
51
|
+
flush();
|
|
52
|
+
onMeta({ [field.key]: `r${Math.abs(Math.round((local.length + 1) * 2654435761)).toString(36).slice(0, 6)}${local.length}` }, coalesce);
|
|
53
|
+
}, children: "\u27F3" })] }));
|
|
54
|
+
}
|
|
55
|
+
/** Weighted-list field: debounced item/weight commits share one mirrored array; add/remove commit now (#1372). */
|
|
56
|
+
function WeightedListField({ field, value, coalesce, onMeta, }) {
|
|
57
|
+
const { value: list, onInput, flush } = useDebouncedCommit(value, (next) => onMeta({ [field.key]: next }, coalesce));
|
|
58
|
+
// Add/remove are discrete structural edits — commit them immediately through the same mirror.
|
|
59
|
+
const setNow = (next) => {
|
|
60
|
+
onInput(next);
|
|
61
|
+
flush();
|
|
62
|
+
};
|
|
63
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: MICRO, children: labelOf(field) }), list.map((entry, index) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: `min-w-0 flex-1 ${INPUT}`, value: entry.item, placeholder: field.itemLabel ?? "id", onChange: (event) => onInput(list.map((e, i) => (i === index ? { ...e, item: event.target.value } : e))), onBlur: flush }), _jsx("input", { type: "number", step: 1, min: 0, className: `w-14 ${INPUT}`, value: entry.weight, onChange: (event) => onInput(list.map((e, i) => (i === index ? { ...e, weight: Math.max(0, Number(event.target.value)) } : e))), onBlur: flush }), _jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-1 text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200 disabled:opacity-40`, disabled: list.length <= 1, onClick: () => setNow(list.filter((_, i) => i !== index)), children: "\u00D7" })] }, index))), _jsx("button", { type: "button", className: `w-full ${BTN}`, onClick: () => setNow([...list, { item: "", weight: 1 }]), children: "+ row" })] }));
|
|
64
|
+
}
|
|
22
65
|
function FieldRow({ field, meta, onMeta }) {
|
|
23
66
|
const params = parseParams({ fields: [field] }, meta);
|
|
24
67
|
const coalesce = `kind:${field.key}`;
|
|
25
68
|
switch (field.type) {
|
|
26
|
-
case "range":
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
case "number": {
|
|
31
|
-
const value = params[field.key];
|
|
32
|
-
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { type: "number", step: field.step ?? 1, className: `w-32 ${INPUT}`, value: value, onChange: (event) => {
|
|
33
|
-
const next = Number(event.target.value);
|
|
34
|
-
if (Number.isFinite(next))
|
|
35
|
-
onMeta({ [field.key]: next }, coalesce);
|
|
36
|
-
} })] }));
|
|
37
|
-
}
|
|
69
|
+
case "range":
|
|
70
|
+
return _jsx(RangeField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
71
|
+
case "number":
|
|
72
|
+
return _jsx(NumberInputField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
38
73
|
case "bool": {
|
|
39
74
|
const value = params[field.key];
|
|
40
75
|
return (_jsxs("label", { className: "flex items-center gap-1.5 text-[10px] text-neutral-400", children: [_jsx("input", { type: "checkbox", className: "accent-emerald-400", checked: value, onChange: (event) => onMeta({ [field.key]: event.target.checked }, coalesce) }), labelOf(field)] }));
|
|
@@ -43,23 +78,14 @@ function FieldRow({ field, meta, onMeta }) {
|
|
|
43
78
|
const value = params[field.key];
|
|
44
79
|
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("select", { className: `w-32 ${INPUT}`, value: value, onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce), children: field.options.map((option) => (_jsx("option", { value: option.value, children: option.label ?? option.value }, option.value))) })] }));
|
|
45
80
|
}
|
|
46
|
-
case "color":
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case "
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
case "seed": {
|
|
55
|
-
const value = params[field.key];
|
|
56
|
-
return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: MICRO, children: labelOf(field) }), _jsx("input", { className: `w-full min-w-0 ${INPUT}`, value: value, placeholder: "reroll\u2026", onChange: (event) => onMeta({ [field.key]: event.target.value }, coalesce) })] }), _jsx("button", { type: "button", className: BTN, title: "Reroll seed", onClick: () => onMeta({ [field.key]: `r${Math.abs(Math.round((value.length + 1) * 2654435761)).toString(36).slice(0, 6)}${value.length}` }, coalesce), children: "\u27F3" })] }));
|
|
57
|
-
}
|
|
58
|
-
case "weightedList": {
|
|
59
|
-
const list = params[field.key];
|
|
60
|
-
const set = (next) => onMeta({ [field.key]: next }, coalesce);
|
|
61
|
-
return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: MICRO, children: labelOf(field) }), list.map((entry, index) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: `min-w-0 flex-1 ${INPUT}`, value: entry.item, placeholder: field.itemLabel ?? "id", onChange: (event) => set(list.map((e, i) => (i === index ? { ...e, item: event.target.value } : e))) }), _jsx("input", { type: "number", step: 1, min: 0, className: `w-14 ${INPUT}`, value: entry.weight, onChange: (event) => set(list.map((e, i) => (i === index ? { ...e, weight: Math.max(0, Number(event.target.value)) } : e))) }), _jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-1 text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200 disabled:opacity-40`, disabled: list.length <= 1, onClick: () => set(list.filter((_, i) => i !== index)), children: "\u00D7" })] }, index))), _jsx("button", { type: "button", className: `w-full ${BTN}`, onClick: () => set([...list, { item: "", weight: 1 }]), children: "+ row" })] }));
|
|
62
|
-
}
|
|
81
|
+
case "color":
|
|
82
|
+
return _jsx(ColorField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
83
|
+
case "text":
|
|
84
|
+
return _jsx(TextField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
85
|
+
case "seed":
|
|
86
|
+
return _jsx(SeedField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
87
|
+
case "weightedList":
|
|
88
|
+
return _jsx(WeightedListField, { field: field, value: params[field.key], coalesce: coalesce, onMeta: onMeta });
|
|
63
89
|
case "action":
|
|
64
90
|
return null; // Action buttons are rendered by the group section, which owns the whole schema.
|
|
65
91
|
}
|
package/dist/TerrainPanel.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from "react";
|
|
3
3
|
import { editorDocumentBounds } from "@jgengine/core/editor/index";
|
|
4
4
|
import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
|
|
5
5
|
import { TERRAIN_MATERIALS } from "./uiStore.js";
|
|
6
6
|
import { SliderRow } from "./chromeFields.js";
|
|
7
|
+
import { useStoreVersion } from "./useStoreSelector.js";
|
|
7
8
|
import { BORDER, CONTROL, CONTROL_ACTIVE, FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
|
|
8
9
|
const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
|
|
9
10
|
const BTN_SM = `${CONTROL} px-2 py-0.5 text-[11px]`;
|
|
@@ -49,10 +50,8 @@ function PaintControls({ session, ui }) {
|
|
|
49
50
|
}
|
|
50
51
|
/** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
|
|
51
52
|
export function TerrainPanel({ session, ui, api }) {
|
|
52
|
-
const [, setTick] = useState(0);
|
|
53
53
|
const [bakeStatus, setBakeStatus] = useState(null);
|
|
54
|
-
|
|
55
|
-
useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
|
|
54
|
+
useStoreVersion(ui, session);
|
|
56
55
|
const uiState = ui.getState();
|
|
57
56
|
const document = session.getState().document;
|
|
58
57
|
const hasTerrain = document.terrain !== undefined;
|
package/dist/TerrainReadout.js
CHANGED
|
@@ -115,9 +115,7 @@ export function TerrainReadout({ groundHeightAt, region, showContours, showSurfa
|
|
|
115
115
|
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
|
|
116
116
|
const hitRef = useRef(new THREE.Vector3());
|
|
117
117
|
const accum = useRef(0);
|
|
118
|
-
|
|
119
|
-
planeRef.current.constant = -(guides.summary.mean ?? 0);
|
|
120
|
-
}, [guides.summary]);
|
|
118
|
+
planeRef.current.constant = -(guides.summary.mean ?? 0);
|
|
121
119
|
useFrame((_, delta) => {
|
|
122
120
|
accum.current += delta;
|
|
123
121
|
if (accum.current < CURSOR_SAMPLE_INTERVAL)
|
package/dist/agent/AgentPanel.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useStoreVersion } from "../useStoreSelector.js";
|
|
3
4
|
import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL, PANEL_BG } from "../shell/theme.js";
|
|
4
5
|
const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
|
|
5
6
|
const INPUT = `px-2 py-1 ${INPUT_CLS}`;
|
|
@@ -44,17 +45,12 @@ export function AgentPanel({ api, onClose, embedded = false, }) {
|
|
|
44
45
|
const [contextTick, setContextTick] = useState(0);
|
|
45
46
|
const listRef = useRef(null);
|
|
46
47
|
const endpoint = useMemo(() => buildEndpoint(url, apiKey), [url, apiKey]);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
unsubSession();
|
|
54
|
-
unsubMode();
|
|
55
|
-
unsubFocus();
|
|
56
|
-
};
|
|
57
|
-
}, [api]);
|
|
48
|
+
// Coarse invalidation of the packed context: any session/mode/focus change (or a finished agent
|
|
49
|
+
// turn, via contextTick) rebuilds the memo.
|
|
50
|
+
const modeSource = useMemo(() => ({ subscribe: api.subscribeMode }), [api]);
|
|
51
|
+
const focusSource = useMemo(() => ({ subscribe: api.subscribeFocus }), [api]);
|
|
52
|
+
const contextVersion = useStoreVersion(api.getSession(), modeSource, focusSource);
|
|
53
|
+
const context = useMemo(() => packAgentContext(api), [api, contextTick, contextVersion]);
|
|
58
54
|
useEffect(() => {
|
|
59
55
|
const el = listRef.current;
|
|
60
56
|
if (el !== null)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure aerial-framing math for the editor orbit camera. `camera_goto`/`camera_frame` used to only
|
|
3
|
+
* pan the orbit *target*, leaving distance and pitch wherever the artist last dragged them — so a
|
|
4
|
+
* `KeyF` frame could bury the camera inside terrain or a building and composing a clean district
|
|
5
|
+
* aerial took many guess-the-y-offset round-trips. These helpers turn an explicit
|
|
6
|
+
* distance + pitch (+ optional yaw / height) into a camera position, and derive a fit-to-region
|
|
7
|
+
* distance from document bounds so one call frames a whole district from above.
|
|
8
|
+
*
|
|
9
|
+
* Dependency-free and side-effect-free so it is trivially unit-testable and safe to import into the
|
|
10
|
+
* R3F camera driver.
|
|
11
|
+
*/
|
|
12
|
+
/** A world-space point the orbit camera looks at. */
|
|
13
|
+
export interface Vec3 {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
z: number;
|
|
17
|
+
}
|
|
18
|
+
/** Axis-aligned bounds as produced by `editorDocumentBounds`. */
|
|
19
|
+
export interface Bounds3 {
|
|
20
|
+
min: Vec3;
|
|
21
|
+
max: Vec3;
|
|
22
|
+
}
|
|
23
|
+
/** Inputs to {@link orbitCameraPosition}. */
|
|
24
|
+
export interface OrbitFrameInput {
|
|
25
|
+
/** Point the camera orbits and looks at. */
|
|
26
|
+
target: Vec3;
|
|
27
|
+
/** Radial distance from target to camera, in world units. Clamped to a small positive minimum. */
|
|
28
|
+
distance: number;
|
|
29
|
+
/** Elevation above the horizon, degrees: 0 = level with the target, 90 = straight-down aerial. */
|
|
30
|
+
pitchDeg: number;
|
|
31
|
+
/** Azimuth around the target, degrees: 0 looks along +Z toward the target, increasing toward +X. */
|
|
32
|
+
yawDeg?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Explicit camera height above the target (world units). When set it overrides the pitch-derived
|
|
35
|
+
* height, letting a caller pin a fixed altitude while pitch still sets the horizontal pull-back.
|
|
36
|
+
*/
|
|
37
|
+
height?: number;
|
|
38
|
+
}
|
|
39
|
+
/** Clamp a pitch to the horizon..straight-down range the orbit rig can express. */
|
|
40
|
+
export declare function clampPitchDeg(pitchDeg: number): number;
|
|
41
|
+
/**
|
|
42
|
+
* Camera position for an orbit rig framing `target` at the given distance, pitch, and yaw. Pitch is
|
|
43
|
+
* measured up from the horizon (90° = directly overhead); `height`, when provided, replaces the
|
|
44
|
+
* pitch-derived vertical offset. Pure: same inputs always yield the same position.
|
|
45
|
+
*/
|
|
46
|
+
export declare function orbitCameraPosition(input: OrbitFrameInput): Vec3;
|
|
47
|
+
/** Center point of axis-aligned bounds. */
|
|
48
|
+
export declare function boundsCenter(bounds: Bounds3): Vec3;
|
|
49
|
+
/** Options for {@link frameDistanceForBounds}. */
|
|
50
|
+
export interface FrameDistanceOptions {
|
|
51
|
+
/** Vertical field of view of the camera, degrees. Default 50 (the shell's default perspective FOV). */
|
|
52
|
+
fovDeg?: number;
|
|
53
|
+
/** Extra breathing room around the region as a multiplier on its radius. Default 1.25. */
|
|
54
|
+
margin?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Distance at which the camera fits the whole `bounds` region into its vertical FOV — the value
|
|
58
|
+
* `camera_frame` uses when a caller asks for an aerial but does not pin an explicit distance, so a
|
|
59
|
+
* district frames itself instead of burying the camera. Uses the region's XZ half-diagonal plus its
|
|
60
|
+
* vertical extent as the radius to cover.
|
|
61
|
+
*/
|
|
62
|
+
export declare function frameDistanceForBounds(bounds: Bounds3, options?: FrameDistanceOptions): number;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure aerial-framing math for the editor orbit camera. `camera_goto`/`camera_frame` used to only
|
|
3
|
+
* pan the orbit *target*, leaving distance and pitch wherever the artist last dragged them — so a
|
|
4
|
+
* `KeyF` frame could bury the camera inside terrain or a building and composing a clean district
|
|
5
|
+
* aerial took many guess-the-y-offset round-trips. These helpers turn an explicit
|
|
6
|
+
* distance + pitch (+ optional yaw / height) into a camera position, and derive a fit-to-region
|
|
7
|
+
* distance from document bounds so one call frames a whole district from above.
|
|
8
|
+
*
|
|
9
|
+
* Dependency-free and side-effect-free so it is trivially unit-testable and safe to import into the
|
|
10
|
+
* R3F camera driver.
|
|
11
|
+
*/
|
|
12
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
13
|
+
/** Smallest distance we will place the camera at, so `distance: 0` never collapses onto the target. */
|
|
14
|
+
const MIN_DISTANCE = 0.01;
|
|
15
|
+
/** Clamp a pitch to the horizon..straight-down range the orbit rig can express. */
|
|
16
|
+
export function clampPitchDeg(pitchDeg) {
|
|
17
|
+
if (!Number.isFinite(pitchDeg))
|
|
18
|
+
return 45;
|
|
19
|
+
return Math.max(0, Math.min(90, pitchDeg));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Camera position for an orbit rig framing `target` at the given distance, pitch, and yaw. Pitch is
|
|
23
|
+
* measured up from the horizon (90° = directly overhead); `height`, when provided, replaces the
|
|
24
|
+
* pitch-derived vertical offset. Pure: same inputs always yield the same position.
|
|
25
|
+
*/
|
|
26
|
+
export function orbitCameraPosition(input) {
|
|
27
|
+
const distance = Math.max(MIN_DISTANCE, Number.isFinite(input.distance) ? input.distance : MIN_DISTANCE);
|
|
28
|
+
const pitchRad = clampPitchDeg(input.pitchDeg) * DEG_TO_RAD;
|
|
29
|
+
const yawRad = (Number.isFinite(input.yawDeg) ? input.yawDeg : 0) * DEG_TO_RAD;
|
|
30
|
+
const horizontal = distance * Math.cos(pitchRad);
|
|
31
|
+
const vertical = distance * Math.sin(pitchRad);
|
|
32
|
+
const height = input.height !== undefined && Number.isFinite(input.height) ? input.height : vertical;
|
|
33
|
+
return {
|
|
34
|
+
x: input.target.x + horizontal * Math.sin(yawRad),
|
|
35
|
+
y: input.target.y + height,
|
|
36
|
+
z: input.target.z + horizontal * Math.cos(yawRad),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Center point of axis-aligned bounds. */
|
|
40
|
+
export function boundsCenter(bounds) {
|
|
41
|
+
return {
|
|
42
|
+
x: (bounds.min.x + bounds.max.x) / 2,
|
|
43
|
+
y: (bounds.min.y + bounds.max.y) / 2,
|
|
44
|
+
z: (bounds.min.z + bounds.max.z) / 2,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Distance at which the camera fits the whole `bounds` region into its vertical FOV — the value
|
|
49
|
+
* `camera_frame` uses when a caller asks for an aerial but does not pin an explicit distance, so a
|
|
50
|
+
* district frames itself instead of burying the camera. Uses the region's XZ half-diagonal plus its
|
|
51
|
+
* vertical extent as the radius to cover.
|
|
52
|
+
*/
|
|
53
|
+
export function frameDistanceForBounds(bounds, options = {}) {
|
|
54
|
+
const fovDeg = clampFovDeg(options.fovDeg ?? 50);
|
|
55
|
+
const margin = options.margin !== undefined && Number.isFinite(options.margin) ? options.margin : 1.25;
|
|
56
|
+
const halfX = (bounds.max.x - bounds.min.x) / 2;
|
|
57
|
+
const halfZ = (bounds.max.z - bounds.min.z) / 2;
|
|
58
|
+
const halfY = (bounds.max.y - bounds.min.y) / 2;
|
|
59
|
+
const radius = Math.hypot(halfX, halfZ) + Math.max(0, halfY);
|
|
60
|
+
const halfFovRad = (fovDeg / 2) * DEG_TO_RAD;
|
|
61
|
+
const tan = Math.tan(halfFovRad);
|
|
62
|
+
const distance = tan > 1e-4 ? (radius * Math.max(0.1, margin)) / tan : radius;
|
|
63
|
+
return Math.max(MIN_DISTANCE, distance);
|
|
64
|
+
}
|
|
65
|
+
function clampFovDeg(fovDeg) {
|
|
66
|
+
if (!Number.isFinite(fovDeg))
|
|
67
|
+
return 50;
|
|
68
|
+
return Math.max(1, Math.min(179, fovDeg));
|
|
69
|
+
}
|
package/dist/chromeFields.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
|
|
2
3
|
import { INPUT } from "./chromeStyles.js";
|
|
3
4
|
export function NumberField({ label, value, onCommit, step = 1, }) {
|
|
4
|
-
|
|
5
|
+
// Mirror locally + debounce the commit so a held spinner / paste doesn't storm scene regen (#1372).
|
|
6
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, onCommit);
|
|
7
|
+
return (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("input", { type: "number", step: step, className: `w-32 ${INPUT}`, value: local, onChange: (event) => {
|
|
5
8
|
const next = Number(event.target.value);
|
|
6
9
|
if (!Number.isFinite(next))
|
|
7
10
|
return;
|
|
8
|
-
|
|
9
|
-
} })] }));
|
|
11
|
+
onInput(next);
|
|
12
|
+
}, onBlur: flush })] }));
|
|
10
13
|
}
|
|
11
14
|
export function SliderRow({ label, value, min, max, step, onChange, format, }) {
|
|
12
|
-
|
|
15
|
+
// The readout + thumb track the live local value; onChange is debounced and flushed on release (#1372).
|
|
16
|
+
const { value: local, onInput, flush } = useDebouncedCommit(value, onChange);
|
|
17
|
+
return (_jsxs("label", { className: "block space-y-1", children: [_jsxs("span", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: label }), _jsx("span", { className: "text-cyan-200", children: format ? format(local) : local.toFixed(2) })] }), _jsx("input", { type: "range", min: min, max: max, step: step, value: local, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, className: "w-full accent-amber-400" })] }));
|
|
13
18
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { HandlerTable } from "./context.js";
|
|
2
2
|
/** Document, selection, camera, mode, asset placement, and status verbs. */
|
|
3
|
-
export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "assign_material" | "set_object_flags">;
|
|
3
|
+
export declare const documentHandlers: Pick<HandlerTable, "editor_status" | "set_mode" | "perf_report" | "list_layers" | "list_selection" | "get_marker" | "get_volume" | "set_transform" | "set_volume" | "set_path" | "add_path" | "add_marker" | "set_marker" | "set_note" | "set_meta" | "apply_preset" | "select" | "clear_selection" | "camera_goto" | "camera_frame" | "scene_summary" | "export_document" | "import_document" | "dispatch" | "undo" | "redo" | "list_assets" | "place_asset" | "batch_set_properties" | "assign_material" | "set_object_flags">;
|