@jgengine/editor 0.10.0 → 0.12.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 +27 -1
- package/README.md +12 -0
- package/dist/AssetBrowser.d.ts +26 -1
- package/dist/AssetBrowser.js +54 -1
- package/dist/AssetDropZone.d.ts +9 -0
- package/dist/AssetDropZone.js +65 -0
- package/dist/CatalogsPanel.d.ts +4 -2
- package/dist/CatalogsPanel.js +162 -9
- package/dist/CityPreview.d.ts +11 -0
- package/dist/CityPreview.js +45 -0
- package/dist/CollectionsPanel.js +5 -5
- package/dist/DebugDraw.d.ts +3 -1
- package/dist/DebugDraw.js +37 -19
- package/dist/EditorApp.d.ts +9 -1
- package/dist/EditorApp.js +208 -37
- package/dist/EditorChrome.d.ts +25 -6
- package/dist/EditorChrome.js +310 -118
- package/dist/InspectorPanel.d.ts +17 -7
- package/dist/InspectorPanel.js +173 -27
- package/dist/LightingPanel.d.ts +11 -0
- package/dist/LightingPanel.js +99 -0
- package/dist/MaterialsWorkspacePanel.d.ts +12 -0
- package/dist/MaterialsWorkspacePanel.js +86 -0
- package/dist/ParentPickerMenu.d.ts +13 -0
- package/dist/ParentPickerMenu.js +33 -0
- package/dist/PerfProbe.js +69 -2
- package/dist/PrefabsPanel.js +5 -5
- package/dist/RuntimePlayBridge.d.ts +5 -3
- package/dist/RuntimePlayBridge.js +6 -13
- package/dist/ScatterPreview.d.ts +3 -1
- package/dist/ScatterPreview.js +10 -7
- package/dist/SchemaInspector.js +21 -8
- package/dist/ScriptingPanel.d.ts +15 -0
- package/dist/ScriptingPanel.js +46 -0
- package/dist/SelectionGizmo.js +114 -30
- package/dist/StandaloneEditor.d.ts +16 -9
- package/dist/StandaloneEditor.js +19 -20
- package/dist/TerrainPanel.d.ts +3 -2
- package/dist/TerrainPanel.js +16 -5
- package/dist/TerrainReadout.d.ts +17 -0
- package/dist/TerrainReadout.js +163 -0
- package/dist/TerrainReadoutHud.d.ts +9 -0
- package/dist/TerrainReadoutHud.js +20 -0
- package/dist/TerrainSculpt.js +98 -59
- package/dist/agent/AgentPanel.d.ts +5 -2
- package/dist/agent/AgentPanel.js +11 -6
- package/dist/agent/toolBridge.js +5 -0
- package/dist/assetImport.d.ts +36 -0
- package/dist/assetImport.js +66 -0
- package/dist/authoredComponentMeta.d.ts +25 -0
- package/dist/authoredComponentMeta.js +65 -0
- package/dist/gizmoPivot.d.ts +4 -0
- package/dist/gizmoPivot.js +34 -0
- package/dist/handlers/catalogs.d.ts +3 -0
- package/dist/handlers/catalogs.js +137 -0
- package/dist/handlers/collections.d.ts +3 -0
- package/dist/handlers/collections.js +57 -0
- package/dist/handlers/context.d.ts +36 -0
- package/dist/handlers/context.js +1 -0
- package/dist/handlers/document.d.ts +3 -0
- package/dist/handlers/document.js +350 -0
- package/dist/handlers/grids.d.ts +3 -0
- package/dist/handlers/grids.js +142 -0
- package/dist/handlers/hierarchy.d.ts +3 -0
- package/dist/handlers/hierarchy.js +55 -0
- package/dist/handlers/index.d.ts +8 -0
- package/dist/handlers/index.js +23 -0
- package/dist/handlers/minimap.d.ts +9 -0
- package/dist/handlers/minimap.js +32 -0
- package/dist/handlers/runtime.d.ts +3 -0
- package/dist/handlers/runtime.js +191 -0
- package/dist/handlers/terrain.d.ts +3 -0
- package/dist/handlers/terrain.js +204 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/listDocumentTriggers.d.ts +42 -0
- package/dist/listDocumentTriggers.js +49 -0
- package/dist/materialAssignments.d.ts +63 -0
- package/dist/materialAssignments.js +84 -0
- package/dist/mcp/fieldSpec.d.ts +32 -0
- package/dist/mcp/fieldSpec.js +173 -0
- package/dist/mcp/rpcRequest.d.ts +8 -5
- package/dist/mcp/rpcRequest.js +72 -83
- package/dist/mcp/tools.js +104 -763
- package/dist/networkSnapshot.d.ts +82 -0
- package/dist/networkSnapshot.js +73 -0
- package/dist/parentCandidates.d.ts +12 -0
- package/dist/parentCandidates.js +25 -0
- package/dist/perfPill.d.ts +21 -0
- package/dist/perfPill.js +20 -0
- package/dist/session.d.ts +167 -5
- package/dist/session.js +36 -879
- package/dist/shell/AnimationPanel.d.ts +17 -0
- package/dist/shell/AnimationPanel.js +134 -0
- package/dist/shell/AssetThumbnail.d.ts +12 -0
- package/dist/shell/AssetThumbnail.js +23 -0
- package/dist/shell/BottomDock.d.ts +25 -0
- package/dist/shell/BottomDock.js +40 -0
- package/dist/shell/CommandPalette.d.ts +12 -0
- package/dist/shell/CommandPalette.js +56 -0
- package/dist/shell/ConsolePanel.d.ts +8 -0
- package/dist/shell/ConsolePanel.js +45 -0
- package/dist/shell/ContentBrowser.d.ts +21 -0
- package/dist/shell/ContentBrowser.js +130 -0
- package/dist/shell/HierarchyPanel.d.ts +26 -0
- package/dist/shell/HierarchyPanel.js +387 -0
- package/dist/shell/NetworkWorkspacePanel.d.ts +11 -0
- package/dist/shell/NetworkWorkspacePanel.js +49 -0
- package/dist/shell/PlayModeBar.d.ts +12 -0
- package/dist/shell/PlayModeBar.js +27 -0
- package/dist/shell/ProfilerPanel.d.ts +10 -0
- package/dist/shell/ProfilerPanel.js +41 -0
- package/dist/shell/SceneToolbar.d.ts +43 -0
- package/dist/shell/SceneToolbar.js +122 -0
- package/dist/shell/StatusBar.d.ts +18 -0
- package/dist/shell/StatusBar.js +41 -0
- package/dist/shell/TopAppBar.d.ts +33 -0
- package/dist/shell/TopAppBar.js +64 -0
- package/dist/shell/ViewportOverlays.d.ts +23 -0
- package/dist/shell/ViewportOverlays.js +136 -0
- package/dist/shell/WorkspaceRail.d.ts +20 -0
- package/dist/shell/WorkspaceRail.js +31 -0
- package/dist/shell/cameraTelemetry.d.ts +27 -0
- package/dist/shell/cameraTelemetry.js +24 -0
- package/dist/shell/commandRegistry.d.ts +58 -0
- package/dist/shell/commandRegistry.js +133 -0
- package/dist/shell/consoleSink.d.ts +11 -0
- package/dist/shell/consoleSink.js +17 -0
- package/dist/shell/consoleStore.d.ts +27 -0
- package/dist/shell/consoleStore.js +43 -0
- package/dist/shell/fields.d.ts +39 -0
- package/dist/shell/fields.js +104 -0
- package/dist/shell/formatSavedRelative.d.ts +2 -0
- package/dist/shell/formatSavedRelative.js +14 -0
- package/dist/shell/glbThumbnailCache.d.ts +45 -0
- package/dist/shell/glbThumbnailCache.js +241 -0
- package/dist/shell/hierarchyA11y.d.ts +21 -0
- package/dist/shell/hierarchyA11y.js +35 -0
- package/dist/shell/icons.d.ts +13 -0
- package/dist/shell/icons.js +85 -0
- package/dist/shell/layoutStore.d.ts +68 -0
- package/dist/shell/layoutStore.js +156 -0
- package/dist/shell/pathFlythrough.d.ts +37 -0
- package/dist/shell/pathFlythrough.js +54 -0
- package/dist/shell/perfHistory.d.ts +70 -0
- package/dist/shell/perfHistory.js +130 -0
- package/dist/shell/renameObject.d.ts +3 -0
- package/dist/shell/renameObject.js +25 -0
- package/dist/shell/theme.d.ts +36 -0
- package/dist/shell/theme.js +37 -0
- package/dist/shell/ui.d.ts +84 -0
- package/dist/shell/ui.js +90 -0
- package/dist/shell/useGlbThumbnail.d.ts +8 -0
- package/dist/shell/useGlbThumbnail.js +19 -0
- package/dist/terrainReadoutStore.d.ts +19 -0
- package/dist/terrainReadoutStore.js +36 -0
- package/dist/uiStore.d.ts +33 -0
- package/dist/uiStore.js +13 -0
- package/dist/viewportContextMenu.d.ts +4 -2
- package/dist/viewportContextMenu.js +5 -1
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +35 -804
- package/skills/jgengine/api.md +190 -79
- package/skills/jgengine/capabilities.md +112 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-editor/SKILL.md +29 -362
- package/skills/jgengine-editor/api.md +519 -18
- package/skills/jgengine-editor/capabilities.md +91 -0
- package/skills/jgengine-editor/reference.md +113 -0
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/OutlinerPanel.d.ts +0 -14
- package/dist/OutlinerPanel.js +0 -84
- package/skills/jgengine/reference-cartridge.md +0 -72
- package/skills/jgengine/reference-orphans.md +0 -9
- package/skills/jgengine/reference-systems.md +0 -60
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TerrainReadoutStore } from "./terrainReadoutStore.js";
|
|
2
|
+
/**
|
|
3
|
+
* The measurable terrain-readability legend: cursor elevation and delta from the `y = 0` reference,
|
|
4
|
+
* plus the region's min/max/mean relief and the active contour interval. Reads live measurements from
|
|
5
|
+
* the readout store the {@link TerrainReadout} overlay publishes; renders nothing until shown.
|
|
6
|
+
*/
|
|
7
|
+
export declare function TerrainReadoutHud({ readout }: {
|
|
8
|
+
readout: TerrainReadoutStore;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useStoreSelector } from "./useStoreSelector.js";
|
|
3
|
+
function fmt(value) {
|
|
4
|
+
return (Math.round(value * 100) / 100).toFixed(2);
|
|
5
|
+
}
|
|
6
|
+
function signed(value) {
|
|
7
|
+
const text = fmt(value);
|
|
8
|
+
return value > 0 ? `+${text}` : text;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The measurable terrain-readability legend: cursor elevation and delta from the `y = 0` reference,
|
|
12
|
+
* plus the region's min/max/mean relief and the active contour interval. Reads live measurements from
|
|
13
|
+
* the readout store the {@link TerrainReadout} overlay publishes; renders nothing until shown.
|
|
14
|
+
*/
|
|
15
|
+
export function TerrainReadoutHud({ readout }) {
|
|
16
|
+
const cursor = useStoreSelector(readout, (s) => s.cursor);
|
|
17
|
+
const summary = useStoreSelector(readout, (s) => s.summary);
|
|
18
|
+
const interval = useStoreSelector(readout, (s) => s.interval);
|
|
19
|
+
return (_jsxs("div", { className: "pointer-events-none absolute bottom-3 left-3 z-40 min-w-[180px] rounded-lg border border-white/[0.08] bg-black/70 px-3 py-2 font-mono text-[11px] leading-relaxed text-neutral-200 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("div", { className: "mb-1 text-[10px] font-semibold uppercase tracking-widest text-neutral-500", children: "Elevation" }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "cursor" }), _jsx("span", { children: cursor === null ? "—" : `${fmt(cursor.height)} (${signed(cursor.delta)})` })] }), summary !== null ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "min / max" }), _jsxs("span", { children: [fmt(summary.min), " / ", fmt(summary.max)] })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "mean / relief" }), _jsxs("span", { children: [fmt(summary.mean), " / ", fmt(summary.range)] })] }), _jsxs("div", { className: "flex justify-between gap-4", children: [_jsx("span", { className: "text-neutral-400", children: "contour" }), _jsx("span", { children: interval > 0 ? `${fmt(interval)} m` : "flat" })] })] })) : (_jsx("div", { className: "text-neutral-500", children: "no terrain sampled" }))] }));
|
|
20
|
+
}
|
package/dist/TerrainSculpt.js
CHANGED
|
@@ -2,12 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useThree } from "@react-three/fiber";
|
|
3
3
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
|
-
import { createTerrainPaletteSampler } from "@jgengine/core/world/terrain";
|
|
5
|
+
import { createTerrainPaletteSampler, raycastHeightField, } from "@jgengine/core/world/terrain";
|
|
6
6
|
import { beginSurfaceStroke, beginTerraformStroke, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
|
|
7
7
|
import { useGameContext } from "@jgengine/react/provider";
|
|
8
|
-
import { normalizeHeightBlend, TerraformBrushCursor } from "@jgengine/shell/terrain";
|
|
8
|
+
import { displaceHeightfieldGeometry, normalizeHeightBlend, TerraformBrushCursor, } from "@jgengine/shell/terrain";
|
|
9
9
|
import { editorPerfMarks } from "./perfMarks.js";
|
|
10
|
-
import { TERRAIN_MATERIAL_COLORS } from "./uiStore.js";
|
|
10
|
+
import { TERRAIN_MATERIAL_COLORS, } from "./uiStore.js";
|
|
11
|
+
import { useStoreSelector } from "./useStoreSelector.js";
|
|
11
12
|
const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
12
13
|
const LOW = new THREE.Color("#3f5d34");
|
|
13
14
|
const HIGH = new THREE.Color("#8f9c66");
|
|
@@ -64,9 +65,18 @@ function editFromSettings(settings, center, clickHeight) {
|
|
|
64
65
|
...(heightLimit === undefined ? {} : { heightLimit }),
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
|
-
const PAINT_TINT = new THREE.Color();
|
|
68
68
|
const BLEND_A = new THREE.Color();
|
|
69
69
|
const BLEND_B = new THREE.Color();
|
|
70
|
+
/** Parsed once — `toneAt` runs per vertex, so hex→Color parsing must not. */
|
|
71
|
+
const SURFACE_COLOR_CACHE = new Map();
|
|
72
|
+
function surfaceColor(hex) {
|
|
73
|
+
let color = SURFACE_COLOR_CACHE.get(hex);
|
|
74
|
+
if (color === undefined) {
|
|
75
|
+
color = new THREE.Color(hex);
|
|
76
|
+
SURFACE_COLOR_CACHE.set(hex, color);
|
|
77
|
+
}
|
|
78
|
+
return color;
|
|
79
|
+
}
|
|
70
80
|
/** World-unit spacing between topographic relief bands drawn on the sculpt preview. */
|
|
71
81
|
const CONTOUR_INTERVAL = 1.5;
|
|
72
82
|
/**
|
|
@@ -92,7 +102,7 @@ function applyRelief(out, height, strength) {
|
|
|
92
102
|
* default — so `materialRegions`/base `colors`/`material` (worn paths, rock outcrops, biome tint)
|
|
93
103
|
* show through in the editor the same way `TerrainGround` renders them at runtime.
|
|
94
104
|
*/
|
|
95
|
-
function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
|
|
105
|
+
function toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors) {
|
|
96
106
|
const layers = terrain.layers;
|
|
97
107
|
if (layers.length > 0) {
|
|
98
108
|
const weights = terrain.weightsAt(x, z);
|
|
@@ -105,10 +115,10 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
|
|
|
105
115
|
const w = weights[l] * (layers[l].opacity ?? 1);
|
|
106
116
|
if (w <= 0)
|
|
107
117
|
continue;
|
|
108
|
-
|
|
109
|
-
r +=
|
|
110
|
-
g +=
|
|
111
|
-
b +=
|
|
118
|
+
const color = layerColors[l];
|
|
119
|
+
r += color.r * w;
|
|
120
|
+
g += color.g * w;
|
|
121
|
+
b += color.b * w;
|
|
112
122
|
total += w;
|
|
113
123
|
}
|
|
114
124
|
if (total > 0) {
|
|
@@ -120,7 +130,7 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
|
|
|
120
130
|
const surface = terrain.surfaceAt(x, z);
|
|
121
131
|
const painted = surface === null ? undefined : TERRAIN_MATERIAL_COLORS[surface];
|
|
122
132
|
if (painted !== undefined) {
|
|
123
|
-
out.copy(
|
|
133
|
+
out.copy(surfaceColor(painted));
|
|
124
134
|
return;
|
|
125
135
|
}
|
|
126
136
|
if (paletteAt !== null) {
|
|
@@ -133,42 +143,35 @@ function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
|
|
|
133
143
|
out.copy(BLEND_A.copy(LOW).lerp(HIGH, t));
|
|
134
144
|
}
|
|
135
145
|
/**
|
|
136
|
-
* Re-samples vertex Y and vertex
|
|
137
|
-
* dirty `region`
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
146
|
+
* Re-samples vertex Y, vertex color, and vertex normals from the live terrain via the shared
|
|
147
|
+
* {@link displaceHeightfieldGeometry} primitive. A dirty `region` keeps a brush stamp O(brush
|
|
148
|
+
* footprint) — no whole-mesh normal or bounding pass per stamp. Painted/blended layers win the
|
|
149
|
+
* vertex color; unpainted ground falls back to the runtime terrain palette (or the flat height
|
|
150
|
+
* gradient with no descriptor). Records rebuild time.
|
|
141
151
|
*/
|
|
142
152
|
function displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength) {
|
|
143
153
|
const started = performance.now();
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
const tone = new THREE.Color();
|
|
154
|
-
for (let index = 0; index < position.count; index += 1) {
|
|
155
|
-
const x = position.getX(index) + cx;
|
|
156
|
-
const z = position.getZ(index) + cz;
|
|
157
|
-
if (!full && (x < region.minX || x > region.maxX || z < region.minZ || z > region.maxZ))
|
|
158
|
-
continue;
|
|
159
|
-
const height = terrain.sampleHeight(x, z);
|
|
160
|
-
position.setY(index, height);
|
|
161
|
-
toneAt(terrain, x, z, height, tone, paletteAt, heightRange);
|
|
162
|
-
applyRelief(tone, height, contourStrength);
|
|
163
|
-
colors.setXYZ(index, tone.r, tone.g, tone.b);
|
|
164
|
-
}
|
|
165
|
-
position.needsUpdate = true;
|
|
166
|
-
colors.needsUpdate = true;
|
|
167
|
-
geometry.computeVertexNormals();
|
|
168
|
-
geometry.computeBoundingSphere();
|
|
154
|
+
const layerColors = terrain.layers.map((layer) => surfaceColor(layerColor(layer)));
|
|
155
|
+
displaceHeightfieldGeometry(geometry, terrain.sampleHeight, {
|
|
156
|
+
bounds,
|
|
157
|
+
region,
|
|
158
|
+
color: (x, z, height, out) => {
|
|
159
|
+
toneAt(terrain, x, z, height, out, paletteAt, heightRange, layerColors);
|
|
160
|
+
applyRelief(out, height, contourStrength);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
169
163
|
editorPerfMarks.record("rebuild", performance.now() - started);
|
|
170
164
|
}
|
|
171
|
-
function
|
|
165
|
+
function unionRect(a, b) {
|
|
166
|
+
return {
|
|
167
|
+
minX: Math.min(a.minX, b.minX),
|
|
168
|
+
minZ: Math.min(a.minZ, b.minZ),
|
|
169
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
170
|
+
maxZ: Math.max(a.maxZ, b.maxZ),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Memoized so cursor-hover state updates in the parent never reconcile the mesh subtree. */
|
|
174
|
+
const SculptMesh = memo(function SculptMesh({ terrain, bounds, segments, revision, pendingRef, paletteAt, heightRange, contourStrength, }) {
|
|
172
175
|
const geometry = useMemo(() => {
|
|
173
176
|
const width = Math.max(1, bounds.maxX - bounds.minX);
|
|
174
177
|
const depth = Math.max(1, bounds.maxZ - bounds.minZ);
|
|
@@ -179,10 +182,33 @@ function SculptMesh({ terrain, bounds, segments, revision, regionRef, meshRef, p
|
|
|
179
182
|
}, [boundsKey(bounds, segments)]);
|
|
180
183
|
useEffect(() => () => geometry.dispose(), [geometry]);
|
|
181
184
|
useEffect(() => {
|
|
182
|
-
|
|
185
|
+
// Consume the queued work; a re-run triggered by anything other than a stamp (palette,
|
|
186
|
+
// contour strength, fresh geometry) sees no pending entry and rebuilds the whole mesh.
|
|
187
|
+
const pending = pendingRef.current;
|
|
188
|
+
pendingRef.current = null;
|
|
189
|
+
const region = pending !== null && pending.kind === "region" ? pending.region : null;
|
|
190
|
+
displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength);
|
|
183
191
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
192
|
}, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
|
|
185
|
-
return (_jsx("mesh", {
|
|
193
|
+
return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
|
|
194
|
+
});
|
|
195
|
+
function selectSculptUi(state) {
|
|
196
|
+
return {
|
|
197
|
+
tool: state.tool,
|
|
198
|
+
terrainMode: state.terrainMode,
|
|
199
|
+
sculptRadius: state.sculpt.radius,
|
|
200
|
+
sculptBrush: state.sculpt.brush,
|
|
201
|
+
sculptInvert: state.sculpt.invert,
|
|
202
|
+
paintRadius: state.paint.radius,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function sculptUiEqual(a, b) {
|
|
206
|
+
return (a.tool === b.tool &&
|
|
207
|
+
a.terrainMode === b.terrainMode &&
|
|
208
|
+
a.sculptRadius === b.sculptRadius &&
|
|
209
|
+
a.sculptBrush === b.sculptBrush &&
|
|
210
|
+
a.sculptInvert === b.sculptInvert &&
|
|
211
|
+
a.paintRadius === b.paintRadius);
|
|
186
212
|
}
|
|
187
213
|
/**
|
|
188
214
|
* Terrain-sculpt viewport layer: renders the editable heightfield and, while the terrain tool is
|
|
@@ -199,26 +225,35 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
|
|
|
199
225
|
const terrainDescriptor = useMemo(() => terrainDescriptorOf(world), [world]);
|
|
200
226
|
const paletteAt = useMemo(() => (terrainDescriptor === undefined ? null : createTerrainPaletteSampler(terrainDescriptor)), [terrainDescriptor]);
|
|
201
227
|
const heightRange = useMemo(() => (terrainDescriptor === undefined ? [0, 1] : heightRangeOf(terrainDescriptor)), [terrainDescriptor]);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
228
|
+
// Selector subscriptions: only the terrain snapshot and the sculpt-relevant UI slice re-render
|
|
229
|
+
// this layer — selection changes, marker edits, and unrelated UI churn no longer touch it.
|
|
230
|
+
const snapshot = useStoreSelector(session, (state) => state.document.terrain ?? null);
|
|
231
|
+
const uiSlice = useStoreSelector(ui, selectSculptUi, sculptUiEqual);
|
|
206
232
|
const base = ctx.world.ground;
|
|
207
|
-
const
|
|
208
|
-
const toolActive = uiState.tool === "terrain";
|
|
233
|
+
const toolActive = uiSlice.tool === "terrain";
|
|
209
234
|
const editableRef = useRef(null);
|
|
210
235
|
const boundsRef = useRef(null);
|
|
211
|
-
const meshRef = useRef(null);
|
|
212
236
|
const syncedRef = useRef(null);
|
|
213
237
|
const gridKeyRef = useRef(null);
|
|
214
|
-
const
|
|
238
|
+
const pendingRef = useRef({ kind: "full" });
|
|
215
239
|
const [revision, setRevision] = useState(0);
|
|
216
240
|
const [cursor, setCursor] = useState(null);
|
|
217
241
|
const segments = snapshot === null ? 0 : segmentsFor(snapshot);
|
|
218
242
|
const gridKey = snapshot === null ? null : `${boundsKey(snapshot.bounds, segments)}:${snapshot.cellSize}`;
|
|
219
|
-
// region === null
|
|
243
|
+
// region === null queues a whole-mesh rebuild; a stamp queues only its dirty footprint. Queued
|
|
244
|
+
// regions union (two stamps before an effect flush both get displaced), and a queued full
|
|
245
|
+
// rebuild is never downgraded by a later stamp.
|
|
220
246
|
const refresh = (region = null) => {
|
|
221
|
-
|
|
247
|
+
const pending = pendingRef.current;
|
|
248
|
+
if (region === null || pending?.kind === "full") {
|
|
249
|
+
pendingRef.current = { kind: "full" };
|
|
250
|
+
}
|
|
251
|
+
else if (pending === null) {
|
|
252
|
+
pendingRef.current = { kind: "region", region };
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
pendingRef.current = { kind: "region", region: unionRect(pending.region, region) };
|
|
256
|
+
}
|
|
222
257
|
setRevision((value) => value + 1);
|
|
223
258
|
};
|
|
224
259
|
// Rebuild the live terrain when the grid changes; otherwise re-sync offsets on undo/redo/import.
|
|
@@ -257,15 +292,19 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
|
|
|
257
292
|
let last = null;
|
|
258
293
|
const pick = (clientX, clientY) => {
|
|
259
294
|
const started = performance.now();
|
|
260
|
-
const mesh = meshRef.current;
|
|
261
295
|
const rect = canvas.getBoundingClientRect();
|
|
262
296
|
ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
263
297
|
raycaster.setFromCamera(ndc, camera);
|
|
264
298
|
let result = null;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
299
|
+
// Heightfield raymarch against the live terrain — O(steps along the ray) instead of a
|
|
300
|
+
// brute-force triangle raycast against the whole preview mesh on every pointer move.
|
|
301
|
+
const terrain = editableRef.current;
|
|
302
|
+
const terrainBounds = boundsRef.current;
|
|
303
|
+
if (terrain !== null && terrainBounds !== null) {
|
|
304
|
+
const { origin, direction } = raycaster.ray;
|
|
305
|
+
const hit = raycastHeightField(terrain.sampleHeight, [origin.x, origin.y, origin.z], [direction.x, direction.y, direction.z], { bounds: terrainBounds, step: Math.max(0.25, (snapshot?.cellSize ?? 1) / 2) });
|
|
306
|
+
if (hit !== null)
|
|
307
|
+
result = { x: hit.x, z: hit.z };
|
|
269
308
|
}
|
|
270
309
|
if (result === null && raycaster.ray.intersectPlane(GROUND_PLANE, planeHit) !== null) {
|
|
271
310
|
result = { x: planeHit.x, z: planeHit.z };
|
|
@@ -411,5 +450,5 @@ export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
|
|
|
411
450
|
}, [toolActive, snapshot, gl, camera, controls, session, ui]);
|
|
412
451
|
if (snapshot === null || editableRef.current === null || boundsRef.current === null)
|
|
413
452
|
return null;
|
|
414
|
-
return (_jsxs(_Fragment, { children: [_jsx(SculptMesh, { terrain: editableRef.current, bounds: boundsRef.current, segments: segments, revision: revision,
|
|
453
|
+
return (_jsxs(_Fragment, { children: [_jsx(SculptMesh, { terrain: editableRef.current, bounds: boundsRef.current, segments: segments, revision: revision, pendingRef: pendingRef, paletteAt: paletteAt, heightRange: heightRange, contourStrength: toolActive ? 1 : 0.45 }), toolActive && cursor !== null ? (_jsx(TerraformBrushCursor, { center: [cursor.x, cursor.z], y: cursor.y + 0.1, radius: uiSlice.terrainMode === "paint" ? uiSlice.paintRadius : uiSlice.sculptRadius, mode: uiSlice.terrainMode === "paint" ? "paint" : effectiveBrush(uiSlice.sculptBrush, uiSlice.sculptInvert) })) : null] }));
|
|
415
454
|
});
|
|
@@ -5,9 +5,12 @@ import { createDefaultAgentEndpoint } from "./endpoint.js";
|
|
|
5
5
|
* Tool calls route through `EditorHostApi.handle`; patches appear in the transcript and can be undone.
|
|
6
6
|
* @internal — mounted by `EditorChrome`.
|
|
7
7
|
*/
|
|
8
|
-
export declare function AgentPanel({ api, onClose, }: {
|
|
8
|
+
export declare function AgentPanel({ api, onClose, embedded, }: {
|
|
9
9
|
api: EditorHostApi;
|
|
10
|
-
|
|
10
|
+
/** Close affordance for the side-dock variant; omit when the host owns dismissal. */
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
/** Fill the parent (bottom-dock tab) instead of rendering the resizable right aside. */
|
|
13
|
+
embedded?: boolean;
|
|
11
14
|
}): import("react").JSX.Element;
|
|
12
15
|
/** Re-export for hosts that want a default endpoint without the panel. */
|
|
13
16
|
export { createDefaultAgentEndpoint };
|
package/dist/agent/AgentPanel.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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 {
|
|
3
|
+
import { BORDER, CONTROL, FOCUS_RING, INPUT_CLS, MICRO_LABEL, PANEL_BG } from "../shell/theme.js";
|
|
4
|
+
const BTN = `${CONTROL} px-2 py-1 text-[11px] disabled:opacity-40`;
|
|
5
|
+
const INPUT = `px-2 py-1 ${INPUT_CLS}`;
|
|
6
|
+
const MICRO = MICRO_LABEL;
|
|
4
7
|
import { createDefaultAgentEndpoint, createHttpAgentEndpoint, createLocalAgentEndpoint, EDITOR_AGENT_KEY_ENV, EDITOR_AGENT_URL_ENV, resolveAgentEndpointConfig, } from "./endpoint.js";
|
|
5
8
|
import { packAgentContext } from "./context.js";
|
|
6
9
|
import { runAgentTurn, undoAgentPatch, } from "./turn.js";
|
|
@@ -28,7 +31,7 @@ function buildEndpoint(url, apiKey) {
|
|
|
28
31
|
* Tool calls route through `EditorHostApi.handle`; patches appear in the transcript and can be undone.
|
|
29
32
|
* @internal — mounted by `EditorChrome`.
|
|
30
33
|
*/
|
|
31
|
-
export function AgentPanel({ api, onClose, }) {
|
|
34
|
+
export function AgentPanel({ api, onClose, embedded = false, }) {
|
|
32
35
|
const stored = useMemo(() => readStoredConfig(), []);
|
|
33
36
|
const [url, setUrl] = useState(stored.url);
|
|
34
37
|
const [apiKey, setApiKey] = useState(stored.apiKey);
|
|
@@ -110,21 +113,23 @@ export function AgentPanel({ api, onClose, }) {
|
|
|
110
113
|
setContextTick((v) => v + 1);
|
|
111
114
|
}, [api, patches]);
|
|
112
115
|
const envHint = resolveAgentEndpointConfig();
|
|
113
|
-
return (_jsxs("aside", { className:
|
|
116
|
+
return (_jsxs("aside", { className: embedded
|
|
117
|
+
? "pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden"
|
|
118
|
+
: `pointer-events-auto flex w-80 min-w-64 max-w-[48vw] resize-x flex-col overflow-hidden border-l ${BORDER} ${PANEL_BG}/95 backdrop-blur-md`, children: [_jsxs("div", { className: `flex items-center gap-2 border-b ${BORDER} px-2 py-2`, children: [_jsx("div", { className: MICRO, children: "Agent" }), _jsx("span", { className: `rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${endpoint.id === "http"
|
|
114
119
|
? "bg-violet-500/20 text-violet-200 ring-1 ring-inset ring-violet-400/30"
|
|
115
|
-
: "bg-white/[0.06] text-neutral-400 ring-1 ring-inset ring-white/10"}`, title: endpoint.id === "http" ? "Remote endpoint" : "Local offline agent", children: endpoint.id }), _jsx("button", { type: "button", className: `${BTN} ml-auto text-[10px]`, onClick: () => setShowConfig((v) => !v), children: "Config" }), _jsx("button", { type: "button", className:
|
|
120
|
+
: "bg-white/[0.06] text-neutral-400 ring-1 ring-inset ring-white/10"}`, title: endpoint.id === "http" ? "Remote endpoint" : "Local offline agent", children: endpoint.id }), _jsx("button", { type: "button", className: `${BTN} ml-auto text-[10px]`, onClick: () => setShowConfig((v) => !v), children: "Config" }), onClose !== undefined ? (_jsx("button", { type: "button", className: `rounded-[5px] px-2 py-1 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200 ${FOCUS_RING}`, onClick: onClose, "aria-label": "Close agent panel", children: "\u00D7" })) : null] }), showConfig ? (_jsxs("div", { className: `space-y-2 border-b ${BORDER} p-2`, children: [_jsx("div", { className: MICRO, children: "Endpoint" }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, placeholder: `${EDITOR_AGENT_URL_ENV} or https://…`, value: url, onChange: (event) => setUrl(event.target.value), onBlur: persistConfig }), _jsx("input", { className: `w-full text-[11px] ${INPUT}`, type: "password", placeholder: `${EDITOR_AGENT_KEY_ENV} / ANTHROPIC_API_KEY`, value: apiKey, onChange: (event) => setApiKey(event.target.value), onBlur: persistConfig }), _jsxs("p", { className: "text-[10px] leading-snug text-neutral-500", children: ["Env: ", EDITOR_AGENT_URL_ENV, envHint.url !== undefined ? " ✓" : " (unset)", " \u00B7 ", EDITOR_AGENT_KEY_ENV, envHint.apiKey !== undefined ? " ✓" : " (unset)", ". Empty URL uses the local command agent (/help). Remote: POST JSON ", "{messages,context,tools}", " \u2192 ", "{message?,toolCalls?}", "."] }), _jsx("button", { type: "button", className: BTN, onClick: () => {
|
|
116
121
|
setUrl("");
|
|
117
122
|
setApiKey("");
|
|
118
123
|
if (typeof localStorage !== "undefined") {
|
|
119
124
|
localStorage.removeItem(STORAGE_URL);
|
|
120
125
|
localStorage.removeItem(STORAGE_KEY);
|
|
121
126
|
}
|
|
122
|
-
}, children: "Use local agent" })] })) : null, _jsxs("div", { className:
|
|
127
|
+
}, children: "Use local agent" })] })) : null, _jsxs("div", { className: `border-b ${BORDER} px-2 py-1.5 text-[10px] text-neutral-500`, children: [_jsx("span", { className: "text-neutral-400", children: context.mode }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: ["sel ", context.selection.length === 0 ? "—" : context.selection.slice(0, 3).join(", "), context.selection.length > 3 ? ` +${context.selection.length - 3}` : ""] }), _jsx("span", { className: "mx-1.5 text-neutral-700", children: "\u00B7" }), _jsxs("span", { children: [context.summary.markers, "m ", context.summary.volumes, "v ", context.summary.paths, "p"] })] }), _jsxs("div", { ref: listRef, className: "min-h-0 flex-1 space-y-2 overflow-auto p-2", children: [transcript.length === 0 ? (_jsxs("div", { className: `rounded-[6px] border border-dashed ${BORDER} bg-black/20 p-3 text-[11px] leading-relaxed text-neutral-500`, children: ["Chat drives the live scene through the same RPC verbs as the MCP bridge. Edits share undo with the toolbar. Local: try ", _jsx("code", { className: "text-cyan-300", children: "/status" }), ",", " ", _jsx("code", { className: "text-cyan-300", children: "/help" }), ", or", " ", _jsx("code", { className: "text-cyan-300", children: "move id x y z" }), "."] })) : null, transcript.map((entry) => (_jsx(TranscriptRow, { entry: entry, patches: patches, onUndo: onUndoPatch }, entry.id))), busy ? _jsx("div", { className: "text-[10px] text-violet-300", children: "Thinking\u2026" }) : null] }), patches.some((p) => !p.undone) ? (_jsxs("div", { className: `max-h-28 overflow-auto border-t ${BORDER} p-2`, children: [_jsx("div", { className: `${MICRO} mb-1`, children: "Agent patches" }), _jsx("ul", { className: "space-y-1", children: patches
|
|
123
128
|
.filter((p) => !p.undone)
|
|
124
129
|
.map((patch, index, live) => {
|
|
125
130
|
const isTop = index === live.length - 1;
|
|
126
131
|
return (_jsxs("li", { className: "flex items-center gap-1 text-[10px] text-neutral-300", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-cyan-200/90", children: patch.summary }), _jsx("button", { type: "button", className: `${BTN} shrink-0 px-1.5 py-0.5 text-[10px] disabled:opacity-40`, disabled: !isTop, title: isTop ? "Undo this edit" : "Undo newer patches first", onClick: () => onUndoPatch(patch.id), children: "Undo" })] }, patch.id));
|
|
127
|
-
}) })] })) : null, _jsxs("div", { className:
|
|
132
|
+
}) })] })) : null, _jsxs("div", { className: `flex gap-1 border-t ${BORDER} p-2`, children: [_jsx("input", { className: `min-w-0 flex-1 text-[12px] ${INPUT}`, placeholder: endpoint.id === "local" ? "/help · /status · move id x y z" : "Ask the agent to edit the scene…", value: input, disabled: busy, onChange: (event) => setInput(event.target.value), onKeyDown: (event) => {
|
|
128
133
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
129
134
|
event.preventDefault();
|
|
130
135
|
void send();
|
package/dist/agent/toolBridge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decodeEditorBridgeRequest } from "../mcp/rpcRequest.js";
|
|
2
|
+
import { emitEditorConsole } from "../shell/consoleSink.js";
|
|
2
3
|
/** Methods that never write the document (no undo step). */
|
|
3
4
|
const READ_ONLY_METHODS = new Set([
|
|
4
5
|
"editor_status",
|
|
@@ -49,6 +50,7 @@ function summarizeCall(name, args, response, mutated) {
|
|
|
49
50
|
export function routeToolCall(api, call) {
|
|
50
51
|
const built = toolCallToRequest(call);
|
|
51
52
|
if (!built.ok) {
|
|
53
|
+
emitEditorConsole("error", "agent", `${call.name} rejected: ${built.error}`);
|
|
52
54
|
return {
|
|
53
55
|
id: call.id,
|
|
54
56
|
name: call.name,
|
|
@@ -58,14 +60,17 @@ export function routeToolCall(api, call) {
|
|
|
58
60
|
patchSummary: `${call.name} rejected: ${built.error}`,
|
|
59
61
|
};
|
|
60
62
|
}
|
|
63
|
+
emitEditorConsole("info", "agent", `tool ${call.name}`);
|
|
61
64
|
const session = api.getSession();
|
|
62
65
|
const before = session.exportJson(false);
|
|
63
66
|
let response;
|
|
64
67
|
try {
|
|
68
|
+
// RPC failures are logged once by the host handle sink; agent layer only records the call.
|
|
65
69
|
response = api.handle(built.request);
|
|
66
70
|
}
|
|
67
71
|
catch (error) {
|
|
68
72
|
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
emitEditorConsole("error", "agent", `${call.name} threw: ${message}`);
|
|
69
74
|
return {
|
|
70
75
|
id: call.id,
|
|
71
76
|
name: call.name,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared model-import helpers used by the standalone editor strip and the in-game Content Browser.
|
|
3
|
+
* Host-agnostic and free of React/shell so unit tests can exercise the importer without a DOM or three.
|
|
4
|
+
*/
|
|
5
|
+
/** One user-supplied model the editor can place: a stable id and a resolvable URL. */
|
|
6
|
+
export interface StandaloneAsset {
|
|
7
|
+
id: string;
|
|
8
|
+
url: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Persists a dropped model file through the editor host so it survives reload as a durable catalog asset,
|
|
13
|
+
* returning the host-assigned id/url (which the manifest scan re-lists on reload). Resolves `null` when no
|
|
14
|
+
* host is listening — a plain browser mount with no dev server — so the caller can fall back to a blob URL.
|
|
15
|
+
*/
|
|
16
|
+
export type AssetImporter = (file: File) => Promise<StandaloneAsset | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Uploads a model to the editor host plugin's import endpoint and returns the durable asset it persisted.
|
|
19
|
+
* Returns `null` on any failure (no host, non-model, network error) so imports degrade to ephemeral blobs.
|
|
20
|
+
* The default {@link AssetImporter} `StandaloneEditor` and the in-game Content Browser use.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export declare function importAssetToHost(file: File, route?: string): Promise<StandaloneAsset | null>;
|
|
24
|
+
/**
|
|
25
|
+
* Turns dropped files into placeable assets: each model is persisted through `importer` when a host answers,
|
|
26
|
+
* else falls back to an ephemeral `blob:` URL (lost on reload). Non-model files are skipped. Pure and
|
|
27
|
+
* host-agnostic so the import wiring is testable without a DOM.
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadDroppedAssets(files: readonly File[], importer: AssetImporter): Promise<StandaloneAsset[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Appends freshly dropped assets, replacing any prior entry that shares an id (re-imports stay a
|
|
33
|
+
* single catalog entry). Shared by the standalone strip and the in-game Content Browser importer.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeStandaloneAssets(current: readonly StandaloneAsset[], next: readonly StandaloneAsset[]): StandaloneAsset[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared model-import helpers used by the standalone editor strip and the in-game Content Browser.
|
|
3
|
+
* Host-agnostic and free of React/shell so unit tests can exercise the importer without a DOM or three.
|
|
4
|
+
*/
|
|
5
|
+
const MODEL_FILE = /\.(glb|gltf)$/i;
|
|
6
|
+
/** Route the `@jgengine/node` editor host plugin persists uploaded models on; must match its `IMPORT_ROUTE`. */
|
|
7
|
+
const IMPORT_ASSET_ROUTE = "/__jgengine/import-asset";
|
|
8
|
+
function assetId(name) {
|
|
9
|
+
return name.replace(MODEL_FILE, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "asset";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Uploads a model to the editor host plugin's import endpoint and returns the durable asset it persisted.
|
|
13
|
+
* Returns `null` on any failure (no host, non-model, network error) so imports degrade to ephemeral blobs.
|
|
14
|
+
* The default {@link AssetImporter} `StandaloneEditor` and the in-game Content Browser use.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export async function importAssetToHost(file, route = IMPORT_ASSET_ROUTE) {
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(route, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/octet-stream",
|
|
23
|
+
"x-jg-filename": encodeURIComponent(file.name),
|
|
24
|
+
},
|
|
25
|
+
body: file,
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
return null;
|
|
29
|
+
const asset = (await response.json());
|
|
30
|
+
if (typeof asset.id !== "string" || typeof asset.url !== "string")
|
|
31
|
+
return null;
|
|
32
|
+
return { id: asset.id, url: asset.url, label: asset.label ?? file.name };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Turns dropped files into placeable assets: each model is persisted through `importer` when a host answers,
|
|
40
|
+
* else falls back to an ephemeral `blob:` URL (lost on reload). Non-model files are skipped. Pure and
|
|
41
|
+
* host-agnostic so the import wiring is testable without a DOM.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export async function loadDroppedAssets(files, importer) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (!MODEL_FILE.test(file.name))
|
|
48
|
+
continue;
|
|
49
|
+
const persisted = await importer(file);
|
|
50
|
+
out.push(persisted ?? { id: assetId(file.name), url: URL.createObjectURL(file), label: file.name });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Appends freshly dropped assets, replacing any prior entry that shares an id (re-imports stay a
|
|
56
|
+
* single catalog entry). Shared by the standalone strip and the in-game Content Browser importer.
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export function mergeStandaloneAssets(current, next) {
|
|
60
|
+
if (next.length === 0)
|
|
61
|
+
return [...current];
|
|
62
|
+
const byId = new Map(current.map((asset) => [asset.id, asset]));
|
|
63
|
+
for (const asset of next)
|
|
64
|
+
byId.set(asset.id, asset);
|
|
65
|
+
return Array.from(byId.values());
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type TriggerSourceKind } from "@jgengine/core/scene/authoredTriggers";
|
|
2
|
+
/**
|
|
3
|
+
* True when an object's meta carries an authored behavior trigger (flat on/action or a multi-list).
|
|
4
|
+
* Empty `on` / no list means the trigger component is not installed.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export declare function hasAuthoredTrigger(meta: Record<string, unknown> | undefined): boolean;
|
|
8
|
+
/** True when the game has registered at least one trigger action for this target kind. @internal */
|
|
9
|
+
export declare function canAuthorTrigger(target: TriggerSourceKind): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Meta patch that installs a default trigger (first registered action, enter event) without
|
|
12
|
+
* inventing action ids. Returns null when no actions are registered.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare function defaultTriggerInstallPatch(target: TriggerSourceKind, meta: Record<string, unknown> | undefined): Record<string, unknown> | null;
|
|
16
|
+
/**
|
|
17
|
+
* Meta patch that clears the trigger vocabulary keys. Action-specific params are left in place so
|
|
18
|
+
* re-adding the same action keeps prior tuning; the component is considered removed once `on`/`action`/list are gone.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export declare function clearTriggerInstallPatch(): Record<string, unknown>;
|
|
22
|
+
/** True when a material assignment is present on the object. @internal */
|
|
23
|
+
export declare function hasMaterialAssignment(meta: Record<string, unknown> | undefined): boolean;
|
|
24
|
+
/** Meta patch that removes material assignment. @internal */
|
|
25
|
+
export declare function clearMaterialAssignmentPatch(): Record<string, unknown>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { listTriggerActions, readFlatTrigger, TRIGGER_ACTION_KEY, TRIGGER_LIST_KEY, TRIGGER_ON_KEY, TRIGGER_RADIUS_KEY, } from "@jgengine/core/scene/authoredTriggers";
|
|
2
|
+
import { defaultParamMeta } from "@jgengine/core/scene/sceneKinds";
|
|
3
|
+
/**
|
|
4
|
+
* True when an object's meta carries an authored behavior trigger (flat on/action or a multi-list).
|
|
5
|
+
* Empty `on` / no list means the trigger component is not installed.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export function hasAuthoredTrigger(meta) {
|
|
9
|
+
if (meta === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
const list = meta[TRIGGER_LIST_KEY];
|
|
12
|
+
if (Array.isArray(list) && list.length > 0)
|
|
13
|
+
return true;
|
|
14
|
+
const flat = readFlatTrigger(meta);
|
|
15
|
+
return flat.on !== "" && flat.action.length > 0;
|
|
16
|
+
}
|
|
17
|
+
/** True when the game has registered at least one trigger action for this target kind. @internal */
|
|
18
|
+
export function canAuthorTrigger(target) {
|
|
19
|
+
return listTriggerActions(target).length > 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Meta patch that installs a default trigger (first registered action, enter event) without
|
|
23
|
+
* inventing action ids. Returns null when no actions are registered.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function defaultTriggerInstallPatch(target, meta) {
|
|
27
|
+
const actions = listTriggerActions(target);
|
|
28
|
+
const first = actions[0];
|
|
29
|
+
if (first === undefined)
|
|
30
|
+
return null;
|
|
31
|
+
const events = first.events;
|
|
32
|
+
const on = events !== undefined && events.length > 0 ? events[0] : "enter";
|
|
33
|
+
const patch = {
|
|
34
|
+
[TRIGGER_ON_KEY]: on,
|
|
35
|
+
[TRIGGER_ACTION_KEY]: first.id,
|
|
36
|
+
[TRIGGER_LIST_KEY]: [],
|
|
37
|
+
};
|
|
38
|
+
const defaults = defaultParamMeta(first.schema);
|
|
39
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
40
|
+
if (meta?.[key] === undefined)
|
|
41
|
+
patch[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return patch;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Meta patch that clears the trigger vocabulary keys. Action-specific params are left in place so
|
|
47
|
+
* re-adding the same action keeps prior tuning; the component is considered removed once `on`/`action`/list are gone.
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export function clearTriggerInstallPatch() {
|
|
51
|
+
return {
|
|
52
|
+
[TRIGGER_ON_KEY]: "",
|
|
53
|
+
[TRIGGER_ACTION_KEY]: "",
|
|
54
|
+
[TRIGGER_LIST_KEY]: [],
|
|
55
|
+
[TRIGGER_RADIUS_KEY]: undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** True when a material assignment is present on the object. @internal */
|
|
59
|
+
export function hasMaterialAssignment(meta) {
|
|
60
|
+
return typeof meta?.["materialId"] === "string" && meta["materialId"].length > 0;
|
|
61
|
+
}
|
|
62
|
+
/** Meta patch that removes material assignment. @internal */
|
|
63
|
+
export function clearMaterialAssignmentPatch() {
|
|
64
|
+
return { materialId: undefined };
|
|
65
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { EditorVec3 } from "@jgengine/core/editor/index";
|
|
2
|
+
import type { GizmoPivot } from "./uiStore.js";
|
|
3
|
+
/** Aggregate a list of positions into a pivot according to the toolbar pivot mode. @internal */
|
|
4
|
+
export declare function resolvePivotPosition(positions: readonly EditorVec3[], pivot: GizmoPivot, primary: EditorVec3 | null): EditorVec3 | null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function medianOf(values) {
|
|
2
|
+
if (values.length === 0)
|
|
3
|
+
return 0;
|
|
4
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
5
|
+
const mid = Math.floor(sorted.length / 2);
|
|
6
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
7
|
+
}
|
|
8
|
+
function meanOf(positions) {
|
|
9
|
+
let x = 0;
|
|
10
|
+
let y = 0;
|
|
11
|
+
let z = 0;
|
|
12
|
+
for (const point of positions) {
|
|
13
|
+
x += point.x;
|
|
14
|
+
y += point.y;
|
|
15
|
+
z += point.z;
|
|
16
|
+
}
|
|
17
|
+
const n = Math.max(1, positions.length);
|
|
18
|
+
return { x: x / n, y: y / n, z: z / n };
|
|
19
|
+
}
|
|
20
|
+
/** Aggregate a list of positions into a pivot according to the toolbar pivot mode. @internal */
|
|
21
|
+
export function resolvePivotPosition(positions, pivot, primary) {
|
|
22
|
+
if (positions.length === 0)
|
|
23
|
+
return primary;
|
|
24
|
+
if (positions.length === 1 || pivot === "origin")
|
|
25
|
+
return primary ?? positions[0];
|
|
26
|
+
if (pivot === "median") {
|
|
27
|
+
return {
|
|
28
|
+
x: medianOf(positions.map((p) => p.x)),
|
|
29
|
+
y: medianOf(positions.map((p) => p.y)),
|
|
30
|
+
z: medianOf(positions.map((p) => p.z)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return meanOf(positions);
|
|
34
|
+
}
|