@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,979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LIVE half of the canvas surface's persistence axis
|
|
3
|
+
* ({@link CanvasWriteTarget}) — a display tree the editor did not author the
|
|
4
|
+
* JSX of, driven through the running `PIXI.Container`s themselves.
|
|
5
|
+
*
|
|
6
|
+
* WHERE A CLOSED GESTURE GOES IS A PARAMETER, not a property of this file.
|
|
7
|
+
* Constructed with no {@link LiveCanvasWriteTargetOptions.persistence} backend
|
|
8
|
+
* it is exactly what it always was: edits apply to the running tree (and,
|
|
9
|
+
* where a body owns the transform, through the freeze→commit→unfreeze physics
|
|
10
|
+
* handshake) and are journaled for UNDO within the session, never saved.
|
|
11
|
+
* Constructed WITH one — which is what
|
|
12
|
+
* `createCreationSiteCanvasWriteTarget` hands it — a closed gesture is offered
|
|
13
|
+
* to the game's OWN SOURCE first, at the line that constructed the object, and
|
|
14
|
+
* only falls back to the session journal when the backend refuses, with the
|
|
15
|
+
* refusal's own reason reported. That is the same seam the three lane uses
|
|
16
|
+
* (`source-persistence-backend.ts`), not a second one: the ingest-authoring
|
|
17
|
+
* model's "every edit edits the game's own CODE or DATA" is one rule, and it
|
|
18
|
+
* would not survive two implementations.
|
|
19
|
+
*
|
|
20
|
+
* A REFUSAL IS PER-EDIT AND CARRIES ITS REASON. Nothing here decides whether a
|
|
21
|
+
* write is possible — `creation-site-edit.ts` decides it on the server against
|
|
22
|
+
* the game's real bytes — and nothing here retries with looser rules or writes
|
|
23
|
+
* anywhere else. The sentence the backend returns is what
|
|
24
|
+
* `transformEditability` shows before the gesture and what the console reports
|
|
25
|
+
* after it.
|
|
26
|
+
*
|
|
27
|
+
* Extracted verbatim from the class this replaces: it was a SECOND canvas
|
|
28
|
+
* adapter keyed on provenance, which ARCHITECTURE-CORE §Rules names the
|
|
29
|
+
* forbidden shape. Its live behaviour is unchanged; only its place in the seam
|
|
30
|
+
* moved, and the persistence parameter is what this unit added.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
AuthoringAdapter2D,
|
|
35
|
+
Overlay2D,
|
|
36
|
+
Transform2DValue,
|
|
37
|
+
} from '@volter/game-runtime/pixi/authoring';
|
|
38
|
+
import type { PhysicsAdapter2D } from '@volter/game-runtime/pixi/system-adapters';
|
|
39
|
+
import type {
|
|
40
|
+
ComponentInstanceApplyResult,
|
|
41
|
+
ComponentInstanceDescription,
|
|
42
|
+
ComponentInstanceOverride,
|
|
43
|
+
ComponentInstancesProvider,
|
|
44
|
+
NodeCreationSite,
|
|
45
|
+
PersistenceProvider,
|
|
46
|
+
PropertyDescriptor,
|
|
47
|
+
TransformChannel,
|
|
48
|
+
TransformEditability,
|
|
49
|
+
} from '@volter/editor-project/adapter';
|
|
50
|
+
import type { Container } from 'pixi.js';
|
|
51
|
+
import type { ChannelValue, CreationSiteLiteralReport } from '@volter/editor-core/creation-site-edit';
|
|
52
|
+
import { creationSiteAnchor, instancesAtSite } from '@volter/editor-core/creation-site-registry';
|
|
53
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
54
|
+
import { JsonHistoryResource } from '../history/json-history-resource';
|
|
55
|
+
import { createEphemeralPersistence } from './ephemeral-persistence';
|
|
56
|
+
import { multiChannelRefusal, persistChannelWrite } from './gesture-persist';
|
|
57
|
+
import type { CanvasWriteContext, CanvasWriteTarget } from './pixi-authoring-adapter';
|
|
58
|
+
import { transform2DChanged } from './pixi-transform-channels';
|
|
59
|
+
import type { SourcePersistenceBackend, SourceWriteSubject } from './source-persistence-backend';
|
|
60
|
+
import {
|
|
61
|
+
LIVE_ONLY_ACK,
|
|
62
|
+
LIVE_ONLY_DESTINATION,
|
|
63
|
+
resolvesLiveOnly,
|
|
64
|
+
runWritePipe,
|
|
65
|
+
type WriteAck,
|
|
66
|
+
type WriteResolution,
|
|
67
|
+
} from '@volter/editor-core/authoring/write-pipe';
|
|
68
|
+
|
|
69
|
+
interface PixiLiveHistoryState {
|
|
70
|
+
overlay: Overlay2D;
|
|
71
|
+
nodes: Record<
|
|
72
|
+
string,
|
|
73
|
+
{
|
|
74
|
+
label: string;
|
|
75
|
+
alpha: number;
|
|
76
|
+
visible: boolean;
|
|
77
|
+
tint?: number;
|
|
78
|
+
transform?: Transform2DValue;
|
|
79
|
+
/** The node's own transform ORIGIN — a container's pivot in local units,
|
|
80
|
+
* a sprite's normalized anchor. Journaled because an origin move is an
|
|
81
|
+
* authored edit like any other, and a snapshot that omitted it would
|
|
82
|
+
* record a transaction whose undo silently restored nothing. */
|
|
83
|
+
pivot?: readonly [number, number];
|
|
84
|
+
anchor?: readonly [number, number];
|
|
85
|
+
}
|
|
86
|
+
>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The point-shaped members this target reads and writes as a pair of numbers.
|
|
90
|
+
* A Pixi `ObservablePoint` is not structured-cloneable, so history holds the
|
|
91
|
+
* two numbers and puts them back through the same setter an edit uses. */
|
|
92
|
+
function pointOf(value: unknown): readonly [number, number] | undefined {
|
|
93
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
94
|
+
const point = value as { x?: unknown; y?: unknown };
|
|
95
|
+
return typeof point.x === 'number' && typeof point.y === 'number'
|
|
96
|
+
? [point.x, point.y]
|
|
97
|
+
: undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Put one journaled origin back through the same setter an edit uses — and
|
|
101
|
+
* only on an object that HAS that member, so restoring a container's snapshot
|
|
102
|
+
* never invents a sprite anchor. */
|
|
103
|
+
function restoreOrigin(
|
|
104
|
+
display: Container,
|
|
105
|
+
member: 'pivot' | 'anchor',
|
|
106
|
+
value: readonly [number, number] | undefined,
|
|
107
|
+
): void {
|
|
108
|
+
const shaped = display as Container & Record<string, unknown>;
|
|
109
|
+
if (!value || !pointOf(shaped[member])) return;
|
|
110
|
+
shaped[member] = { x: value[0], y: value[1] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function numToHex(n: number): string {
|
|
114
|
+
return `#${(n & 0xffffff).toString(16).padStart(6, '0')}`;
|
|
115
|
+
}
|
|
116
|
+
function hexToNum(hex: string): number {
|
|
117
|
+
return Number.parseInt(hex.replace('#', ''), 16);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The three transform channels, in the order `endTransformEdit` reports them. */
|
|
121
|
+
const TRANSFORM_CHANNELS = ['position', 'rotation', 'scale'] as const;
|
|
122
|
+
|
|
123
|
+
export interface LiveCanvasWriteTargetOptions {
|
|
124
|
+
/** Transform ownership + the freeze/commit/unfreeze handshake, so a drag
|
|
125
|
+
* sticks instead of being stomped by the next physics step. */
|
|
126
|
+
readonly physics: PhysicsAdapter2D;
|
|
127
|
+
/**
|
|
128
|
+
* Where a closed gesture's value goes. ABSENT ⇒ live-only for the session,
|
|
129
|
+
* which is the honest answer for a tree with no reachable source; present ⇒
|
|
130
|
+
* the game's own source, per edit, when every gate the backend owns is open.
|
|
131
|
+
*/
|
|
132
|
+
readonly persistence?: SourcePersistenceBackend | undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createLiveCanvasWriteTarget(
|
|
136
|
+
options: LiveCanvasWriteTargetOptions,
|
|
137
|
+
): CanvasWriteTarget {
|
|
138
|
+
const { physics } = options;
|
|
139
|
+
const persist = options.persistence ?? null;
|
|
140
|
+
let a2d: AuthoringAdapter2D;
|
|
141
|
+
let notify: () => void = () => {};
|
|
142
|
+
let historyResource: JsonHistoryResource<PixiLiveHistoryState> | null = null;
|
|
143
|
+
let editStartState: PixiLiveHistoryState | undefined;
|
|
144
|
+
/** The transform in force when the current gesture began — the value the
|
|
145
|
+
* creation-site planner's equality backstop is checked against. */
|
|
146
|
+
let editBaseline: Transform2DValue | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Did the current gesture move the node's ORIGIN (pivot/anchor)?
|
|
149
|
+
*
|
|
150
|
+
* The origin handle writes two things at once — the origin itself and a
|
|
151
|
+
* compensating `position` that keeps the drawn content still — and the origin
|
|
152
|
+
* is NOT one of `TRANSFORM_CHANNELS`. Counting only those would see a lone
|
|
153
|
+
* `position` move, write it into the game's source, and leave the origin half
|
|
154
|
+
* in the session alone; one Ctrl+Z would then undo half the drag, which is the
|
|
155
|
+
* exact failure `endTransformEdit`'s multi-property refusal exists to prevent.
|
|
156
|
+
* So the origin counts as a moved property there.
|
|
157
|
+
*/
|
|
158
|
+
let editMovedOrigin = false;
|
|
159
|
+
/** Has this session already raised the "structural edits stay live-only"
|
|
160
|
+
* warning? (See `reportStructureLiveOnly`.) */
|
|
161
|
+
let structureLiveOnlyAnnounced = false;
|
|
162
|
+
|
|
163
|
+
const captureHistoryState = (): PixiLiveHistoryState => {
|
|
164
|
+
const nodes: PixiLiveHistoryState['nodes'] = {};
|
|
165
|
+
const visit = (id: string): void => {
|
|
166
|
+
const node = a2d.node(id);
|
|
167
|
+
const display = a2d.displayObject(id) as (Container & { tint?: number }) | null;
|
|
168
|
+
if (!node || !display) return;
|
|
169
|
+
let transform: Transform2DValue | null = null;
|
|
170
|
+
try {
|
|
171
|
+
transform = a2d.getTransform(id);
|
|
172
|
+
} catch {
|
|
173
|
+
// A custom adapter may expose inspectable container-like nodes without
|
|
174
|
+
// Pixi transform observables. Their non-transform fields stay journaled.
|
|
175
|
+
}
|
|
176
|
+
const pivot = pointOf((display as { pivot?: unknown }).pivot);
|
|
177
|
+
const anchor = pointOf((display as { anchor?: unknown }).anchor);
|
|
178
|
+
nodes[id] = {
|
|
179
|
+
label: display.label ?? '',
|
|
180
|
+
alpha: display.alpha,
|
|
181
|
+
visible: display.visible,
|
|
182
|
+
...('tint' in display ? { tint: display.tint } : {}),
|
|
183
|
+
...(transform ? { transform } : {}),
|
|
184
|
+
...(pivot ? { pivot } : {}),
|
|
185
|
+
...(anchor ? { anchor } : {}),
|
|
186
|
+
};
|
|
187
|
+
for (const childId of node.childIds) visit(childId);
|
|
188
|
+
};
|
|
189
|
+
for (const root of a2d.roots()) visit(root.id);
|
|
190
|
+
return { overlay: structuredClone(a2d.serializeOverlay()), nodes };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const restoreHistoryState = (state: PixiLiveHistoryState): void => {
|
|
194
|
+
for (const [id, value] of Object.entries(state.nodes)) {
|
|
195
|
+
const display = a2d.displayObject(id) as (Container & { tint?: number }) | null;
|
|
196
|
+
if (!display) continue;
|
|
197
|
+
display.label = value.label;
|
|
198
|
+
display.alpha = value.alpha;
|
|
199
|
+
display.visible = value.visible;
|
|
200
|
+
if (value.tint !== undefined && 'tint' in display) display.tint = value.tint;
|
|
201
|
+
restoreOrigin(display, 'pivot', value.pivot);
|
|
202
|
+
restoreOrigin(display, 'anchor', value.anchor);
|
|
203
|
+
if (value.transform) a2d.setTransform(id, value.transform);
|
|
204
|
+
}
|
|
205
|
+
a2d.replaceOverlay(state.overlay);
|
|
206
|
+
notify();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* JOURNALED LOUDLY. A rejected `record(...)` means the edit IS applied to the
|
|
211
|
+
* live tree and has NO history entry: the user sees the value they changed
|
|
212
|
+
* and Ctrl+Z does nothing, with nothing said. Same statement (and same
|
|
213
|
+
* wording) as the three lane's `recordHistory`
|
|
214
|
+
* (`three-authoring-adapter.ts`) and the canvas structure lane's
|
|
215
|
+
* `CanvasStructureHistory.run`.
|
|
216
|
+
*
|
|
217
|
+
* WHY THE REPORT IS HERE AND NOT IN THE VERB'S ACK. Every value verb funnels
|
|
218
|
+
* through this function, and the ones that carry an ack — `set`,
|
|
219
|
+
* `endTransformEdit` — reach it on the arm where the pipe resolved LIVE-ONLY,
|
|
220
|
+
* whose ack answers a different question (where the BYTES went — nowhere, by
|
|
221
|
+
* design, on that arm) and cannot carry a journaling failure without claiming
|
|
222
|
+
* it was a persistence one. The rest (`revertOverrides`, the unpersisted
|
|
223
|
+
* `set`/`endTransformEdit` paths) return `void`, so no caller can await this
|
|
224
|
+
* write at all.
|
|
225
|
+
*/
|
|
226
|
+
const recordHistory = (label: string, before: PixiLiveHistoryState | undefined): void => {
|
|
227
|
+
if (historyResource && before) {
|
|
228
|
+
void historyResource.record(label, before, captureHistoryState()).catch((error: unknown) => {
|
|
229
|
+
editorConsole.error(
|
|
230
|
+
`[canvas] failed to journal "${label}" into project history — the edit is applied ` +
|
|
231
|
+
`but has no history entry (it cannot be undone): ${
|
|
232
|
+
error instanceof Error ? error.message : String(error)
|
|
233
|
+
}`,
|
|
234
|
+
'authoring',
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const writeProp = (id: string, path: string, value: unknown): void => {
|
|
241
|
+
const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
|
|
242
|
+
if (!display) return;
|
|
243
|
+
if (path === 'name') a2d.set(id, 'label', value as string);
|
|
244
|
+
else if (path === 'tint') a2d.set(id, 'tint', hexToNum(value as string));
|
|
245
|
+
else if (path === 'visible') a2d.set(id, 'visible', value as boolean);
|
|
246
|
+
else if (path === 'alpha') a2d.set(id, 'alpha', value as number);
|
|
247
|
+
else display[path] = value;
|
|
248
|
+
notify();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const readProp = (id: string, path: string): unknown => {
|
|
252
|
+
const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
|
|
253
|
+
if (!display) return undefined;
|
|
254
|
+
if (path === 'name') return display.label;
|
|
255
|
+
if (path === 'tint') return numToHex((display as { tint?: number }).tint ?? 0xffffff);
|
|
256
|
+
return display[path];
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// ───────────────────────────────────────────── the persistence collaboration
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One property's value in the shape the creation-site planner speaks.
|
|
263
|
+
*
|
|
264
|
+
* Pixi's own vocabulary, not the editor's neutral 3D transform: a canvas
|
|
265
|
+
* `position` is TWO numbers and a canvas `rotation` is ONE scalar in radians,
|
|
266
|
+
* which is what the game's own source holds and therefore what a literal at a
|
|
267
|
+
* creation site can be compared against. Translating to the neutral transform
|
|
268
|
+
* here would compare a quaternion to a number.
|
|
269
|
+
*/
|
|
270
|
+
const readChannel = (id: string, property: string): ChannelValue | undefined => {
|
|
271
|
+
const transform = a2d.getTransform(id);
|
|
272
|
+
if (property === 'position') return transform ? [...transform.position] : undefined;
|
|
273
|
+
if (property === 'rotation') return transform ? transform.rotation : undefined;
|
|
274
|
+
if (property === 'scale') return transform ? [...transform.scale] : undefined;
|
|
275
|
+
return readProp(id, property) as ChannelValue | undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Put one property back on the live object — the undo/redo half of a
|
|
279
|
+
* persisted edit, routed through the SAME writers an ordinary edit uses. */
|
|
280
|
+
const applyChannel = (id: string, property: string, value: ChannelValue): void => {
|
|
281
|
+
if (property === 'position' && Array.isArray(value)) {
|
|
282
|
+
a2d.setTransform(id, { position: [value[0] ?? 0, value[1] ?? 0] });
|
|
283
|
+
} else if (property === 'rotation' && typeof value === 'number') {
|
|
284
|
+
a2d.setTransform(id, { rotation: value });
|
|
285
|
+
} else if (property === 'scale' && Array.isArray(value)) {
|
|
286
|
+
a2d.setTransform(id, { scale: [value[0] ?? 1, value[1] ?? 1] });
|
|
287
|
+
} else {
|
|
288
|
+
writeProp(id, property, value);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
notify();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The creation site an edit to `property` would have to be written at, plus
|
|
296
|
+
* how many objects that site built.
|
|
297
|
+
*
|
|
298
|
+
* There is no OWNER HOP on this surface, and that is a fact rather than an
|
|
299
|
+
* omission: a Pixi display object holds its own tint, alpha and visibility —
|
|
300
|
+
* there is no separate material object with its own `new`, which is the case
|
|
301
|
+
* that forces `ChannelOwner` to exist on three.
|
|
302
|
+
*/
|
|
303
|
+
const subjectFor = (id: string, property: string): SourceWriteSubject => {
|
|
304
|
+
const anchor = creationSiteAnchor(a2d.displayObject(id) ?? null);
|
|
305
|
+
return {
|
|
306
|
+
entityId: id,
|
|
307
|
+
property,
|
|
308
|
+
surface: 'pixi',
|
|
309
|
+
anchor,
|
|
310
|
+
// This surface plans ONE lane and its absence: a construction literal in
|
|
311
|
+
// the game's own module, or nothing at all. There is no serve-time prop
|
|
312
|
+
// stamp here and no data-record anchor, and claiming either would be a
|
|
313
|
+
// kind this target cannot write.
|
|
314
|
+
anchorKind: anchor.anchored ? 'construction-literal' : 'live-only',
|
|
315
|
+
instances: anchor.anchored && anchor.kind === 'source' ? instancesAtSite(anchor) : 0,
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const labelOf = (id: string): string => a2d.node(id)?.label ?? '2D Object';
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Close out one gesture: write it into the game's own source if every gate is
|
|
323
|
+
* open, and otherwise journal it live-only.
|
|
324
|
+
*
|
|
325
|
+
* ONE gesture becomes ONE history entry either way — the persisted path's
|
|
326
|
+
* transaction carries both the file and the live value, so the live-only
|
|
327
|
+
* journal is SKIPPED when it succeeds rather than added to it. Transcribed
|
|
328
|
+
* from `ThreeAuthoringAdapter.persistOrJournal`, including its rule that a
|
|
329
|
+
* gesture which moved more than one channel is refused AS A WHOLE: a partial
|
|
330
|
+
* write would leave one channel in the file and one only in the session, and
|
|
331
|
+
* one Ctrl+Z would then undo half a drag.
|
|
332
|
+
*/
|
|
333
|
+
const persistOrJournal = (
|
|
334
|
+
id: string,
|
|
335
|
+
label: string,
|
|
336
|
+
before: PixiLiveHistoryState | undefined,
|
|
337
|
+
property: string | null,
|
|
338
|
+
baseline: ChannelValue | undefined,
|
|
339
|
+
next: ChannelValue | undefined,
|
|
340
|
+
): Promise<WriteAck> | undefined => {
|
|
341
|
+
if (!persist) return undefined;
|
|
342
|
+
if (property === null || baseline === undefined || next === undefined) {
|
|
343
|
+
// Nothing single-channel to write, so there is no edit for the pipe to
|
|
344
|
+
// carry — the live values still have to be undoable.
|
|
345
|
+
recordHistory(label, before);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
// The shared kit resolution (`gesture-persist.ts`). NOTE the drift it
|
|
349
|
+
// retired: this copy used to check the LANE before the gate, so a
|
|
350
|
+
// live-only subject whose gate was also shut earned a different refusal
|
|
351
|
+
// sentence here than on the three lane. The documented order — gate
|
|
352
|
+
// first, because it names the precise reason — now holds everywhere.
|
|
353
|
+
return persistChannelWrite({
|
|
354
|
+
backend: persist,
|
|
355
|
+
subject: () => subjectFor(id, property),
|
|
356
|
+
baseline,
|
|
357
|
+
next,
|
|
358
|
+
label,
|
|
359
|
+
journal: () => recordHistory(label, before),
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const listProperties = (id: string): PropertyDescriptor[] => {
|
|
364
|
+
const props: PropertyDescriptor[] = [
|
|
365
|
+
{ path: 'name', label: 'Name', type: 'string' },
|
|
366
|
+
{ path: 'visible', label: 'Visible', type: 'boolean' },
|
|
367
|
+
{ path: 'alpha', label: 'Alpha', type: 'number' },
|
|
368
|
+
];
|
|
369
|
+
const display = a2d.displayObject(id);
|
|
370
|
+
if (display && 'tint' in display) props.push({ path: 'tint', label: 'Tint', type: 'color' });
|
|
371
|
+
return props;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// ─────────────────────────────────── component instances: the site IS the component
|
|
375
|
+
//
|
|
376
|
+
// On this surface a "component" is not a declared React/prefab component at
|
|
377
|
+
// all — it is the CONSTRUCTION STATEMENT. `new Bubble(0.5, 0.5)` is the
|
|
378
|
+
// declaration, its literals are the defaults, and the live object the editor
|
|
379
|
+
// is showing is the instance. So an OVERRIDE is exactly "the live value is not
|
|
380
|
+
// the one that line names", REVERT puts the line's own value back on the
|
|
381
|
+
// object, and APPLY writes the live value INTO the line — which is what makes
|
|
382
|
+
// every sibling that line constructs inherit it on the next load.
|
|
383
|
+
//
|
|
384
|
+
// The literals come from the server (`/__ingest-source/inspect`), because
|
|
385
|
+
// deciding what a literal is means parsing the game's source, and that parser
|
|
386
|
+
// is deliberately not in the browser bundle. `describe` is synchronous, so it
|
|
387
|
+
// answers from a CACHE and asks for a cold one — see `literalsFor`.
|
|
388
|
+
|
|
389
|
+
/** Literals per SITE, not per node: they are a property of the line, and the
|
|
390
|
+
* same line can have constructed several of the nodes being inspected. */
|
|
391
|
+
const literalsBySite = new Map<string, Record<string, CreationSiteLiteralReport>>();
|
|
392
|
+
/** ONE read in flight per site, ever — the answer's promise, so a synchronous
|
|
393
|
+
* `describe` and an awaiting `revert` share the same request rather than
|
|
394
|
+
* racing two. A failed read resolves to `{}` and stays cached for the
|
|
395
|
+
* session, the same direction `loadIngestOwnership` fails in. */
|
|
396
|
+
const literalRequests = new Map<string, Promise<Record<string, CreationSiteLiteralReport>>>();
|
|
397
|
+
|
|
398
|
+
const siteKeyOf = (anchor: NodeCreationSite): string | null =>
|
|
399
|
+
anchor.anchored && anchor.kind === 'source'
|
|
400
|
+
? `${anchor.file}:${anchor.line}:${anchor.col}`
|
|
401
|
+
: null;
|
|
402
|
+
|
|
403
|
+
/** The channel paths a canvas subject can be described against: its three
|
|
404
|
+
* transform channels plus its own reflected inspector rows. */
|
|
405
|
+
const instanceProperties = (id: string): ReadonlyArray<{ path: string; label: string }> => [
|
|
406
|
+
{ path: 'position', label: 'Position' },
|
|
407
|
+
{ path: 'rotation', label: 'Rotation' },
|
|
408
|
+
{ path: 'scale', label: 'Scale' },
|
|
409
|
+
...listProperties(id).map((property) => ({ path: property.path, label: property.label })),
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
/** Read the site once, for the whole subject, and notify when it lands so the
|
|
413
|
+
* inspector draws the description on the next render. */
|
|
414
|
+
const loadLiterals = (
|
|
415
|
+
id: string,
|
|
416
|
+
key: string,
|
|
417
|
+
anchor: NodeCreationSite,
|
|
418
|
+
instances: number,
|
|
419
|
+
): Promise<Record<string, CreationSiteLiteralReport>> => {
|
|
420
|
+
const existing = literalRequests.get(key);
|
|
421
|
+
if (existing) return existing;
|
|
422
|
+
const read = persist?.readSiteLiterals;
|
|
423
|
+
const properties = instanceProperties(id)
|
|
424
|
+
.map((property) => ({ property: property.path, live: readChannel(id, property.path) }))
|
|
425
|
+
.filter(
|
|
426
|
+
(entry): entry is { property: string; live: ChannelValue } => entry.live !== undefined,
|
|
427
|
+
);
|
|
428
|
+
if (!read || properties.length === 0) return Promise.resolve({});
|
|
429
|
+
const request = read({
|
|
430
|
+
anchor,
|
|
431
|
+
instances,
|
|
432
|
+
writeScope: 'creation-site',
|
|
433
|
+
surface: 'pixi',
|
|
434
|
+
properties,
|
|
435
|
+
}).then((answer) => {
|
|
436
|
+
literalsBySite.set(key, answer);
|
|
437
|
+
notify();
|
|
438
|
+
return answer;
|
|
439
|
+
});
|
|
440
|
+
literalRequests.set(key, request);
|
|
441
|
+
return request;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The site's literals for `id`, or `null` while they are still being read.
|
|
446
|
+
*
|
|
447
|
+
* `null` is an absence of KNOWLEDGE, never a claim that the site names
|
|
448
|
+
* nothing — the caller reports no description at all for it, which is what the
|
|
449
|
+
* shell already reads as "this subject is not a component instance", and the
|
|
450
|
+
* read's own notify brings the real answer one render later.
|
|
451
|
+
*/
|
|
452
|
+
const literalsFor = (
|
|
453
|
+
id: string,
|
|
454
|
+
key: string,
|
|
455
|
+
anchor: NodeCreationSite,
|
|
456
|
+
instances: number,
|
|
457
|
+
): Record<string, CreationSiteLiteralReport> | null => {
|
|
458
|
+
const known = literalsBySite.get(key);
|
|
459
|
+
if (known) return known;
|
|
460
|
+
void loadLiterals(id, key, anchor, instances);
|
|
461
|
+
return null;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/** Float slack for "is the live value still the one the line names" — a
|
|
465
|
+
* running game's own arithmetic lands a few ULPs off an authored decimal, and
|
|
466
|
+
* reporting that as an override would put a phantom row in every inspector. */
|
|
467
|
+
const CHANNEL_EPS = 1e-6;
|
|
468
|
+
|
|
469
|
+
const sameChannelValue = (a: ChannelValue, b: ChannelValue): boolean => {
|
|
470
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
471
|
+
return a.length === b.length && a.every((value, index) => sameChannelValue(value, b[index]!));
|
|
472
|
+
}
|
|
473
|
+
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) <= CHANNEL_EPS;
|
|
474
|
+
return a === b;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/** The game's OWN name for what this node is: the class its construction
|
|
478
|
+
* statement built. Never a name this editor invented for it. */
|
|
479
|
+
const componentNameOf = (id: string): string => {
|
|
480
|
+
const display = a2d.displayObject(id);
|
|
481
|
+
return display?.constructor?.name || (a2d.node(id)?.kind ?? '2D Object');
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const affectedInstances = (count: number): string =>
|
|
485
|
+
`${count} ${count === 1 ? 'instance' : 'instances'}`;
|
|
486
|
+
|
|
487
|
+
const overrideOf = (
|
|
488
|
+
id: string,
|
|
489
|
+
property: { path: string; label: string },
|
|
490
|
+
report: CreationSiteLiteralReport | undefined,
|
|
491
|
+
subject: ReturnType<typeof subjectFor>,
|
|
492
|
+
): ComponentInstanceOverride | null => {
|
|
493
|
+
const live = readChannel(id, property.path);
|
|
494
|
+
if (!report || report.literal === null || live === undefined) return null;
|
|
495
|
+
if (sameChannelValue(report.literal, live)) return null;
|
|
496
|
+
const gate = persist?.gate({
|
|
497
|
+
...subject,
|
|
498
|
+
property: property.path,
|
|
499
|
+
writeScope: 'creation-site',
|
|
500
|
+
});
|
|
501
|
+
const unavailable = !report.writable ? report.reason : gate?.ok ? undefined : gate?.reason;
|
|
502
|
+
return {
|
|
503
|
+
path: property.path,
|
|
504
|
+
label: property.label,
|
|
505
|
+
value: live,
|
|
506
|
+
...(report.text ? { defaultText: report.text } : {}),
|
|
507
|
+
canApplyToComponent: unavailable === undefined,
|
|
508
|
+
...(unavailable === undefined ? {} : { applyUnavailableReason: unavailable }),
|
|
509
|
+
affectedInstanceCount: subject.instances,
|
|
510
|
+
};
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The two values an apply would write — or the reason it cannot happen, in the
|
|
515
|
+
* voice that OWNS that refusal: the planner's words when the source is what
|
|
516
|
+
* stands in the way, the backend gate's when the session mode or ownership is.
|
|
517
|
+
*/
|
|
518
|
+
type ApplyPlan =
|
|
519
|
+
| {
|
|
520
|
+
readonly ok: true;
|
|
521
|
+
readonly backend: SourcePersistenceBackend;
|
|
522
|
+
readonly baseline: ChannelValue;
|
|
523
|
+
readonly next: ChannelValue;
|
|
524
|
+
}
|
|
525
|
+
| { readonly ok: false; readonly message: string };
|
|
526
|
+
|
|
527
|
+
const planApply = (
|
|
528
|
+
subject: SourceWriteSubject,
|
|
529
|
+
path: string,
|
|
530
|
+
report: CreationSiteLiteralReport | undefined,
|
|
531
|
+
live: ChannelValue | undefined,
|
|
532
|
+
): ApplyPlan => {
|
|
533
|
+
if (!persist || !report || report.literal === null || live === undefined) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
message:
|
|
537
|
+
report?.reason ??
|
|
538
|
+
`The construction site names no ${path} for this object, so there is no default to change.`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (!report.writable) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
message: report.reason ?? `The construction site's ${path} cannot be rewritten.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const verdict = persist.gate({ ...subject, writeScope: 'creation-site' });
|
|
548
|
+
if (!verdict.ok) return { ok: false, message: verdict.reason };
|
|
549
|
+
return { ok: true, backend: persist, baseline: report.literal, next: live };
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The site's literals for `id`, AWAITED — what an ACTING caller needs, where
|
|
554
|
+
* `literalsFor` is what a rendering one needs. A revert or an apply is a
|
|
555
|
+
* deliberate act with somewhere to await, so it must not silently do nothing
|
|
556
|
+
* merely because nothing happened to have described this node first (an agent
|
|
557
|
+
* driving the provider through `vgai eval` never does).
|
|
558
|
+
*/
|
|
559
|
+
const literalsNow = async (
|
|
560
|
+
id: string,
|
|
561
|
+
): Promise<Record<string, CreationSiteLiteralReport> | null> => {
|
|
562
|
+
const subject = subjectFor(id, 'position');
|
|
563
|
+
const key = siteKeyOf(subject.anchor);
|
|
564
|
+
if (!key) return null;
|
|
565
|
+
return (
|
|
566
|
+
literalsBySite.get(key) ?? (await loadLiterals(id, key, subject.anchor, subject.instances))
|
|
567
|
+
);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const instancesProvider: ComponentInstancesProvider = {
|
|
571
|
+
describe: (id): ComponentInstanceDescription | null => {
|
|
572
|
+
const subject = subjectFor(id, 'position');
|
|
573
|
+
const key = siteKeyOf(subject.anchor);
|
|
574
|
+
if (!key || !a2d.displayObject(id)) return null;
|
|
575
|
+
const literals = literalsFor(id, key, subject.anchor, subject.instances);
|
|
576
|
+
// An EMPTY map is not "the site names nothing" — the server answers one
|
|
577
|
+
// entry per property asked, `literal: null` and all. It is "nothing
|
|
578
|
+
// answered": a tier with no `/__ingest-source/*` route, or a transport
|
|
579
|
+
// that failed. Describing an instance from it would report "using
|
|
580
|
+
// component defaults" about defaults nobody read.
|
|
581
|
+
if (!literals || Object.keys(literals).length === 0) return null;
|
|
582
|
+
const overrides = instanceProperties(id)
|
|
583
|
+
.map((property) => overrideOf(id, property, literals[property.path], subject))
|
|
584
|
+
.filter((override): override is ComponentInstanceOverride => override !== null);
|
|
585
|
+
return {
|
|
586
|
+
componentName: componentNameOf(id),
|
|
587
|
+
...(subject.anchor.anchored ? { sourcePath: subject.anchor.display } : {}),
|
|
588
|
+
overrides,
|
|
589
|
+
};
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
// REVERT IS A LIVE WRITE, and only a live write: the value being restored is
|
|
593
|
+
// the one the source already holds, so there is nothing to write back to it.
|
|
594
|
+
// One transaction for the batch — reverting three overrides is one Ctrl+Z.
|
|
595
|
+
revert: async (id, paths) => {
|
|
596
|
+
const literals = await literalsNow(id);
|
|
597
|
+
if (!literals) return;
|
|
598
|
+
const before = captureHistoryState();
|
|
599
|
+
const reverted: string[] = [];
|
|
600
|
+
for (const path of paths) {
|
|
601
|
+
const literal = literals[path]?.literal;
|
|
602
|
+
if (literal === null || literal === undefined) continue;
|
|
603
|
+
applyChannel(id, path, literal);
|
|
604
|
+
reverted.push(path);
|
|
605
|
+
}
|
|
606
|
+
if (reverted.length === 0) return;
|
|
607
|
+
recordHistory(
|
|
608
|
+
reverted.length === 1
|
|
609
|
+
? `Revert ${labelOf(id)} ${reverted[0]}`
|
|
610
|
+
: `Revert ${reverted.length} ${labelOf(id)} overrides`,
|
|
611
|
+
before,
|
|
612
|
+
);
|
|
613
|
+
notify();
|
|
614
|
+
return LIVE_ONLY_ACK;
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
|
|
618
|
+
const subject = { ...subjectFor(id, path), writeScope: 'creation-site' as const };
|
|
619
|
+
const key = siteKeyOf(subject.anchor);
|
|
620
|
+
const report = (await literalsNow(id))?.[path];
|
|
621
|
+
const live = readChannel(id, path);
|
|
622
|
+
const plan = planApply(subject, path, report, live);
|
|
623
|
+
if (!plan.ok) return { changed: false, message: plan.message };
|
|
624
|
+
const label =
|
|
625
|
+
`Apply ${path} to ${componentNameOf(id)} default ` +
|
|
626
|
+
`(${affectedInstances(subject.instances)})`;
|
|
627
|
+
// BASELINE IS THE LITERAL, not the live value: the planner may only
|
|
628
|
+
// rewrite a literal it can prove is the value in force at that slot, and
|
|
629
|
+
// for this gesture the value in force AT THE SITE is exactly what the site
|
|
630
|
+
// says. The live value is the one being written INTO it.
|
|
631
|
+
const persisted = await plan.backend.write({
|
|
632
|
+
...subject,
|
|
633
|
+
baseline: plan.baseline,
|
|
634
|
+
next: plan.next,
|
|
635
|
+
label,
|
|
636
|
+
});
|
|
637
|
+
if (!persisted) {
|
|
638
|
+
return {
|
|
639
|
+
changed: false,
|
|
640
|
+
message: `${label} did not land — the editor console names why.`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
// The line says something new now; the next describe re-reads it.
|
|
644
|
+
if (key) literalsBySite.delete(key);
|
|
645
|
+
notify();
|
|
646
|
+
return {
|
|
647
|
+
changed: true,
|
|
648
|
+
message: `${path} now reads ${JSON.stringify(live)} for all ${affectedInstances(subject.instances)} at ${
|
|
649
|
+
subject.anchor.anchored ? subject.anchor.display : 'its creation site'
|
|
650
|
+
}.`,
|
|
651
|
+
write: {
|
|
652
|
+
destination: subject.anchor.anchored ? subject.anchor.display : 'creation site',
|
|
653
|
+
persisted: true,
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
provenance: persist
|
|
661
|
+
? {
|
|
662
|
+
source: 'foreign',
|
|
663
|
+
label: 'creation-site',
|
|
664
|
+
detail:
|
|
665
|
+
'Live 2D (PixiJS) tree projected directly — an edit is written into the game’s own ' +
|
|
666
|
+
'source at the line that constructed the object, and says why whenever it cannot be.',
|
|
667
|
+
}
|
|
668
|
+
: {
|
|
669
|
+
source: 'foreign',
|
|
670
|
+
label: 'live-only',
|
|
671
|
+
detail:
|
|
672
|
+
'Live 2D (PixiJS) tree projected directly — edits apply for this session only and are never saved.',
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
// A creation-site write commits per edit through project history at the
|
|
676
|
+
// moment of the gesture, so there is no whole-document save to flush — but
|
|
677
|
+
// `destination` is still the one-line answer to "where do edits go", and it
|
|
678
|
+
// is a GETTER because holding or releasing the game changes the answer
|
|
679
|
+
// mid-session.
|
|
680
|
+
get persistence(): PersistenceProvider {
|
|
681
|
+
const backend = persist;
|
|
682
|
+
if (!backend) {
|
|
683
|
+
return { ...createEphemeralPersistence(), destination: LIVE_ONLY_DESTINATION };
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
...createEphemeralPersistence(),
|
|
687
|
+
get destination() {
|
|
688
|
+
return backend.destination();
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// The READ surface answers from the same literal report the write path uses.
|
|
694
|
+
// A recorded `new` proves WHERE the object came from, but does not prove
|
|
695
|
+
// that this particular property is writable there. While the source read
|
|
696
|
+
// is in flight the lane is deliberately unknown; once it lands, a write
|
|
697
|
+
// the planner says it would accept earns `construction-literal` — a
|
|
698
|
+
// rewritable literal, or an insertable named assignment (`writable` with
|
|
699
|
+
// `literal: null`). A proven refusal is `live-only`, in agreement with
|
|
700
|
+
// the write pipe's actual destination rather than the constructor stamp
|
|
701
|
+
// alone.
|
|
702
|
+
truth: (id, property) => {
|
|
703
|
+
const subject = subjectFor(id, property);
|
|
704
|
+
if (subject.anchorKind === 'live-only') {
|
|
705
|
+
return { site: subject.anchor, writeAnchorKind: 'live-only' };
|
|
706
|
+
}
|
|
707
|
+
const key = siteKeyOf(subject.anchor);
|
|
708
|
+
if (!key) return { site: subject.anchor, writeAnchorKind: 'live-only' };
|
|
709
|
+
const known = literalsBySite.get(key);
|
|
710
|
+
if (!known) {
|
|
711
|
+
void loadLiterals(id, key, subject.anchor, subject.instances);
|
|
712
|
+
return { site: subject.anchor, writeAnchorKind: undefined };
|
|
713
|
+
}
|
|
714
|
+
const report = known[property];
|
|
715
|
+
return {
|
|
716
|
+
site: subject.anchor,
|
|
717
|
+
writeAnchorKind: report?.writable ? 'construction-literal' : 'live-only',
|
|
718
|
+
};
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
// A class-owned Pixi object carries a real component identity in its own
|
|
722
|
+
// constructor. That identity is enough to associate the instance with the
|
|
723
|
+
// project's portable CSF stories (generic PIXI.Container/Sprite names
|
|
724
|
+
// simply match nothing). Keep the source path absent: the construction
|
|
725
|
+
// site is vendor source while the colocated authoring component may be a
|
|
726
|
+
// project-owned TSX facade, and pretending those are the same file would
|
|
727
|
+
// incorrectly defeat the registry's name-based association.
|
|
728
|
+
componentIdentity: (id) => (a2d.displayObject(id) ? { name: componentNameOf(id) } : null),
|
|
729
|
+
|
|
730
|
+
// Present exactly when a backend can READ the game's own source: with no
|
|
731
|
+
// reachable source there are no literals to call defaults, and a description
|
|
732
|
+
// built without them would be the fabrication the doctrine forbids.
|
|
733
|
+
...(persist?.readSiteLiterals ? { instances: () => instancesProvider } : {}),
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Mid-gesture origin write — see {@link CanvasWriteTarget.writeOrigin}. It
|
|
737
|
+
* pushes no history of its own: `beginTransformEdit` has already captured
|
|
738
|
+
* the before-state (which now carries pivot/anchor), and
|
|
739
|
+
* `endTransformEdit` records the one entry covering the whole gesture.
|
|
740
|
+
*/
|
|
741
|
+
writeOrigin(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void {
|
|
742
|
+
historyResource?.assertCanMutate();
|
|
743
|
+
const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
|
|
744
|
+
if (!display || !pointOf(display[kind])) return;
|
|
745
|
+
display[kind] = { x: value[0], y: value[1] };
|
|
746
|
+
editMovedOrigin = true;
|
|
747
|
+
notify();
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
bind(context: CanvasWriteContext): void {
|
|
751
|
+
a2d = context.a2d;
|
|
752
|
+
notify = context.notify;
|
|
753
|
+
const history = context.store.projectHistory;
|
|
754
|
+
historyResource = history
|
|
755
|
+
? new JsonHistoryResource({
|
|
756
|
+
history,
|
|
757
|
+
kind: 'session-state',
|
|
758
|
+
scope: 'session',
|
|
759
|
+
// The SUBJECT's session, not this mount's — see the ownership block
|
|
760
|
+
// in `history/json-history-resource.ts`. A held canvas world is
|
|
761
|
+
// remounted by HMR and by a re-entered ingest, and its entity ids
|
|
762
|
+
// are identical across both, so its undo stack belongs to the world.
|
|
763
|
+
subject: { ...context.journal, id: `${context.journal.id}/world-2d-edits` },
|
|
764
|
+
displayName: 'World 2D edits',
|
|
765
|
+
capture: captureHistoryState,
|
|
766
|
+
// The snapshot mirrors every live display object, but this is a
|
|
767
|
+
// RUNNING game that moves its own sprites every frame, so the full
|
|
768
|
+
// snapshot stops matching a frame after it is taken. The OVERLAY is
|
|
769
|
+
// this resource's real content — only editor edits touch it — so it
|
|
770
|
+
// alone decides "did something else change this resource?". Without
|
|
771
|
+
// it every transaction fails preflight with `content-conflict` and
|
|
772
|
+
// undo silently does nothing (the three lane paid for this as #81).
|
|
773
|
+
conflictIdentity: (state) => state.overlay,
|
|
774
|
+
restore: async (state) => {
|
|
775
|
+
restoreHistoryState(state);
|
|
776
|
+
},
|
|
777
|
+
})
|
|
778
|
+
: null;
|
|
779
|
+
persist?.attach({ read: readChannel, apply: applyChannel });
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
onReindex(): void {
|
|
783
|
+
// Ids are structural, so a re-walk can move them; nothing here caches an
|
|
784
|
+
// id across one.
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
// A live tree's transform is writable wherever Pixi will accept it, which is
|
|
788
|
+
// everywhere. What the backend adds is the SENTENCE: where this particular
|
|
789
|
+
// object's edit will land, or the named reason it will only live in the
|
|
790
|
+
// session — the per-edit honesty the doctrine asks for, read before the
|
|
791
|
+
// gesture rather than after it. `removable` rides the same answer when the
|
|
792
|
+
// backend declares a removal door that is open for this subject — the flag
|
|
793
|
+
// `compose.ts` turns into the field's `resettable`, which is what lets
|
|
794
|
+
// `remove-inspection-field` reach {@link removeTransform} instead of
|
|
795
|
+
// refusing by name (the three lane's `removableFlag` is the transcribed
|
|
796
|
+
// precedent).
|
|
797
|
+
transformEditability(id: string, channel: TransformChannel): TransformEditability {
|
|
798
|
+
if (!persist) return { writable: true };
|
|
799
|
+
const subject = subjectFor(id, channel);
|
|
800
|
+
const removable =
|
|
801
|
+
subject.anchorKind !== 'live-only' && persist.removal?.available(subject)
|
|
802
|
+
? { removable: true as const }
|
|
803
|
+
: {};
|
|
804
|
+
return { writable: true, reason: persist.describe(subject), ...removable };
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
beginTransformEdit(id: string): void {
|
|
808
|
+
historyResource?.assertCanMutate();
|
|
809
|
+
const display = a2d.displayObject(id);
|
|
810
|
+
if (display && physics.ownerOf(display) === 'physics') physics.freeze(display);
|
|
811
|
+
editStartState = captureHistoryState();
|
|
812
|
+
editBaseline = persist ? (a2d.getTransform(id) ?? undefined) : undefined;
|
|
813
|
+
editMovedOrigin = false;
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
writeTransform(id: string, next: Transform2DValue): void {
|
|
817
|
+
historyResource?.assertCanMutate();
|
|
818
|
+
const display = a2d.displayObject(id);
|
|
819
|
+
if (display && physics.ownerOf(display) === 'physics') {
|
|
820
|
+
physics.commit(display, next.position, next.rotation);
|
|
821
|
+
}
|
|
822
|
+
a2d.setTransform(id, next);
|
|
823
|
+
notify();
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
// Pushes exactly ONE undo entry per gesture: `writeTransform` fires many
|
|
827
|
+
// times during a live drag (no undo push there), and this fires once on
|
|
828
|
+
// commit, which is where the before/after pair is recorded — and where a
|
|
829
|
+
// source write is attempted.
|
|
830
|
+
endTransformEdit(id: string): void | Promise<WriteAck> {
|
|
831
|
+
const display = a2d.displayObject(id);
|
|
832
|
+
if (display && physics.ownerOf(display) === 'physics') physics.unfreeze(display);
|
|
833
|
+
const label = `Transform ${labelOf(id)}`;
|
|
834
|
+
const before = editStartState;
|
|
835
|
+
const baseline = editBaseline;
|
|
836
|
+
const movedOrigin = editMovedOrigin;
|
|
837
|
+
editStartState = undefined;
|
|
838
|
+
editBaseline = undefined;
|
|
839
|
+
editMovedOrigin = false;
|
|
840
|
+
if (!persist) {
|
|
841
|
+
recordHistory(label, before);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const after = a2d.getTransform(id) ?? undefined;
|
|
845
|
+
// `origin` first, and it is not a writable channel — see `editMovedOrigin`.
|
|
846
|
+
const changed: string[] = movedOrigin ? ['origin'] : [];
|
|
847
|
+
if (baseline && after) {
|
|
848
|
+
changed.push(
|
|
849
|
+
...TRANSFORM_CHANNELS.filter((channel) => transform2DChanged(channel, baseline, after)),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
if (changed.length > 1) {
|
|
853
|
+
persist.report(label, multiChannelRefusal(changed));
|
|
854
|
+
}
|
|
855
|
+
const property = changed.length === 1 && changed[0] !== 'origin' ? changed[0]! : null;
|
|
856
|
+
return persistOrJournal(
|
|
857
|
+
id,
|
|
858
|
+
label,
|
|
859
|
+
before,
|
|
860
|
+
property,
|
|
861
|
+
property ? readChannelOf(baseline, property) : undefined,
|
|
862
|
+
property ? readChannelOf(after, property) : undefined,
|
|
863
|
+
);
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* THE REMOVAL DOOR for one transform channel — the byte-absence half the
|
|
868
|
+
* insertion arm makes necessary on this lane too: an authored canvas
|
|
869
|
+
* transform can ADD a `receiver.member.axis = value;` statement for an
|
|
870
|
+
* axis the game's source never named, and only deleting that statement
|
|
871
|
+
* restores byte-absence. Through the SAME pipe every other edit takes,
|
|
872
|
+
* resolving on the backend's removal verb; no live half is journalled (a
|
|
873
|
+
* removal changes the FILE — the running object keeps its value, and the
|
|
874
|
+
* undo is the backend's own project-source transaction). Transcribed from
|
|
875
|
+
* `ThreeAuthoringAdapter.pipedRemove`.
|
|
876
|
+
*/
|
|
877
|
+
removeTransform(id: string, channel: TransformChannel): void | Promise<WriteAck> {
|
|
878
|
+
const backend = persist;
|
|
879
|
+
const removal = backend?.removal;
|
|
880
|
+
if (!backend || !removal) return;
|
|
881
|
+
const label = `Remove ${labelOf(id)} ${channel}`;
|
|
882
|
+
return runWritePipe({
|
|
883
|
+
resolve: (): WriteResolution => {
|
|
884
|
+
const subject = subjectFor(id, channel);
|
|
885
|
+
if (subject.anchorKind === 'live-only' || !removal.available(subject)) {
|
|
886
|
+
return resolvesLiveOnly(backend.describe(subject));
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
reaches: 'writer',
|
|
890
|
+
anchorKind: subject.anchorKind,
|
|
891
|
+
destination: backend.destination(),
|
|
892
|
+
write: () => removal.perform({ ...subject, label }),
|
|
893
|
+
};
|
|
894
|
+
},
|
|
895
|
+
record: () => {},
|
|
896
|
+
report: (reason) => {
|
|
897
|
+
if (backend.armed()) backend.report(label, reason);
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* ONCE AS A WARNING, THEN AS A LOG — because the fact is a property of the
|
|
904
|
+
* LANE, not of each op.
|
|
905
|
+
*
|
|
906
|
+
* With a backend the user armed, every refused op reports through the same
|
|
907
|
+
* channel a refused transform does: they asked for source writes, so each
|
|
908
|
+
* one that did not land matters individually. Unarmed, the first structural
|
|
909
|
+
* edit of the session raises the warning that reaches the product's own
|
|
910
|
+
* doors (`vgai status`, the session journal, one ack to clear) — a whole
|
|
911
|
+
* class of this author's edits will not be saved, which is exactly what an
|
|
912
|
+
* unresolved warning is for — and every later op logs, because repeating an
|
|
913
|
+
* ack-requiring entry per created node would flood the set with the lane
|
|
914
|
+
* working as designed.
|
|
915
|
+
*/
|
|
916
|
+
reportStructureLiveOnly(label: string, reason: string): void {
|
|
917
|
+
if (persist?.armed()) {
|
|
918
|
+
persist.report(label, reason);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (!structureLiveOnlyAnnounced) {
|
|
922
|
+
structureLiveOnlyAnnounced = true;
|
|
923
|
+
editorConsole.warn(
|
|
924
|
+
`[canvas] structural edits in this world are live-only — ${reason}. ` +
|
|
925
|
+
`First one: ${label}.`,
|
|
926
|
+
'authoring',
|
|
927
|
+
);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
editorConsole.log(`[canvas] ${label} is live-only — ${reason}.`, 'authoring');
|
|
931
|
+
},
|
|
932
|
+
|
|
933
|
+
properties: listProperties,
|
|
934
|
+
|
|
935
|
+
get(id: string, path: string): unknown {
|
|
936
|
+
return readProp(id, path);
|
|
937
|
+
},
|
|
938
|
+
|
|
939
|
+
set(id: string, path: string, value: unknown): void | Promise<WriteAck> {
|
|
940
|
+
historyResource?.assertCanMutate();
|
|
941
|
+
const before = captureHistoryState();
|
|
942
|
+
const label = `Set ${labelOf(id)} ${path}`;
|
|
943
|
+
if (!persist) {
|
|
944
|
+
writeProp(id, path, value);
|
|
945
|
+
recordHistory(label, before);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// Read the baseline BEFORE the write: the creation-site planner may only
|
|
949
|
+
// rewrite a literal it can prove is the value currently in force, and
|
|
950
|
+
// "currently" means before this gesture touched anything.
|
|
951
|
+
const baseline = readChannel(id, path);
|
|
952
|
+
writeProp(id, path, value);
|
|
953
|
+
const next = readChannel(id, path);
|
|
954
|
+
const moved =
|
|
955
|
+
baseline !== undefined &&
|
|
956
|
+
next !== undefined &&
|
|
957
|
+
JSON.stringify(baseline) !== JSON.stringify(next);
|
|
958
|
+
return persistOrJournal(id, label, before, moved ? path : null, baseline, next);
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
dispose(): void {
|
|
962
|
+
historyResource?.dispose();
|
|
963
|
+
historyResource = null;
|
|
964
|
+
persist?.dispose();
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** One channel out of a 2D transform, in the planner's own shape. */
|
|
970
|
+
function readChannelOf(
|
|
971
|
+
transform: Transform2DValue | undefined,
|
|
972
|
+
property: string,
|
|
973
|
+
): ChannelValue | undefined {
|
|
974
|
+
if (!transform) return undefined;
|
|
975
|
+
if (property === 'position') return [...transform.position];
|
|
976
|
+
if (property === 'rotation') return transform.rotation;
|
|
977
|
+
if (property === 'scale') return [...transform.scale];
|
|
978
|
+
return undefined;
|
|
979
|
+
}
|