@scenoco-three/editor 0.2.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/README.md +101 -0
- package/app/cameraRig.ts +57 -0
- package/app/commands.ts +74 -0
- package/app/desktopControls.ts +44 -0
- package/app/document.ts +500 -0
- package/app/gizmo.ts +75 -0
- package/app/index.html +103 -0
- package/app/main.ts +302 -0
- package/app/panels.ts +553 -0
- package/app/session.ts +294 -0
- package/app/toolbar.ts +131 -0
- package/app/touchControls.ts +104 -0
- package/app/vite-env.d.ts +5 -0
- package/app/wsClient.ts +66 -0
- package/dist/args.d.ts +8 -0
- package/dist/args.js +28 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +74 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +150 -0
- package/dist/protocol.d.ts +157 -0
- package/dist/protocol.js +9 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +264 -0
- package/package.json +53 -0
package/app/document.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { Color, Euler, Vector2, Vector3, type Object3D } from 'three';
|
|
2
|
+
import {
|
|
3
|
+
Registry,
|
|
4
|
+
elementType,
|
|
5
|
+
getAttachmentDef,
|
|
6
|
+
isArrayType,
|
|
7
|
+
type Component,
|
|
8
|
+
type CompiledScene,
|
|
9
|
+
type EnumDef,
|
|
10
|
+
type PropType,
|
|
11
|
+
type PropertyType,
|
|
12
|
+
type SceneGraph,
|
|
13
|
+
} from '@scenoco-three/core';
|
|
14
|
+
import type { NodeOrigin, SceneProvenance } from '@scenoco-three/compiler';
|
|
15
|
+
import type { EditOp, EditTarget } from '../src/protocol.js';
|
|
16
|
+
import { UndoStack } from './commands.js';
|
|
17
|
+
|
|
18
|
+
export type TransformChannel = 'position' | 'rotation' | 'scale';
|
|
19
|
+
type Scalar = number | boolean | string | number[];
|
|
20
|
+
type AttrMap = Record<string, Scalar>;
|
|
21
|
+
|
|
22
|
+
/** A node's attachment slot as the inspector needs it. */
|
|
23
|
+
export interface AttachmentView {
|
|
24
|
+
slot: number;
|
|
25
|
+
tag: string;
|
|
26
|
+
attrs: AttrMap;
|
|
27
|
+
/** Inline attr props are editable (authored scene node, not src-loaded). */
|
|
28
|
+
editable: boolean;
|
|
29
|
+
/** `src=` file the attachment was loaded from (props read-only, src editable), else null. */
|
|
30
|
+
src: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The editable model of the loaded scene and the single funnel for every change.
|
|
35
|
+
*
|
|
36
|
+
* Each mutator builds an undoable command that writes to **both** the live
|
|
37
|
+
* Three.js object (so the viewport updates) **and** the compiled scene (the
|
|
38
|
+
* serialization source of truth). Net edits to save are derived by diffing the
|
|
39
|
+
* live compiled scene against a snapshot taken at load — so undo/redo, which
|
|
40
|
+
* rewinds the compiled scene, is reflected automatically with no extra bookkeeping.
|
|
41
|
+
*/
|
|
42
|
+
export class SceneDocument {
|
|
43
|
+
private readonly compiled: CompiledScene;
|
|
44
|
+
/** Pristine copy at load — diffed against `compiled` to produce save edits. */
|
|
45
|
+
private original: CompiledScene;
|
|
46
|
+
private readonly undo: UndoStack;
|
|
47
|
+
private readonly listeners = new Set<() => void>();
|
|
48
|
+
/** Attachment pool indices this document created via copy-on-write (mutate in place). */
|
|
49
|
+
private readonly cowAttachments = new Set<number>();
|
|
50
|
+
/** `src` edits (prefab instance / material) — no compiled home, tracked explicitly. */
|
|
51
|
+
private readonly srcEdits = new Map<string, EditOp>();
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly graph: SceneGraph,
|
|
55
|
+
private readonly provenance?: SceneProvenance,
|
|
56
|
+
) {
|
|
57
|
+
this.compiled = graph.compiled;
|
|
58
|
+
this.original = structuredClone(graph.compiled);
|
|
59
|
+
this.undo = new UndoStack(() => this.emit());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── editability (driven by provenance) ─────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** Where a node came from — authored scene, a prefab instance root, or prefab-internal. */
|
|
65
|
+
nodeOrigin(node: Object3D): NodeOrigin {
|
|
66
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
67
|
+
return (i !== undefined && this.provenance?.nodes[i]) || { kind: 'scene' };
|
|
68
|
+
}
|
|
69
|
+
/** Transform is editable on authored nodes and prefab-instance roots (their own override). */
|
|
70
|
+
canEditTransform(node: Object3D): boolean {
|
|
71
|
+
const k = this.nodeOrigin(node).kind;
|
|
72
|
+
return k === 'scene' || k === 'prefab-root';
|
|
73
|
+
}
|
|
74
|
+
/** Components are editable only on authored scene nodes. */
|
|
75
|
+
canEditComponents(node: Object3D): boolean {
|
|
76
|
+
return this.nodeOrigin(node).kind === 'scene';
|
|
77
|
+
}
|
|
78
|
+
/** The prefab `src` of a prefab-instance root, else undefined. */
|
|
79
|
+
prefabSrc(node: Object3D): string | undefined {
|
|
80
|
+
const o = this.nodeOrigin(node);
|
|
81
|
+
return o.kind === 'prefab-root' ? o.src : undefined;
|
|
82
|
+
}
|
|
83
|
+
private attachmentSrcOf(poolIndex: number): string | null {
|
|
84
|
+
return this.provenance?.attachments[poolIndex] ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── subscription ───────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
onChange(cb: () => void): () => void {
|
|
90
|
+
this.listeners.add(cb);
|
|
91
|
+
return () => this.listeners.delete(cb);
|
|
92
|
+
}
|
|
93
|
+
private emit(): void {
|
|
94
|
+
for (const cb of this.listeners) cb();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get canUndo(): boolean {
|
|
98
|
+
return this.undo.canUndo;
|
|
99
|
+
}
|
|
100
|
+
get canRedo(): boolean {
|
|
101
|
+
return this.undo.canRedo;
|
|
102
|
+
}
|
|
103
|
+
/** Any unsaved change (cheap: scenes are small). */
|
|
104
|
+
get isDirty(): boolean {
|
|
105
|
+
return this.netEdits().length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
undoLast(): void {
|
|
109
|
+
this.undo.undo();
|
|
110
|
+
}
|
|
111
|
+
redoLast(): void {
|
|
112
|
+
this.undo.redo();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── transform edits ──────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/** Set a node's position/scale (world units) or rotation (radians, XYZ). */
|
|
118
|
+
setTransform(node: Object3D, channel: TransformChannel, value: number[], opts?: { coalesce?: boolean }): void {
|
|
119
|
+
if (!this.canEditTransform(node)) return;
|
|
120
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
121
|
+
if (i === undefined) return;
|
|
122
|
+
const t = this.compiled.nodes[i]!.transform;
|
|
123
|
+
const prev = t[channel].slice();
|
|
124
|
+
const next = value.slice();
|
|
125
|
+
if (numbersEqual(prev, next)) return; // no-op (e.g. an unchanged channel after a drag)
|
|
126
|
+
this.undo.run({
|
|
127
|
+
label: `${channel} ${node.name || node.type}`,
|
|
128
|
+
coalesceKey: opts?.coalesce ? `node:${i}:${channel}` : undefined,
|
|
129
|
+
apply: () => {
|
|
130
|
+
t[channel] = next.slice();
|
|
131
|
+
applyTransform(node, channel, next);
|
|
132
|
+
},
|
|
133
|
+
invert: () => {
|
|
134
|
+
t[channel] = prev.slice();
|
|
135
|
+
applyTransform(node, channel, prev);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setVisible(node: Object3D, value: boolean): void {
|
|
141
|
+
if (!this.canEditTransform(node)) return;
|
|
142
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
143
|
+
if (i === undefined) return;
|
|
144
|
+
const t = this.compiled.nodes[i]!.transform;
|
|
145
|
+
const prev = t.visible;
|
|
146
|
+
if (prev === value) return;
|
|
147
|
+
this.undo.run({
|
|
148
|
+
label: `visible ${node.name || node.type}`,
|
|
149
|
+
apply: () => {
|
|
150
|
+
t.visible = value;
|
|
151
|
+
node.visible = value;
|
|
152
|
+
},
|
|
153
|
+
invert: () => {
|
|
154
|
+
t.visible = prev;
|
|
155
|
+
node.visible = prev;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── component property edits ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/** Set a scalar/vector/color/bool/string `@property` in compiled form. */
|
|
163
|
+
setComponentProp(
|
|
164
|
+
component: Component,
|
|
165
|
+
name: string,
|
|
166
|
+
value: number | boolean | string | number[],
|
|
167
|
+
opts?: { coalesce?: boolean },
|
|
168
|
+
): void {
|
|
169
|
+
const ci = this.graph.componentIndexOf.get(component);
|
|
170
|
+
if (ci === undefined) return;
|
|
171
|
+
const cc = this.compiled.components[ci]!;
|
|
172
|
+
if ((this.provenance?.nodes[cc.node]?.kind ?? 'scene') !== 'scene') return; // prefab content read-only
|
|
173
|
+
const meta = Registry.getComponent(cc.type)?.meta.properties.get(name);
|
|
174
|
+
if (!meta) return;
|
|
175
|
+
const elem = elementType(meta.type);
|
|
176
|
+
|
|
177
|
+
const field = component as unknown as Record<string, unknown>;
|
|
178
|
+
const had = name in cc.props;
|
|
179
|
+
if (had && valuesEqual(cc.props[name], value)) return; // no-op
|
|
180
|
+
const prevCompiled = had ? structuredClone(cc.props[name]!) : undefined;
|
|
181
|
+
const prevRuntime = cloneRuntime(field[name]);
|
|
182
|
+
const nextRuntime = toRuntime(meta.type, value);
|
|
183
|
+
this.undo.run({
|
|
184
|
+
label: `${cc.type}.${name}`,
|
|
185
|
+
coalesceKey: opts?.coalesce ? `comp:${ci}:${name}` : undefined,
|
|
186
|
+
apply: () => {
|
|
187
|
+
cc.props[name] = structuredClone(value);
|
|
188
|
+
field[name] = cloneRuntime(nextRuntime);
|
|
189
|
+
},
|
|
190
|
+
invert: () => {
|
|
191
|
+
if (had) cc.props[name] = structuredClone(prevCompiled!);
|
|
192
|
+
else delete cc.props[name];
|
|
193
|
+
field[name] = cloneRuntime(prevRuntime);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
void elem; // reserved: array-element handling lands with array editing
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── attachment edits (geometry / material / scene settings) ─────────────────────
|
|
200
|
+
|
|
201
|
+
/** The attachment slots on a node, with editability + src provenance for the inspector. */
|
|
202
|
+
attachmentsOf(node: Object3D): AttachmentView[] {
|
|
203
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
204
|
+
if (i === undefined) return [];
|
|
205
|
+
const sceneNode = this.nodeOrigin(node).kind === 'scene';
|
|
206
|
+
return this.compiled.nodes[i]!.attachments.map((idx, slot) => {
|
|
207
|
+
const asset = this.compiled.attachments[idx]!;
|
|
208
|
+
const src = this.attachmentSrcOf(idx);
|
|
209
|
+
return { slot, tag: asset.tag, attrs: asset.attrs as AttrMap, editable: sceneNode && src === null, src };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Current compiled value of an attachment attr (fresh — editors read it per change). */
|
|
214
|
+
attachmentAttr(node: Object3D, slot: number, attr: string): Scalar | undefined {
|
|
215
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
216
|
+
if (i === undefined) return undefined;
|
|
217
|
+
const idx = this.compiled.nodes[i]!.attachments[slot];
|
|
218
|
+
return idx === undefined ? undefined : (this.compiled.attachments[idx]!.attrs[attr] as Scalar | undefined);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Set an inline attachment attribute, **copy-on-write**: the first edit clones the
|
|
223
|
+
* pooled `CompiledAsset` into a private entry (so sibling nodes sharing it are
|
|
224
|
+
* untouched); later edits mutate the private copy in place. The live resource is
|
|
225
|
+
* rebuilt from the attachment's own builder, so it's unshared automatically.
|
|
226
|
+
*/
|
|
227
|
+
setAttachmentProp(node: Object3D, slot: number, attr: string, value: Scalar, opts?: { coalesce?: boolean }): void {
|
|
228
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
229
|
+
if (i === undefined) return;
|
|
230
|
+
const cnode = this.compiled.nodes[i]!;
|
|
231
|
+
const curIdx = cnode.attachments[slot];
|
|
232
|
+
if (curIdx === undefined) return;
|
|
233
|
+
const src = this.compiled.attachments[curIdx]!;
|
|
234
|
+
if (this.nodeOrigin(node).kind !== 'scene' || this.attachmentSrcOf(curIdx) !== null) return; // read-only
|
|
235
|
+
const tag = src.tag;
|
|
236
|
+
const reattach = (attrs: AttrMap) => getAttachmentDef(tag)?.make(attrs).attach({ node });
|
|
237
|
+
|
|
238
|
+
if (this.cowAttachments.has(curIdx)) {
|
|
239
|
+
// Already our private copy — mutate in place.
|
|
240
|
+
const asset = src;
|
|
241
|
+
const had = attr in asset.attrs;
|
|
242
|
+
if (had && valuesEqual(asset.attrs[attr], value)) return;
|
|
243
|
+
const prev = had ? structuredClone(asset.attrs[attr]!) : undefined;
|
|
244
|
+
this.undo.run({
|
|
245
|
+
label: `${tag}.${attr}`,
|
|
246
|
+
coalesceKey: opts?.coalesce ? `att:${curIdx}:${attr}` : undefined,
|
|
247
|
+
apply: () => {
|
|
248
|
+
asset.attrs[attr] = structuredClone(value);
|
|
249
|
+
reattach(asset.attrs as AttrMap);
|
|
250
|
+
},
|
|
251
|
+
invert: () => {
|
|
252
|
+
if (had) asset.attrs[attr] = structuredClone(prev!);
|
|
253
|
+
else delete asset.attrs[attr];
|
|
254
|
+
reattach(asset.attrs as AttrMap);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (valuesEqual(src.attrs[attr], value)) return; // no-op against the shared value
|
|
261
|
+
const newIdx = this.compiled.attachments.length;
|
|
262
|
+
const newAttrs = { ...structuredClone(src.attrs), [attr]: structuredClone(value) };
|
|
263
|
+
this.undo.run({
|
|
264
|
+
label: `${tag}.${attr}`,
|
|
265
|
+
coalesceKey: opts?.coalesce ? `att:slot:${i}:${slot}:${attr}` : undefined,
|
|
266
|
+
apply: () => {
|
|
267
|
+
this.compiled.attachments[newIdx] = { tag, attrs: newAttrs };
|
|
268
|
+
cnode.attachments[slot] = newIdx;
|
|
269
|
+
this.cowAttachments.add(newIdx);
|
|
270
|
+
reattach(newAttrs as AttrMap);
|
|
271
|
+
},
|
|
272
|
+
invert: () => {
|
|
273
|
+
cnode.attachments[slot] = curIdx;
|
|
274
|
+
this.cowAttachments.delete(newIdx);
|
|
275
|
+
reattach(src.attrs as AttrMap);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Edit a prefab-instance node's `src` (save-only — reopen to see the swapped content). */
|
|
281
|
+
setNodeSrc(node: Object3D, src: string): void {
|
|
282
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
283
|
+
if (i === undefined || this.nodeOrigin(node).kind !== 'prefab-root') return;
|
|
284
|
+
const cnode = this.compiled.nodes[i]!;
|
|
285
|
+
this.recordSrcEdit(`node:${i}:src`, { kind: 'node', nodeIndex: i, ...(cnode.id ? { id: cnode.id } : {}) }, src);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Edit a src-loaded attachment's `src` (save-only — reopen to see the swapped content). */
|
|
289
|
+
setAttachmentSrc(node: Object3D, slot: number, src: string): void {
|
|
290
|
+
const i = this.graph.nodeIndexOf.get(node);
|
|
291
|
+
if (i === undefined) return;
|
|
292
|
+
const idx = this.compiled.nodes[i]!.attachments[slot];
|
|
293
|
+
if (idx === undefined || this.attachmentSrcOf(idx) === null) return;
|
|
294
|
+
this.recordSrcEdit(`att:${i}:${slot}:src`, this.attachmentTarget(i, slot, this.compiled.attachments[idx]!.tag), src);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private recordSrcEdit(key: string, target: EditTarget, src: string): void {
|
|
298
|
+
const prev = this.srcEdits.get(key);
|
|
299
|
+
this.undo.run({
|
|
300
|
+
label: `src ${key}`,
|
|
301
|
+
coalesceKey: `src:${key}`,
|
|
302
|
+
apply: () => void this.srcEdits.set(key, { target, attr: 'src', value: src }),
|
|
303
|
+
invert: () => {
|
|
304
|
+
if (prev) this.srcEdits.set(key, prev);
|
|
305
|
+
else this.srcEdits.delete(key);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── persistence ───────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
/** The minimal set of changed attributes vs. the loaded source, for surgical save. */
|
|
313
|
+
netEdits(): EditOp[] {
|
|
314
|
+
const out: EditOp[] = [];
|
|
315
|
+
this.compiled.nodes.forEach((node, i) => {
|
|
316
|
+
const orig = this.original.nodes[i]!;
|
|
317
|
+
const target = (): EditTarget => ({ kind: 'node', nodeIndex: i, ...(node.id ? { id: node.id } : {}) });
|
|
318
|
+
if (!numbersEqual(node.transform.position, orig.transform.position))
|
|
319
|
+
out.push({ target: target(), attr: 'position', value: node.transform.position.slice() });
|
|
320
|
+
if (!numbersEqual(node.transform.rotation, orig.transform.rotation))
|
|
321
|
+
out.push({ target: target(), attr: 'rotation', value: node.transform.rotation.slice() });
|
|
322
|
+
if (!numbersEqual(node.transform.scale, orig.transform.scale))
|
|
323
|
+
out.push({ target: target(), attr: 'scale', value: node.transform.scale.slice() });
|
|
324
|
+
if (node.transform.visible !== orig.transform.visible)
|
|
325
|
+
out.push({ target: target(), attr: 'visible', value: node.transform.visible });
|
|
326
|
+
});
|
|
327
|
+
this.compiled.components.forEach((comp, ci) => {
|
|
328
|
+
const orig = this.original.components[ci]!;
|
|
329
|
+
for (const [name, value] of Object.entries(comp.props)) {
|
|
330
|
+
if (!isSavableScalar(value) || valuesEqual(value, orig.props[name])) continue;
|
|
331
|
+
out.push({ target: this.componentTarget(ci), attr: name, value: value as number | boolean | string | number[] });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
// Attachment attrs: a slot whose pool index changed (copy-on-write) vs. its original asset.
|
|
335
|
+
this.compiled.nodes.forEach((node, i) => {
|
|
336
|
+
const orig = this.original.nodes[i]!;
|
|
337
|
+
node.attachments.forEach((attIdx, slot) => {
|
|
338
|
+
const origIdx = orig.attachments[slot];
|
|
339
|
+
if (origIdx === undefined || attIdx === origIdx) return;
|
|
340
|
+
const cur = this.compiled.attachments[attIdx]!;
|
|
341
|
+
const base = this.original.attachments[origIdx]!;
|
|
342
|
+
for (const [attr, val] of Object.entries(cur.attrs)) {
|
|
343
|
+
if (!isSavableScalar(val) || valuesEqual(val, base.attrs[attr])) continue;
|
|
344
|
+
out.push({ target: this.attachmentTarget(i, slot, cur.tag), attr, value: val as number | boolean | string | number[] });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
// `src` edits (prefab instance / material) have no compiled home — tracked explicitly.
|
|
349
|
+
out.push(...this.srcEdits.values());
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Mark the current state as the saved baseline (keeps the undo stack). */
|
|
354
|
+
markSaved(): void {
|
|
355
|
+
this.original = structuredClone(this.compiled);
|
|
356
|
+
this.srcEdits.clear();
|
|
357
|
+
this.emit();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Re-apply a set of edits onto this (freshly loaded) document — used to preserve
|
|
362
|
+
* the user's unsaved changes after a `src=` dependency reload. Resolution is by
|
|
363
|
+
* id/tag/ordinal (best-effort): edits whose target no longer exists are dropped.
|
|
364
|
+
*/
|
|
365
|
+
applyEdits(ops: EditOp[]): void {
|
|
366
|
+
for (const op of ops) this.applyEdit(op);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private applyEdit(op: EditOp): void {
|
|
370
|
+
const t = op.target;
|
|
371
|
+
if (t.kind === 'node') {
|
|
372
|
+
const node = this.nodeById(t.id);
|
|
373
|
+
if (!node) return;
|
|
374
|
+
if (op.attr === 'src') this.setNodeSrc(node, op.value as string);
|
|
375
|
+
else if (op.attr === 'visible') this.setVisible(node, op.value as boolean);
|
|
376
|
+
else if (op.attr === 'position' || op.attr === 'rotation' || op.attr === 'scale')
|
|
377
|
+
this.setTransform(node, op.attr, op.value as number[]);
|
|
378
|
+
} else if (t.kind === 'component') {
|
|
379
|
+
const comp = this.componentByTarget(t.nodeId, t.type, t.ordinal);
|
|
380
|
+
if (comp) this.setComponentProp(comp, op.attr, op.value);
|
|
381
|
+
} else {
|
|
382
|
+
const node = t.root ? this.graph.nodeByIndex[this.compiled.root] : this.nodeById(t.nodeId);
|
|
383
|
+
if (!node) return;
|
|
384
|
+
const slot = this.attachmentSlot(node, t.tag, t.ordinal);
|
|
385
|
+
if (slot === undefined) return;
|
|
386
|
+
if (op.attr === 'src') this.setAttachmentSrc(node, slot, op.value as string);
|
|
387
|
+
else this.setAttachmentProp(node, slot, op.attr, op.value);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private nodeById(id?: string): Object3D | undefined {
|
|
392
|
+
if (!id) return undefined;
|
|
393
|
+
const i = this.compiled.nodes.findIndex((n) => n.id === id);
|
|
394
|
+
return i >= 0 ? this.graph.nodeByIndex[i] : undefined;
|
|
395
|
+
}
|
|
396
|
+
private componentByTarget(nodeId: string | undefined, type: string, ordinal: number): Component | undefined {
|
|
397
|
+
if (!nodeId) return undefined;
|
|
398
|
+
const ni = this.compiled.nodes.findIndex((n) => n.id === nodeId);
|
|
399
|
+
if (ni < 0) return undefined;
|
|
400
|
+
const ci = this.compiled.nodes[ni]!.components.filter((x) => this.compiled.components[x]!.type === type)[ordinal];
|
|
401
|
+
if (ci === undefined) return undefined;
|
|
402
|
+
for (const [inst, idx] of this.graph.componentIndexOf) if (idx === ci) return inst;
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
private attachmentSlot(node: Object3D, tag: string, ordinal: number): number | undefined {
|
|
406
|
+
const ni = this.graph.nodeIndexOf.get(node);
|
|
407
|
+
if (ni === undefined) return undefined;
|
|
408
|
+
const atts = this.compiled.nodes[ni]!.attachments;
|
|
409
|
+
let count = 0;
|
|
410
|
+
for (let slot = 0; slot < atts.length; slot++) {
|
|
411
|
+
if (this.compiled.attachments[atts[slot]!]!.tag !== tag) continue;
|
|
412
|
+
if (count === ordinal) return slot;
|
|
413
|
+
count++;
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private attachmentTarget(i: number, slot: number, tag: string): EditTarget {
|
|
419
|
+
const node = this.compiled.nodes[i]!;
|
|
420
|
+
const ordinal = node.attachments.slice(0, slot + 1).filter((idx) => this.compiled.attachments[idx]!.tag === tag).length - 1;
|
|
421
|
+
return {
|
|
422
|
+
kind: 'attachment',
|
|
423
|
+
nodeIndex: i,
|
|
424
|
+
...(node.id ? { nodeId: node.id } : {}),
|
|
425
|
+
...(i === this.compiled.root ? { root: true } : {}),
|
|
426
|
+
tag,
|
|
427
|
+
ordinal,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private componentTarget(ci: number): EditTarget {
|
|
432
|
+
const cc = this.compiled.components[ci]!;
|
|
433
|
+
const node = this.compiled.nodes[cc.node]!;
|
|
434
|
+
const sameType = node.components.filter((x) => this.compiled.components[x]!.type === cc.type);
|
|
435
|
+
return {
|
|
436
|
+
kind: 'component',
|
|
437
|
+
componentIndex: ci,
|
|
438
|
+
nodeIndex: cc.node,
|
|
439
|
+
...(node.id ? { nodeId: node.id } : {}),
|
|
440
|
+
type: cc.type,
|
|
441
|
+
ordinal: sameType.indexOf(ci),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── value helpers ────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
function applyTransform(node: Object3D, channel: TransformChannel, v: number[]): void {
|
|
449
|
+
if (channel === 'rotation') node.rotation.set(v[0]!, v[1]!, v[2]!);
|
|
450
|
+
else node[channel].fromArray(v);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Compiled scalar → the Three.js-typed value a component field holds (mirrors instantiate). */
|
|
454
|
+
function toRuntime(type: PropertyType, value: number | boolean | string | number[]): unknown {
|
|
455
|
+
const elem = elementType(type);
|
|
456
|
+
if (isArrayType(type)) return (value as number[]).map((v) => toRuntimeScalar(elem, v));
|
|
457
|
+
return toRuntimeScalar(elem, value as number | boolean | string | number[]);
|
|
458
|
+
}
|
|
459
|
+
function toRuntimeScalar(elem: PropType | { new (): unknown } | EnumDef, value: unknown): unknown {
|
|
460
|
+
switch (elem) {
|
|
461
|
+
case 'vec2':
|
|
462
|
+
return new Vector2().fromArray(value as number[]);
|
|
463
|
+
case 'vec3':
|
|
464
|
+
return new Vector3().fromArray(value as number[]);
|
|
465
|
+
case 'euler': {
|
|
466
|
+
const e = value as number[];
|
|
467
|
+
return new Euler(e[0]!, e[1]!, e[2]!);
|
|
468
|
+
}
|
|
469
|
+
case 'color':
|
|
470
|
+
return new Color(value as number);
|
|
471
|
+
default:
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Clone a runtime value so undo snapshots don't alias live Three.js objects. */
|
|
477
|
+
function cloneRuntime(v: unknown): unknown {
|
|
478
|
+
if (v == null) return v;
|
|
479
|
+
const o = v as { isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; clone?: () => unknown };
|
|
480
|
+
if (o.isVector2 || o.isVector3 || o.isColor) return o.clone!();
|
|
481
|
+
if (o.isEuler) {
|
|
482
|
+
const e = v as Euler;
|
|
483
|
+
return new Euler(e.x, e.y, e.z, e.order);
|
|
484
|
+
}
|
|
485
|
+
return Array.isArray(v) ? v.map(cloneRuntime) : v;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const isSavableScalar = (v: unknown): boolean =>
|
|
489
|
+
typeof v === 'number' ||
|
|
490
|
+
typeof v === 'boolean' ||
|
|
491
|
+
typeof v === 'string' ||
|
|
492
|
+
(Array.isArray(v) && v.every((x) => typeof x === 'number'));
|
|
493
|
+
|
|
494
|
+
function numbersEqual(a: number[], b: number[]): boolean {
|
|
495
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
496
|
+
}
|
|
497
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
498
|
+
if (Array.isArray(a) && Array.isArray(b)) return numbersEqual(a as number[], b as number[]);
|
|
499
|
+
return a === b;
|
|
500
|
+
}
|
package/app/gizmo.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Camera, Object3D, Scene } from 'three';
|
|
2
|
+
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
|
3
|
+
|
|
4
|
+
export type GizmoMode = 'translate' | 'rotate' | 'scale';
|
|
5
|
+
|
|
6
|
+
export interface GizmoOptions {
|
|
7
|
+
/** Live refresh while dragging (e.g. update the inspector numbers). */
|
|
8
|
+
onObjectChange?: () => void;
|
|
9
|
+
/** Drag finished — commit the object's new transform as one undoable edit. */
|
|
10
|
+
onCommit?: (object: Object3D) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thin wrapper over three's `TransformControls` (translate/rotate/scale handles).
|
|
15
|
+
* The control mutates the attached `Object3D` directly during a drag; on drag end
|
|
16
|
+
* we hand the object to `onCommit`, which records a single undoable edit (the
|
|
17
|
+
* compiled scene was untouched during the drag, so it still holds the pre-drag
|
|
18
|
+
* value — exactly the right "before" for undo).
|
|
19
|
+
*
|
|
20
|
+
* Camera controls read `dragging` to stand down while a handle is being dragged,
|
|
21
|
+
* so the fly camera and the gizmo never fight over the same pointer/touch.
|
|
22
|
+
*/
|
|
23
|
+
export class Gizmo {
|
|
24
|
+
private readonly controls: TransformControls;
|
|
25
|
+
private readonly helper: Object3D;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
camera: Camera,
|
|
29
|
+
domElement: HTMLElement,
|
|
30
|
+
private readonly scene: Scene,
|
|
31
|
+
touch: boolean,
|
|
32
|
+
opts: GizmoOptions = {},
|
|
33
|
+
) {
|
|
34
|
+
this.controls = new TransformControls(camera, domElement);
|
|
35
|
+
this.controls.setSize(touch ? 1.4 : 1); // fat-finger handles on touch
|
|
36
|
+
this.helper = this.controls.getHelper();
|
|
37
|
+
this.helper.userData.scenocoEditorOnly = true; // keep it out of the Hierarchy + picking
|
|
38
|
+
scene.add(this.helper);
|
|
39
|
+
|
|
40
|
+
this.controls.addEventListener('objectChange', () => opts.onObjectChange?.());
|
|
41
|
+
this.controls.addEventListener('dragging-changed', (e) => {
|
|
42
|
+
// Fired true at drag start, false at drag end.
|
|
43
|
+
if (!e.value && this.controls.object) opts.onCommit?.(this.controls.object);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True while a handle is being dragged — camera controls check this to yield. */
|
|
48
|
+
get dragging(): boolean {
|
|
49
|
+
return this.controls.dragging;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setMode(mode: GizmoMode): void {
|
|
53
|
+
this.controls.setMode(mode);
|
|
54
|
+
}
|
|
55
|
+
get mode(): GizmoMode {
|
|
56
|
+
return this.controls.mode as GizmoMode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
attach(object: Object3D | null): void {
|
|
60
|
+
if (object) this.controls.attach(object);
|
|
61
|
+
else this.controls.detach();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True if `object` is part of the gizmo's own helper (so picks can ignore it). */
|
|
65
|
+
contains(object: Object3D): boolean {
|
|
66
|
+
for (let o: Object3D | null = object; o; o = o.parent) if (o === this.helper) return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dispose(): void {
|
|
71
|
+
this.controls.detach();
|
|
72
|
+
this.scene.remove(this.helper);
|
|
73
|
+
this.controls.dispose();
|
|
74
|
+
}
|
|
75
|
+
}
|
package/app/index.html
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>SceNoCo Editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
html, body { margin: 0; height: 100%; background: #15181f; overflow: hidden; color: #d6e2f0; }
|
|
9
|
+
canvas { display: block; width: 100%; height: 100%; }
|
|
10
|
+
:root { --panel: rgba(20, 24, 32, 0.92); --line: #2a3344; --accent: #4af0e0; }
|
|
11
|
+
/* Each side is a slide-out drawer: a panel + a thin arrow tab that stays on
|
|
12
|
+
screen when collapsed. */
|
|
13
|
+
.sc-drawer { position: fixed; top: 0; bottom: 0; z-index: 5; display: flex;
|
|
14
|
+
transition: transform 0.22s ease; }
|
|
15
|
+
.sc-left { left: 0; }
|
|
16
|
+
.sc-right { right: 0; }
|
|
17
|
+
.sc-left.collapsed { transform: translateX(calc(-100% + 22px)); }
|
|
18
|
+
.sc-right.collapsed { transform: translateX(calc(100% - 22px)); }
|
|
19
|
+
.sc-panel {
|
|
20
|
+
width: 280px; height: 100%; background: var(--panel); overflow: auto;
|
|
21
|
+
font: 12px/1.5 ui-monospace, monospace; backdrop-filter: blur(6px);
|
|
22
|
+
}
|
|
23
|
+
.sc-left .sc-panel { border-right: 1px solid var(--line); }
|
|
24
|
+
.sc-right .sc-panel { border-left: 1px solid var(--line); }
|
|
25
|
+
.sc-tab {
|
|
26
|
+
flex: 0 0 22px; background: var(--panel); color: #9fe7ff; cursor: pointer;
|
|
27
|
+
border: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line);
|
|
28
|
+
font-size: 15px; display: flex; align-items: center; justify-content: center;
|
|
29
|
+
}
|
|
30
|
+
.sc-tab:hover { color: var(--accent); background: rgba(74, 240, 224, 0.1); }
|
|
31
|
+
.sc-title { padding: 8px 12px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
|
|
32
|
+
text-transform: uppercase; font-size: 11px; border-bottom: 1px solid var(--line); position: sticky; top: 0;
|
|
33
|
+
background: var(--panel); }
|
|
34
|
+
.sc-node { display: flex; align-items: center; gap: 4px; min-height: 28px; padding: 2px 8px; cursor: pointer;
|
|
35
|
+
white-space: nowrap; overflow: hidden; }
|
|
36
|
+
.sc-node:hover { background: rgba(74, 240, 224, 0.08); }
|
|
37
|
+
.sc-node.sel { background: rgba(74, 240, 224, 0.18); color: #eafdff; }
|
|
38
|
+
.sc-node .ty { color: #6f93b8; }
|
|
39
|
+
.sc-caret { flex: 0 0 14px; width: 14px; text-align: center; color: #6f93b8; cursor: pointer; user-select: none; }
|
|
40
|
+
.sc-inspector { padding: 8px 10px; }
|
|
41
|
+
.sc-sec { display: flex; align-items: center; gap: 4px; margin: 10px 0 4px; color: #9fe7ff; font-weight: 700;
|
|
42
|
+
border-bottom: 1px solid var(--line); padding-bottom: 3px; cursor: pointer; user-select: none;
|
|
43
|
+
text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
|
|
44
|
+
.sc-sec.sc-sec-plain { cursor: default; }
|
|
45
|
+
.sc-sec .sc-caret { flex: 0 0 12px; width: 12px; }
|
|
46
|
+
.sc-sec-body { padding: 1px 0 2px; }
|
|
47
|
+
.sc-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; padding: 2px 0; }
|
|
48
|
+
.sc-row .k { color: #8fa6c4; flex: 0 0 auto; }
|
|
49
|
+
.sc-row .v { color: #e7f0fb; text-align: right; word-break: break-word; display: flex; align-items: center;
|
|
50
|
+
justify-content: flex-end; gap: 4px; }
|
|
51
|
+
.sc-empty { color: #6f8099; padding: 12px 0; }
|
|
52
|
+
/* editable controls — 16px font avoids iOS tap-zoom; generous hit areas for touch */
|
|
53
|
+
.sc-input { background: #0e1219; color: #e7f0fb; border: 1px solid var(--line); border-radius: 5px;
|
|
54
|
+
padding: 4px 6px; font: 16px ui-monospace, monospace; min-height: 28px; box-sizing: border-box; }
|
|
55
|
+
.sc-input:focus { outline: none; border-color: var(--accent); }
|
|
56
|
+
.sc-num { width: 64px; text-align: right; }
|
|
57
|
+
.sc-axes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
|
58
|
+
.sc-axis { display: flex; align-items: center; gap: 2px; }
|
|
59
|
+
.sc-axis-k { color: #6f93b8; font-size: 11px; width: 12px; text-align: center; }
|
|
60
|
+
.sc-scrub { cursor: ew-resize; touch-action: none; }
|
|
61
|
+
.sc-toggle { width: 22px; height: 22px; accent-color: var(--accent); }
|
|
62
|
+
.sc-color { display: flex; align-items: center; gap: 5px; }
|
|
63
|
+
.sc-swatch { width: 30px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 5px;
|
|
64
|
+
background: none; cursor: pointer; }
|
|
65
|
+
.sc-hex { width: 78px; }
|
|
66
|
+
.sc-select { min-width: 96px; cursor: pointer; }
|
|
67
|
+
#hint { position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); padding: 6px 14px;
|
|
68
|
+
border-radius: 8px; background: rgba(0,0,0,.55); color: #9fb5cf; font: 11px ui-monospace, monospace;
|
|
69
|
+
pointer-events: none; }
|
|
70
|
+
/* floating toolbars (touch-reachable, top center): play panel + movement panel */
|
|
71
|
+
.sc-toolbars { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 6;
|
|
72
|
+
display: flex; align-items: flex-start; gap: 8px; }
|
|
73
|
+
.sc-toolbar { display: flex; align-items: center; gap: 2px; padding: 5px; border-radius: 12px;
|
|
74
|
+
background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(6px); }
|
|
75
|
+
.sc-tbtn { position: relative; width: 40px; height: 40px; display: flex; align-items: center;
|
|
76
|
+
justify-content: center; padding: 0; border: 1px solid transparent; border-radius: 9px;
|
|
77
|
+
background: transparent; color: #cfe0f2; cursor: pointer; }
|
|
78
|
+
.sc-tbtn svg { width: 22px; height: 22px; display: block; }
|
|
79
|
+
.sc-tbtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
80
|
+
.sc-tbtn.active { background: rgba(74, 240, 224, 0.2); border-color: var(--accent); color: #eafdff; }
|
|
81
|
+
.sc-tbtn:disabled { opacity: 0.3; cursor: default; }
|
|
82
|
+
.sc-tbtn.sc-save.dirty { color: var(--accent); }
|
|
83
|
+
.sc-tbtn.sc-save.dirty::after { content: ''; position: absolute; top: 7px; right: 7px;
|
|
84
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
|
85
|
+
.sc-tbtn.sc-save.saving { opacity: 0.5; }
|
|
86
|
+
.sc-tsep { width: 1px; height: 24px; background: var(--line); margin: 0 3px; }
|
|
87
|
+
/* conflict banner: unsaved edits vs. an external file change */
|
|
88
|
+
#sc-conflict { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
|
|
89
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px;
|
|
90
|
+
background: rgba(40, 26, 26, 0.96); border: 1px solid #6b3a3a; color: #ffd9d9;
|
|
91
|
+
font: 13px ui-monospace, monospace; backdrop-filter: blur(6px); }
|
|
92
|
+
#sc-conflict button { border: 1px solid #6b3a3a; border-radius: 7px; background: transparent;
|
|
93
|
+
color: #ffd9d9; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
|
|
94
|
+
#sc-conflict button:first-of-type { background: var(--accent); color: #0e1219; border-color: var(--accent); }
|
|
95
|
+
#sc-conflict button:hover { filter: brightness(1.1); }
|
|
96
|
+
</style>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<canvas id="app"></canvas>
|
|
100
|
+
<div id="hint">tap to select · right-drag look · WASD move · Q/E down/up · G/R/Y gizmo move/rotate/scale</div>
|
|
101
|
+
<script type="module" src="/main.ts"></script>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|