@volter/editor-game 0.5.65
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/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- package/src/xstate/xstate-layout.ts +76 -0
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PixiAuthoringAdapter — THE editor {@link AuthoringAdapter} for the CANVAS
|
|
3
|
+
* surface, whatever authored the display tree it is handed.
|
|
4
|
+
*
|
|
5
|
+
* It is keyed on the surface, never on provenance (ARCHITECTURE-CORE §Rules):
|
|
6
|
+
* a second adapter class keyed on where the graph came from is the forbidden
|
|
7
|
+
* shape, so a first-party `@pixi/react` world and an ingested game's captured
|
|
8
|
+
* stage are the SAME class here, differing only along two declared axes:
|
|
9
|
+
*
|
|
10
|
+
* - **identity** — {@link CanvasIdentity}, the engine-side collaborator the
|
|
11
|
+
* tree walk takes. `createOidCanvasIdentity(worldId)` keys rows on the
|
|
12
|
+
* `data-oid` a first-party TSX world's containers carry (stable across a
|
|
13
|
+
* remount, so selection survives one); `STRUCTURAL_CANVAS_IDENTITY` keys
|
|
14
|
+
* them on the tree's shape, which is all a graph with no authored address
|
|
15
|
+
* can honestly offer.
|
|
16
|
+
* - **persistence** — {@link CanvasWriteTarget}, this module's collaborator.
|
|
17
|
+
* `createSourceCanvasWriteTarget` writes literal JSX props back into the
|
|
18
|
+
* project's `.tsx` through the existing `ui-source` writer;
|
|
19
|
+
* `createLiveCanvasWriteTarget` has nowhere to write and says so
|
|
20
|
+
* (`provenance.detail`), journaling its edits for the session only.
|
|
21
|
+
*
|
|
22
|
+
* Everything else — the tree projection, selection, viewport picking, the 2D
|
|
23
|
+
* transform vocabulary, late-structural-commit re-indexing — is shared, because
|
|
24
|
+
* none of it depends on who wrote the tree. Selection ink is the shared 2D
|
|
25
|
+
* overlay (`rects`); this adapter never writes filters onto live objects.
|
|
26
|
+
*
|
|
27
|
+
* It wraps the engine's backend-neutral {@link AuthoringAdapter2D} (the walk,
|
|
28
|
+
* the ids, the 2D transform primitives) and translates the engine's 2D
|
|
29
|
+
* transform (x, y, scalar rotation) to/from the editor's neutral
|
|
30
|
+
* {@link Transform} through `pixi-transform-channels.ts`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
AuthoringAdapter2D,
|
|
35
|
+
type CanvasIdentity,
|
|
36
|
+
type Transform2DValue,
|
|
37
|
+
} from '@volter/game-runtime/pixi/authoring';
|
|
38
|
+
import type {
|
|
39
|
+
AssetDropContext,
|
|
40
|
+
AssetDropProvider,
|
|
41
|
+
AuthoringAdapter,
|
|
42
|
+
AuthoringCapabilities,
|
|
43
|
+
AuthoringProvenance,
|
|
44
|
+
AuthoringTruth,
|
|
45
|
+
BoxEditProvider,
|
|
46
|
+
ComponentInstancesProvider,
|
|
47
|
+
DOMRectLike,
|
|
48
|
+
EditorNode,
|
|
49
|
+
HierarchyProvider,
|
|
50
|
+
InspectorProvider,
|
|
51
|
+
PersistenceProvider,
|
|
52
|
+
PickProvider,
|
|
53
|
+
PropertyDescriptor,
|
|
54
|
+
RectProvider,
|
|
55
|
+
RelatedSubjectsProvider,
|
|
56
|
+
SelectionProvider,
|
|
57
|
+
SpatialHandlesProvider,
|
|
58
|
+
SpatialPoint3,
|
|
59
|
+
StoriesProvider,
|
|
60
|
+
StructuralIdWrite,
|
|
61
|
+
StructuralWriteOutcome,
|
|
62
|
+
StructureProvider,
|
|
63
|
+
Transform,
|
|
64
|
+
TransformChannel,
|
|
65
|
+
TransformEditability,
|
|
66
|
+
TransformProvider,
|
|
67
|
+
TruthProvider,
|
|
68
|
+
WriteAck,
|
|
69
|
+
} from '@volter/editor-project/adapter';
|
|
70
|
+
import type { Container, Graphics, Matrix, PointData, Sprite, Text, Texture } from 'pixi.js';
|
|
71
|
+
import * as shellPixi from 'pixi.js';
|
|
72
|
+
import type { CanvasPixiNamespace } from '../canvas-entry-runtime';
|
|
73
|
+
import { componentStatesProvider } from '@volter/editor-core/component-states-registry';
|
|
74
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
75
|
+
import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
|
|
76
|
+
import type { JournalSubject } from '../history/json-history-resource';
|
|
77
|
+
import { PixiProjector } from '../projection/pixi';
|
|
78
|
+
import { creationSiteRelated } from './creation-site-related';
|
|
79
|
+
import {
|
|
80
|
+
isCanvasComponentInstanceRoot,
|
|
81
|
+
readContainerAuthoringComponent,
|
|
82
|
+
readContainerAuthoringLabel,
|
|
83
|
+
} from './pixi-source-identity';
|
|
84
|
+
import { CanvasStructureHistory } from './pixi-structure-history';
|
|
85
|
+
import { fromNeutralTransform, toNeutralTransform } from './pixi-transform-channels';
|
|
86
|
+
import { resolvesLiveOnly, runWritePipe } from '@volter/editor-core/authoring/write-pipe';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a write target is handed at construction — the live view it edits
|
|
90
|
+
* through, and the store it journals/notifies against. A target never walks
|
|
91
|
+
* the tree itself; the adapter owns that.
|
|
92
|
+
*/
|
|
93
|
+
export interface CanvasWriteContext {
|
|
94
|
+
readonly a2d: AuthoringAdapter2D;
|
|
95
|
+
readonly store: EditorShellStore;
|
|
96
|
+
/** Whose journal a live-only edit through this target belongs to — passed
|
|
97
|
+
* through from {@link PixiAuthoringOptions.journal}. */
|
|
98
|
+
readonly journal: JournalSubject;
|
|
99
|
+
/** Tell the adapter's subscribers something changed. */
|
|
100
|
+
notify(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* THE PERSISTENCE AXIS. One implementation writes the project's own TSX
|
|
105
|
+
* source; the other has no source to write and reports that honestly.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately a boundary seam with two genuine implementers — the one
|
|
108
|
+
* exception the no-wrappers rule records — rather than an `if (source)` branch
|
|
109
|
+
* threaded through the adapter, because the two halves disagree about every
|
|
110
|
+
* question a write raises: what a channel's editability is, what an inspector
|
|
111
|
+
* row means, and what happens when an edit ends.
|
|
112
|
+
*/
|
|
113
|
+
export interface CanvasWriteTarget {
|
|
114
|
+
/** Declared ONCE per adapter and rendered at the seam — never per row. */
|
|
115
|
+
readonly provenance: AuthoringProvenance;
|
|
116
|
+
/** A target with nowhere to write reports the ephemeral provider; a source
|
|
117
|
+
* target reports the auto-save destination and project-history failures even
|
|
118
|
+
* though there is no pending whole-document flush. */
|
|
119
|
+
readonly persistence?: PersistenceProvider;
|
|
120
|
+
/** Source-backed structural edits when the target can rewrite its native
|
|
121
|
+
* document. Absent targets receive the adapter's live Pixi operations. */
|
|
122
|
+
readonly structure?: StructureProvider;
|
|
123
|
+
/** Source-native file/component placement when this target can persist it. */
|
|
124
|
+
readonly assetDrop?: AssetDropProvider;
|
|
125
|
+
/**
|
|
126
|
+
* The source address of one node, for the inspector's READ surface.
|
|
127
|
+
*
|
|
128
|
+
* ABSENT ⇒ this target's substrate has no creation-site index to answer from,
|
|
129
|
+
* and the adapter reports no `truth` provider at all rather than a
|
|
130
|
+
* fabricated "unknown". Present ⇒ every id gets an answer, anchored or
|
|
131
|
+
* reasoned. First-party TSX uses the same OID index as its guarded writes.
|
|
132
|
+
*/
|
|
133
|
+
truth?(id: string, property: string): AuthoringTruth;
|
|
134
|
+
/**
|
|
135
|
+
* The COMPONENT-INSTANCE seam over this target's own truth: on the canvas
|
|
136
|
+
* surface the CREATION SITE is the component, its literals are the defaults,
|
|
137
|
+
* and the live object is the instance that may differ from them.
|
|
138
|
+
*
|
|
139
|
+
* ABSENT ⇒ this target has no source to diff a live value against, and the
|
|
140
|
+
* adapter reports no `instances` provider at all rather than one whose
|
|
141
|
+
* "defaults" it invented — the same conditional shape as `creationSite`.
|
|
142
|
+
*/
|
|
143
|
+
instances?(): ComponentInstancesProvider;
|
|
144
|
+
/** Portable-CSF association for a native component instance root. */
|
|
145
|
+
componentIdentity?(id: string): { name: string; sourcePath?: string } | null;
|
|
146
|
+
/**
|
|
147
|
+
* Put a node's transform ORIGIN — a container's `pivot`, a sprite's normalized
|
|
148
|
+
* `anchor` — on the live object, mid-gesture.
|
|
149
|
+
*
|
|
150
|
+
* BRACKETED BY `beginTransformEdit`/`endTransformEdit`, exactly as
|
|
151
|
+
* {@link writeTransform} is, because moving an origin is one gesture that
|
|
152
|
+
* moves TWO values: the origin itself and the `position` that compensates for
|
|
153
|
+
* it so the rendered content stays where the author put it. One bracket is
|
|
154
|
+
* what makes the pair one history transaction.
|
|
155
|
+
*
|
|
156
|
+
* ABSENT ⇒ this target cannot write an origin, and the adapter's spatial
|
|
157
|
+
* handle reports itself non-writable rather than moving something that will
|
|
158
|
+
* snap back.
|
|
159
|
+
*/
|
|
160
|
+
writeOrigin?(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void;
|
|
161
|
+
/** Called once, before any other method. */
|
|
162
|
+
bind(context: CanvasWriteContext): void;
|
|
163
|
+
/** The live tree was re-walked — ids may have moved. */
|
|
164
|
+
onReindex(): void;
|
|
165
|
+
transformEditability(id: string, channel: TransformChannel): TransformEditability;
|
|
166
|
+
beginTransformEdit(id: string): void;
|
|
167
|
+
/** Mid-gesture: put `next` on the live object (and anything that owns it). */
|
|
168
|
+
writeTransform(id: string, next: Transform2DValue): void;
|
|
169
|
+
/** Close the gesture and ANSWER for whatever write it triggered — the
|
|
170
|
+
* persistence pipe's per-edit ack (`write-pipe.ts`), or nothing when this
|
|
171
|
+
* target performed no write. */
|
|
172
|
+
endTransformEdit(id: string): void | Promise<WriteAck>;
|
|
173
|
+
/** Drop the native JSX prop(s) that author one transform channel. Absent
|
|
174
|
+
* when this target has no source-level notion of byte-absence. */
|
|
175
|
+
removeTransform?(id: string, channel: TransformChannel): void | Promise<WriteAck>;
|
|
176
|
+
properties(id: string): PropertyDescriptor[];
|
|
177
|
+
get(id: string, path: string): unknown;
|
|
178
|
+
/** Same ack contract as {@link endTransformEdit}. */
|
|
179
|
+
set(id: string, path: string, value: unknown): void | Promise<WriteAck>;
|
|
180
|
+
/** Remove one authored override and let the component default take over —
|
|
181
|
+
* the only door that restores byte-ABSENCE, since {@link set} can only write
|
|
182
|
+
* a value. Same ack contract as {@link set}. */
|
|
183
|
+
remove?(id: string, path: string): void | Promise<WriteAck>;
|
|
184
|
+
/**
|
|
185
|
+
* Say — in THIS target's own voice — that a completed structural op lives
|
|
186
|
+
* only in the session.
|
|
187
|
+
*
|
|
188
|
+
* A structural op has no `transformEditability` sentence to read before the
|
|
189
|
+
* gesture (the {@link StructureProvider} contract has no such member), so
|
|
190
|
+
* this report is the ONLY place its persistence story can be told, and a
|
|
191
|
+
* silent live-only structural edit is exactly the two-truths divergence the
|
|
192
|
+
* lane forbids elsewhere. It is a hook rather than a fixed sentence because
|
|
193
|
+
* the two shipped targets already speak about a refused write in different
|
|
194
|
+
* channels — the creation-site one through the ingest refusal report, the TSX
|
|
195
|
+
* one through its `[canvas-source <entry>]` console line — and inventing a
|
|
196
|
+
* third voice here is how a lane acquires two sentences for one act.
|
|
197
|
+
*
|
|
198
|
+
* ABSENT ⇒ the adapter says it itself, generically, through the editor
|
|
199
|
+
* console. Never nothing.
|
|
200
|
+
*/
|
|
201
|
+
reportStructureLiveOnly?(label: string, reason: string): void;
|
|
202
|
+
dispose(): void;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface PixiAuthoringOptions {
|
|
206
|
+
/** The persistence axis — required, because there is no default answer to
|
|
207
|
+
* "where does an edit go". */
|
|
208
|
+
readonly target: CanvasWriteTarget;
|
|
209
|
+
/**
|
|
210
|
+
* THE `pixi.js` OF THE GRAPH THIS TREE CAME FROM
|
|
211
|
+
* (`resolveCanvasPixiForEditor()`), and the only namespace this adapter
|
|
212
|
+
* constructs or class-checks with.
|
|
213
|
+
*
|
|
214
|
+
* ABSENT ⇒ this bundle's own copy, which is the right answer on every
|
|
215
|
+
* runtime with ONE module graph (dev, hosted, and every headless
|
|
216
|
+
* suite that builds its tree with a direct `pixi.js` import). Under the
|
|
217
|
+
* PACKAGED runtime the tree is the project's and this must be too: the
|
|
218
|
+
* duplicate gate is `object.constructor === pixi.Container`, which is an
|
|
219
|
+
* exact identity test on purpose — a game's own `class Bubble extends
|
|
220
|
+
* Container` must refuse — and an exact test against the wrong instance
|
|
221
|
+
* refuses everything. See `../../vite-plugin-module-doorways.ts` for the
|
|
222
|
+
* measured list.
|
|
223
|
+
*/
|
|
224
|
+
readonly pixi?: CanvasPixiNamespace | undefined;
|
|
225
|
+
/**
|
|
226
|
+
* The UNDO axis — whose journal this adapter's live edits belong to. Required
|
|
227
|
+
* for the same reason `target` is: there is no default answer to "does this
|
|
228
|
+
* stack survive my teardown", and the answer differs per lane
|
|
229
|
+
* (`authoringJournal` for a held/design world whose subject outlives every
|
|
230
|
+
* remount, `playJournal` for a run that ends on ■). See the ownership block
|
|
231
|
+
* in `history/json-history-resource.ts`.
|
|
232
|
+
*/
|
|
233
|
+
readonly journal: JournalSubject;
|
|
234
|
+
/** The identity axis. Omitted ⇒ structural paths (the engine's default). */
|
|
235
|
+
readonly identity?: CanvasIdentity | undefined;
|
|
236
|
+
/**
|
|
237
|
+
* The element whose client rect maps viewport pixels into this stage's own
|
|
238
|
+
* coordinate space — the canvas analogue of `viewport-pick-context.ts` for
|
|
239
|
+
* three (a `pickable` needs a screen↔world mapping the adapter cannot own,
|
|
240
|
+
* because it is handed only a `Container`).
|
|
241
|
+
*
|
|
242
|
+
* A resolver, not an element, because a play/ingest surface is torn down and
|
|
243
|
+
* rebuilt under a longer-lived adapter; `null` at pick time is an honest
|
|
244
|
+
* degrade (no pick), never a throw. ABSENT ⇒ this adapter reports no
|
|
245
|
+
* `pickable` at all — a stage with no mapped surface cannot answer where a
|
|
246
|
+
* client point landed, and guessing one would fabricate a hit.
|
|
247
|
+
*
|
|
248
|
+
* When the element carries `data-vgai-stage-width`/`-height`, those are the
|
|
249
|
+
* stage's LOGICAL size and the mapping divides the measured rect by them —
|
|
250
|
+
* which is what lets a design surface presented through the shared pan/zoom
|
|
251
|
+
* camera (a CSS scale) pick correctly. Without them the mapping is 1:1,
|
|
252
|
+
* which is every runtime surface (`create-runtime.ts` sizes the renderer to
|
|
253
|
+
* its container, so stage units are that element's client pixels).
|
|
254
|
+
*/
|
|
255
|
+
readonly surface?: (() => HTMLElement | null) | undefined;
|
|
256
|
+
/**
|
|
257
|
+
* The DESIGN-TIME STATES axis: whatever can put this world into one of its
|
|
258
|
+
* own named states, in the editor's ordinary stories vocabulary.
|
|
259
|
+
*
|
|
260
|
+
* A collaborator rather than something this class builds, for the same reason
|
|
261
|
+
* `target` and `identity` are: the answer differs per lane and this adapter is
|
|
262
|
+
* keyed on the SURFACE, not on provenance. An ingested game's states are the
|
|
263
|
+
* scenes it declares through the game contract
|
|
264
|
+
* (`contract-scenes-stories.ts`), which is a WORLD-level provider.
|
|
265
|
+
*
|
|
266
|
+
* ABSENT ⇒ the adapter falls back to the CSF-component lane whenever the
|
|
267
|
+
* target names the component a node came from — see the `stories` field on
|
|
268
|
+
* the class for why that provider is unconditional and where its honesty
|
|
269
|
+
* lives instead.
|
|
270
|
+
*/
|
|
271
|
+
readonly stories?: StoriesProvider | undefined;
|
|
272
|
+
/** Optional editor-camera mapping for a native Canvas Scene. The stage
|
|
273
|
+
* remains in authored world coordinates while its viewport renderer applies
|
|
274
|
+
* a presentation-only matrix; this inverse maps a client pointer back into
|
|
275
|
+
* those same world coordinates. Runtime/artboard surfaces omit it and keep
|
|
276
|
+
* the size-based mapping above. */
|
|
277
|
+
readonly pointFromClient?:
|
|
278
|
+
| ((clientX: number, clientY: number, surfaceRect: DOMRect) => PointData | null)
|
|
279
|
+
| undefined;
|
|
280
|
+
/**
|
|
281
|
+
* How an asset path becomes a texture for {@link AssetDropProvider}.
|
|
282
|
+
* Defaults to Pixi's own `Assets.load`, which is what every mounted surface
|
|
283
|
+
* uses; injectable so a headless test can drive the drop without a network
|
|
284
|
+
* or a renderer (the same seam, and the same reason, as the creation-site
|
|
285
|
+
* backend's injectable `writer`).
|
|
286
|
+
*/
|
|
287
|
+
readonly loadTexture?: ((assetPath: string) => Promise<Texture>) | undefined;
|
|
288
|
+
/** Photograph one native display subtree on an independent preview
|
|
289
|
+
* Application. The live surface renderer is never a render target. */
|
|
290
|
+
readonly capturePreview?:
|
|
291
|
+
| ((
|
|
292
|
+
object: Container,
|
|
293
|
+
size: { readonly width: number; readonly height: number },
|
|
294
|
+
) => Promise<string | null>)
|
|
295
|
+
| undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* WHAT THIS SURFACE CAN CONSTRUCT — the creation palette's whole contents.
|
|
300
|
+
*
|
|
301
|
+
* Four kinds, each of which is visible the moment it is created (a sprite gets
|
|
302
|
+
* `Texture.WHITE` and a size; a graphics gets a drawn rect): a node the author
|
|
303
|
+
* cannot see is indistinguishable from a create that silently failed.
|
|
304
|
+
*/
|
|
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
|
+
];
|
|
311
|
+
|
|
312
|
+
/** What a canvas world can take from the asset browser: an image becomes a
|
|
313
|
+
* Sprite. A model or an audio file has no display object to become here. */
|
|
314
|
+
const IMAGE_ASSET_RE = /\.(png|jpe?g|webp|gif|svg)(\?.*)?$/i;
|
|
315
|
+
|
|
316
|
+
/** Why every structural op on this surface is live-only, in one sentence — the
|
|
317
|
+
* same reason for all of them, because they all come down to the same fact:
|
|
318
|
+
* the game's source contains statements that CONSTRUCT objects, and this lane
|
|
319
|
+
* plans property writes, never statements. */
|
|
320
|
+
const STRUCTURE_LIVE_ONLY_REASON =
|
|
321
|
+
'a canvas structural edit would have to add, delete or move a construction statement in the ' +
|
|
322
|
+
"world's own source, and this lane plans property writes only — an editor-created node has no " +
|
|
323
|
+
'construction site there at all';
|
|
324
|
+
|
|
325
|
+
/** The one draggable point a canvas node offers (see `spatialHandles`). Stable
|
|
326
|
+
* only within its owning node, per the {@link SpatialDragHandle} contract. */
|
|
327
|
+
const ORIGIN_HANDLE_ID = 'origin';
|
|
328
|
+
|
|
329
|
+
/** The origin handle's ink. Deliberately the canvas-guide language's own
|
|
330
|
+
* measure pink rather than the selection accent, so an origin never reads as
|
|
331
|
+
* selection chrome. */
|
|
332
|
+
const ORIGIN_HANDLE_COLOR = '#ff2d92';
|
|
333
|
+
|
|
334
|
+
/** What the adapter says when the target has no origin write at all — the
|
|
335
|
+
* generic voice the target's own sentence replaces whenever it has one. */
|
|
336
|
+
const ORIGIN_UNWRITABLE_REASON =
|
|
337
|
+
"this world's write target has no origin write, so the pivot/anchor can be seen here but not " +
|
|
338
|
+
'dragged';
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The anchor-origin view of a node: its `anchor` and `texture`, or `null` when
|
|
342
|
+
* this node's origin is a plain container pivot.
|
|
343
|
+
*
|
|
344
|
+
* STRUCTURAL, not `instanceof`, and that is load-bearing under the packaged
|
|
345
|
+
* runtime: a canvas world is mounted with the PROJECT's own `pixi.js` (see
|
|
346
|
+
* `../../vite-plugin-module-doorways.ts`), which is a different module instance
|
|
347
|
+
* from the one this prebuilt shell bundles — so a real `Sprite` from the world
|
|
348
|
+
* fails `instanceof Sprite` here and every selected sprite would silently read
|
|
349
|
+
* as a pivot node. The engine's own 2D authoring walk already refuses class
|
|
350
|
+
* identity for the same reason (`@volter/game-runtime/pixi/authoring`'s `kindOf` keys on the
|
|
351
|
+
* constructor NAME); asking for the two fields the anchor question is actually
|
|
352
|
+
* about is the same move without depending on a name a minifier may mangle.
|
|
353
|
+
*
|
|
354
|
+
* It also answers correctly for the sprite-shaped classes that are NOT `Sprite`
|
|
355
|
+
* subclasses — `TilingSprite`, `NineSliceSprite` — which carry a real `anchor`
|
|
356
|
+
* over a real `texture` and were previously mis-read as pivot nodes.
|
|
357
|
+
*/
|
|
358
|
+
function anchoredSprite(object: Container): { anchor: PointData; texture: Texture } | null {
|
|
359
|
+
const candidate = object as unknown as Partial<Pick<Sprite, 'anchor' | 'texture'>>;
|
|
360
|
+
const anchor = candidate.anchor;
|
|
361
|
+
const texture = candidate.texture;
|
|
362
|
+
return anchor && texture?.orig ? { anchor, texture } : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The frame a node's `anchor` is normalized against, in its own local units —
|
|
367
|
+
* `null` for anything that is not an anchored sprite.
|
|
368
|
+
*
|
|
369
|
+
* This is the node's OWN view bounds, not `texture.orig`, and the two are only
|
|
370
|
+
* the same for `Sprite`. Pixi normalizes an anchor against whatever its
|
|
371
|
+
* `updateBounds` measures, and `TilingSprite`/`NineSliceSprite` measure their
|
|
372
|
+
* authored `_width`/`_height` — the tiled/stretched region — while their
|
|
373
|
+
* texture stays the little source tile. Dividing a drag delta by the texture
|
|
374
|
+
* instead moved a 512-wide banner's anchor by the width of its 32px tile, so
|
|
375
|
+
* the compensating position write no longer cancelled the shift and the
|
|
376
|
+
* content jumped under the pointer.
|
|
377
|
+
*
|
|
378
|
+
* `bounds` is `ViewContainer`'s own accessor (Sprite, TilingSprite,
|
|
379
|
+
* NineSliceSprite all extend it) and reports the view's box WITHOUT children,
|
|
380
|
+
* which is exactly the anchored frame. Read structurally for the same reason
|
|
381
|
+
* {@link anchoredSprite} is: under the packaged runtime this object comes from
|
|
382
|
+
* the project's `pixi.js`, not the shell's.
|
|
383
|
+
*/
|
|
384
|
+
function anchorFrameSize(object: Container): PointData | null {
|
|
385
|
+
const view = anchoredSprite(object);
|
|
386
|
+
if (!view) return null;
|
|
387
|
+
const bounds = (object as unknown as { bounds?: { width?: unknown; height?: unknown } }).bounds;
|
|
388
|
+
if (typeof bounds?.width === 'number' && typeof bounds.height === 'number') {
|
|
389
|
+
return { x: bounds.width, y: bounds.height };
|
|
390
|
+
}
|
|
391
|
+
return { x: view.texture.orig.width, y: view.texture.orig.height };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The default node label per kind — a legible row in the hierarchy the moment
|
|
395
|
+
* it exists, rather than an empty name the panel renders as the kind. */
|
|
396
|
+
function defaultLabelFor(kind: string): string {
|
|
397
|
+
const entry = CANVAS_CREATABLE_KINDS.find((candidate) => candidate.kind === kind);
|
|
398
|
+
return entry?.label ?? kind;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Construct one new display object, or `null` for a kind this surface does not
|
|
402
|
+
* know — never a fabricated stand-in.
|
|
403
|
+
*
|
|
404
|
+
* `pixi` is THIS SURFACE's namespace ({@link PixiAuthoringOptions.pixi}): a
|
|
405
|
+
* node built from another graph's classes is a foreign object in the world's
|
|
406
|
+
* own display list, and the world's renderer is the one that has to draw it. */
|
|
407
|
+
function createDisplayObject(pixi: CanvasPixiNamespace, kind: string): Container | null {
|
|
408
|
+
switch (kind) {
|
|
409
|
+
case 'container':
|
|
410
|
+
return new pixi.Container();
|
|
411
|
+
case 'sprite': {
|
|
412
|
+
const sprite = new pixi.Sprite(pixi.Texture.WHITE);
|
|
413
|
+
sprite.setSize(64, 64);
|
|
414
|
+
return sprite;
|
|
415
|
+
}
|
|
416
|
+
case 'text':
|
|
417
|
+
return new pixi.Text({ text: 'Text', style: { fill: 0xffffff, fontSize: 24 } });
|
|
418
|
+
case 'graphics':
|
|
419
|
+
return new pixi.Graphics().rect(0, 0, 100, 100).fill(0xffffff);
|
|
420
|
+
default:
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A deep copy of one display object, or a thrown refusal naming what it is.
|
|
427
|
+
*
|
|
428
|
+
* The refusal is the point: a game's own `class Bubble extends Container` holds
|
|
429
|
+
* state this code cannot see (timers, sim references, its own texture logic),
|
|
430
|
+
* and a "clone" that copied only the Container half would put a dead prop in
|
|
431
|
+
* the world that LOOKS like the real thing. Only the four shapes the editor
|
|
432
|
+
* itself can construct are cloneable, plus their children, recursively.
|
|
433
|
+
*/
|
|
434
|
+
function cloneDisplayObject(pixi: CanvasPixiNamespace, object: Container): Container {
|
|
435
|
+
const clone = cloneShallow(pixi, object);
|
|
436
|
+
copyCommonProperties(object, clone);
|
|
437
|
+
for (const child of object.children ?? []) {
|
|
438
|
+
clone.addChild(cloneDisplayObject(pixi, child as Container));
|
|
439
|
+
}
|
|
440
|
+
return clone;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function cloneShallow(pixi: CanvasPixiNamespace, object: Container): Container {
|
|
444
|
+
// Constructor IDENTITY, not `instanceof`: a subclass passes an instanceof
|
|
445
|
+
// check and is exactly the case that must refuse.
|
|
446
|
+
//
|
|
447
|
+
// AGAINST THIS SURFACE'S OWN NAMESPACE, not a static import — and that is
|
|
448
|
+
// what makes the exact test survive the packaged runtime. `object` comes from
|
|
449
|
+
// the graph the world mounted in; the shell's `Container` is a different
|
|
450
|
+
// class object there, so `===` against it was false for EVERY node and a
|
|
451
|
+
// plain project container was refused with the sentence below, which is about
|
|
452
|
+
// a game's own subclass. Structural duck-typing is not the fix here the way
|
|
453
|
+
// it is for `anchoredSprite` above: this test's whole job is exactness.
|
|
454
|
+
if (object.constructor === pixi.Container) return new pixi.Container();
|
|
455
|
+
if (object.constructor === pixi.Sprite) {
|
|
456
|
+
const source = object as Sprite;
|
|
457
|
+
const sprite = new pixi.Sprite(source.texture);
|
|
458
|
+
sprite.anchor.set(source.anchor.x, source.anchor.y);
|
|
459
|
+
return sprite;
|
|
460
|
+
}
|
|
461
|
+
if (object.constructor === pixi.Text) {
|
|
462
|
+
const source = object as Text;
|
|
463
|
+
const text = new pixi.Text({ text: source.text, style: source.style.clone() });
|
|
464
|
+
text.anchor.set(source.anchor.x, source.anchor.y);
|
|
465
|
+
return text;
|
|
466
|
+
}
|
|
467
|
+
if (object.constructor === pixi.Graphics) return (object as Graphics).clone(true);
|
|
468
|
+
throw new Error(
|
|
469
|
+
`"${object.label ?? object.constructor?.name ?? 'this node'}" cannot be duplicated: a ` +
|
|
470
|
+
`${object.constructor?.name ?? 'custom'} is constructed by the game's own code, and copying ` +
|
|
471
|
+
'only its display half would put a lookalike with no behaviour into the world.',
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function copyCommonProperties(source: Container, target: Container): void {
|
|
476
|
+
target.label = source.label;
|
|
477
|
+
target.position.set(source.position.x, source.position.y);
|
|
478
|
+
target.scale.set(source.scale.x, source.scale.y);
|
|
479
|
+
target.pivot.set(source.pivot.x, source.pivot.y);
|
|
480
|
+
target.skew.set(source.skew.x, source.skew.y);
|
|
481
|
+
target.rotation = source.rotation;
|
|
482
|
+
target.alpha = source.alpha;
|
|
483
|
+
target.visible = source.visible;
|
|
484
|
+
if ('tint' in source && 'tint' in target) {
|
|
485
|
+
(target as Container & { tint: number }).tint = (source as Container & { tint: number }).tint;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
interface CanvasBoxEditSession {
|
|
490
|
+
readonly id: string;
|
|
491
|
+
readonly transform: Transform;
|
|
492
|
+
readonly rect: { x: number; y: number; width: number; height: number };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function boxPatchChannels(patch: Record<string, number>): TransformChannel[] {
|
|
496
|
+
const channels = new Set<TransformChannel>();
|
|
497
|
+
if (
|
|
498
|
+
patch['x'] !== undefined ||
|
|
499
|
+
patch['y'] !== undefined ||
|
|
500
|
+
patch['originX'] !== undefined ||
|
|
501
|
+
patch['originY'] !== undefined
|
|
502
|
+
) {
|
|
503
|
+
channels.add('position');
|
|
504
|
+
}
|
|
505
|
+
if (
|
|
506
|
+
patch['width'] !== undefined ||
|
|
507
|
+
patch['height'] !== undefined ||
|
|
508
|
+
patch['scaleXFactor'] !== undefined ||
|
|
509
|
+
patch['scaleYFactor'] !== undefined
|
|
510
|
+
) {
|
|
511
|
+
channels.add('scale');
|
|
512
|
+
if (patch['width'] !== undefined || patch['height'] !== undefined) channels.add('position');
|
|
513
|
+
}
|
|
514
|
+
if (patch['rotate'] !== undefined) channels.add('rotation');
|
|
515
|
+
return [...channels];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function rotateTransformZ(transform: Transform, degrees: number): Transform['rotation'] {
|
|
519
|
+
const half = (degrees * Math.PI) / 180 / 2;
|
|
520
|
+
const deltaZ = Math.sin(half);
|
|
521
|
+
const deltaW = Math.cos(half);
|
|
522
|
+
const [x, y, z, w] = transform.rotation;
|
|
523
|
+
return [
|
|
524
|
+
x * deltaW + y * deltaZ,
|
|
525
|
+
y * deltaW - x * deltaZ,
|
|
526
|
+
z * deltaW + w * deltaZ,
|
|
527
|
+
w * deltaW - z * deltaZ,
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function transformForBoxPatch(
|
|
532
|
+
session: CanvasBoxEditSession,
|
|
533
|
+
patch: Record<string, number>,
|
|
534
|
+
): Transform {
|
|
535
|
+
const next: Transform = {
|
|
536
|
+
position: [...session.transform.position],
|
|
537
|
+
rotation: [...session.transform.rotation],
|
|
538
|
+
scale: [...session.transform.scale],
|
|
539
|
+
};
|
|
540
|
+
const rotate = patch['rotate'];
|
|
541
|
+
if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, rotate);
|
|
542
|
+
const width = patch['width'];
|
|
543
|
+
if (width !== undefined && session.rect.width > 0) {
|
|
544
|
+
next.scale[0] = session.transform.scale[0] * Math.max(0.0001, width / session.rect.width);
|
|
545
|
+
}
|
|
546
|
+
const height = patch['height'];
|
|
547
|
+
if (height !== undefined && session.rect.height > 0) {
|
|
548
|
+
next.scale[1] = session.transform.scale[1] * Math.max(0.0001, height / session.rect.height);
|
|
549
|
+
}
|
|
550
|
+
const scaleStep = patch['scaleStep'];
|
|
551
|
+
const snapScale = (value: number): number =>
|
|
552
|
+
scaleStep !== undefined && scaleStep > 0 ? Math.round(value / scaleStep) * scaleStep : value;
|
|
553
|
+
const nonZeroScale = (value: number): number =>
|
|
554
|
+
Math.abs(value) >= 0.0001 ? value : value < 0 ? -0.0001 : 0.0001;
|
|
555
|
+
const scaleXFactor = patch['scaleXFactor'];
|
|
556
|
+
if (scaleXFactor !== undefined) {
|
|
557
|
+
next.scale[0] = nonZeroScale(snapScale(session.transform.scale[0] * scaleXFactor));
|
|
558
|
+
}
|
|
559
|
+
const scaleYFactor = patch['scaleYFactor'];
|
|
560
|
+
if (scaleYFactor !== undefined) {
|
|
561
|
+
next.scale[1] = nonZeroScale(snapScale(session.transform.scale[1] * scaleYFactor));
|
|
562
|
+
}
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function requestedBoxEditDelta(
|
|
567
|
+
currentOrigin: { x: number; y: number } | null,
|
|
568
|
+
currentRect: DOMRectLike | null,
|
|
569
|
+
sessionRect: DOMRectLike,
|
|
570
|
+
patch: Record<string, number>,
|
|
571
|
+
): { dx: number; dy: number } {
|
|
572
|
+
const usesOrigin = patch['originX'] !== undefined || patch['originY'] !== undefined;
|
|
573
|
+
if (usesOrigin) {
|
|
574
|
+
return {
|
|
575
|
+
dx: patch['originX'] !== undefined && currentOrigin ? patch['originX'] - currentOrigin.x : 0,
|
|
576
|
+
dy: patch['originY'] !== undefined && currentOrigin ? patch['originY'] - currentOrigin.y : 0,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
dx: currentRect ? (patch['x'] ?? sessionRect.x) - currentRect.x : 0,
|
|
581
|
+
dy: currentRect ? (patch['y'] ?? sessionRect.y) - currentRect.y : 0,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export class PixiAuthoringAdapter implements AuthoringAdapter {
|
|
586
|
+
readonly capabilities: AuthoringCapabilities;
|
|
587
|
+
readonly provenance: AuthoringProvenance;
|
|
588
|
+
readonly pickable?: PickProvider;
|
|
589
|
+
/**
|
|
590
|
+
* The selection chrome's geometry — outlines, hover, marquee — in the SAME
|
|
591
|
+
* authored-space frame `RootSelectionOverlay` reads for every other surface.
|
|
592
|
+
*
|
|
593
|
+
* Present exactly when {@link PixiAuthoringOptions.surface} is, and for the
|
|
594
|
+
* same reason it gates `pickable`: both need a mapped surface, and without
|
|
595
|
+
* one there is no honest frame to answer in. `hasRectCapableChild` is what
|
|
596
|
+
* decides whether the shared overlay renders at all, so this is also what
|
|
597
|
+
* makes a canvas world SELECTABLE by clicking it — the overlay's own
|
|
598
|
+
* interaction layer routes its picks back through `pickable`.
|
|
599
|
+
*/
|
|
600
|
+
readonly rects?: RectProvider;
|
|
601
|
+
/** Present only when the target HAS one. A source-writing target uses this
|
|
602
|
+
* to disclose its auto-save destination and last project-history failure. */
|
|
603
|
+
readonly persistence?: PersistenceProvider;
|
|
604
|
+
/**
|
|
605
|
+
* Present exactly when the target can answer where a node came from. The
|
|
606
|
+
* provider is the id→object hop and nothing else — the index itself is a
|
|
607
|
+
* host-side `WeakMap` (`creation-site-registry.ts`), so a running game is
|
|
608
|
+
* never asked anything and never observes that it was indexed.
|
|
609
|
+
*/
|
|
610
|
+
readonly truth?: TruthProvider;
|
|
611
|
+
/**
|
|
612
|
+
* Present exactly when {@link truth} is, and derived from the same
|
|
613
|
+
* index: the ONE jump a canvas subject honestly has is to the line that
|
|
614
|
+
* constructed it. A target with no creation-site index advertises no empty
|
|
615
|
+
* capability (the same conditional shape as `pickable`/`rects`), and an
|
|
616
|
+
* indexed node whose object the index never saw gets `[]` — an absence, not a
|
|
617
|
+
* fabricated link.
|
|
618
|
+
*/
|
|
619
|
+
readonly related?: RelatedSubjectsProvider;
|
|
620
|
+
readonly structure: StructureProvider;
|
|
621
|
+
/**
|
|
622
|
+
* The handed-in {@link PixiAuthoringOptions.stories} when there is one;
|
|
623
|
+
* otherwise the CSF-COMPONENT provider, built here for any target that can
|
|
624
|
+
* name the component a node came from.
|
|
625
|
+
*
|
|
626
|
+
* That second arm is UNCONDITIONAL, and it is not the empty picker the
|
|
627
|
+
* conditional shape (`pickable`/`rects`) exists to avoid. Which stories a
|
|
628
|
+
* node has is the project's story registry's answer, and the registry is
|
|
629
|
+
* populated by story-module discovery long after this constructor runs — so
|
|
630
|
+
* there is no honest construction-time predicate to gate on, and the sibling
|
|
631
|
+
* CSF lanes resolve it the same way: `R3fSourceAuthoringAdapter` and
|
|
632
|
+
* `ReactRootAuthoringAdapter` both declare `stories` NON-optionally and build
|
|
633
|
+
* it unconditionally, pushing the honesty into `storiesFor(id)` — which
|
|
634
|
+
* answers `[]` for a node whose component has none.
|
|
635
|
+
*
|
|
636
|
+
* That is where the surface is hidden, and `inspector-stories-gating.ts`
|
|
637
|
+
* says so twice: gate on `storiesFor(...)` returning rows, "NEVER on
|
|
638
|
+
* `adapter.stories` truthiness". Presence of the provider is not an
|
|
639
|
+
* advertisement — a picker only appears where there is something to pick.
|
|
640
|
+
*
|
|
641
|
+
* Conditionality still belongs to whatever CAN be decided up front: a
|
|
642
|
+
* WORLD-level provider knows its own list, which is why
|
|
643
|
+
* `createContractScenesStories` answers `null` for a game that declares no
|
|
644
|
+
* scenes.
|
|
645
|
+
*/
|
|
646
|
+
readonly stories?: StoriesProvider;
|
|
647
|
+
/**
|
|
648
|
+
* Present exactly when the target can read the game's own source
|
|
649
|
+
* ({@link CanvasWriteTarget.instances}). On this surface the CREATION SITE is
|
|
650
|
+
* the component — its literals are the defaults an instance overrides — so a
|
|
651
|
+
* target with no readable source has no defaults to diff against and this
|
|
652
|
+
* adapter advertises no instance capability rather than inventing one.
|
|
653
|
+
*/
|
|
654
|
+
readonly instances?: ComponentInstancesProvider;
|
|
655
|
+
readonly transforms: TransformProvider;
|
|
656
|
+
|
|
657
|
+
private readonly a2d: AuthoringAdapter2D;
|
|
658
|
+
private readonly projector: PixiProjector;
|
|
659
|
+
private readonly target: CanvasWriteTarget;
|
|
660
|
+
/** THE namespace — see {@link PixiAuthoringOptions.pixi}. */
|
|
661
|
+
private readonly pixi: CanvasPixiNamespace;
|
|
662
|
+
private readonly structureHistory: CanvasStructureHistory;
|
|
663
|
+
private readonly loadTexture: (assetPath: string) => Promise<Texture>;
|
|
664
|
+
private readonly capturePreview:
|
|
665
|
+
| ((
|
|
666
|
+
object: Container,
|
|
667
|
+
size: { readonly width: number; readonly height: number },
|
|
668
|
+
) => Promise<string | null>)
|
|
669
|
+
| undefined;
|
|
670
|
+
private listeners = new Set<() => void>();
|
|
671
|
+
private boxEditSessions = new Map<string, CanvasBoxEditSession>();
|
|
672
|
+
private lockedIds = new Set<string>();
|
|
673
|
+
|
|
674
|
+
constructor(
|
|
675
|
+
private readonly root: Container,
|
|
676
|
+
private readonly store: EditorShellStore,
|
|
677
|
+
opts: PixiAuthoringOptions,
|
|
678
|
+
) {
|
|
679
|
+
this.target = opts.target;
|
|
680
|
+
this.pixi = opts.pixi ?? shellPixi;
|
|
681
|
+
this.transforms = {
|
|
682
|
+
dimensions: () => '2d',
|
|
683
|
+
get: (id): Transform => {
|
|
684
|
+
const value = this.a2d.getTransform(id);
|
|
685
|
+
if (!value) return { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
|
|
686
|
+
return toNeutralTransform(value);
|
|
687
|
+
},
|
|
688
|
+
editability: (id, channel) => this.target.transformEditability(id, channel),
|
|
689
|
+
beginEdit: (id) => this.target.beginTransformEdit(id),
|
|
690
|
+
apply: (id, transform) => {
|
|
691
|
+
this.target.writeTransform(id, fromNeutralTransform(transform));
|
|
692
|
+
this.notify();
|
|
693
|
+
},
|
|
694
|
+
endEdit: (id) => {
|
|
695
|
+
return this.target.endTransformEdit(id);
|
|
696
|
+
},
|
|
697
|
+
...(this.target.removeTransform
|
|
698
|
+
? {
|
|
699
|
+
remove: (id: string, channel: TransformChannel) =>
|
|
700
|
+
this.target.removeTransform?.(id, channel),
|
|
701
|
+
}
|
|
702
|
+
: {}),
|
|
703
|
+
};
|
|
704
|
+
// THIS SURFACE's `Assets`, so a dropped image lands in the cache the
|
|
705
|
+
// world's own loader reads — the shell's is a separate cache that would
|
|
706
|
+
// fetch and decode the same file a second time.
|
|
707
|
+
this.loadTexture =
|
|
708
|
+
opts.loadTexture ?? ((assetPath) => this.pixi.Assets.load<Texture>(assetPath));
|
|
709
|
+
this.capturePreview = opts.capturePreview;
|
|
710
|
+
this.structureHistory = new CanvasStructureHistory(root, store, opts.journal, () =>
|
|
711
|
+
this.afterStructuralChange(),
|
|
712
|
+
);
|
|
713
|
+
this.a2d = new AuthoringAdapter2D(root, {
|
|
714
|
+
...(opts.identity ? { identity: opts.identity } : {}),
|
|
715
|
+
});
|
|
716
|
+
this.projector = new PixiProjector(root, this.a2d, {
|
|
717
|
+
surface: opts.surface ?? (() => null),
|
|
718
|
+
...(opts.pointFromClient ? { pointFromClient: opts.pointFromClient } : {}),
|
|
719
|
+
});
|
|
720
|
+
this.provenance = this.target.provenance;
|
|
721
|
+
const surface = opts.surface;
|
|
722
|
+
if (surface) {
|
|
723
|
+
this.pickable = {
|
|
724
|
+
pick: (clientX, clientY) =>
|
|
725
|
+
this.projector.pick(clientX, clientY, (id) => this.isPickLocked(id)),
|
|
726
|
+
candidates: (clientX, clientY) =>
|
|
727
|
+
this.projector.candidates(clientX, clientY, (id) => this.isPickLocked(id)),
|
|
728
|
+
};
|
|
729
|
+
this.rects = {
|
|
730
|
+
rect: (id) => this.projector.rect(id),
|
|
731
|
+
contextRects: (id) => this.projector.contextRects(id),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (opts.stories) this.stories = opts.stories;
|
|
735
|
+
this.watchStructure(root);
|
|
736
|
+
this.capabilities = {
|
|
737
|
+
transform: true,
|
|
738
|
+
inspectorFields: true,
|
|
739
|
+
// First-party source targets auto-save per edit but still expose the
|
|
740
|
+
// destination and rollback failure through PersistenceProvider. A
|
|
741
|
+
// foreign/live target's provider may be only an ephemeral disclosure,
|
|
742
|
+
// which is not a persistence capability.
|
|
743
|
+
persist:
|
|
744
|
+
this.target.provenance.source === 'source-code' && this.target.persistence !== undefined,
|
|
745
|
+
};
|
|
746
|
+
this.target.bind({ a2d: this.a2d, store, journal: opts.journal, notify: () => this.notify() });
|
|
747
|
+
this.structure = this.target.structure ?? this.liveStructure;
|
|
748
|
+
this.assetDrop =
|
|
749
|
+
this.target.assetDrop ??
|
|
750
|
+
({
|
|
751
|
+
accepts: (nodeId, assetPath) =>
|
|
752
|
+
IMAGE_ASSET_RE.test(assetPath) && this.containerFor(nodeId || null) !== null,
|
|
753
|
+
drop: (nodeId, assetPath, context) => this.dropAsset(nodeId, assetPath, context),
|
|
754
|
+
} satisfies AssetDropProvider);
|
|
755
|
+
const persistence = this.target.persistence;
|
|
756
|
+
if (persistence) this.persistence = persistence;
|
|
757
|
+
const truthOf = this.target.truth?.bind(this.target);
|
|
758
|
+
if (truthOf) {
|
|
759
|
+
this.truth = { resolve: (id, property) => truthOf(id, property) };
|
|
760
|
+
this.related = creationSiteRelated((id) => truthOf(id, 'position').site);
|
|
761
|
+
}
|
|
762
|
+
// The CSF-component lane: a target that names the component a node came
|
|
763
|
+
// from gets the portable-CSF story association, unconditionally, exactly as
|
|
764
|
+
// the R3F and React lanes build theirs. `storiesFor(id)` is where a node
|
|
765
|
+
// with no story says so — see the `stories` field for why that is the only
|
|
766
|
+
// place the answer is knowable.
|
|
767
|
+
if (!opts.stories && this.target.componentIdentity) {
|
|
768
|
+
this.stories = componentStatesProvider(
|
|
769
|
+
'canvas',
|
|
770
|
+
store,
|
|
771
|
+
(id) => this.target.componentIdentity?.(id) ?? null,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const instances = this.target.instances?.();
|
|
775
|
+
if (instances) {
|
|
776
|
+
this.instances = {
|
|
777
|
+
// NO GATE HERE — the TARGET answers for its own instances.
|
|
778
|
+
//
|
|
779
|
+
// This used to wrap `describe` in "the CSF provider has a story for
|
|
780
|
+
// this node", which is the portable-CSF lane's own sentence
|
|
781
|
+
// (`r3f-source-authoring-adapter.ts` says it inside the adapter that
|
|
782
|
+
// owns both halves). Asked as a blanket wrapper it is asked of targets
|
|
783
|
+
// whose instance notion is NOT CSF at all: once the live target learned
|
|
784
|
+
// to name a class-owned object's component, every ingested game's
|
|
785
|
+
// instances capability went dark for any class with no matching story —
|
|
786
|
+
// the exact silent capability deletion this wrapper was last edited to
|
|
787
|
+
// prevent. The CSF gate now lives in `pixi-source-write-target.ts`,
|
|
788
|
+
// beside the component identity it is a statement about; a target whose
|
|
789
|
+
// component IS its construction statement has no story to require.
|
|
790
|
+
describe: (id) => instances.describe(id),
|
|
791
|
+
...(instances.openComponent
|
|
792
|
+
? { openComponent: (id: string) => instances.openComponent?.(id) }
|
|
793
|
+
: {}),
|
|
794
|
+
revert: (id, paths) => instances.revert(id, paths),
|
|
795
|
+
applyToComponent: (id, path) => instances.applyToComponent(id, path),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
refresh(): { count: number; sprites: number } {
|
|
801
|
+
return this.projector.refresh();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** The selected live display subtree as pixels, or `null` when this mounted
|
|
805
|
+
* surface did not expose a renderer-backed capture door. */
|
|
806
|
+
previewImage(id: string, width: number, height: number): Promise<string | null> {
|
|
807
|
+
const object = this.projector.object(id);
|
|
808
|
+
return object && this.capturePreview
|
|
809
|
+
? this.capturePreview(object, { width, height })
|
|
810
|
+
: Promise.resolve(null);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private notify(): void {
|
|
814
|
+
for (const listener of this.listeners) listener();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ------------------------------------------------- late structural commits
|
|
818
|
+
//
|
|
819
|
+
// Re-indexing at the READ (see `hierarchy` below) is what makes a late
|
|
820
|
+
// subtree NAMEABLE; this is what makes the panel ask again. Pixi emits
|
|
821
|
+
// `childAdded`/`childRemoved` on the parent, so watching the root plus every
|
|
822
|
+
// indexed object covers every path a new subtree can enter through, and the
|
|
823
|
+
// listeners are re-attached after each notification so a freshly-committed
|
|
824
|
+
// subtree is itself watched. Both axes need it, for different reasons: an
|
|
825
|
+
// ingested port waits on its resources, and a first-party world commonly
|
|
826
|
+
// gates its whole content on an atlas.
|
|
827
|
+
|
|
828
|
+
private watchedForStructure: Container[] = [];
|
|
829
|
+
private structureNotifyQueued = false;
|
|
830
|
+
|
|
831
|
+
private readonly onStructureChanged = (): void => {
|
|
832
|
+
// STALENESS IS ANSWERED NOW; the walk is what waits. The projector holds
|
|
833
|
+
// one projection per synchronous turn, and it can only know to drop it
|
|
834
|
+
// when a mover tells it — its own write paths do, and this watcher is the
|
|
835
|
+
// other mover: the GAME committed, behind the adapter's back, which is the
|
|
836
|
+
// whole reason this watcher exists. A read arriving before the microtask
|
|
837
|
+
// below (a status/coverage pass, `hierarchy.roots()` from a panel) would
|
|
838
|
+
// otherwise answer from the walk taken before the commit.
|
|
839
|
+
this.projector.invalidate();
|
|
840
|
+
// One notification per commit burst: React's commit is synchronous, so a
|
|
841
|
+
// microtask runs after the WHOLE subtree is in the tree, however many
|
|
842
|
+
// `childAdded` events it fired.
|
|
843
|
+
if (this.structureNotifyQueued) return;
|
|
844
|
+
this.structureNotifyQueued = true;
|
|
845
|
+
queueMicrotask(() => {
|
|
846
|
+
this.structureNotifyQueued = false;
|
|
847
|
+
this.projector.reproject();
|
|
848
|
+
this.watchStructure(this.root);
|
|
849
|
+
this.target.onReindex();
|
|
850
|
+
this.notify();
|
|
851
|
+
this.store.notifyIngestEdit();
|
|
852
|
+
});
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
private watchStructure(root: Container): void {
|
|
856
|
+
for (const object of this.watchedForStructure) {
|
|
857
|
+
object.off('childAdded', this.onStructureChanged);
|
|
858
|
+
object.off('childRemoved', this.onStructureChanged);
|
|
859
|
+
}
|
|
860
|
+
this.watchedForStructure = [];
|
|
861
|
+
const watch = (object: Container): void => {
|
|
862
|
+
// A tracked node is not guaranteed to be a real PixiJS display object —
|
|
863
|
+
// the same reason `getTransform` tolerates one without a position. An
|
|
864
|
+
// emitter it does not have is a node whose structure cannot change under
|
|
865
|
+
// us either, so skipping it loses nothing.
|
|
866
|
+
if (typeof object.on === 'function' && typeof object.off === 'function') {
|
|
867
|
+
object.on('childAdded', this.onStructureChanged);
|
|
868
|
+
object.on('childRemoved', this.onStructureChanged);
|
|
869
|
+
this.watchedForStructure.push(object);
|
|
870
|
+
}
|
|
871
|
+
for (const child of object.children ?? []) watch(child as Container);
|
|
872
|
+
};
|
|
873
|
+
watch(root);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// -------------------------------------------------------------- hierarchy
|
|
877
|
+
|
|
878
|
+
readonly hierarchy: HierarchyProvider = {
|
|
879
|
+
// THE READ IS THE REFRESH POINT. A world whose content arrives after the
|
|
880
|
+
// adapter was handed its root commits real nodes into this same tree
|
|
881
|
+
// later, and a node the last index never saw carries no id: its parent
|
|
882
|
+
// reports a child it cannot name, so the hierarchy stops at the last row
|
|
883
|
+
// that WAS indexed while the viewport draws the whole world. The index is a
|
|
884
|
+
// walk of a display list, so re-doing it per read is cheaper than any
|
|
885
|
+
// scheme for noticing when it went stale.
|
|
886
|
+
roots: () => {
|
|
887
|
+
this.projector.project();
|
|
888
|
+
return this.projector
|
|
889
|
+
.roots()
|
|
890
|
+
.map((node) => this.toEditorNode(node.id))
|
|
891
|
+
.filter((node): node is EditorNode => node !== null);
|
|
892
|
+
},
|
|
893
|
+
node: (id) => this.toEditorNode(id),
|
|
894
|
+
// No `object3D`/`idForObject3D`: canvas entities are PIXI display objects,
|
|
895
|
+
// not THREE — the concept does not apply here (no 3D gizmo binding).
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
private toEditorNode(id: string): EditorNode | null {
|
|
899
|
+
const node = this.projector.node(id);
|
|
900
|
+
if (!node) return null;
|
|
901
|
+
const object = this.projector.object(id);
|
|
902
|
+
const componentRoot = object ? isCanvasComponentInstanceRoot(object) : false;
|
|
903
|
+
const component = componentRoot ? readContainerAuthoringComponent(object) : undefined;
|
|
904
|
+
return {
|
|
905
|
+
id: node.id,
|
|
906
|
+
label:
|
|
907
|
+
(componentRoot && object ? readContainerAuthoringLabel(object) : undefined) ?? node.label,
|
|
908
|
+
role: componentRoot ? 'component' : 'entity',
|
|
909
|
+
kind: node.kind,
|
|
910
|
+
...(component ? { typeLabel: component } : {}),
|
|
911
|
+
parentId: node.parentId,
|
|
912
|
+
childIds: node.childIds,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
readonly selection: SelectionProvider = {
|
|
917
|
+
get: () => [...this.store.selectedEntityIds],
|
|
918
|
+
set: (ids) => {
|
|
919
|
+
this.store.selectMultiple(ids);
|
|
920
|
+
this.notify();
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// -------------------------------------------------------------- structure
|
|
925
|
+
//
|
|
926
|
+
// EVERY OP IS A LIVE OP ON THE CAPTURED TREE — Pixi's own
|
|
927
|
+
// `addChild`/`removeChild`/`addChildAt` — bracketed in ONE
|
|
928
|
+
// `CanvasStructureHistory` transaction, so one Ctrl+Z reverts the whole op.
|
|
929
|
+
// That is the same project-history path a transform gesture takes; see that
|
|
930
|
+
// module for why the structural state is a resource of its own.
|
|
931
|
+
//
|
|
932
|
+
// PERSISTENCE IS REPORTED, NEVER PRETENDED. A canvas structural edit would
|
|
933
|
+
// have to add, delete or move a CONSTRUCTION STATEMENT in the world's own
|
|
934
|
+
// source, and neither persistence axis plans statements: the TSX target
|
|
935
|
+
// writes JSX props, the creation-site target rewrites a literal at the line
|
|
936
|
+
// that constructed an object. An editor-created node has no such line at all.
|
|
937
|
+
// So the live op proceeds — a canvas world is authorable in the session the
|
|
938
|
+
// same way a live three world is — and the target says, in its own voice,
|
|
939
|
+
// that it stays there (`reportStructureLiveOnly`). Source insertion is a
|
|
940
|
+
// separate capability, and inventing half of it here would be the sidecar the
|
|
941
|
+
// ingest-authoring model forbids.
|
|
942
|
+
//
|
|
943
|
+
// Absent on purpose: `wrap`/`unwrap`/`group`/`ungroup`/`copy`/`cut`/`paste`.
|
|
944
|
+
// The shell hides those affordances for this adapter, which is the honest
|
|
945
|
+
// degrade — a half-answer would be a menu item that silently does nothing.
|
|
946
|
+
|
|
947
|
+
private readonly liveStructure: StructureProvider = {
|
|
948
|
+
create: (kind, parentId) => this.createStructuralNode(kind, parentId ?? null),
|
|
949
|
+
remove: (id) => this.removeStructuralNodes([id]),
|
|
950
|
+
removeMany: (ids) => this.removeStructuralNodes(ids),
|
|
951
|
+
duplicate: (id) => this.duplicateStructuralNode(id),
|
|
952
|
+
reparent: (id, newParentId) => this.reparentStructuralNode(id, newParentId),
|
|
953
|
+
reorder: (id, beforeSiblingId) => this.reorderStructuralNode(id, beforeSiblingId),
|
|
954
|
+
// The palette is the same everywhere in this tree: any container can hold
|
|
955
|
+
// any of these, including the stage root (`parentId === null`).
|
|
956
|
+
creatableKinds: () => CANVAS_CREATABLE_KINDS.map((entry) => ({ ...entry })),
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
/** The container an op's `parentId` names — `null`/`''` is the stage root,
|
|
960
|
+
* which is a legal parent even though it is not one of this adapter's rows. */
|
|
961
|
+
private containerFor(parentId: string | null): Container | null {
|
|
962
|
+
if (!parentId) return this.root;
|
|
963
|
+
const object = this.projector.object(parentId);
|
|
964
|
+
if (!object || typeof object.addChild !== 'function') return null;
|
|
965
|
+
return object;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private refuseStructure(reason: string): Promise<WriteAck> {
|
|
969
|
+
return runWritePipe({
|
|
970
|
+
resolve: () => resolvesLiveOnly(reason),
|
|
971
|
+
record: () => undefined,
|
|
972
|
+
report: (said) =>
|
|
973
|
+
editorConsole.warn(`[canvas] structural edit refused — ${said}`, 'authoring'),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Say where a completed structural op lives, in the target's own voice when it
|
|
979
|
+
* has one (see {@link CanvasWriteTarget.reportStructureLiveOnly}) — and ANSWER
|
|
980
|
+
* FOR IT through the pipe.
|
|
981
|
+
*
|
|
982
|
+
* This lane has no structural writer at all (see the block comment above), so
|
|
983
|
+
* its resolution is the live-only arm, which by the pipe's type has no `write`
|
|
984
|
+
* member: an ack claiming a destination is a compile error here rather than a
|
|
985
|
+
* discipline. Acking `persisted: true` for an op that only ever touched RAM is
|
|
986
|
+
* exactly the blanket answer the pipe replaced.
|
|
987
|
+
*/
|
|
988
|
+
private reportStructureLiveOnly(label: string): Promise<WriteAck> {
|
|
989
|
+
return runWritePipe({
|
|
990
|
+
resolve: () => resolvesLiveOnly(STRUCTURE_LIVE_ONLY_REASON),
|
|
991
|
+
record: () => undefined,
|
|
992
|
+
report: (reason) => {
|
|
993
|
+
if (this.target.reportStructureLiveOnly) {
|
|
994
|
+
this.target.reportStructureLiveOnly(label, reason);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
editorConsole.log(`[canvas] ${label} is live-only — ${reason}.`, 'authoring');
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Re-index and tell the panels, SYNCHRONOUSLY.
|
|
1004
|
+
*
|
|
1005
|
+
* The `childAdded` watcher below queues the same work in a microtask (one
|
|
1006
|
+
* notification per React commit burst), but an op's own caller needs the new
|
|
1007
|
+
* id NOW — `create` returns one, and "create then immediately select it" is
|
|
1008
|
+
* the ordinary shell sequence.
|
|
1009
|
+
*/
|
|
1010
|
+
private afterStructuralChange(): void {
|
|
1011
|
+
this.projector.reproject();
|
|
1012
|
+
this.watchStructure(this.root);
|
|
1013
|
+
this.target.onReindex();
|
|
1014
|
+
this.notify();
|
|
1015
|
+
this.store.notifyIngestEdit();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private idOf(object: Container): string {
|
|
1019
|
+
return (object as Container & { __authId?: string }).__authId ?? '';
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* The lane that mints a GENUINELY SYNCHRONOUS id: the object is in the running
|
|
1024
|
+
* tree and re-indexed before this returns, so the caller can select it in the
|
|
1025
|
+
* same turn. The live-only report is the other half of the answer and rides
|
|
1026
|
+
* back in `ack` (see `StructuralIdWrite`) instead of being fired `void`.
|
|
1027
|
+
*/
|
|
1028
|
+
private createStructuralNode(kind: string, parentId: string | null): StructuralIdWrite {
|
|
1029
|
+
const parent = this.containerFor(parentId);
|
|
1030
|
+
if (!parent) {
|
|
1031
|
+
return {
|
|
1032
|
+
id: '',
|
|
1033
|
+
ack: this.refuseStructure(`no node with id "${parentId}" can hold children.`),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
const node = createDisplayObject(this.pixi, kind);
|
|
1037
|
+
if (!node) {
|
|
1038
|
+
return {
|
|
1039
|
+
id: '',
|
|
1040
|
+
ack: this.refuseStructure(`"${kind}" is not a kind this canvas surface can construct.`),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
node.label = defaultLabelFor(kind);
|
|
1044
|
+
const label = `Create ${node.label}`;
|
|
1045
|
+
this.structureHistory.track(parent);
|
|
1046
|
+
this.structureHistory.track(node);
|
|
1047
|
+
this.structureHistory.run(label, () => {
|
|
1048
|
+
parent.addChild(node);
|
|
1049
|
+
});
|
|
1050
|
+
this.afterStructuralChange();
|
|
1051
|
+
return { id: this.idOf(node), ack: this.reportStructureLiveOnly(label) };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** One transaction for the whole batch — `remove` is the single-id case of
|
|
1055
|
+
* the same op, so a multi-delete is one Ctrl+Z rather than N. */
|
|
1056
|
+
private removeStructuralNodes(ids: readonly string[]): Promise<WriteAck> {
|
|
1057
|
+
const doomed: Container[] = [];
|
|
1058
|
+
let refusal: Promise<WriteAck> | null = null;
|
|
1059
|
+
for (const id of ids) {
|
|
1060
|
+
const object = this.projector.object(id);
|
|
1061
|
+
if (!object?.parent) {
|
|
1062
|
+
refusal = this.refuseStructure(`"${id}" is not a removable node in this tree.`);
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
doomed.push(object);
|
|
1066
|
+
}
|
|
1067
|
+
if (doomed.length === 0) {
|
|
1068
|
+
return refusal ?? this.refuseStructure('the selection held no removable node.');
|
|
1069
|
+
}
|
|
1070
|
+
const label =
|
|
1071
|
+
doomed.length === 1
|
|
1072
|
+
? `Delete ${doomed[0]!.label ?? 'node'}`
|
|
1073
|
+
: `Delete ${doomed.length} nodes`;
|
|
1074
|
+
for (const object of doomed) {
|
|
1075
|
+
this.structureHistory.track(object);
|
|
1076
|
+
if (object.parent) this.structureHistory.track(object.parent as Container);
|
|
1077
|
+
}
|
|
1078
|
+
this.structureHistory.run(label, () => {
|
|
1079
|
+
for (const object of doomed) object.parent?.removeChild(object);
|
|
1080
|
+
});
|
|
1081
|
+
this.afterStructuralChange();
|
|
1082
|
+
return this.reportStructureLiveOnly(label);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private duplicateStructuralNode(id: string): StructuralIdWrite {
|
|
1086
|
+
const object = this.projector.object(id);
|
|
1087
|
+
const parent = (object?.parent as Container | null) ?? null;
|
|
1088
|
+
if (!object || !parent) {
|
|
1089
|
+
// A THROW, not a silent '' — `duplicate` has no refusal channel in its
|
|
1090
|
+
// signature, and a caller that gets an empty id back cannot tell "refused"
|
|
1091
|
+
// from "the new node has no id yet".
|
|
1092
|
+
throw new Error(`"${id}" is not a duplicable node in this tree.`);
|
|
1093
|
+
}
|
|
1094
|
+
// Built BEFORE the transaction opens: an uncloneable node must refuse
|
|
1095
|
+
// without having recorded a history entry for a copy that never happened.
|
|
1096
|
+
const clone = cloneDisplayObject(this.pixi, object);
|
|
1097
|
+
clone.label = `${object.label || 'node'} copy`;
|
|
1098
|
+
const label = `Duplicate ${object.label ?? 'node'}`;
|
|
1099
|
+
const index = parent.children.indexOf(object);
|
|
1100
|
+
this.structureHistory.track(parent);
|
|
1101
|
+
this.structureHistory.track(clone);
|
|
1102
|
+
this.structureHistory.run(label, () => {
|
|
1103
|
+
parent.addChildAt(clone, index + 1);
|
|
1104
|
+
});
|
|
1105
|
+
this.afterStructuralChange();
|
|
1106
|
+
return { id: this.idOf(clone), ack: this.reportStructureLiveOnly(label) };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private reparentStructuralNode(id: string, newParentId: string | null): Promise<WriteAck> {
|
|
1110
|
+
const object = this.projector.object(id);
|
|
1111
|
+
if (!object) return this.refuseStructure(`"${id}" is not a node in this tree.`);
|
|
1112
|
+
const parent = this.containerFor(newParentId);
|
|
1113
|
+
if (!parent) {
|
|
1114
|
+
return this.refuseStructure(`no node with id "${newParentId}" can hold children.`);
|
|
1115
|
+
}
|
|
1116
|
+
if (parent === object || this.isAncestorOf(object, parent)) {
|
|
1117
|
+
return this.refuseStructure('a node cannot be reparented into itself or its own descendant.');
|
|
1118
|
+
}
|
|
1119
|
+
// WORLD TRANSFORM PRESERVED, computed from the accumulated LOCAL walk —
|
|
1120
|
+
// deliberately not Pixi's own `reparentChild`, which reads `worldTransform`.
|
|
1121
|
+
// That matrix is populated by the RENDER pass (see `hitTestChildren`), so on
|
|
1122
|
+
// a stage no renderer has drawn yet it is identity, and `reparentChild`
|
|
1123
|
+
// would silently teleport the node to the new parent's origin.
|
|
1124
|
+
const world = this.localToRootMatrix(object);
|
|
1125
|
+
const parentWorld = this.localToRootMatrix(parent);
|
|
1126
|
+
const label = `Move ${object.label ?? 'node'}`;
|
|
1127
|
+
this.structureHistory.track(object);
|
|
1128
|
+
if (object.parent) this.structureHistory.track(object.parent as Container);
|
|
1129
|
+
this.structureHistory.track(parent);
|
|
1130
|
+
this.structureHistory.run(label, () => {
|
|
1131
|
+
parent.addChild(object);
|
|
1132
|
+
object.setFromMatrix(parentWorld.clone().invert().append(world));
|
|
1133
|
+
});
|
|
1134
|
+
this.afterStructuralChange();
|
|
1135
|
+
return this.reportStructureLiveOnly(label);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private reorderStructuralNode(id: string, beforeSiblingId: string | null): Promise<WriteAck> {
|
|
1139
|
+
const object = this.projector.object(id);
|
|
1140
|
+
const parent = (object?.parent as Container | null) ?? null;
|
|
1141
|
+
if (!object || !parent) {
|
|
1142
|
+
return this.refuseStructure(`"${id}" is not a reorderable node in this tree.`);
|
|
1143
|
+
}
|
|
1144
|
+
const sibling = beforeSiblingId ? this.projector.object(beforeSiblingId) : null;
|
|
1145
|
+
if (beforeSiblingId && (!sibling || sibling.parent !== parent)) {
|
|
1146
|
+
return this.refuseStructure(`"${beforeSiblingId}" is not a sibling of "${id}".`);
|
|
1147
|
+
}
|
|
1148
|
+
const label = `Reorder ${object.label ?? 'node'}`;
|
|
1149
|
+
this.structureHistory.track(parent);
|
|
1150
|
+
this.structureHistory.track(object);
|
|
1151
|
+
this.structureHistory.run(label, () => {
|
|
1152
|
+
const current = parent.children.indexOf(object);
|
|
1153
|
+
// `addChildAt` on the SAME parent lifts the node out first, so every
|
|
1154
|
+
// index above it shifts down by one — which is why a downward move lands
|
|
1155
|
+
// one slot earlier than the target's current index. `null` means "to the
|
|
1156
|
+
// end", i.e. the last slot of the lifted-out array.
|
|
1157
|
+
const target = sibling ? parent.children.indexOf(sibling) : parent.children.length;
|
|
1158
|
+
parent.addChildAt(object, Math.max(0, current < target ? target - 1 : target));
|
|
1159
|
+
});
|
|
1160
|
+
this.afterStructuralChange();
|
|
1161
|
+
return this.reportStructureLiveOnly(label);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
private isAncestorOf(ancestor: Container, node: Container): boolean {
|
|
1165
|
+
let cursor: Container | null = node.parent as Container | null;
|
|
1166
|
+
while (cursor) {
|
|
1167
|
+
if (cursor === ancestor) return true;
|
|
1168
|
+
cursor = cursor.parent as Container | null;
|
|
1169
|
+
}
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* One object's matrix in the stage root's frame, accumulated from each
|
|
1175
|
+
* ancestor's own `localTransform` — the same render-pass-independent walk
|
|
1176
|
+
* `hitTestChildren` does, for the same reason.
|
|
1177
|
+
*/
|
|
1178
|
+
private localToRootMatrix(object: Container): Matrix {
|
|
1179
|
+
const chain: Container[] = [];
|
|
1180
|
+
let cursor: Container | null = object;
|
|
1181
|
+
while (cursor && cursor !== this.root) {
|
|
1182
|
+
chain.push(cursor);
|
|
1183
|
+
cursor = cursor.parent as Container | null;
|
|
1184
|
+
}
|
|
1185
|
+
const matrix = new this.pixi.Matrix();
|
|
1186
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
1187
|
+
const node = chain[i]!;
|
|
1188
|
+
node.updateLocalTransform();
|
|
1189
|
+
matrix.append(node.localTransform);
|
|
1190
|
+
}
|
|
1191
|
+
return matrix;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ------------------------------------------------------------- asset drop
|
|
1195
|
+
//
|
|
1196
|
+
// "Drag an image from the asset browser onto the world" — the canvas lane's
|
|
1197
|
+
// half of the core authoring loop. An image becomes a Sprite through the SAME
|
|
1198
|
+
// create machinery and the same one-transaction bracket as
|
|
1199
|
+
// `structure.create`, so it is one Ctrl+Z and it carries the same honest
|
|
1200
|
+
// live-only report.
|
|
1201
|
+
//
|
|
1202
|
+
// Two callers, two shapes of target (see `AssetDropProvider`): the VIEWPORT
|
|
1203
|
+
// passes `''` (the world itself) plus the point under the cursor, in the
|
|
1204
|
+
// stage's own coordinate space — the frame `rects` answers in; the HIERARCHY
|
|
1205
|
+
// passes the row dropped on and no position, and the sprite lands at that
|
|
1206
|
+
// parent's origin.
|
|
1207
|
+
|
|
1208
|
+
readonly assetDrop: AssetDropProvider;
|
|
1209
|
+
|
|
1210
|
+
private async dropAsset(
|
|
1211
|
+
nodeId: string,
|
|
1212
|
+
assetPath: string,
|
|
1213
|
+
context?: AssetDropContext,
|
|
1214
|
+
): Promise<WriteAck> {
|
|
1215
|
+
if (!IMAGE_ASSET_RE.test(assetPath)) {
|
|
1216
|
+
// Both shell callers consult `accepts` first, so this is the direct-call
|
|
1217
|
+
// path — it still says why rather than dropping the gesture on the floor.
|
|
1218
|
+
return this.refuseStructure(
|
|
1219
|
+
`${assetPath} is not an image. A canvas world mounts an image as a Sprite; a model or an ` +
|
|
1220
|
+
'audio file has no display object to become here.',
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
const parent = this.containerFor(nodeId || null);
|
|
1224
|
+
if (!parent) {
|
|
1225
|
+
return this.refuseStructure(`no node with id "${nodeId}" can hold a dropped asset.`);
|
|
1226
|
+
}
|
|
1227
|
+
let texture: Texture;
|
|
1228
|
+
try {
|
|
1229
|
+
texture = await this.loadTexture(assetPath);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
return this.refuseStructure(
|
|
1232
|
+
`${assetPath} could not be loaded as a texture: ${
|
|
1233
|
+
error instanceof Error ? error.message : String(error)
|
|
1234
|
+
}`,
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
const sprite = new this.pixi.Sprite(texture);
|
|
1238
|
+
// Centred on the drop point, which is where the user aimed.
|
|
1239
|
+
sprite.anchor.set(0.5, 0.5);
|
|
1240
|
+
sprite.label = assetPath.split('/').pop() ?? 'Sprite';
|
|
1241
|
+
const point = context?.position;
|
|
1242
|
+
if (point) {
|
|
1243
|
+
const local = this.localToRootMatrix(parent).clone().invert().apply({
|
|
1244
|
+
x: point[0],
|
|
1245
|
+
y: point[1],
|
|
1246
|
+
});
|
|
1247
|
+
sprite.position.set(local.x, local.y);
|
|
1248
|
+
}
|
|
1249
|
+
const label = `Add ${sprite.label}`;
|
|
1250
|
+
this.structureHistory.track(parent);
|
|
1251
|
+
this.structureHistory.track(sprite);
|
|
1252
|
+
this.structureHistory.run(label, () => {
|
|
1253
|
+
parent.addChild(sprite);
|
|
1254
|
+
});
|
|
1255
|
+
this.afterStructuralChange();
|
|
1256
|
+
// A drop is a structural write like any other, so it answers with its own
|
|
1257
|
+
// ack (`AssetDropProvider.drop`) rather than producing one and discarding it.
|
|
1258
|
+
return this.reportStructureLiveOnly(label);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// -------------------------------------------------------------- transforms
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Canvas-native manipulation contribution for the shared 2D selection
|
|
1265
|
+
* overlay. The overlay speaks absolute stage-space rectangles and rotation
|
|
1266
|
+
* deltas; this provider translates those gestures into the same neutral
|
|
1267
|
+
* transform channel the inspector uses. The Pixi target still owns source
|
|
1268
|
+
* persistence, physics coordination, and the one-undo-step bracket.
|
|
1269
|
+
*/
|
|
1270
|
+
readonly boxEdit: BoxEditProvider = {
|
|
1271
|
+
begin: (id) => {
|
|
1272
|
+
const rect = this.projector.rect(id);
|
|
1273
|
+
if (!rect) {
|
|
1274
|
+
this.boxEditSessions.delete(id);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
this.boxEditSessions.set(id, { id, rect, transform: this.transforms.get(id) });
|
|
1278
|
+
this.transforms.beginEdit?.(id);
|
|
1279
|
+
},
|
|
1280
|
+
apply: (id, patch) => this.applyBoxEdit(id, patch),
|
|
1281
|
+
end: (id) => {
|
|
1282
|
+
const session = this.boxEditSessions.get(id);
|
|
1283
|
+
this.boxEditSessions.delete(id);
|
|
1284
|
+
if (!session) return;
|
|
1285
|
+
this.transforms.endEdit?.(id);
|
|
1286
|
+
},
|
|
1287
|
+
gizmoOrigin: (id) => this.gizmoOriginOf(id),
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
/** Pixi's position projected through its parent chain is the invariant
|
|
1291
|
+
* point around which its native pivot rotates/scales. For a Sprite, anchor
|
|
1292
|
+
* shifts the rendered vertices around this same point; neither case can be
|
|
1293
|
+
* recovered from the visual bounds' center. */
|
|
1294
|
+
private gizmoOriginOf(id: string): { x: number; y: number } | null {
|
|
1295
|
+
const object = this.projector.object(id);
|
|
1296
|
+
if (!object || typeof object.getGlobalPosition !== 'function') return null;
|
|
1297
|
+
const point = object.getGlobalPosition();
|
|
1298
|
+
return { x: point.x, y: point.y };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
private applyBoxEdit(id: string, patch: Record<string, number>): void {
|
|
1302
|
+
const session = this.boxEditSessions.get(id);
|
|
1303
|
+
if (!session) return;
|
|
1304
|
+
if (
|
|
1305
|
+
boxPatchChannels(patch).some(
|
|
1306
|
+
(channel) => this.transforms.editability?.(id, channel).writable === false,
|
|
1307
|
+
)
|
|
1308
|
+
) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const next = transformForBoxPatch(session, patch);
|
|
1312
|
+
this.transforms.apply(id, next);
|
|
1313
|
+
if (
|
|
1314
|
+
patch['x'] === undefined &&
|
|
1315
|
+
patch['y'] === undefined &&
|
|
1316
|
+
patch['originX'] === undefined &&
|
|
1317
|
+
patch['originY'] === undefined &&
|
|
1318
|
+
patch['width'] === undefined &&
|
|
1319
|
+
patch['height'] === undefined
|
|
1320
|
+
) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
this.repositionBoxEditBounds(id, session, patch, next);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private repositionBoxEditBounds(
|
|
1327
|
+
id: string,
|
|
1328
|
+
session: CanvasBoxEditSession,
|
|
1329
|
+
patch: Record<string, number>,
|
|
1330
|
+
next: Transform,
|
|
1331
|
+
): void {
|
|
1332
|
+
const object = this.projector.object(id);
|
|
1333
|
+
if (!object) return;
|
|
1334
|
+
const currentOrigin = this.gizmoOriginOf(id);
|
|
1335
|
+
const currentRect = this.projector.rect(id);
|
|
1336
|
+
const { dx, dy } = requestedBoxEditDelta(currentOrigin, currentRect, session.rect, patch);
|
|
1337
|
+
const parent = object.parent;
|
|
1338
|
+
if (parent && typeof parent.toLocal === 'function') {
|
|
1339
|
+
const origin = parent.toLocal({ x: 0, y: 0 });
|
|
1340
|
+
const shifted = parent.toLocal({ x: dx, y: dy });
|
|
1341
|
+
next.position[0] += shifted.x - origin.x;
|
|
1342
|
+
next.position[1] += shifted.y - origin.y;
|
|
1343
|
+
} else {
|
|
1344
|
+
next.position[0] += dx;
|
|
1345
|
+
next.position[1] += dy;
|
|
1346
|
+
}
|
|
1347
|
+
this.transforms.apply(id, next);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// --------------------------------------------------------- spatial handles
|
|
1351
|
+
//
|
|
1352
|
+
// THE ONE COMPONENT-OWNED SPATIAL VALUE A CANVAS NODE HAS that its box does
|
|
1353
|
+
// not already report: its transform ORIGIN — a container's `pivot`, a
|
|
1354
|
+
// sprite's normalized `anchor`. Everything else the three lane draws here (an
|
|
1355
|
+
// attenuation radius, a light cone, a collider extent) is a component this
|
|
1356
|
+
// surface has no analogue of; the origin is the one that is real, and it is
|
|
1357
|
+
// invisible in every other panel because it is not a rect and not a position.
|
|
1358
|
+
//
|
|
1359
|
+
// WHY THE ORIGIN MOVE COMPENSATES `position`. The origin's world point IS the
|
|
1360
|
+
// node's global position (Pixi maps the pivot to `position`, and a sprite's
|
|
1361
|
+
// anchor point to its local zero), so changing the origin alone can never move
|
|
1362
|
+
// the handle — the dot would spring back under the cursor on every drag. The
|
|
1363
|
+
// gesture therefore does what every editor's origin affordance does: the
|
|
1364
|
+
// CONTENT stays where the author put it and the ORIGIN follows the pointer,
|
|
1365
|
+
// which takes one write to the origin and one compensating write to
|
|
1366
|
+
// `position`. Both land inside ONE `beginTransformEdit`/`endTransformEdit`
|
|
1367
|
+
// bracket, so they are one history transaction and one Ctrl+Z.
|
|
1368
|
+
//
|
|
1369
|
+
// The category is `origin` rather than one of the shell's named helper
|
|
1370
|
+
// channels: `HelperVisibility` names lights, colliders, audio and their
|
|
1371
|
+
// siblings, none of which this is, and the contract's own rule for a category
|
|
1372
|
+
// the shell does not know is that it obeys the master Helpers toggle (see
|
|
1373
|
+
// `SpatialHandleLayer`) — which is the correct behaviour here.
|
|
1374
|
+
|
|
1375
|
+
readonly spatialHandles: SpatialHandlesProvider = {
|
|
1376
|
+
layers: (id) => {
|
|
1377
|
+
const origin = this.originOf(id);
|
|
1378
|
+
if (!origin) return [];
|
|
1379
|
+
const editability = this.target.transformEditability(id, 'position');
|
|
1380
|
+
const writable = !!this.target.writeOrigin && editability.writable;
|
|
1381
|
+
return [
|
|
1382
|
+
{
|
|
1383
|
+
id: `origin:${id}`,
|
|
1384
|
+
category: 'origin',
|
|
1385
|
+
guides: [],
|
|
1386
|
+
handles: [
|
|
1387
|
+
{
|
|
1388
|
+
id: ORIGIN_HANDLE_ID,
|
|
1389
|
+
label: origin.kind === 'anchor' ? 'Sprite anchor' : 'Pivot',
|
|
1390
|
+
position: [origin.world.x, origin.world.y, 0],
|
|
1391
|
+
color: ORIGIN_HANDLE_COLOR,
|
|
1392
|
+
writable,
|
|
1393
|
+
// The target's own sentence when it has one; when it has no
|
|
1394
|
+
// origin write at all, the adapter says so itself — the same
|
|
1395
|
+
// "ABSENT ⇒ the adapter says it generically" rule
|
|
1396
|
+
// `reportStructureLiveOnly` follows.
|
|
1397
|
+
...(writable
|
|
1398
|
+
? {}
|
|
1399
|
+
: {
|
|
1400
|
+
reason:
|
|
1401
|
+
this.target.writeOrigin && editability.reason
|
|
1402
|
+
? editability.reason
|
|
1403
|
+
: ORIGIN_UNWRITABLE_REASON,
|
|
1404
|
+
}),
|
|
1405
|
+
},
|
|
1406
|
+
],
|
|
1407
|
+
},
|
|
1408
|
+
];
|
|
1409
|
+
},
|
|
1410
|
+
preview: (id, handleId, worldPosition) => {
|
|
1411
|
+
this.moveOrigin(id, handleId, worldPosition, 'preview');
|
|
1412
|
+
},
|
|
1413
|
+
commit: (id, handleId, worldPosition) => this.moveOrigin(id, handleId, worldPosition, 'commit'),
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
/** The node whose origin gesture is currently open, so a `commit` that
|
|
1417
|
+
* arrives without a preview still brackets exactly one gesture. */
|
|
1418
|
+
private originEditId: string | null = null;
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* One node's transform origin: which native value it is, its value in the
|
|
1422
|
+
* node's own units, and where it sits in the stage's frame (the SAME frame
|
|
1423
|
+
* `rects` answers in, so the overlay draws it with no correction).
|
|
1424
|
+
*/
|
|
1425
|
+
private originOf(
|
|
1426
|
+
id: string,
|
|
1427
|
+
): { kind: 'pivot' | 'anchor'; value: PointData; world: PointData } | null {
|
|
1428
|
+
const object = this.projector.object(id);
|
|
1429
|
+
if (!object || typeof object.updateLocalTransform !== 'function') return null;
|
|
1430
|
+
const sprite = anchoredSprite(object);
|
|
1431
|
+
const kind = sprite ? 'anchor' : 'pivot';
|
|
1432
|
+
const value = sprite
|
|
1433
|
+
? { x: sprite.anchor.x, y: sprite.anchor.y }
|
|
1434
|
+
: { x: object.pivot.x, y: object.pivot.y };
|
|
1435
|
+
// The LOCAL point the origin occupies: a container's pivot is that point
|
|
1436
|
+
// outright; a sprite's anchor names a point ON THE TEXTURE, which Pixi
|
|
1437
|
+
// always renders at the sprite's local zero.
|
|
1438
|
+
const local: PointData = sprite ? { x: 0, y: 0 } : value;
|
|
1439
|
+
const world = this.localToRootMatrix(object).apply({ x: local.x, y: local.y });
|
|
1440
|
+
return { kind, value, world: { x: world.x, y: world.y } };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Move `id`'s origin to a stage-space point, keeping the rendered content
|
|
1445
|
+
* still. `phase` is the whole difference between a live preview and the end
|
|
1446
|
+
* of the gesture: both write the same two values, and only `commit` closes
|
|
1447
|
+
* the bracket that records them.
|
|
1448
|
+
*/
|
|
1449
|
+
private moveOrigin(
|
|
1450
|
+
id: string,
|
|
1451
|
+
handleId: string,
|
|
1452
|
+
worldPosition: SpatialPoint3,
|
|
1453
|
+
phase: 'preview' | 'commit',
|
|
1454
|
+
): StructuralWriteOutcome {
|
|
1455
|
+
if (handleId !== ORIGIN_HANDLE_ID) return;
|
|
1456
|
+
const origin = this.originOf(id);
|
|
1457
|
+
const object = this.projector.object(id);
|
|
1458
|
+
const writeOrigin = this.target.writeOrigin;
|
|
1459
|
+
if (!origin || !object || !writeOrigin) return;
|
|
1460
|
+
if (!this.target.transformEditability(id, 'position').writable) return;
|
|
1461
|
+
if (this.originEditId !== id) {
|
|
1462
|
+
this.target.beginTransformEdit(id);
|
|
1463
|
+
this.originEditId = id;
|
|
1464
|
+
}
|
|
1465
|
+
object.updateLocalTransform();
|
|
1466
|
+
// The local point that currently lands under the pointer — the point the
|
|
1467
|
+
// origin has to become for the content to stay where it is.
|
|
1468
|
+
const target = this.localToRootMatrix(object)
|
|
1469
|
+
.clone()
|
|
1470
|
+
.invert()
|
|
1471
|
+
.apply({ x: worldPosition[0], y: worldPosition[1] });
|
|
1472
|
+
const localOrigin = origin.kind === 'anchor' ? { x: 0, y: 0 } : origin.value;
|
|
1473
|
+
const delta = { x: target.x - localOrigin.x, y: target.y - localOrigin.y };
|
|
1474
|
+
const size = origin.kind === 'anchor' ? anchorFrameSize(object) : null;
|
|
1475
|
+
if (origin.kind === 'anchor' && (!size || size.x === 0 || size.y === 0)) return;
|
|
1476
|
+
const next: readonly [number, number] = size
|
|
1477
|
+
? [origin.value.x + delta.x / size.x, origin.value.y + delta.y / size.y]
|
|
1478
|
+
: [target.x, target.y];
|
|
1479
|
+
// `position` compensates through the object's own rotation/scale, which is
|
|
1480
|
+
// what keeps the content still under a rotated or scaled node.
|
|
1481
|
+
const matrix = object.localTransform;
|
|
1482
|
+
const current = this.a2d.getTransform(id);
|
|
1483
|
+
writeOrigin(id, origin.kind, next);
|
|
1484
|
+
if (current) {
|
|
1485
|
+
this.target.writeTransform(id, {
|
|
1486
|
+
...current,
|
|
1487
|
+
position: [
|
|
1488
|
+
current.position[0] + matrix.a * delta.x + matrix.c * delta.y,
|
|
1489
|
+
current.position[1] + matrix.b * delta.x + matrix.d * delta.y,
|
|
1490
|
+
],
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
this.notify();
|
|
1494
|
+
if (phase === 'commit') {
|
|
1495
|
+
const ack = this.target.endTransformEdit(id);
|
|
1496
|
+
this.originEditId = null;
|
|
1497
|
+
return ack;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// --------------------------------------------------------------- inspector
|
|
1502
|
+
|
|
1503
|
+
readonly inspector: InspectorProvider = {
|
|
1504
|
+
properties: (id) => [
|
|
1505
|
+
...this.target.properties(id),
|
|
1506
|
+
{ path: 'locked', label: 'Locked', type: 'boolean', group: 'Visibility' },
|
|
1507
|
+
],
|
|
1508
|
+
get: (id, path) => (path === 'locked' ? this.lockedIds.has(id) : this.target.get(id, path)),
|
|
1509
|
+
set: (id, path, value) => {
|
|
1510
|
+
if (path === 'locked') {
|
|
1511
|
+
if (value === true) this.lockedIds.add(id);
|
|
1512
|
+
else this.lockedIds.delete(id);
|
|
1513
|
+
this.notify();
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
// The TARGET's per-edit ack, passed straight through.
|
|
1517
|
+
return this.target.set(id, path, value);
|
|
1518
|
+
},
|
|
1519
|
+
remove: (id, path) => this.target.remove?.(id, path),
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
private isPickLocked(id: string): boolean {
|
|
1523
|
+
let current: string | null = id;
|
|
1524
|
+
while (current) {
|
|
1525
|
+
if (this.lockedIds.has(current)) return true;
|
|
1526
|
+
current = this.projector.node(current)?.parentId ?? null;
|
|
1527
|
+
}
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ----------------------------------------------------------------- related
|
|
1532
|
+
//
|
|
1533
|
+
// A canvas subject's ONE honest jump is to the line that constructed it —
|
|
1534
|
+
// the shared kit piece (`creation-site-related.ts`), wired in the
|
|
1535
|
+
// constructor exactly when {@link truth} is present.
|
|
1536
|
+
|
|
1537
|
+
subscribe(listener: () => void): () => void {
|
|
1538
|
+
this.listeners.add(listener);
|
|
1539
|
+
return () => this.listeners.delete(listener);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
dispose(): void {
|
|
1543
|
+
this.boxEditSessions.clear();
|
|
1544
|
+
this.lockedIds.clear();
|
|
1545
|
+
for (const object of this.watchedForStructure) {
|
|
1546
|
+
object.off('childAdded', this.onStructureChanged);
|
|
1547
|
+
object.off('childRemoved', this.onStructureChanged);
|
|
1548
|
+
}
|
|
1549
|
+
this.watchedForStructure = [];
|
|
1550
|
+
this.structureHistory.dispose();
|
|
1551
|
+
this.target.dispose();
|
|
1552
|
+
this.listeners.clear();
|
|
1553
|
+
}
|
|
1554
|
+
}
|