@driftengine/editor 3.61.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.
@@ -0,0 +1,117 @@
1
+ /** What a node with no name of its own is called. */
2
+ const UNNAMED = 'node';
3
+ export class SceneTree {
4
+ /** What is selected, or null. Assigning is fine; nothing here caches it. */
5
+ selected = null;
6
+ names = new Map();
7
+ collapsed = new Set();
8
+ /**
9
+ * Rows, reused.
10
+ *
11
+ * `rowCount` is how many of them are live, so a rebuild that produces fewer rows than the last
12
+ * one does not allocate and does not have to clear the tail. `rows` slices to the live ones.
13
+ */
14
+ pool = [];
15
+ rowCount = 0;
16
+ live = [];
17
+ /** The rows the last `rebuild` produced, in the order they draw. */
18
+ get rows() {
19
+ return this.live;
20
+ }
21
+ /** What a node is called. Nodes are `node` until something names them. */
22
+ name(node) {
23
+ return this.names.get(node) ?? UNNAMED;
24
+ }
25
+ setName(node, name) {
26
+ this.names.set(node, name);
27
+ }
28
+ /** Whether a node's children are showing. Everything starts expanded. */
29
+ expanded(node) {
30
+ return !this.collapsed.has(node);
31
+ }
32
+ setExpanded(node, expanded) {
33
+ if (expanded)
34
+ this.collapsed.delete(node);
35
+ else
36
+ this.collapsed.add(node);
37
+ }
38
+ toggle(node) {
39
+ this.setExpanded(node, !this.expanded(node));
40
+ }
41
+ /**
42
+ * Walk the hierarchy into rows, honouring collapse.
43
+ *
44
+ * A collapsed node contributes its own row and nothing beneath it, which is what collapsing is;
45
+ * its children keep whatever expansion state they had, so expanding a branch again shows what was
46
+ * showing before rather than a flattened one.
47
+ *
48
+ * Cheap enough to call every frame, and it allocates nothing once the deepest tree it has seen
49
+ * has been seen.
50
+ */
51
+ rebuild(root) {
52
+ this.rowCount = 0;
53
+ this.visit(root, 0);
54
+ this.live.length = 0;
55
+ for (let at = 0; at < this.rowCount; at++)
56
+ this.live.push(this.pool[at]);
57
+ }
58
+ /** Where a node sits in the last rebuild's rows, or `-1`. */
59
+ rowOf(node) {
60
+ for (let at = 0; at < this.live.length; at++) {
61
+ if (this.live[at].node === node)
62
+ return at;
63
+ }
64
+ return -1;
65
+ }
66
+ /**
67
+ * Move a node under a new parent. `false` where the hierarchy refuses.
68
+ *
69
+ * **The refusal is `SceneNode`'s and is not re-implemented here**, which was checked rather than
70
+ * assumed: `attachChild` already rejects a node as its own child and any ancestor as its own
71
+ * descendant. What this adds is answering rather than throwing, because the caller is a drag in a
72
+ * frame and an exception there takes down the frame somebody is dragging in.
73
+ */
74
+ reparent(child, parent) {
75
+ try {
76
+ parent.attachChild(child);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ /** Forget a node's name and expansion, for a node that has left the tree. */
84
+ forget(node) {
85
+ this.names.delete(node);
86
+ this.collapsed.delete(node);
87
+ if (this.selected === node)
88
+ this.selected = null;
89
+ }
90
+ visit(node, depth) {
91
+ const row = this.rowAt(this.rowCount);
92
+ this.rowCount += 1;
93
+ row.node = node;
94
+ row.depth = depth;
95
+ row.hasChildren = node.children.length > 0;
96
+ row.expanded = this.expanded(node);
97
+ row.name = this.name(node);
98
+ if (!row.expanded)
99
+ return;
100
+ for (const child of node.children)
101
+ this.visit(child, depth + 1);
102
+ }
103
+ rowAt(index) {
104
+ const existing = this.pool[index];
105
+ if (existing !== undefined)
106
+ return existing;
107
+ const made = {
108
+ node: undefined,
109
+ depth: 0,
110
+ hasChildren: false,
111
+ expanded: true,
112
+ name: UNNAMED,
113
+ };
114
+ this.pool.push(made);
115
+ return made;
116
+ }
117
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@driftengine/editor",
3
+ "version": "3.61.0",
4
+ "description": "The model of a scene editor: a tree of what exists, an inspector over what is selected, and play-in-editor",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "peerDependencies": {
30
+ "@driftengine/core": "3.61.0",
31
+ "@driftengine/entities": "3.61.0",
32
+ "@driftengine/ui2d": "3.61.0"
33
+ },
34
+ "author": "Drift Technologies",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/drftrun/driftengine.git",
38
+ "directory": "packages/editor"
39
+ },
40
+ "homepage": "https://github.com/drftrun/driftengine#readme",
41
+ "bugs": "https://github.com/drftrun/driftengine/issues",
42
+ "keywords": [
43
+ "driftengine",
44
+ "3d",
45
+ "webgl",
46
+ "webgpu",
47
+ "typescript",
48
+ "scene-editor",
49
+ "inspector",
50
+ "gizmo",
51
+ "play-in-editor"
52
+ ],
53
+ "engines": {
54
+ "node": ">=22.12.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Selection, the gizmo, and play-in-editor.
3
+ *
4
+ * **The loop already has the seam this needs.** `LoopHooks.shouldSimulate` exists so a paused game
5
+ * keeps rendering — the world stays on screen behind the menu instead of freezing on whatever frame
6
+ * it stopped at — and that is exactly what an editor wants. `shouldSimulate` here is handed to
7
+ * `startLoop` and is the whole of pause.
8
+ *
9
+ * ## What makes stop harder than it looks
10
+ *
11
+ * **`deserializeWorld` creates entities.** It does not replace a world's contents; it appends to
12
+ * them. So restoring a snapshot by loading it leaves the world holding both what was authored and
13
+ * what play produced, which is not a restore — it is a duplication that grows every time somebody
14
+ * presses stop. This clears first, walking the same `store.dense` per type that `serializeWorld`
15
+ * walks to decide what is live.
16
+ *
17
+ * **And every entity handle changes across a stop**, because the restored entities are new ones. A
18
+ * selection held as a handle is stale the moment play ends — pointing at nothing, or at whatever
19
+ * reused the slot, which is the worse of the two because it looks like it worked. So an entity
20
+ * selection is remembered by its *index in the snapshot*, which is the only identity that survives,
21
+ * and an entity created during play has no index and the selection is cleared rather than moved to
22
+ * a stranger.
23
+ *
24
+ * **What a snapshot covers is the component types it was given and nothing else.** Not the node
25
+ * hierarchy, not a physics world, not an audio graph. `serializeWorld` makes the same point about
26
+ * types: a scene is a decision about what to save.
27
+ */
28
+ import { Gizmo } from '@driftengine/core';
29
+ import type { SceneNode } from '@driftengine/core';
30
+ import { deserializeWorld, serializeWorld } from '@driftengine/entities';
31
+ import type { ComponentType, Entity, SerializedScene, World } from '@driftengine/entities';
32
+ import { Inspector } from './inspector.ts';
33
+ import { SceneTree } from './sceneTree.ts';
34
+
35
+ export type EditorMode = 'edit' | 'play' | 'paused';
36
+
37
+ export interface EditorWorld {
38
+ readonly world: World;
39
+ /** What a snapshot covers. See the header: this is a decision, not a discovery. */
40
+ readonly types: readonly ComponentType[];
41
+ }
42
+
43
+ export class EditorHost {
44
+ readonly tree = new SceneTree();
45
+ readonly inspector = new Inspector();
46
+ readonly gizmo = new Gizmo();
47
+
48
+ private state: EditorMode = 'edit';
49
+ private readonly bound: EditorWorld | null;
50
+ private snapshot: SerializedScene | null = null;
51
+ /** Where the selected entity sat in the snapshot, so it can be found again after a restore. */
52
+ private selectedIndex = -1;
53
+ private selectedEntity: Entity | null = null;
54
+ /** Consumed by the next `shouldSimulate`, which is what makes a step exactly one tick. */
55
+ private pendingStep = false;
56
+
57
+ constructor(bound: EditorWorld | null = null) {
58
+ this.bound = bound;
59
+ }
60
+
61
+ get mode(): EditorMode {
62
+ return this.state;
63
+ }
64
+
65
+ /** The entity selected, or null. Cleared by a stop that did not restore it. */
66
+ get entity(): Entity | null {
67
+ return this.selectedEntity;
68
+ }
69
+
70
+ /**
71
+ * Hand this to `startLoop` as `shouldSimulate`.
72
+ *
73
+ * **It has a side effect and that is the point**: a pending step is consumed here, so exactly one
74
+ * tick runs and the next call answers false. A step expressed as a duration would advance a
75
+ * variable number of ticks depending on which frame it landed in, which is the opposite of what
76
+ * stepping is for.
77
+ */
78
+ shouldSimulate = (): boolean => {
79
+ if (this.state === 'play') return true;
80
+ if (this.pendingStep) {
81
+ this.pendingStep = false;
82
+ return true;
83
+ }
84
+ return false;
85
+ };
86
+
87
+ /** Select a node, point the gizmo at it, and show its transform. */
88
+ select(node: SceneNode | null): void {
89
+ this.tree.selected = node;
90
+ this.selectedEntity = null;
91
+ this.selectedIndex = -1;
92
+ if (node === null) {
93
+ this.inspector.showNothing();
94
+ return;
95
+ }
96
+ this.inspector.showNode(node);
97
+ this.syncGizmo();
98
+ }
99
+
100
+ /**
101
+ * Select an entity, and remember where it sits in the snapshot if one is open.
102
+ *
103
+ * The index is taken now rather than at stop, because at stop the entity may already be gone and
104
+ * there would be nothing left to look it up by.
105
+ */
106
+ selectEntity(entity: Entity | null): void {
107
+ this.tree.selected = null;
108
+ this.selectedEntity = entity;
109
+ this.selectedIndex = entity === null ? -1 : this.indexInSnapshot(entity);
110
+ if (entity === null || this.bound === null) {
111
+ this.inspector.showNothing();
112
+ return;
113
+ }
114
+ this.inspector.showEntity(this.bound.world, entity, this.bound.types);
115
+ }
116
+
117
+ /** Move the gizmo onto the selection. A selection of nothing leaves it where it was. */
118
+ syncGizmo(): void {
119
+ const node = this.tree.selected;
120
+ if (node === null) return;
121
+ this.gizmo.position.set(node.position);
122
+ this.gizmo.rotation.set(node.rotation);
123
+ this.gizmo.scale.set(node.scale);
124
+ }
125
+
126
+ /**
127
+ * Write the gizmo's transform onto the selection, and refresh what the inspector shows.
128
+ *
129
+ * Through the node's setters, for the reason `inspector.ts` gives: writing the arrays leaves the
130
+ * world matrix stale and the node draws where it used to be.
131
+ */
132
+ applyGizmo(): void {
133
+ const node = this.tree.selected;
134
+ if (node === null) return;
135
+ node.setPosition(
136
+ this.gizmo.position[0] as number,
137
+ this.gizmo.position[1] as number,
138
+ this.gizmo.position[2] as number,
139
+ );
140
+ node.setScale(
141
+ this.gizmo.scale[0] as number,
142
+ this.gizmo.scale[1] as number,
143
+ this.gizmo.scale[2] as number,
144
+ );
145
+ node.rotation.set(this.gizmo.rotation);
146
+ node.markMoved();
147
+ this.inspector.refresh();
148
+ }
149
+
150
+ /**
151
+ * Enter play, taking a snapshot first. `false` when there is no world bound to snapshot.
152
+ *
153
+ * Playing from `paused` resumes without re-snapshotting, so pausing does not throw away the state
154
+ * stop would have returned to.
155
+ */
156
+ play(): boolean {
157
+ if (this.state === 'paused') {
158
+ this.state = 'play';
159
+ return true;
160
+ }
161
+ if (this.bound === null) return false;
162
+ this.snapshot = serializeWorld(this.bound.world, this.bound.types);
163
+ if (this.selectedEntity !== null)
164
+ this.selectedIndex = this.indexInSnapshot(this.selectedEntity);
165
+ this.state = 'play';
166
+ return true;
167
+ }
168
+
169
+ pause(): void {
170
+ if (this.state === 'play') this.state = 'paused';
171
+ }
172
+
173
+ /** One tick, then paused. Does nothing outside play mode, where there is nothing to step. */
174
+ step(): void {
175
+ if (this.state === 'edit') return;
176
+ this.state = 'paused';
177
+ this.pendingStep = true;
178
+ }
179
+
180
+ /**
181
+ * Leave play and put the world back. `false` when there is nothing to put back.
182
+ *
183
+ * See the header for why this is a clear and then a load rather than a load.
184
+ */
185
+ stop(): boolean {
186
+ if (this.bound === null || this.snapshot === null) {
187
+ this.state = 'edit';
188
+ return false;
189
+ }
190
+ this.clearWorld();
191
+ const result = deserializeWorld(this.bound.world, this.snapshot, this.bound.types);
192
+ this.state = 'edit';
193
+ this.pendingStep = false;
194
+ if (!result.loaded) return false;
195
+
196
+ /* The selection follows its index, which is the only identity that survived. An entity that was
197
+ created during play has none, and the selection is emptied rather than pointed at a stranger. */
198
+ const restored = this.selectedIndex >= 0 ? result.entities[this.selectedIndex] : undefined;
199
+ this.selectEntity(restored ?? null);
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Every entity any of the bound types holds, destroyed.
205
+ *
206
+ * The same walk `serializeWorld` does, and for the same reason: a world's live set is what its
207
+ * stores hold, and there is no other enumeration of it. Collected before destroying, because
208
+ * destroying while walking a store's dense array moves entries under the walk.
209
+ */
210
+ private clearWorld(): void {
211
+ if (this.bound === null) return;
212
+ const { world, types } = this.bound;
213
+ const seen = new Set<Entity>();
214
+ for (const type of types) {
215
+ const store = world.store(type);
216
+ for (let at = 0; at < store.size; at++) seen.add(store.dense[at] as Entity);
217
+ }
218
+ for (const entity of seen) world.destroy(entity);
219
+ }
220
+
221
+ /** Where an entity sits in the snapshot's ordering, or `-1`. */
222
+ private indexInSnapshot(entity: Entity): number {
223
+ if (this.bound === null) return -1;
224
+ const { world, types } = this.bound;
225
+ const seen = new Set<Entity>();
226
+ let index = 0;
227
+ for (const type of types) {
228
+ const store = world.store(type);
229
+ for (let at = 0; at < store.size; at++) {
230
+ const candidate = store.dense[at] as Entity;
231
+ if (seen.has(candidate)) continue;
232
+ if (candidate === entity) return index;
233
+ seen.add(candidate);
234
+ index += 1;
235
+ }
236
+ }
237
+ return -1;
238
+ }
239
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/editor` — the model of a scene editor.
4
+ *
5
+ * A tree of what exists, an inspector over what is selected, and play-in-editor. No pixels: the
6
+ * panel builders produce `@driftengine/ui2d` nodes and the consumer draws them, the way `DebugLines`
7
+ * produces segments and `drawLines` draws them.
8
+ *
9
+ * **A package rather than part of core**, because it imports `@driftengine/entities` and core does
10
+ * not — that package imports no other engine package, deliberately, and having core reach it to
11
+ * serve an editor would invert that for every game that never opens one.
12
+ */
13
+ export { SceneTree } from './sceneTree.ts';
14
+ export type { TreeRow } from './sceneTree.ts';
15
+ export { Inspector, fieldKindOf } from './inspector.ts';
16
+ export type { FieldKind, InspectorField } from './inspector.ts';
17
+ export { EditorHost } from './editorHost.ts';
18
+ export type { EditorMode, EditorWorld } from './editorHost.ts';
19
+ export {
20
+ FIELD_ROW_PREFIX,
21
+ TREE_ROW_PREFIX,
22
+ buildInspectorPanel,
23
+ buildTreePanel,
24
+ formatValue,
25
+ rowIndexOf,
26
+ } from './panel.ts';