@volter/editor-game 0.5.67 → 0.5.68

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,95 @@
1
+ /**
2
+ * BUILD PLAYER (`workspace.document`) — the project's last build, run as a
3
+ * player runs it: its own `dist/`, served by the editor server, in a frame
4
+ * with nothing of the editor inside it. Build Profiles' Build And Run opens
5
+ * it, as Unity's Build And Run launches the player and Godot's Web export
6
+ * runs in a browser; each run reloads the frame.
7
+ *
8
+ * What the build prints reaches the editor's console, prefixed
9
+ * `[Build Player]`, as a Godot export run with remote debug reports into the
10
+ * editor's debugger: its console calls, and its uncaught errors. The capture
11
+ * door photographs the build itself (`framed-document-capture.ts`).
12
+ */
13
+
14
+ import type { ToolContributionProps } from '@volter/editor-sdk/contributions';
15
+ import { registerFramedCapture } from '@volter/editor-sdk/kit/framed-document-capture';
16
+ import { useSyncExternalStore } from 'react';
17
+ import { BUILD_PLAYER_URL, buildRunCount, subscribeBuildRuns } from '../src/build/build-session';
18
+
19
+ export const point = 'workspace.document';
20
+ export const title = 'Build Player';
21
+
22
+ const FORWARDED = ['log', 'info', 'warn', 'error'] as const;
23
+
24
+ const HOOKED = Symbol('build-player-output');
25
+
26
+ /** Hook the player's document once it exists: the build's module scripts run
27
+ * before `load`, after its HTML is parsed and while their bundle is fetched,
28
+ * so the frame is watched from insertion until its document is the build's. */
29
+ function watchFrame(frame: HTMLIFrameElement | null): void {
30
+ if (!frame) return;
31
+ const tick = () => {
32
+ if (!frame.isConnected) return;
33
+ const player = frame.contentWindow as (Window & typeof globalThis & { [HOOKED]?: true }) | null;
34
+ if (player?.location.pathname === new URL(BUILD_PLAYER_URL, location.href).pathname) {
35
+ if (!player[HOOKED]) {
36
+ forwardOutput(player);
37
+ registerFramedCapture(frame, {
38
+ container: () => player.document.getElementById('game-canvas') ?? player.document.body,
39
+ canvasFrame: (canvas) => copyAfterRender(player, canvas),
40
+ });
41
+ }
42
+ if (player.document.readyState === 'complete') return;
43
+ }
44
+ setTimeout(tick, 0);
45
+ };
46
+ tick();
47
+ }
48
+
49
+ /** A copy of `canvas` taken in the player's next frame, after the game's own
50
+ * frame callbacks (registered a frame earlier) have drawn it. */
51
+ function copyAfterRender(player: Window & typeof globalThis, canvas: HTMLCanvasElement): Promise<CanvasImageSource | null> {
52
+ return new Promise((resolve) => {
53
+ player.requestAnimationFrame(() => {
54
+ const copy = player.document.createElement('canvas');
55
+ copy.width = canvas.width;
56
+ copy.height = canvas.height;
57
+ const context = copy.getContext('2d');
58
+ if (!context) return resolve(null);
59
+ context.drawImage(canvas, 0, 0);
60
+ resolve(copy);
61
+ });
62
+ });
63
+ }
64
+
65
+ function forwardOutput(player: Window & typeof globalThis & { [HOOKED]?: true }): void {
66
+ player[HOOKED] = true;
67
+ for (const level of FORWARDED) {
68
+ const own = player.console[level].bind(player.console);
69
+ player.console[level] = (...args: unknown[]) => {
70
+ own(...args);
71
+ console[level]('[Build Player]', ...args);
72
+ };
73
+ }
74
+ player.addEventListener('error', (event) => {
75
+ console.error('[Build Player] Uncaught', event.error ?? event.message);
76
+ });
77
+ player.addEventListener('unhandledrejection', (event) => {
78
+ console.error('[Build Player] Unhandled rejection', event.reason);
79
+ });
80
+ console.info(`[Build Player] Running ${player.document.title || 'the build'}`);
81
+ }
82
+
83
+ export default function BuildPlayerDocument(_props: ToolContributionProps) {
84
+ const run = useSyncExternalStore(subscribeBuildRuns, buildRunCount);
85
+ return (
86
+ <iframe
87
+ key={run}
88
+ title="Build Player"
89
+ data-build-player={run}
90
+ src={`${BUILD_PLAYER_URL}?run=${run}`}
91
+ ref={watchFrame}
92
+ style={{ width: '100%', height: '100%', border: 0, display: 'block', background: '#000' }}
93
+ />
94
+ );
95
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-game",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only",
5
- "version": "0.5.67",
5
+ "version": "0.5.68",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -42,6 +42,7 @@
42
42
  "./contributions/autoplay.service.ts",
43
43
  "./contributions/bridge.command.ts",
44
44
  "./contributions/build-profiles.document.tsx",
45
+ "./contributions/build-player.document.tsx",
45
46
  "./contributions/build-progress.status.tsx",
46
47
  "./contributions/build.action.ts",
47
48
  "./contributions/build.command.ts",
@@ -100,11 +101,11 @@
100
101
  "serving": "./dist-node/serving.mjs"
101
102
  },
