@jgengine/editor 0.10.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 +325 -0
- package/dist/AssetBrowser.d.ts +20 -0
- package/dist/AssetBrowser.js +49 -0
- package/dist/CatalogsPanel.d.ts +10 -0
- package/dist/CatalogsPanel.js +41 -0
- package/dist/CollectionsPanel.d.ts +10 -0
- package/dist/CollectionsPanel.js +35 -0
- package/dist/DebugDraw.d.ts +17 -0
- package/dist/DebugDraw.js +233 -0
- package/dist/EditorApp.d.ts +26 -0
- package/dist/EditorApp.js +333 -0
- package/dist/EditorCameraDriver.d.ts +5 -0
- package/dist/EditorCameraDriver.js +18 -0
- package/dist/EditorChrome.d.ts +22 -0
- package/dist/EditorChrome.js +559 -0
- package/dist/EditorContextMenu.d.ts +11 -0
- package/dist/EditorContextMenu.js +12 -0
- package/dist/InspectorPanel.d.ts +14 -0
- package/dist/InspectorPanel.js +110 -0
- package/dist/MaterialDropZone.d.ts +10 -0
- package/dist/MaterialDropZone.js +77 -0
- package/dist/OutlinerPanel.d.ts +14 -0
- package/dist/OutlinerPanel.js +84 -0
- package/dist/PerfProbe.d.ts +5 -0
- package/dist/PerfProbe.js +34 -0
- package/dist/PrefabsPanel.d.ts +13 -0
- package/dist/PrefabsPanel.js +42 -0
- package/dist/RuntimePlayBridge.d.ts +18 -0
- package/dist/RuntimePlayBridge.js +140 -0
- package/dist/ScatterPreview.d.ts +11 -0
- package/dist/ScatterPreview.js +30 -0
- package/dist/SchemaInspector.d.ts +19 -0
- package/dist/SchemaInspector.js +78 -0
- package/dist/SelectionGizmo.d.ts +25 -0
- package/dist/SelectionGizmo.js +455 -0
- package/dist/StandaloneEditor.d.ts +44 -0
- package/dist/StandaloneEditor.js +92 -0
- package/dist/TerrainPanel.d.ts +7 -0
- package/dist/TerrainPanel.js +59 -0
- package/dist/TerrainSculpt.d.ts +14 -0
- package/dist/TerrainSculpt.js +415 -0
- package/dist/TriggerInspector.d.ts +13 -0
- package/dist/TriggerInspector.js +60 -0
- package/dist/agent/AgentPanel.d.ts +13 -0
- package/dist/agent/AgentPanel.js +156 -0
- package/dist/agent/context.d.ts +27 -0
- package/dist/agent/context.js +39 -0
- package/dist/agent/endpoint.d.ts +68 -0
- package/dist/agent/endpoint.js +186 -0
- package/dist/agent/toolBridge.d.ts +35 -0
- package/dist/agent/toolBridge.js +93 -0
- package/dist/agent/turn.d.ts +71 -0
- package/dist/agent/turn.js +113 -0
- package/dist/chromeFields.d.ts +15 -0
- package/dist/chromeFields.js +13 -0
- package/dist/chromeStyles.d.ts +3 -0
- package/dist/chromeStyles.js +3 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/mcp/bridgeServer.d.ts +13 -0
- package/dist/mcp/bridgeServer.js +1 -0
- package/dist/mcp/loadGameCatalogs.d.ts +24 -0
- package/dist/mcp/loadGameCatalogs.js +87 -0
- package/dist/mcp/rpcRequest.d.ts +22 -0
- package/dist/mcp/rpcRequest.js +97 -0
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.js +764 -0
- package/dist/outlinerModel.d.ts +76 -0
- package/dist/outlinerModel.js +95 -0
- package/dist/perfMarks.d.ts +25 -0
- package/dist/perfMarks.js +36 -0
- package/dist/session.d.ts +400 -0
- package/dist/session.js +1032 -0
- package/dist/uiStore.d.ts +108 -0
- package/dist/uiStore.js +128 -0
- package/dist/useF2Chord.d.ts +2 -0
- package/dist/useF2Chord.js +32 -0
- package/dist/useStoreSelector.d.ts +31 -0
- package/dist/useStoreSelector.js +51 -0
- package/dist/viewportContextMenu.d.ts +24 -0
- package/dist/viewportContextMenu.js +32 -0
- package/package.json +58 -0
- package/skills/jgengine/SKILL.md +823 -0
- package/skills/jgengine/api.md +416 -0
- package/skills/jgengine/capabilities.md +21 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine/reference-orphans.md +9 -0
- package/skills/jgengine/reference-systems.md +60 -0
- package/skills/jgengine-editor/SKILL.md +382 -0
- package/skills/jgengine-editor/api.md +416 -0
- package/skills/jgengine-editor/capabilities.md +29 -0
- package/skills/jgengine-editor/reference.md +64 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useThree } from "@react-three/fiber";
|
|
3
|
+
import { memo, useEffect, useMemo } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { findEditorMarker, findEditorNote, findEditorPath, findEditorVolume, } from "@jgengine/core/editor/index";
|
|
6
|
+
import { TransformGizmo, } from "@jgengine/shell/structures/TransformGizmo";
|
|
7
|
+
import { isPointerClick } from "./viewportContextMenu.js";
|
|
8
|
+
import { newPlacementId } from "./uiStore.js";
|
|
9
|
+
import { useStoreSelector } from "./useStoreSelector.js";
|
|
10
|
+
const CLICK_SLOP_PX = 5;
|
|
11
|
+
const SCREEN_PICK_RADIUS_PX = 22;
|
|
12
|
+
const PATH_HANDLE_RADIUS_PX = 14;
|
|
13
|
+
const MARKER_LIFT = 1.2;
|
|
14
|
+
const NOTE_LIFT = 1;
|
|
15
|
+
const SELECT_TAGS = ["jgEditorId", "jgEntityId", "jgObjectId"];
|
|
16
|
+
const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
17
|
+
function selectGizmoUi(state) {
|
|
18
|
+
return { gizmoMode: state.gizmoMode, snapMode: state.snapMode, gridSize: state.gridSize, pathPoint: state.pathPoint };
|
|
19
|
+
}
|
|
20
|
+
function gizmoUiEqual(a, b) {
|
|
21
|
+
return a.gizmoMode === b.gizmoMode && a.snapMode === b.snapMode && a.gridSize === b.gridSize && a.pathPoint === b.pathPoint;
|
|
22
|
+
}
|
|
23
|
+
/** Reads only the gizmo-relevant UI slice (mode, snap, path-point anchor) — skips re-render on
|
|
24
|
+
* unrelated UI churn (sculpt/paint drags, tool switches, placement) that the old blanket
|
|
25
|
+
* `ui.subscribe` force-update re-rendered the gizmo for. */
|
|
26
|
+
function useGizmoUiState(ui) {
|
|
27
|
+
return useStoreSelector(ui, selectGizmoUi, gizmoUiEqual);
|
|
28
|
+
}
|
|
29
|
+
function vecEqual(a, b) {
|
|
30
|
+
return a.x === b.x && a.y === b.y && a.z === b.z;
|
|
31
|
+
}
|
|
32
|
+
function pathCentroid(points) {
|
|
33
|
+
let x = 0;
|
|
34
|
+
let y = 0;
|
|
35
|
+
let z = 0;
|
|
36
|
+
for (const point of points) {
|
|
37
|
+
x += point.x;
|
|
38
|
+
y += point.y;
|
|
39
|
+
z += point.z;
|
|
40
|
+
}
|
|
41
|
+
const n = Math.max(1, points.length);
|
|
42
|
+
return { x: x / n, y: y / n, z: z / n };
|
|
43
|
+
}
|
|
44
|
+
function isEditorOverlayNode(node) {
|
|
45
|
+
let current = node;
|
|
46
|
+
while (current !== null) {
|
|
47
|
+
if (current.type.startsWith("TransformControls"))
|
|
48
|
+
return true;
|
|
49
|
+
for (const tag of SELECT_TAGS) {
|
|
50
|
+
if (typeof current.userData[tag] === "string")
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
current = current.parent;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Canvas click-to-select and click-to-place. Document objects pick by screen proximity
|
|
59
|
+
* (registration always matches what you see) with click-cycling through stacked candidates
|
|
60
|
+
* and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast
|
|
61
|
+
* against the tagged scene graph. When a placement tool is armed, clicks author new
|
|
62
|
+
* markers, volumes, notes, or path points at the ground hit instead of selecting.
|
|
63
|
+
*/
|
|
64
|
+
export const ViewportSelect = memo(function ViewportSelect({ api, ui }) {
|
|
65
|
+
const gl = useThree((state) => state.gl);
|
|
66
|
+
const camera = useThree((state) => state.camera);
|
|
67
|
+
const scene = useThree((state) => state.scene);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const canvas = gl.domElement;
|
|
70
|
+
const raycaster = new THREE.Raycaster();
|
|
71
|
+
const ndc = new THREE.Vector2();
|
|
72
|
+
const projected = new THREE.Vector3();
|
|
73
|
+
const planeHit = new THREE.Vector3();
|
|
74
|
+
let down = null;
|
|
75
|
+
const session = () => api.getSession();
|
|
76
|
+
const screenDistance = (point, lift, clickX, clickY, rect) => {
|
|
77
|
+
projected.set(point.x, point.y + lift, point.z).project(camera);
|
|
78
|
+
if (projected.z < -1 || projected.z > 1)
|
|
79
|
+
return null;
|
|
80
|
+
const px = (projected.x * 0.5 + 0.5) * rect.width;
|
|
81
|
+
const py = (-projected.y * 0.5 + 0.5) * rect.height;
|
|
82
|
+
return Math.hypot(px - clickX, py - clickY);
|
|
83
|
+
};
|
|
84
|
+
const groundPoint = (clickX, clickY, rect) => {
|
|
85
|
+
ndc.set((clickX / rect.width) * 2 - 1, -(clickY / rect.height) * 2 + 1);
|
|
86
|
+
raycaster.setFromCamera(ndc, camera);
|
|
87
|
+
for (const hit of raycaster.intersectObjects(scene.children, true)) {
|
|
88
|
+
if (!hit.object.isMesh || hit.object.visible === false)
|
|
89
|
+
continue;
|
|
90
|
+
if (isEditorOverlayNode(hit.object))
|
|
91
|
+
continue;
|
|
92
|
+
return { x: hit.point.x, y: hit.point.y, z: hit.point.z };
|
|
93
|
+
}
|
|
94
|
+
if (raycaster.ray.intersectPlane(GROUND_PLANE, planeHit) !== null) {
|
|
95
|
+
return { x: planeHit.x, y: planeHit.y, z: planeHit.z };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
const place = (point, keepTool) => {
|
|
100
|
+
const placement = ui.getState().placement;
|
|
101
|
+
if (placement === null)
|
|
102
|
+
return;
|
|
103
|
+
if (placement.tool === "path") {
|
|
104
|
+
ui.pushDraftPoint(point);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (placement.tool === "marker") {
|
|
108
|
+
session().dispatch({
|
|
109
|
+
type: "addMarker",
|
|
110
|
+
marker: {
|
|
111
|
+
id: newPlacementId(placement.kind),
|
|
112
|
+
kind: placement.kind,
|
|
113
|
+
position: point,
|
|
114
|
+
label: placement.kind,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else if (placement.tool === "volume") {
|
|
119
|
+
session().dispatch({
|
|
120
|
+
type: "addVolume",
|
|
121
|
+
volume: {
|
|
122
|
+
id: newPlacementId(placement.kind),
|
|
123
|
+
kind: placement.kind,
|
|
124
|
+
shape: placement.shape,
|
|
125
|
+
center: point,
|
|
126
|
+
...(placement.shape === "box" ? { halfExtents: { x: 8, y: 4, z: 8 } } : { radius: 10 }),
|
|
127
|
+
...(placement.shape === "cylinder" ? { height: 8 } : {}),
|
|
128
|
+
label: placement.kind,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
session().dispatch({
|
|
134
|
+
type: "addNote",
|
|
135
|
+
note: {
|
|
136
|
+
id: newPlacementId("note"),
|
|
137
|
+
text: "New note",
|
|
138
|
+
position: point,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (!keepTool)
|
|
143
|
+
ui.cancelPlacement();
|
|
144
|
+
};
|
|
145
|
+
const pickDocumentId = (clickX, clickY, rect, cycleFrom) => {
|
|
146
|
+
const state = session().getState();
|
|
147
|
+
const visibility = api.getVisibility();
|
|
148
|
+
const candidates = [];
|
|
149
|
+
const consider = (id, kind, point, lift) => {
|
|
150
|
+
if (visibility[kind] === false)
|
|
151
|
+
return;
|
|
152
|
+
const distance = screenDistance(point, lift, clickX, clickY, rect);
|
|
153
|
+
if (distance !== null && distance < SCREEN_PICK_RADIUS_PX)
|
|
154
|
+
candidates.push({ id, distance });
|
|
155
|
+
};
|
|
156
|
+
for (const marker of state.document.markers)
|
|
157
|
+
consider(marker.id, marker.kind, marker.position, MARKER_LIFT);
|
|
158
|
+
for (const volume of state.document.volumes)
|
|
159
|
+
consider(volume.id, volume.kind, volume.center, 0);
|
|
160
|
+
for (const note of state.document.annotations)
|
|
161
|
+
consider(note.id, "note", note.position, NOTE_LIFT);
|
|
162
|
+
for (const path of state.document.paths) {
|
|
163
|
+
if (visibility[path.kind] === false)
|
|
164
|
+
continue;
|
|
165
|
+
let best = null;
|
|
166
|
+
for (const point of path.points) {
|
|
167
|
+
const distance = screenDistance(point, 0.8, clickX, clickY, rect);
|
|
168
|
+
if (distance !== null && distance < SCREEN_PICK_RADIUS_PX && (best === null || distance < best)) {
|
|
169
|
+
best = distance;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (best !== null)
|
|
173
|
+
candidates.push({ id: path.id, distance: best });
|
|
174
|
+
}
|
|
175
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
176
|
+
if (candidates.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
if (cycleFrom !== undefined) {
|
|
179
|
+
const at = candidates.findIndex((candidate) => candidate.id === cycleFrom);
|
|
180
|
+
if (at >= 0)
|
|
181
|
+
return candidates[(at + 1) % candidates.length].id;
|
|
182
|
+
}
|
|
183
|
+
return candidates[0].id;
|
|
184
|
+
};
|
|
185
|
+
const pickTaggedId = (clickX, clickY, rect) => {
|
|
186
|
+
ndc.set((clickX / rect.width) * 2 - 1, -(clickY / rect.height) * 2 + 1);
|
|
187
|
+
raycaster.setFromCamera(ndc, camera);
|
|
188
|
+
for (const hit of raycaster.intersectObjects(scene.children, true)) {
|
|
189
|
+
if (!hit.object.isMesh || hit.object.visible === false)
|
|
190
|
+
continue;
|
|
191
|
+
let node = hit.object;
|
|
192
|
+
let gizmoHit = false;
|
|
193
|
+
let taggedId = null;
|
|
194
|
+
while (node !== null && taggedId === null && !gizmoHit) {
|
|
195
|
+
if (node.type.startsWith("TransformControls"))
|
|
196
|
+
gizmoHit = true;
|
|
197
|
+
for (const tag of SELECT_TAGS) {
|
|
198
|
+
const id = node.userData[tag];
|
|
199
|
+
if (typeof id === "string") {
|
|
200
|
+
taggedId = id;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
node = node.parent;
|
|
205
|
+
}
|
|
206
|
+
if (gizmoHit)
|
|
207
|
+
continue;
|
|
208
|
+
if (taggedId !== null)
|
|
209
|
+
return taggedId;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
};
|
|
214
|
+
const onDown = (event) => {
|
|
215
|
+
if (ui.getState().tool === "terrain")
|
|
216
|
+
return;
|
|
217
|
+
if (event.button === 0 || event.button === 2)
|
|
218
|
+
down = { x: event.clientX, y: event.clientY, button: event.button };
|
|
219
|
+
};
|
|
220
|
+
const onUp = (event) => {
|
|
221
|
+
if (ui.getState().tool === "terrain")
|
|
222
|
+
return;
|
|
223
|
+
if (down === null || event.button !== down.button)
|
|
224
|
+
return;
|
|
225
|
+
const start = down;
|
|
226
|
+
down = null;
|
|
227
|
+
const slop = event.button === 2 ? undefined : CLICK_SLOP_PX;
|
|
228
|
+
if (!isPointerClick(start.x, start.y, event.clientX, event.clientY, slop))
|
|
229
|
+
return;
|
|
230
|
+
const rect = canvas.getBoundingClientRect();
|
|
231
|
+
const clickX = event.clientX - rect.left;
|
|
232
|
+
const clickY = event.clientY - rect.top;
|
|
233
|
+
if (event.button === 2) {
|
|
234
|
+
const docId = pickDocumentId(clickX, clickY, rect);
|
|
235
|
+
const taggedId = docId === null ? pickTaggedId(clickX, clickY, rect) : null;
|
|
236
|
+
const hitId = docId ?? taggedId;
|
|
237
|
+
if (hitId !== null) {
|
|
238
|
+
const selection = session().getState().selection;
|
|
239
|
+
if (!selection.includes(hitId)) {
|
|
240
|
+
session().dispatch({ type: "select", ids: [hitId] });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
ui.patch({
|
|
244
|
+
pathPoint: null,
|
|
245
|
+
contextMenu: {
|
|
246
|
+
clientX: event.clientX,
|
|
247
|
+
clientY: event.clientY,
|
|
248
|
+
hitId,
|
|
249
|
+
ground: groundPoint(clickX, clickY, rect),
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
|
255
|
+
if (ui.getState().placement !== null) {
|
|
256
|
+
const point = groundPoint(clickX, clickY, rect);
|
|
257
|
+
if (point !== null)
|
|
258
|
+
place(point, event.shiftKey || ui.getState().placement?.tool === "path");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const state = session().getState();
|
|
262
|
+
const selection = state.selection;
|
|
263
|
+
const selectedPathId = selection.length === 1 ? selection[0] : undefined;
|
|
264
|
+
const selectedPath = selectedPathId === undefined ? undefined : findEditorPath(state.document, selectedPathId);
|
|
265
|
+
if (selectedPath !== undefined) {
|
|
266
|
+
let bestIndex = -1;
|
|
267
|
+
let bestHandle = PATH_HANDLE_RADIUS_PX;
|
|
268
|
+
selectedPath.points.forEach((point, index) => {
|
|
269
|
+
const distance = screenDistance(point, 0.8, clickX, clickY, rect);
|
|
270
|
+
if (distance !== null && distance < bestHandle) {
|
|
271
|
+
bestHandle = distance;
|
|
272
|
+
bestIndex = index;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
if (bestIndex >= 0) {
|
|
276
|
+
ui.patch({ pathPoint: { pathId: selectedPath.id, index: bestIndex } });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const primary = selection[0];
|
|
281
|
+
const docId = pickDocumentId(clickX, clickY, rect, !additive && primary !== undefined ? primary : undefined);
|
|
282
|
+
if (docId !== null) {
|
|
283
|
+
if (additive) {
|
|
284
|
+
const next = selection.includes(docId)
|
|
285
|
+
? selection.filter((id) => id !== docId)
|
|
286
|
+
: [...selection, docId];
|
|
287
|
+
session().dispatch({ type: "select", ids: next });
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
session().dispatch({ type: "select", ids: [docId] });
|
|
291
|
+
}
|
|
292
|
+
ui.patch({ pathPoint: null, contextMenu: null });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const taggedId = pickTaggedId(clickX, clickY, rect);
|
|
296
|
+
if (taggedId !== null) {
|
|
297
|
+
if (additive) {
|
|
298
|
+
const next = selection.includes(taggedId)
|
|
299
|
+
? selection.filter((id) => id !== taggedId)
|
|
300
|
+
: [...selection, taggedId];
|
|
301
|
+
session().dispatch({ type: "select", ids: next });
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
session().dispatch({ type: "select", ids: [taggedId] });
|
|
305
|
+
}
|
|
306
|
+
ui.patch({ pathPoint: null, contextMenu: null });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!additive) {
|
|
310
|
+
ui.patch({ pathPoint: null, contextMenu: null });
|
|
311
|
+
if (selection.length > 0)
|
|
312
|
+
session().dispatch({ type: "clearSelection" });
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const onContextMenu = (event) => {
|
|
316
|
+
event.preventDefault();
|
|
317
|
+
};
|
|
318
|
+
canvas.addEventListener("pointerdown", onDown);
|
|
319
|
+
canvas.addEventListener("pointerup", onUp);
|
|
320
|
+
canvas.addEventListener("contextmenu", onContextMenu);
|
|
321
|
+
return () => {
|
|
322
|
+
canvas.removeEventListener("pointerdown", onDown);
|
|
323
|
+
canvas.removeEventListener("pointerup", onUp);
|
|
324
|
+
canvas.removeEventListener("contextmenu", onContextMenu);
|
|
325
|
+
};
|
|
326
|
+
}, [gl, camera, scene, api, ui]);
|
|
327
|
+
return null;
|
|
328
|
+
});
|
|
329
|
+
function resolveTarget(session, selectedId, pathPoint) {
|
|
330
|
+
const { document } = session.getState();
|
|
331
|
+
if (pathPoint !== null) {
|
|
332
|
+
const path = findEditorPath(document, pathPoint.pathId);
|
|
333
|
+
const point = path?.points[pathPoint.index];
|
|
334
|
+
if (point !== undefined)
|
|
335
|
+
return { position: point, lift: 0.8, rotationY: 0, kind: "pathPoint" };
|
|
336
|
+
}
|
|
337
|
+
if (selectedId === undefined)
|
|
338
|
+
return null;
|
|
339
|
+
const marker = findEditorMarker(document, selectedId);
|
|
340
|
+
if (marker !== undefined) {
|
|
341
|
+
return { position: marker.position, lift: MARKER_LIFT, rotationY: marker.rotationY ?? 0, kind: "marker" };
|
|
342
|
+
}
|
|
343
|
+
const volume = findEditorVolume(document, selectedId);
|
|
344
|
+
if (volume !== undefined)
|
|
345
|
+
return { position: volume.center, lift: 0, rotationY: 0, kind: "volume" };
|
|
346
|
+
const path = findEditorPath(document, selectedId);
|
|
347
|
+
if (path !== undefined && path.points.length > 0) {
|
|
348
|
+
return { position: pathCentroid(path.points), lift: 0.8, rotationY: 0, kind: "path" };
|
|
349
|
+
}
|
|
350
|
+
const note = findEditorNote(document, selectedId);
|
|
351
|
+
if (note !== undefined)
|
|
352
|
+
return { position: note.position, lift: NOTE_LIFT, rotationY: 0, kind: "note" };
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
function effectiveMode(mode, kind) {
|
|
356
|
+
if (kind === "marker")
|
|
357
|
+
return mode === "scale" ? "translate" : mode;
|
|
358
|
+
if (kind === "volume")
|
|
359
|
+
return mode === "rotate" ? "translate" : mode;
|
|
360
|
+
return "translate";
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Drag-to-transform gizmo bound to the current selection, dispatching editor commands on
|
|
364
|
+
* release. Mounts the shared shell `TransformGizmo` and maps release poses onto editor
|
|
365
|
+
* commands (multi-select translate, volume scale, path vertex move).
|
|
366
|
+
*/
|
|
367
|
+
export const SelectionGizmo = memo(function SelectionGizmo({ session, ui, groundSnap, }) {
|
|
368
|
+
const uiState = useGizmoUiState(ui);
|
|
369
|
+
const state = session.getState();
|
|
370
|
+
const selectedId = state.selection[0];
|
|
371
|
+
const target = useMemo(() => resolveTarget(session, selectedId, uiState.pathPoint), [state, selectedId, uiState.pathPoint, session]);
|
|
372
|
+
const mode = target === null ? "translate" : effectiveMode(uiState.gizmoMode, target.kind);
|
|
373
|
+
if (target === null)
|
|
374
|
+
return null;
|
|
375
|
+
const onRelease = (pose) => {
|
|
376
|
+
const dropped = pose.position;
|
|
377
|
+
if (target.kind === "pathPoint" && uiState.pathPoint !== null) {
|
|
378
|
+
const path = findEditorPath(session.getState().document, uiState.pathPoint.pathId);
|
|
379
|
+
if (path === undefined)
|
|
380
|
+
return;
|
|
381
|
+
const original = path.points[uiState.pathPoint.index];
|
|
382
|
+
if (original !== undefined && vecEqual(original, dropped))
|
|
383
|
+
return;
|
|
384
|
+
const points = path.points.map((point, index) => index === uiState.pathPoint.index ? dropped : point);
|
|
385
|
+
session.dispatch({ type: "setPath", id: path.id, patch: { points } });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (mode === "scale" && target.kind === "volume" && selectedId !== undefined) {
|
|
389
|
+
const volume = findEditorVolume(session.getState().document, selectedId);
|
|
390
|
+
if (volume === undefined)
|
|
391
|
+
return;
|
|
392
|
+
const sx = Math.abs(pose.scale.x);
|
|
393
|
+
const sy = Math.abs(pose.scale.y);
|
|
394
|
+
const sz = Math.abs(pose.scale.z);
|
|
395
|
+
if (sx === 1 && sy === 1 && sz === 1 && vecEqual(dropped, volume.center))
|
|
396
|
+
return;
|
|
397
|
+
if (volume.shape === "box" && volume.halfExtents !== undefined) {
|
|
398
|
+
session.dispatch({
|
|
399
|
+
type: "setVolume",
|
|
400
|
+
id: selectedId,
|
|
401
|
+
patch: {
|
|
402
|
+
center: dropped,
|
|
403
|
+
halfExtents: {
|
|
404
|
+
x: Math.max(0.5, volume.halfExtents.x * sx),
|
|
405
|
+
y: Math.max(0.5, volume.halfExtents.y * sy),
|
|
406
|
+
z: Math.max(0.5, volume.halfExtents.z * sz),
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const radius = Math.max(0.5, (volume.radius ?? 5) * Math.max(sx, sz));
|
|
413
|
+
session.dispatch({
|
|
414
|
+
type: "setVolume",
|
|
415
|
+
id: selectedId,
|
|
416
|
+
patch: {
|
|
417
|
+
center: dropped,
|
|
418
|
+
radius,
|
|
419
|
+
...(volume.height === undefined ? {} : { height: Math.max(0.5, volume.height * sy) }),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (mode === "rotate" && target.kind === "marker" && selectedId !== undefined) {
|
|
426
|
+
if (vecEqual(dropped, target.position) && pose.rotationY === target.rotationY)
|
|
427
|
+
return;
|
|
428
|
+
session.dispatch({
|
|
429
|
+
type: "setTransform",
|
|
430
|
+
id: selectedId,
|
|
431
|
+
position: dropped,
|
|
432
|
+
rotationY: pose.rotationY,
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const delta = {
|
|
437
|
+
x: dropped.x - target.position.x,
|
|
438
|
+
y: dropped.y - target.position.y,
|
|
439
|
+
z: dropped.z - target.position.z,
|
|
440
|
+
};
|
|
441
|
+
if (state.selection.length > 1 || target.kind === "path") {
|
|
442
|
+
if (delta.x === 0 && delta.y === 0 && delta.z === 0)
|
|
443
|
+
return;
|
|
444
|
+
session.dispatch({ type: "translate", ids: state.selection, delta });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (selectedId === undefined)
|
|
448
|
+
return;
|
|
449
|
+
if (vecEqual(dropped, target.position))
|
|
450
|
+
return;
|
|
451
|
+
session.dispatch({ type: "setTransform", id: selectedId, position: dropped });
|
|
452
|
+
};
|
|
453
|
+
const snapMode = uiState.snapMode === "grid" ? "grid" : uiState.snapMode === "ground" ? "ground" : "free";
|
|
454
|
+
return (_jsx(TransformGizmo, { position: target.position, rotationY: target.rotationY, mode: mode, snapMode: snapMode, gridSize: uiState.gridSize, lift: target.lift, groundSnap: mode === "translate" && target.kind !== "path" ? groundSnap : undefined, onRelease: onRelease }));
|
|
455
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EditorLayersInput } from "@jgengine/core/editor/index";
|
|
2
|
+
import { type EnvironmentWorldFeature } from "@jgengine/core/world/features";
|
|
3
|
+
import type { PlayableGame } from "@jgengine/shell/registry";
|
|
4
|
+
import { type EditorSaveFn } from "./EditorApp.js";
|
|
5
|
+
/** One user-supplied model the standalone 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
|
+
/** Options for the blank, gameless world the standalone editor authors over. */
|
|
12
|
+
export interface BlankPlayableOptions {
|
|
13
|
+
name?: string;
|
|
14
|
+
assets?: readonly StandaloneAsset[];
|
|
15
|
+
world?: EnvironmentWorldFeature;
|
|
16
|
+
}
|
|
17
|
+
/** The default flat-ground world the standalone editor opens on when the host supplies none. */
|
|
18
|
+
export declare function blankWorld(seed?: string): EnvironmentWorldFeature;
|
|
19
|
+
/** Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over. */
|
|
20
|
+
export declare function createBlankPlayable(options?: BlankPlayableOptions): PlayableGame;
|
|
21
|
+
/** A save fn that hands the scene JSON back to the browser as a downloaded file — the exit path when no dev server is listening. */
|
|
22
|
+
export declare function downloadSaver(filename?: string): EditorSaveFn;
|
|
23
|
+
/** Props for the gameless scene editor — everything optional so it boots on a blank world with nothing wired. */
|
|
24
|
+
export interface StandaloneEditorProps {
|
|
25
|
+
/** Prefs/draft namespace; also the gameId the save endpoint resolves. Default `"standalone"`. */
|
|
26
|
+
sceneId?: string;
|
|
27
|
+
/** Scene document (or its JSON) to open on — a loaded world file. */
|
|
28
|
+
scene?: EditorLayersInput;
|
|
29
|
+
/** Models the user can place. */
|
|
30
|
+
assets?: readonly StandaloneAsset[];
|
|
31
|
+
/** World to author over. Default: a flat grass field. */
|
|
32
|
+
world?: EnvironmentWorldFeature;
|
|
33
|
+
/** Where Save writes. Default: the installed dev-server endpoint, else a browser download. */
|
|
34
|
+
save?: EditorSaveFn;
|
|
35
|
+
/** Hide the built-in open-scene / add-assets strip (host supplies its own loading UI). */
|
|
36
|
+
hidePickers?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The scene editor, mounted over a blank gameless world instead of a game — the same `EditorApp`
|
|
40
|
+
* every jgengine game ships, usable standalone on the user's own project (CLI `jgengine editor`,
|
|
41
|
+
* desktop app, or any React host). Ships a slim strip to open a world file and pull in an asset
|
|
42
|
+
* folder; both are also settable up front through props.
|
|
43
|
+
*/
|
|
44
|
+
export declare function StandaloneEditor({ sceneId, scene, assets, world, save, hidePickers, }: StandaloneEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
|
|
4
|
+
import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
|
|
5
|
+
import { environment, grass, sky, terrain, } from "@jgengine/core/world/features";
|
|
6
|
+
import { defineGame } from "@jgengine/shell/defineGame";
|
|
7
|
+
import { EditorApp } from "./EditorApp.js";
|
|
8
|
+
/** The default flat-ground world the standalone editor opens on when the host supplies none. */
|
|
9
|
+
export function blankWorld(seed = "standalone") {
|
|
10
|
+
return environment({
|
|
11
|
+
terrain: terrain({ bounds: { w: 128, d: 128 }, height: 0, material: "grass" }),
|
|
12
|
+
sky: sky({ preset: "day" }),
|
|
13
|
+
vegetation: grass({ area: { w: 100, d: 100 }, density: 1.5, colors: ["#3f7d2d", "#6bbf4a"], seed }),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over. */
|
|
17
|
+
export function createBlankPlayable(options = {}) {
|
|
18
|
+
const assets = createAssetCatalog();
|
|
19
|
+
for (const asset of options.assets ?? [])
|
|
20
|
+
assets.register(asset.id, { url: asset.url });
|
|
21
|
+
return defineGame({
|
|
22
|
+
name: options.name ?? "Standalone Scene",
|
|
23
|
+
assets,
|
|
24
|
+
world: options.world ?? blankWorld(),
|
|
25
|
+
server: { mode: "single" },
|
|
26
|
+
save: "none",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** A save fn that hands the scene JSON back to the browser as a downloaded file — the exit path when no dev server is listening. */
|
|
30
|
+
export function downloadSaver(filename = "editor.scene.json") {
|
|
31
|
+
return async (json) => {
|
|
32
|
+
try {
|
|
33
|
+
const blob = new Blob([json], { type: "application/json" });
|
|
34
|
+
const url = URL.createObjectURL(blob);
|
|
35
|
+
const anchor = document.createElement("a");
|
|
36
|
+
anchor.href = url;
|
|
37
|
+
anchor.download = filename;
|
|
38
|
+
anchor.click();
|
|
39
|
+
URL.revokeObjectURL(url);
|
|
40
|
+
return { ok: true, path: filename };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const MODEL_FILE = /\.(glb|gltf)$/i;
|
|
48
|
+
function assetId(name) {
|
|
49
|
+
return name.replace(MODEL_FILE, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "asset";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The scene editor, mounted over a blank gameless world instead of a game — the same `EditorApp`
|
|
53
|
+
* every jgengine game ships, usable standalone on the user's own project (CLI `jgengine editor`,
|
|
54
|
+
* desktop app, or any React host). Ships a slim strip to open a world file and pull in an asset
|
|
55
|
+
* folder; both are also settable up front through props.
|
|
56
|
+
*/
|
|
57
|
+
export function StandaloneEditor({ sceneId = "standalone", scene, assets, world, save, hidePickers = false, }) {
|
|
58
|
+
const [loadedScene, setLoadedScene] = useState(scene);
|
|
59
|
+
const [loadedAssets, setLoadedAssets] = useState(assets ?? []);
|
|
60
|
+
const [rev, setRev] = useState(0);
|
|
61
|
+
const sceneInput = useRef(null);
|
|
62
|
+
const assetInput = useRef(null);
|
|
63
|
+
const playable = useMemo(() => createBlankPlayable({ assets: loadedAssets, world }), [loadedAssets, world]);
|
|
64
|
+
const saveFn = useMemo(() => save ?? (getSaveEndpoint() !== null ? undefined : downloadSaver()), [save]);
|
|
65
|
+
const openScene = useCallback(async (file) => {
|
|
66
|
+
const text = await file.text();
|
|
67
|
+
setLoadedScene(JSON.parse(text));
|
|
68
|
+
setRev((value) => value + 1);
|
|
69
|
+
}, []);
|
|
70
|
+
const addAssets = useCallback((files) => {
|
|
71
|
+
const next = [];
|
|
72
|
+
for (const file of Array.from(files)) {
|
|
73
|
+
if (!MODEL_FILE.test(file.name))
|
|
74
|
+
continue;
|
|
75
|
+
next.push({ id: assetId(file.name), url: URL.createObjectURL(file), label: file.name });
|
|
76
|
+
}
|
|
77
|
+
if (next.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
setLoadedAssets((current) => [...current, ...next]);
|
|
80
|
+
setRev((value) => value + 1);
|
|
81
|
+
}, []);
|
|
82
|
+
return (_jsxs("div", { className: "relative h-full w-full", children: [_jsx(EditorApp, { gameId: sceneId, playable: playable, layers: loadedScene, save: saveFn }, rev), hidePickers ? null : (_jsxs("div", { className: "pointer-events-none absolute bottom-3 left-1/2 z-[70] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/70 px-2 py-1.5 text-[11px] text-neutral-200 shadow-lg shadow-black/40 backdrop-blur-md", children: [_jsx("button", { type: "button", className: "pointer-events-auto rounded-full px-3 py-1 transition-colors hover:bg-white/10", onClick: () => sceneInput.current?.click(), children: "\uD83D\uDCC2 Open scene" }), _jsx("span", { className: "h-4 w-px bg-white/15" }), _jsx("button", { type: "button", className: "pointer-events-auto rounded-full px-3 py-1 transition-colors hover:bg-white/10", onClick: () => assetInput.current?.click(), children: "\uD83E\uDDCA Add assets" }), loadedAssets.length > 0 ? (_jsx("span", { className: "rounded-full bg-white/10 px-2 py-0.5 text-[10px] text-neutral-300", children: loadedAssets.length })) : null, _jsx("input", { ref: sceneInput, type: "file", accept: ".json,application/json", className: "hidden", onChange: (event) => {
|
|
83
|
+
const file = event.target.files?.[0];
|
|
84
|
+
if (file !== undefined)
|
|
85
|
+
void openScene(file);
|
|
86
|
+
event.target.value = "";
|
|
87
|
+
} }), _jsx("input", { ref: assetInput, type: "file", accept: ".glb,.gltf,model/gltf-binary", multiple: true, className: "hidden", onChange: (event) => {
|
|
88
|
+
if (event.target.files !== null)
|
|
89
|
+
addAssets(event.target.files);
|
|
90
|
+
event.target.value = "";
|
|
91
|
+
} })] }))] }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EditorSession } from "./session.js";
|
|
2
|
+
import { type EditorUiStore } from "./uiStore.js";
|
|
3
|
+
/** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
|
|
4
|
+
export declare function TerrainPanel({ session, ui }: {
|
|
5
|
+
session: EditorSession;
|
|
6
|
+
ui: EditorUiStore;
|
|
7
|
+
}): import("react").JSX.Element;
|