102
103
  "dependencies": {
103
- "@volter/editor-sdk": "0.5.67",
104
- "@volter/editor-project": "0.5.67",
105
- "@volter/editor-threejs": "0.5.67",
106
- "@volter/threejs-runtime": "0.5.67",
107
- "@volter/game-runtime": "0.5.67",
104
+ "@volter/editor-sdk": "0.5.68",
105
+ "@volter/editor-project": "0.5.68",
106
+ "@volter/editor-threejs": "0.5.68",
107
+ "@volter/threejs-runtime": "0.5.68",
108
+ "@volter/game-runtime": "0.5.68",
108
109
  "@colyseus/msgpackr": "^1.11.3",
109
110
  "@colyseus/schema": "^4.0.14",
110
111
  "@colyseus/sdk": "^0.17.34",
@@ -137,7 +138,7 @@
137
138
  "xstate": "^5.32.4",
138
139
  "zod": "^4.3.6",
139
140
  "@fortawesome/fontawesome-svg-core": "^7.2.0",
140
- "@volter/editor-react": "0.5.67"
141
+ "@volter/editor-react": "0.5.68"
141
142
  },
142
143
  "peerDependencies": {
143
144
  "@sparkjsdev/spark": "^2.1.0",
@@ -32,6 +32,7 @@ import {
32
32
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
33
33
  import {
34
34
  type BuildTarget,
35
+ buildAndRun,
35
36
  buildSessionVersion,
36
37
  cancelBuild,
37
38
  getBuildSession,
@@ -331,6 +332,16 @@ export function BuildProfilesPanel() {
331
332
  >
332
333
  {phase === 'done' ? 'Build Again' : 'Build'}
333
334
  </Button>
335
+ <Button
336
+ data-testid="build-profiles-build-and-run"
337
+ onClick={() => {
338
+ if (!selected) return;
339
+ if (phase === 'done') resetBuildSession();
340
+ void buildAndRun(selected);
341
+ }}
342
+ >
343
+ Build And Run
344
+ </Button>
334
345
  </>
335
346
  )}
336
347
  </div>
@@ -17,6 +17,7 @@ import {
17
17
  } from '../host/api/configurations';
18
18
  import type { EditorHostOutputDiagnostic } from '@volter/editor-sdk/host';
19
19
  import { editorHost } from '@volter/editor-sdk/host';
20
+ import { BASE } from '@volter/editor-sdk/kit/api-base';
20
21
  import type { BuildReport } from '@volter/editor-sdk/session/build-report';
21
22
  import { useEffect, useState } from 'react';
22
23
  import { formatBytes } from './format-bytes';
@@ -34,6 +35,40 @@ export function openBuildProfilesDocument(): boolean {
34
35
  return editorHost().workspace.openContributedDocument(BUILD_PROFILES_DOCUMENT_ID);
35
36
  }
36
37
 
38
+ /** The Build Player document's contribution id (`build-player.document.tsx`). */
39
+ export const BUILD_PLAYER_DOCUMENT_ID = 'build-player.document';
40
+
41
+ /** Where the editor server serves the project's built `dist/` (`routes/configurations.ts`). */
42
+ export const BUILD_PLAYER_URL = `${BASE}/build/web/index.html`;
43
+
44
+ let _runs = 0;
45
+ const _runListeners = new Set<() => void>();
46
+
47
+ /** How many times the build has been run: the player reloads on each. */
48
+ export function buildRunCount(): number {
49
+ return _runs;
50
+ }
51
+
52
+ export function subscribeBuildRuns(fn: () => void): () => void {
53
+ _runListeners.add(fn);
54
+ return () => {
55
+ _runListeners.delete(fn);
56
+ };
57
+ }
58
+
59
+ /** Run the last build in the Build Player document, fresh. */
60
+ export function runBuild(): boolean {
61
+ _runs++;
62
+ for (const fn of _runListeners) fn();
63
+ return editorHost().workspace.openContributedDocument(BUILD_PLAYER_DOCUMENT_ID);
64
+ }
65
+
66
+ /** Unity's Build And Run: build, and run the result when the build succeeds. */
67
+ export async function buildAndRun(target: BuildTarget): Promise<void> {
68
+ await startBuild(target);
69
+ if (_session.phase === 'done' && _session.result?.ok) runBuild();
70
+ }
71
+
37
72
  /** A build-role configuration's id (ARCHITECTURE-CORE §The project model). */
38
73
  export type BuildTarget = string;
39
74
  export type BuildPhase = 'idle' | 'building' | 'done';
@@ -74,7 +74,8 @@ import {
74
74
  } from '@volter/editor-sdk/kit/workspace-document-registry';
75
75
  import { storyBoardPresentation } from '@volter/editor-sdk/kit/stories/story-presentation';
76
76
  import { getProjectStoryModules, subscribeProjectStoryModules } from '@volter/editor-sdk/kit/stories/story-registry';
77
- import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
77
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
78
+ import type { AuthoringAdapter, EditorNode } from '@volter/editor-project/adapter';
78
79
  import {
79
80
  buildCanvasBoard,
80
81
  type CanvasBoard,
@@ -113,6 +114,8 @@ interface CanvasBoardPresentation {
113
114
  zoomAt(clientX: number, clientY: number): boolean;
114
115
  /** The exhibit the board is currently showing as active, for the Inspector. */
115
116
  activeStoryId(): string | null;
117
+ /** Select one frame by its id, as a click on it does (the layers list's row). */
118
+ selectStory(storyId: string): boolean;
116
119
  dispose(): void;
117
120
  }
118
121
 
@@ -131,6 +134,53 @@ interface CanvasBoardPresentation {
131
134
  * An ungrouped exhibit (no district) carries no title and falls back to the
132
135
  * substrate's own module-path derivation, exactly like an untitled dom story.
133
136
  */
137
+ /**
138
+ * THE BOARD'S LAYERS — Figma's layers panel lists a page's frames; this adapter hands the
139
+ * Hierarchy the board's frames the same way, each with its district beside its name. It owns no
140
+ * transform, inspector or structure: a frame is chosen here, and choosing one is the same act as
141
+ * clicking it on the board.
142
+ */
143
+ function canvasBoardAdapter(
144
+ board: CanvasBoard,
145
+ selected: () => string | null,
146
+ select: (storyId: string) => void,
147
+ ): AuthoringAdapter & { notify(): void } {
148
+ const listeners = new Set<() => void>();
149
+ const nodes: EditorNode[] = boardRows(board).map((row) => ({
150
+ id: row.id,
151
+ label: row.label,
152
+ ...(row.title ? { secondaryLabel: row.title } : {}),
153
+ kind: 'frame',
154
+ typeLabel: 'Frame',
155
+ parentId: null,
156
+ childIds: [],
157
+ }));
158
+ return {
159
+ capabilities: { transform: false, inspectorFields: false, persist: false },
160
+ hierarchy: {
161
+ roots: () => nodes,
162
+ node: (id) => nodes.find((node) => node.id === id) ?? null,
163
+ },
164
+ selection: {
165
+ get: () => {
166
+ const id = selected();
167
+ return id ? [id] : [];
168
+ },
169
+ set: (ids) => {
170
+ const id = ids.at(-1);
171
+ if (id) select(id);
172
+ },
173
+ },
174
+ subscribe(listener) {
175
+ listeners.add(listener);
176
+ return () => listeners.delete(listener);
177
+ },
178
+ notify() {
179
+ for (const listener of listeners) listener();
180
+ },
181
+ };
182
+ }
183
+
134
184
  function boardRows(board: CanvasBoard): ReactStoryBoardStory[] {
135
185
  return [
136
186
  ...board.exhibits.map((exhibit) => ({
@@ -305,6 +355,13 @@ function presentCanvasBoard(
305
355
  activeStoryId(): string | null {
306
356
  return selectedStoryId;
307
357
  },
358
+ selectStory(storyId: string): boolean {
359
+ if (!rows.some((row) => row.id === storyId)) return false;
360
+ selectedStoryId = storyId;
361
+ storyBoard?.activate(storyId, false);
362
+ onSelectionChanged?.();
363
+ return true;
364
+ },
308
365
  zoomAt(clientX: number, clientY: number): boolean {
309
366
  return storyBoard?.zoomAtClientPoint(clientX, clientY) ?? false;
310
367
  },
@@ -446,13 +503,29 @@ function CanvasBoardContent({ active }: WorkspaceDocumentContentProps) {
446
503
  // never reach the no-selection seam at all
447
504
  // (`workspace-document-registry.ts`'s `WorkspaceDocumentSelection`, and
448
505
  // `components/AssetEditorShell.tsx`, which registers the same pair).
506
+ // The layers list: the board's frames through their own adapter. The node stays null so the
507
+ // Inspector keeps describing the chosen frame through the subject published below.
508
+ const boardAdapter = useMemo(
509
+ () =>
510
+ board
511
+ ? canvasBoardAdapter(
512
+ board,
513
+ () => presentationRef.current?.activeStoryId() ?? null,
514
+ (storyId) => presentationRef.current?.selectStory(storyId),
515
+ )
516
+ : null,
517
+ [board],
518
+ );
519
+ useEffect(() => {
520
+ boardAdapter?.notify();
521
+ }, [boardAdapter, selectedStoryId]);
449
522
  useEffect(() => {
450
523
  if (!active) return;
451
524
  return registerWorkspaceDocumentSelection(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
452
- adapter: null,
525
+ adapter: boardAdapter,
453
526
  nodeId: null,
454
527
  }));
455
- }, [active]);
528
+ }, [active, boardAdapter]);
456
529
  useEffect(() => {
457
530
  if (!active || !selectedExhibit) return;
458
531
  return publishDocumentInspectionSubject(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
@@ -82,8 +82,10 @@ import {
82
82
  readContainerAuthoringLabel,
83
83
  } from './pixi-source-identity';
84
84
  import { CanvasStructureHistory } from './pixi-structure-history';
85
+ import { PIXI_CREATABLE_KINDS } from './pixi-creatable-kinds';
85
86
  import { fromNeutralTransform, toNeutralTransform } from './pixi-transform-channels';
86
87
  import { resolvesLiveOnly, runWritePipe } from '@volter/editor-sdk/kit/write-pipe';
88
+ import { editorHost } from '@volter/editor-sdk/host';
87
89
 
88
90
  /**
89
91
  * What a write target is handed at construction — the live view it edits
@@ -302,12 +304,19 @@ export interface PixiAuthoringOptions {
302
304
  * `Texture.WHITE` and a size; a graphics gets a drawn rect): a node the author
303
305
  * cannot see is indistinguishable from a create that silently failed.
304
306
  */
305
- const CANVAS_CREATABLE_KINDS: readonly { kind: string; label: string }[] = [
306
- { kind: 'container', label: 'Container' },
307
- { kind: 'sprite', label: 'Sprite' },
308
- { kind: 'text', label: 'Text' },
309
- { kind: 'graphics', label: 'Graphics' },
310
- ];
307
+ /** The project-local section holding each canvas world's locked and grouped label paths. */
308
+ const EDIT_LOCKS_SECTION = 'canvasEditLocks';
309
+ type EditLocks = Record<string, { readonly locked: readonly string[]; readonly grouped: readonly string[] }>;
310
+
311
+ function readEditLocks(): EditLocks {
312
+ return editorHost().projectLocalState.read<EditLocks>(EDIT_LOCKS_SECTION) ?? {};
313
+ }
314
+
315
+ function writeEditLocks(world: string, locks: EditLocks[string]): void {
316
+ editorHost().projectLocalState.write(EDIT_LOCKS_SECTION, { ...readEditLocks(), [world]: locks });
317
+ }
318
+
319
+ const CANVAS_CREATABLE_KINDS = PIXI_CREATABLE_KINDS;
311
320
 
312
321
  /** What a canvas world can take from the asset browser: an image becomes a
313
322
  * Sprite. A model or an audio file has no display object to become here. */
@@ -417,6 +426,19 @@ function createDisplayObject(pixi: CanvasPixiNamespace, kind: string): Container
417
426
  return new pixi.Text({ text: 'Text', style: { fill: 0xffffff, fontSize: 24 } });
418
427
  case 'graphics':
419
428
  return new pixi.Graphics().rect(0, 0, 100, 100).fill(0xffffff);
429
+ case 'animatedSprite': {
430
+ const animated = new pixi.AnimatedSprite([pixi.Texture.WHITE]);
431
+ animated.setSize(64, 64);
432
+ return animated;
433
+ }
434
+ case 'tilingSprite':
435
+ return new pixi.TilingSprite({ texture: pixi.Texture.WHITE, width: 100, height: 100 });
436
+ case 'nineSliceSprite':
437
+ return new pixi.NineSliceSprite({ texture: pixi.Texture.WHITE, width: 100, height: 100 });
438
+ case 'bitmapText':
439
+ return new pixi.BitmapText({ text: 'BitmapText', style: { fill: 0xffffff, fontSize: 24 } });
440
+ case 'htmlText':
441
+ return new pixi.HTMLText({ text: 'HTMLText', style: { fill: 0xffffff, fontSize: 24 } });
420
442
  default:
421
443
  return null;
422
444
  }
@@ -490,6 +512,35 @@ interface CanvasBoxEditSession {
490
512
  readonly id: string;
491
513
  readonly transform: Transform;
492
514
  readonly rect: { x: number; y: number; width: number; height: number };
515
+ /** The parent's world matrix's linear part (Pixi's a, b, c, d), when the node has a parent. */
516
+ readonly parentLinear?: { a: number; b: number; c: number; d: number };
517
+ }
518
+
519
+ /**
520
+ * The local turn, in degrees, that turns the node's own x axis by `degrees` on screen. Godot measures
521
+ * a rotate drag in the parent's space; a turn added straight to the local rotation goes backwards
522
+ * under a mirrored parent and by the wrong amount under an unevenly scaled one.
523
+ */
524
+ function localTurn(session: CanvasBoxEditSession, degrees: number): number {
525
+ const m = session.parentLinear;
526
+ if (!m) return degrees;
527
+ const det = m.a * m.d - m.b * m.c;
528
+ if (Math.abs(det) < 1e-9) return degrees;
529
+ const [, , z, w] = session.transform.rotation;
530
+ const r = 2 * Math.atan2(z, w);
531
+ // The node's x axis on screen, turned by the drag, taken back into the parent's space.
532
+ const vx = m.a * Math.cos(r) + m.c * Math.sin(r);
533
+ const vy = m.b * Math.cos(r) + m.d * Math.sin(r);
534
+ const t = (degrees * Math.PI) / 180;
535
+ const wx = vx * Math.cos(t) - vy * Math.sin(t);
536
+ const wy = vx * Math.sin(t) + vy * Math.cos(t);
537
+ const ux = (m.d * wx - m.c * wy) / det;
538
+ const uy = (-m.b * wx + m.a * wy) / det;
539
+ // A long drag passes half a turn: keep the answer beside the turn's own direction in that space.
540
+ const base = det < 0 ? -t : t;
541
+ const raw = Math.atan2(uy, ux) - r - base;
542
+ const wrapped = Math.atan2(Math.sin(raw), Math.cos(raw));
543
+ return ((base + wrapped) * 180) / Math.PI;
493
544
  }
494
545
 
495
546
  function boxPatchChannels(patch: Record<string, number>): TransformChannel[] {
@@ -538,7 +589,7 @@ function transformForBoxPatch(
538
589
  scale: [...session.transform.scale],
539
590
  };
540
591
  const rotate = patch['rotate'];
541
- if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, rotate);
592
+ if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, localTurn(session, rotate));
542
593
  const width = patch['width'];
543
594
  if (width !== undefined && session.rect.width > 0) {
544
595
  next.scale[0] = session.transform.scale[0] * Math.max(0.0001, width / session.rect.width);
@@ -669,10 +720,17 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
669
720
  | undefined;
670
721
  private listeners = new Set<() => void>();
671
722
  private boxEditSessions = new Map<string, CanvasBoxEditSession>();
672
- private lockedIds = new Set<string>();
673
- /** Godot's Group: a click on any node inside a grouped one selects the group. Session-local, as
674
- * the lock is. */
675
- private groupedIds = new Set<string>();
723
+ /**
724
+ * Lock (a click passes through the node) and Godot's Group (a click inside a grouped node selects
725
+ * the group), held by each node's label path in this world so they survive the remount a source
726
+ * write causes and the next session. Kept per checkout, as Unity keeps scene pickability per
727
+ * user; Godot writes them into the scene file instead.
728
+ */
729
+ private lockedPaths = new Set<string>();
730
+ private groupedPaths = new Set<string>();
731
+ private readonly editLockWorld: string;
732
+ /** Each node's label path, cleared whenever the tree is re-projected. */
733
+ private pathKeys = new Map<string, string>();
676
734
 
677
735
  constructor(
678
736
  private readonly root: Container,
@@ -728,8 +786,10 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
728
786
  const hit = this.projector.pick(clientX, clientY, (id) => this.isPickLocked(id));
729
787
  return hit ? this.groupOf(hit) : hit;
730
788
  },
731
- candidates: (clientX, clientY) =>
732
- this.projector.candidates(clientX, clientY, (id) => this.isPickLocked(id)),
789
+ candidates: (clientX, clientY, options) =>
790
+ this.projector.candidates(clientX, clientY, (id) =>
791
+ options?.includeLocked ? false : this.isPickLocked(id),
792
+ ),
733
793
  };
734
794
  this.rects = {
735
795
  rect: (id) => this.projector.rect(id),
@@ -750,7 +810,18 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
750
810
  this.target.provenance.source === 'source-code' && this.target.persistence !== undefined,
751
811
  };
752
812
  this.target.bind({ a2d: this.a2d, store, journal: opts.journal, notify: () => this.notify() });
753
- this.structure = this.target.structure ?? this.liveStructure;
813
+ this.editLockWorld = opts.journal.id;
814
+ const saved = readEditLocks()[this.editLockWorld];
815
+ this.lockedPaths = new Set(saved?.locked ?? []);
816
+ this.groupedPaths = new Set(saved?.grouped ?? []);
817
+ const structure = this.target.structure ?? this.liveStructure;
818
+ // A placed creation reaches the structure in the new node's PARENT's own space: the target
819
+ // writes `x`/`y` as authored, and only this adapter knows the frame `rects` answer in.
820
+ this.structure = {
821
+ ...structure,
822
+ create: (kind, parentId, at) =>
823
+ structure.create(kind, parentId, at ? this.pointInParent(parentId ?? null, at) : undefined),
824
+ };
754
825
  this.assetDrop =
755
826
  this.target.assetDrop ??
756
827
  ({
@@ -851,6 +922,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
851
922
  queueMicrotask(() => {
852
923
  this.structureNotifyQueued = false;
853
924
  this.projector.reproject();
925
+ this.pathKeys.clear();
854
926
  this.watchStructure(this.root);
855
927
  this.target.onReindex();
856
928
  this.notify();
@@ -951,7 +1023,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
951
1023
  // degrade — a half-answer would be a menu item that silently does nothing.
952
1024
 
953
1025
  private readonly liveStructure: StructureProvider = {
954
- create: (kind, parentId) => this.createStructuralNode(kind, parentId ?? null),
1026
+ create: (kind, parentId, at) => this.createStructuralNode(kind, parentId ?? null, at),
955
1027
  remove: (id) => this.removeStructuralNodes([id]),
956
1028
  removeMany: (ids) => this.removeStructuralNodes(ids),
957
1029
  duplicate: (id) => this.duplicateStructuralNode(id),
@@ -1015,6 +1087,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1015
1087
  */
1016
1088
  private afterStructuralChange(): void {
1017
1089
  this.projector.reproject();
1090
+ this.pathKeys.clear();
1018
1091
  this.watchStructure(this.root);
1019
1092
  this.target.onReindex();
1020
1093
  this.notify();
@@ -1031,7 +1104,22 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1031
1104
  * same turn. The live-only report is the other half of the answer and rides
1032
1105
  * back in `ack` (see `StructuralIdWrite`) instead of being fired `void`.
1033
1106
  */
1034
- private createStructuralNode(kind: string, parentId: string | null): StructuralIdWrite {
1107
+ /** A point in the `rects` frame, in the space of the container a new child of `parentId` joins. */
1108
+ private pointInParent(
1109
+ parentId: string | null,
1110
+ at: { readonly x: number; readonly y: number },
1111
+ ): { x: number; y: number } | undefined {
1112
+ const parent = this.containerFor(parentId);
1113
+ if (!parent || typeof parent.updateLocalTransform !== 'function') return undefined;
1114
+ const local = this.localToRootMatrix(parent).clone().invert().apply({ x: at.x, y: at.y });
1115
+ return { x: local.x, y: local.y };
1116
+ }
1117
+
1118
+ private createStructuralNode(
1119
+ kind: string,
1120
+ parentId: string | null,
1121
+ at?: { readonly x: number; readonly y: number },
1122
+ ): StructuralIdWrite {
1035
1123
  const parent = this.containerFor(parentId);
1036
1124
  if (!parent) {
1037
1125
  return {
@@ -1047,6 +1135,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1047
1135
  };
1048
1136
  }
1049
1137
  node.label = defaultLabelFor(kind);
1138
+ if (at) node.position.set(at.x, at.y);
1050
1139
  const label = `Create ${node.label}`;
1051
1140
  this.structureHistory.track(parent);
1052
1141
  this.structureHistory.track(node);
@@ -1280,7 +1369,13 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1280
1369
  this.boxEditSessions.delete(id);
1281
1370
  return;
1282
1371
  }
1283
- this.boxEditSessions.set(id, { id, rect, transform: this.transforms.get(id) });
1372
+ const parent = this.projector.object(id)?.parent?.worldTransform;
1373
+ this.boxEditSessions.set(id, {
1374
+ id,
1375
+ rect,
1376
+ transform: this.transforms.get(id),
1377
+ ...(parent ? { parentLinear: { a: parent.a, b: parent.b, c: parent.c, d: parent.d } } : {}),
1378
+ });
1284
1379
  this.transforms.beginEdit?.(id);
1285
1380
  },
1286
1381
  apply: (id, patch) => this.applyBoxEdit(id, patch),
@@ -1514,15 +1609,19 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1514
1609
  ],
1515
1610
  get: (id, path) =>
1516
1611
  path === 'locked'
1517
- ? this.lockedIds.has(id)
1612
+ ? this.lockedPaths.has(this.pathKeyOf(id))
1518
1613
  : path === 'grouped'
1519
- ? this.groupedIds.has(id)
1614
+ ? this.groupedPaths.has(this.pathKeyOf(id))
1520
1615
  : this.target.get(id, path),
1521
1616
  set: (id, path, value) => {
1522
1617
  if (path === 'locked' || path === 'grouped') {
1523
- const set = path === 'locked' ? this.lockedIds : this.groupedIds;
1524
- if (value === true) set.add(id);
1525
- else set.delete(id);
1618
+ const set = path === 'locked' ? this.lockedPaths : this.groupedPaths;
1619
+ if (value === true) set.add(this.pathKeyOf(id));
1620
+ else set.delete(this.pathKeyOf(id));
1621
+ writeEditLocks(this.editLockWorld, {
1622
+ locked: [...this.lockedPaths],
1623
+ grouped: [...this.groupedPaths],
1624
+ });
1526
1625
  this.notify();
1527
1626
  return;
1528
1627
  }
@@ -1532,12 +1631,36 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1532
1631
  remove: (id, path) => this.target.remove?.(id, path),
1533
1632
  };
1534
1633
 
1634
+ /** `id`'s label path from its world's root: each step the authored label and its place among
1635
+ * same-labelled siblings, so it holds across line edits and reloads (a rename drops it). */
1636
+ private pathKeyOf(id: string): string {
1637
+ const known = this.pathKeys.get(id);
1638
+ if (known !== undefined) return known;
1639
+ const labelOf = (nodeId: string): string => this.projector.object(nodeId)?.label ?? '';
1640
+ const segments: string[] = [];
1641
+ let current: string | null = id;
1642
+ while (current) {
1643
+ const node = this.projector.node(current);
1644
+ if (!node) break;
1645
+ const siblings = node.parentId
1646
+ ? (this.projector.node(node.parentId)?.childIds ?? [])
1647
+ : this.projector.roots().map((root) => root.id);
1648
+ const label = labelOf(current);
1649
+ const place = siblings.filter((sibling) => labelOf(sibling) === label).indexOf(current);
1650
+ segments.unshift(`${label}#${Math.max(0, place)}`);
1651
+ current = node.parentId ?? null;
1652
+ }
1653
+ const key = segments.join('/');
1654
+ this.pathKeys.set(id, key);
1655
+ return key;
1656
+ }
1657
+
1535
1658
  /** The outermost grouped node that contains `id`, or `id` itself when none does. */
1536
1659
  private groupOf(id: string): string {
1537
1660
  let group = id;
1538
1661
  let current: string | null = id;
1539
1662
  while (current) {
1540
- if (this.groupedIds.has(current)) group = current;
1663
+ if (this.groupedPaths.has(this.pathKeyOf(current))) group = current;
1541
1664
  current = this.projector.node(current)?.parentId ?? null;
1542
1665
  }
1543
1666
  return group;
@@ -1546,7 +1669,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1546
1669
  private isPickLocked(id: string): boolean {
1547
1670
  let current: string | null = id;
1548
1671
  while (current) {
1549
- if (this.lockedIds.has(current)) return true;
1672
+ if (this.lockedPaths.has(this.pathKeyOf(current))) return true;
1550
1673
  current = this.projector.node(current)?.parentId ?? null;
1551
1674
  }
1552
1675
  return false;
@@ -1565,8 +1688,7 @@ export class PixiAuthoringAdapter implements AuthoringAdapter {
1565
1688
 
1566
1689
  dispose(): void {
1567
1690
  this.boxEditSessions.clear();
1568
- this.lockedIds.clear();
1569
- this.groupedIds.clear();
1691
+ this.pathKeys.clear();
1570
1692
  for (const object of this.watchedForStructure) {
1571
1693
  object.off('childAdded', this.onStructureChanged);
1572
1694
  object.off('childRemoved', this.onStructureChanged);