From 2e38e9e31cf0b9a2718681ea95a6b8054f4a7a93 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Sun, 28 Jun 2026 22:13:27 -0400 Subject: [PATCH] Tear down legacy archived tool samples --- .../tools/old_Parallax Scene Studio/README.md | 22 - .../old_Parallax Scene Studio/how_to_use.html | 91 - .../old_Parallax Scene Studio/index.html | 280 - .../tools/old_Parallax Scene Studio/main.js | 2082 -- .../parallaxEditor.css | 419 - .../samples/images/cloud-bands.svg | 23 - .../samples/images/foreground-occluder.svg | 19 - .../samples/images/hero-proxy.svg | 9 - .../samples/images/mountain-ridge-short.svg | 4 - .../samples/images/mountain-ridge-tall.svg | 4 - .../samples/images/mountain-ridge.svg | 5 - .../samples/images/sky-gradient.svg | 10 - .../samples/mario_style_bushes_foreground.svg | 32 - .../samples/mario_style_clouds.svg | 29 - .../samples/mario_style_hills.svg | 13 - .../samples/mario_style_leaf_overlay.svg | 9 - .../mario_style_learning_level_parallax.json | 109 - .../samples/mario_style_mountains.svg | 13 - .../samples/mario_style_sky.svg | 11 - .../samples/parallax_sample.json | 108 - .../samples/sample-manifest.json | 21 - .../samples/tilemap_parallax_sample.json | 75 - .../old_object-vector-studio-v2/README.md | 50 - .../docs/BATCH_GUARDRAIL_CONTRACT.md | 51 - .../docs/CONTROL_SERVICE_CONTRACTS.md | 174 - .../OBJECT_VECTOR_STUDIO_V2_REQUIREMENTS.md | 121 - .../how_to_use.html | 49 - .../old_object-vector-studio-v2/index.html | 392 - .../js/ToolStarterApp.js | 9178 -------- .../js/bootstrap.js | 114 - .../js/controls/AccordionSection.js | 30 - .../js/controls/ActionNavControl.js | 84 - .../js/controls/InspectorControl.js | 10 - .../js/controls/PreviewPanelControl.js | 30 - .../js/controls/SourceInputControl.js | 40 - .../js/controls/StatusLogControl.js | 28 - .../js/controls/ToolStarterShellControl.js | 130 - .../ObjectVectorStudioV2SchemaService.js | 545 - .../js/services/ToolStateSerializer.js | 15 - .../playwright.config.mjs | 10 - .../possible.future.adds.txt | 31 - .../styles/toolStarter.css | 2408 -- .../vector-native-arcade/README.md | 17 - .../vector-native-primary.palette.json | 29 - .../parallax/template-backdrop.parallax.json | 5 - .../data/parallax/template-backdrop.svg | 7 - .../data/tilemaps/template-arena.tilemap.json | 5 - .../data/tilemaps/template-ui.tileset.json | 6 - .../data/vectors/template-hud.vector.json | 23 - .../template-obstacle-large.vector.json | 20 - .../template-obstacle-small.vector.json | 20 - .../data/vectors/template-player.vector.json | 23 - .../data/vectors/template-title.vector.json | 23 - .../config/template.project.json | 6 - .../docs/ROLLBACK_NOTES.md | 7 - .../docs/STARTER_GUIDE.md | 10 - .../runtime/bootstrap.runtime.json | 6 - .../PROJECT_INSTRUCTIONS.md | 2 +- .../PROJECT_INSTRUCTIONS_VERSION.md | 3 +- .../ProjectInstructions/PROJECT_STATE.md | 13 +- .../backlog/BACKLOG_MASTER.md | 2 +- .../canonical_repository_structure.md | 1 - ...ames-samples-teardown_branch-validation.md | 13 + ...amples-teardown_manual-validation-notes.md | 19 + ...ve-legacy-games-samples-teardown_report.md | 40 + ...-samples-teardown_requirement-checklist.md | 17 + ...ames-samples-teardown_validation-report.md | 34 + dev/reports/codex_changed_files.txt | 76 +- dev/reports/codex_review.diff | 18182 +++++++++++++++- .../checkSharedExtractionGuard.baseline.json | 40 - www/src/tools/common/GameManifestLoader.js | 102 - .../tools/common/WorkspaceDirtyNotifier.js | 97 - 72 files changed, 17971 insertions(+), 17755 deletions(-) delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json delete mode 100644 dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/OBJECT_VECTOR_STUDIO_V2_REQUIREMENTS.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/how_to_use.html delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md delete mode 100644 dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json create mode 100644 dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md create mode 100644 dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md create mode 100644 dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md create mode 100644 dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md create mode 100644 dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md delete mode 100644 www/src/tools/common/GameManifestLoader.js delete mode 100644 www/src/tools/common/WorkspaceDirtyNotifier.js diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md deleted file mode 100644 index 69d45af59..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md +++ /dev/null @@ -1,22 +0,0 @@ -Toolbox Aid -David Quesenberry -04/21/2026 -README.md - -# Parallax Scene Studio - -High-level overview for 'Parallax Scene Studio' under 'tools/Parallax Scene Studio/'. - -## Purpose -- Provide a focused workspace for this tool's primary authoring/debug workflow. -- Keep output deterministic and compatible with shared Toolbox Aid contracts. -- Integrate cleanly with shared header, workspace lifecycle, and tool navigation. - -## What To Expect -- Browser-based tool interface with panel/workspace controls. -- Shared header context and status integration. -- Safe workflows for loading, editing, previewing, and exporting relevant data. - -## Entry Points -- Tool UI: 'tools/Parallax Scene Studio/index.html' -- How-to guide: 'tools/Parallax Scene Studio/how_to_use.html' diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html deleted file mode 100644 index 1e6f31a85..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - Parallax Scene Studio - How To Use - - - - - -
-
-
-

Parallax Scene Studio: How To Use

-

Detailed workflow with practical steps and control guidance.

-
- -
-

Details

-
-

Parallax composition studio for layered scene depth, sample loading, simulation, and export flows.

-
-
- -
-

Tool vs Workspace Saves

-
-

Tool save/export
Action: Use tool save/export controls for JSON, PNG, sheets, runtime payloads, and tool-local artifacts.
Why this step matters: Creates the actual outputs consumed by samples/games/pipeline steps.

-

Workspace save
Action: Use Workspace Manager save controls to persist cross-tool session state.
Why this step matters: Restores coordinated context (selected tool state, shared refs) but does not replace explicit artifact exports.

-
-
-
-

Workflow

-
-
-

Tool Mode

-

1. Compose parallax scenes
Action: Launch this tool from tools/<tool>/index.html (not through Workspace Manager).
Why this step matters: Uses this tool as a focused single-tool surface.

-

2. Build/edit in the tool
Action: Arrange parallax layers, tune depth/motion, and preview scene behavior.
Why this step matters: Creates layered scene composition data for runtime playback.

-

3. Save/export deliverables
Action: Save scene data and export assets/config used in target experiences.
Why this step matters: Preserves visual composition and reproducible setup.

-

4. Continue or close
Action: Open another tool directly as needed.
Why this step matters: Tool mode is best for isolated tasks and rapid single-tool iteration.

-
- -
-

Workspace Mode

-

1. Create a named workspace
Action: Click New Workspace and enter a meaningful name (example: UAT 001 Workspace).
Why this step matters: Starts from a clear baseline and makes saved snapshots easy to identify.

-

2. Open tools from Workspace Manager tiles
Action: Select needed tool tiles and perform each tool's primary task.
Why this step matters: Keeps multi-tool work coordinated under one shared context.

-

3. Save both levels intentionally
Action: Use tool-level save/export for artifacts, then click Save Workspace for session state.
Why this step matters: Preserves both deliverable files and cross-tool orchestration state.

-

4. Checkpoint and resume
Action: Use Save Workspace As for milestones and Open Workspace to resume later.
Why this step matters: Supports versioned progress and reliable continuation.

-
-
-
- -
-

Buttons

-
-

Add Layer #addLayerButton
Details: Triggers a tool action that updates your current workflow state.

-

Apply Layer Settings #applyLayerSettingsButton
Details: Triggers a tool action that updates your current workflow state.

-

Apply Map Meta #applyMapMetaButton
Details: Triggers a tool action that updates your current workflow state.

-

Apply Source #applyImageSourceButton
Details: Triggers a tool action that updates your current workflow state.

-

Apply Suggested Fix #applyRemediationButton
Details: Triggers a tool action that updates your current workflow state.

-

Choose File (layerImageFileInput) #layerImageFileInput
Details: Triggers a tool action that updates your current workflow state.

-

Choose File (loadAssetRegistryInput) #loadAssetRegistryInput
Details: Loads saved data so you can continue existing work.

-

Choose File (loadProjectInput) #loadProjectInput
Details: Loads saved data so you can continue existing work.

-

Duplicate #duplicateLayerButton
Details: Clones current content to speed iterative changes.

-

Exit Simulation #exitSimulationButton
Details: Triggers a tool action that updates your current workflow state.

-

Export Tilemap Patch #exportParallaxPatchButton
Details: Writes deliverable output for downstream use.

-

Inspect Issues #inspectRemediationButton
Details: Checks state/output so issues are caught before final save/export.

-

Jump to Problem #jumpToProblemButton
Details: Triggers a tool action that updates your current workflow state.

-

Load Assets Registry #loadAssetRegistryButton
Details: Loads saved data so you can continue existing work.

-

Load Project #loadProjectButton
Details: Loads saved data so you can continue existing work.

-

Load Sample #loadSampleButton
Details: Loads saved data so you can continue existing work.

-

Move Down #moveLayerDownButton
Details: Triggers a tool action that updates your current workflow state.

-

Move Up #moveLayerUpButton
Details: Triggers a tool action that updates your current workflow state.

-

New Project #newProjectButton
Details: Starts a fresh working baseline for the next task.

-

Package Project #packageProjectButton
Details: Writes deliverable output for downstream use.

-

Pause #pauseSimulationButton
Details: Triggers a tool action that updates your current workflow state.

-

Play #playSimulationButton
Details: Triggers a tool action that updates your current workflow state.

-

Refresh Debug View #refreshDebugVisualizationButton
Details: Checks state/output so issues are caught before final save/export.

-

Refresh Pipeline View #refreshExperienceButton
Details: Checks state/output so issues are caught before final save/export.

-

Remove Layer #removeLayerButton
Details: Removes current selection or closes the active context.

-

Restart Position #restartSimulationButton
Details: Triggers a tool action that updates your current workflow state.

-

Save Assets Registry #saveAssetRegistryButton
Details: Persists current state to prevent data loss and preserve progress.

-

Save Project #saveProjectButton
Details: Persists current state to prevent data loss and preserve progress.

-

Simulate #simulateButton
Details: Triggers a tool action that updates your current workflow state.

-
-
-
- - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html deleted file mode 100644 index deca47a3f..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - - Tools Platform - - - - - - -
- -
-
-
-
-
-
-
-
- - - - - - - - - - - - - - -
- -
- - - - - -
- -
- -
- - -
-
- Parallax Preview - - -
-
- -
- - -
-
- - - - - -
-
-
Mode: EDIT
-
Ready.
-
- - -
-
- -
- - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js deleted file mode 100644 index c505107f3..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js +++ /dev/null @@ -1,2082 +0,0 @@ -/* -Toolbox Aid -David Quesenberry -03/30/2026 -main.js -*/ -import { - buildAssetDependencyGraph, - createAssetId, - createAssetRegistry, - createRegistryDownloadPayload, - findRegistryEntryById, - mergeAssetRegistries, - normalizeProjectRelativePath, - sanitizeAssetRegistry, - upsertRegistryEntry -} from "../shared/projectAssetRegistry.js"; -import { downloadTextFile, readFileText } from '../../src/engine/persistence/FilePersistenceService.js'; -import { registerAssetPipelineCandidate } from "../shared/assetPipelineFoundation.js"; -import { - getBlockingAssetValidationMessage, - hasBlockingAssetValidationFindings, - summarizeAssetValidation, - validateProjectAssetState -} from "../shared/projectAssetValidation.js"; -import { - buildProjectAssetRemediation, - getPrimaryRemediationAction, - summarizeProjectAssetRemediation -} from "../shared/projectAssetRemediation.js"; -import { buildProjectPackage, summarizeProjectPackaging } from "../shared/projectPackaging.js"; -import { buildEditorExperienceLayer, summarizeEditorExperienceLayer } from "../shared/editorExperienceLayer.js"; -import { buildDebugVisualizationLayer, summarizeDebugVisualizationLayer } from "../shared/debugVisualizationLayer.js"; -import { registerToolBootContract } from "../shared/toolBootContract.js"; -import { - getToolLoadQuerySnapshot, - getToolLoadRequestedDataPaths, - summarizeToolLoadData, - logToolLoadRequest, - logToolLoadFetch, - logToolLoadLoaded, - logToolLoadWarning -} from "../shared/toolLoadDiagnostics.js"; -import { createLivePreviewSyncBridge, validateStateBindingPayload } from "../shared/livePreviewSyncChannel.js"; -import { addToolModeMetadata, assertStandaloneToolDocument, offerImportMismatchOptions } from "../shared/documentModeGuards.js"; - -function normalizeSamplePresetPath(pathValue) { - if (typeof pathValue !== "string") { - return ""; - } - const trimmed = pathValue.trim().replace(/\\/g, "/"); - if (!trimmed || trimmed.includes("..")) { - return ""; - } - if (trimmed.startsWith("/samples/")) { - return trimmed; - } - if (trimmed.startsWith("./samples/")) { - return trimmed; - } - if (trimmed.startsWith("samples/")) { - return `./${trimmed}`; - } - return ""; -} - -function buildPresetLoadedStatus(sampleId, samplePresetPath) { - const normalizedSampleId = typeof sampleId === "string" ? sampleId.trim() : ""; - if (normalizedSampleId) { - return `Loaded preset from sample ${normalizedSampleId}.`; - } - const normalizedPath = typeof samplePresetPath === "string" ? samplePresetPath.trim() : ""; - return normalizedPath ? `Loaded preset from ${normalizedPath}.` : "Loaded preset."; -} - -function clamp(value, min, max, fallback) { - const number = Number(value); - if (!Number.isFinite(number)) { - return fallback; - } - return Math.max(min, Math.min(max, number)); -} - -function cloneDeep(value) { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return JSON.parse(JSON.stringify(value)); -} - -function normalizeMapMeta(rawMap) { - const width = clamp(rawMap?.width, 4, 1024, 32); - const height = clamp(rawMap?.height, 4, 1024, 18); - const tileSize = clamp(rawMap?.tileSize, 8, 128, 24); - const name = typeof rawMap?.name === "string" && rawMap.name.trim() ? rawMap.name.trim() : "untitled-map"; - - return { - name, - width, - height, - tileSize, - pixelWidth: width * tileSize, - pixelHeight: height * tileSize - }; -} - -function createDefaultLayer(index = 0, name = "Parallax Layer") { - return { - id: `parallax-layer-${Date.now()}-${index}`, - name, - parallaxSourceId: "", - drawOrder: index, - imageSource: "", - scrollFactorX: 0.4, - scrollFactorY: 0.3, - offsetX: 0, - offsetY: 0, - repeatX: true, - repeatY: false, - wrapMode: "wrap", - opacity: 1, - visible: true - }; -} - -function normalizeLayer(rawLayer, index = 0) { - const fallback = createDefaultLayer(index, `Parallax Layer ${index + 1}`); - const repeatX = rawLayer?.repeatX !== false; - const repeatY = rawLayer?.repeatY === true; - const wrapMode = rawLayer?.wrapMode === "clamp" ? "clamp" : "wrap"; - - return { - id: typeof rawLayer?.id === "string" && rawLayer.id.trim() ? rawLayer.id.trim() : fallback.id, - name: typeof rawLayer?.name === "string" && rawLayer.name.trim() ? rawLayer.name.trim() : fallback.name, - parallaxSourceId: typeof rawLayer?.parallaxSourceId === "string" ? rawLayer.parallaxSourceId.trim() : "", - drawOrder: Math.trunc(clamp(rawLayer?.drawOrder, -999, 999, index)), - imageSource: typeof rawLayer?.imageSource === "string" ? rawLayer.imageSource : "", - scrollFactorX: clamp(rawLayer?.scrollFactorX, -4, 4, fallback.scrollFactorX), - scrollFactorY: clamp(rawLayer?.scrollFactorY, -4, 4, fallback.scrollFactorY), - offsetX: Math.trunc(clamp(rawLayer?.offsetX, -4096, 4096, 0)), - offsetY: Math.trunc(clamp(rawLayer?.offsetY, -4096, 4096, 0)), - repeatX, - repeatY, - wrapMode, - opacity: clamp(rawLayer?.opacity, 0, 1, 1), - visible: rawLayer?.visible !== false - }; -} - -function sortLayersByOrder(layers) { - layers.sort((a, b) => { - if (a.drawOrder !== b.drawOrder) { - return a.drawOrder - b.drawOrder; - } - return a.name.localeCompare(b.name); - }); -} - -function normalizeDrawOrderSequence(layers) { - sortLayersByOrder(layers); - layers.forEach((layer, index) => { - layer.drawOrder = index; - }); -} - -function sanitizeAssetRefs(rawAssetRefs) { - if (!rawAssetRefs || typeof rawAssetRefs !== "object") { - return { - parallaxSourceIds: [] - }; - } - - const parallaxSourceIds = Array.isArray(rawAssetRefs.parallaxSourceIds) - ? rawAssetRefs.parallaxSourceIds.filter((value) => typeof value === "string" && value.trim()) - : []; - - return { - parallaxSourceIds: Array.from(new Set(parallaxSourceIds)) - }; -} - -function createRegistryManagedParallaxSaveDocument(documentModel) { - const output = cloneDeep(documentModel); - output.assetRefs = sanitizeAssetRefs(output.assetRefs); - - output.layers = (Array.isArray(output.layers) ? output.layers : []).map((layer) => { - const nextLayer = normalizeLayer(layer); - if (nextLayer.parallaxSourceId && !normalizeProjectRelativePath(nextLayer.imageSource || "")) { - nextLayer.imageSource = ""; - } - return nextLayer; - }); - - return output; -} - -function createInitialParallaxDocument(options = {}) { - const map = normalizeMapMeta(options.map || {}); - const createdAt = new Date().toISOString(); - const layers = [ - { - ...createDefaultLayer(0, "Sky"), - scrollFactorX: 0.2, - scrollFactorY: 0.05, - opacity: 1, - repeatY: false - }, - { - ...createDefaultLayer(1, "Far Mountains"), - scrollFactorX: 0.4, - scrollFactorY: 0.15, - opacity: 0.95 - }, - { - ...createDefaultLayer(2, "Near Trees"), - scrollFactorX: 0.75, - scrollFactorY: 0.35, - opacity: 0.9 - } - ]; - - normalizeDrawOrderSequence(layers); - - return { - schema: "toolbox.parallax/1", - version: 1, - companionEditor: "ParallaxEditor", - map, - layers, - assetRefs: { - parallaxSourceIds: [] - }, - metadata: { - createdAt, - updatedAt: createdAt, - source: "parallax-editor-companion" - } - }; -} - -function sanitizeParallaxDocument(rawDocument, fallbackMap = null) { - const fallback = createInitialParallaxDocument({ map: fallbackMap || undefined }); - if (!rawDocument || typeof rawDocument !== "object") { - return fallback; - } - - const map = normalizeMapMeta(rawDocument.map || fallback.map); - const rawLayers = Array.isArray(rawDocument.layers) ? rawDocument.layers : []; - const layers = rawLayers.length > 0 - ? rawLayers.map((layer, index) => normalizeLayer(layer, index)) - : cloneDeep(fallback.layers); - normalizeDrawOrderSequence(layers); - - return { - schema: "toolbox.parallax/1", - version: Number.isFinite(rawDocument.version) ? rawDocument.version : 1, - companionEditor: "ParallaxEditor", - map, - layers, - assetRefs: sanitizeAssetRefs(rawDocument.assetRefs), - metadata: { - createdAt: typeof rawDocument?.metadata?.createdAt === "string" ? rawDocument.metadata.createdAt : fallback.metadata.createdAt, - updatedAt: new Date().toISOString(), - source: "parallax-editor-companion" - } - }; -} - -function extractParallaxDocument(rawAnyDocument) { - if (!rawAnyDocument || typeof rawAnyDocument !== "object") { - throw new Error("Expected a JSON object."); - } - - if (rawAnyDocument.schema === "toolbox.parallax/1") { - return sanitizeParallaxDocument(rawAnyDocument); - } - - if (rawAnyDocument.schema === "toolbox.tilemap/1") { - const map = normalizeMapMeta(rawAnyDocument.map || {}); - const rawParallax = rawAnyDocument.parallax && typeof rawAnyDocument.parallax === "object" - ? rawAnyDocument.parallax - : { schema: "toolbox.parallax/1", layers: [] }; - - const merged = { - schema: "toolbox.parallax/1", - version: 1, - companionEditor: "ParallaxEditor", - map, - layers: Array.isArray(rawParallax.layers) ? rawParallax.layers : [], - assetRefs: rawAnyDocument.assetRefs - }; - - return sanitizeParallaxDocument(merged, map); - } - - if (rawAnyDocument.parallax && rawAnyDocument.map) { - const map = normalizeMapMeta(rawAnyDocument.map); - const merged = { - schema: "toolbox.parallax/1", - version: 1, - companionEditor: "ParallaxEditor", - map, - layers: Array.isArray(rawAnyDocument.parallax.layers) ? rawAnyDocument.parallax.layers : [], - assetRefs: rawAnyDocument.assetRefs - }; - return sanitizeParallaxDocument(merged, map); - } - - throw new Error("Unsupported schema. Expected toolbox.tilemap/1 or toolbox.parallax/1."); -} - -function extractParallaxDocumentFromSamplePreset(rawPreset) { - if (!rawPreset || typeof rawPreset !== "object") { - return rawPreset; - } - - const payload = rawPreset.payload; - if (payload && typeof payload === "object") { - if (payload.parallaxDocument && typeof payload.parallaxDocument === "object") { - return payload.parallaxDocument; - } - if (payload.parallax && typeof payload.parallax === "object") { - return payload.parallax; - } - const config = payload.config && typeof payload.config === "object" - ? payload.config - : null; - if (config?.parallaxDocument && typeof config.parallaxDocument === "object") { - return config.parallaxDocument; - } - if (config?.parallax && typeof config.parallax === "object") { - return config.parallax; - } - } - const rootConfig = rawPreset.config && typeof rawPreset.config === "object" - ? rawPreset.config - : null; - if (rootConfig?.parallaxDocument && typeof rootConfig.parallaxDocument === "object") { - return rootConfig.parallaxDocument; - } - if (rootConfig?.parallax && typeof rootConfig.parallax === "object") { - return rootConfig.parallax; - } - - return rawPreset; -} - -function createTilemapParallaxPatch(parallaxDocument) { - return { - schema: "toolbox.tilemap-parallax-patch/1", - map: cloneDeep(parallaxDocument.map), - assetRefs: sanitizeAssetRefs(parallaxDocument.assetRefs), - parallax: { - schema: "toolbox.parallax/1", - companionEditor: "ParallaxEditor", - layers: cloneDeep(parallaxDocument.layers) - }, - metadata: { - generatedAt: new Date().toISOString(), - generatedBy: "tools/Parallax Scene Studio" - } - }; -} - -function summarizeGraphFindings(findings) { - return Array.isArray(findings) && findings.length > 0 - ? ` Graph findings: ${findings.length}.` - : " Graph findings: none."; -} - -function mod(value, range) { - if (range === 0) { - return 0; - } - return ((value % range) + range) % range; -} - -function getLayerVisualColor(layer) { - const hashSource = `${layer.id}|${layer.name}`; - let hash = 0; - for (let i = 0; i < hashSource.length; i += 1) { - hash = ((hash << 5) - hash) + hashSource.charCodeAt(i); - hash |= 0; - } - const hue = Math.abs(hash) % 360; - return `hsl(${hue} 56% 42%)`; -} - -class ParallaxEditorApp { - constructor(documentModel) { - this.documentModel = documentModel; - this.selectedLayerId = documentModel.layers[0]?.id || ""; - this.cameraX = 0; - this.cameraY = 0; - this.refs = {}; - this.imageCache = new Map(); - this.imageCacheVersion = 0; - this.isSimulationMode = false; - this.simulation = { - rafId: 0, - lastTimestamp: 0, - accumulatorMs: 0, - playing: false, - baseCameraX: 0, - baseCameraY: 0, - startCameraX: 0, - startCameraY: 0, - endCameraX: 0, - endCameraY: 0, - traversedDistance: 0, - traversalDistance: 0, - traversalDurationMs: 14000 - }; - this.assetRegistry = createAssetRegistry({ projectId: documentModel?.map?.name || "parallax-project" }); - this.assetDependencyGraphSnapshot = null; - this.validationResult = null; - this.remediationResult = { remediation: { status: "unavailable", actions: [] } }; - this.lastPackageResult = null; - this.lastRuntimeResult = null; - this.editorExperienceResult = null; - this.debugVisualizationResult = null; - this.livePreviewSync = createLivePreviewSyncBridge({ sourceId: "parallax-editor" }); - this.livePreviewSyncFrame = 0; - this.pendingLivePreviewReason = "init"; - this.boundRuntimeState = null; - this.lastRuntimeBindingStatusAt = 0; - this.skipExternalProjectStateUntil = 0; - this.transientLayerImageSourceByName = new Map(); - } - - invalidateImageCache() { - this.imageCache.clear(); - this.imageCacheVersion += 1; - } - - clearTransientLayerImageSources() { - this.transientLayerImageSourceByName.forEach((objectUrl) => { - if (typeof objectUrl === "string" && objectUrl.startsWith("blob:")) { - URL.revokeObjectURL(objectUrl); - } - }); - this.transientLayerImageSourceByName.clear(); - } - - setTransientLayerImageSource(imageSource, objectUrl) { - const key = typeof imageSource === "string" ? imageSource.trim() : ""; - if (!key || typeof objectUrl !== "string" || !objectUrl.trim()) { - return; - } - const existing = this.transientLayerImageSourceByName.get(key); - if (typeof existing === "string" && existing.startsWith("blob:")) { - URL.revokeObjectURL(existing); - } - this.transientLayerImageSourceByName.set(key, objectUrl.trim()); - } - - getTransientLayerImageSource(imageSource) { - const key = typeof imageSource === "string" ? imageSource.trim() : ""; - if (!key) { - return ""; - } - return this.transientLayerImageSourceByName.get(key) || ""; - } - - init(rootDocument) { - this.captureRefs(rootDocument); - this.attachEvents(); - this.syncFullscreenState(); - this.syncInputsFromDocument(); - this.renderAll(); - this.bindRuntimeStateSync(); - this.queueLivePreviewSync("init"); - void this.tryLoadPresetFromQuery(); - } - - captureRefs(rootDocument) { - this.refs.newProjectButton = rootDocument.getElementById("newProjectButton"); - this.refs.loadProjectButton = rootDocument.getElementById("loadProjectButton"); - this.refs.loadProjectInput = rootDocument.getElementById("loadProjectInput"); - this.refs.saveProjectButton = rootDocument.getElementById("saveProjectButton"); - this.refs.loadAssetRegistryButton = rootDocument.getElementById("loadAssetRegistryButton"); - this.refs.loadAssetRegistryInput = rootDocument.getElementById("loadAssetRegistryInput"); - this.refs.saveAssetRegistryButton = rootDocument.getElementById("saveAssetRegistryButton"); - this.refs.simulateButton = rootDocument.getElementById("simulateButton"); - this.refs.playSimulationButton = rootDocument.getElementById("playSimulationButton"); - this.refs.pauseSimulationButton = rootDocument.getElementById("pauseSimulationButton"); - this.refs.restartSimulationButton = rootDocument.getElementById("restartSimulationButton"); - this.refs.exitSimulationButton = rootDocument.getElementById("exitSimulationButton"); - this.refs.exportParallaxPatchButton = rootDocument.getElementById("exportParallaxPatchButton"); - this.refs.packageProjectButton = rootDocument.getElementById("packageProjectButton"); - this.refs.remediationSummaryText = rootDocument.getElementById("remediationSummaryText"); - this.refs.experienceSummaryText = rootDocument.getElementById("experienceSummaryText"); - this.refs.experienceDetailsText = rootDocument.getElementById("experienceDetailsText"); - this.refs.refreshExperienceButton = rootDocument.getElementById("refreshExperienceButton"); - this.refs.debugSummaryText = rootDocument.getElementById("debugSummaryText"); - this.refs.debugDetailsText = rootDocument.getElementById("debugDetailsText"); - this.refs.refreshDebugVisualizationButton = rootDocument.getElementById("refreshDebugVisualizationButton"); - this.refs.inspectRemediationButton = rootDocument.getElementById("inspectRemediationButton"); - this.refs.jumpToProblemButton = rootDocument.getElementById("jumpToProblemButton"); - this.refs.applyRemediationButton = rootDocument.getElementById("applyRemediationButton"); - - this.refs.projectNameInput = rootDocument.getElementById("projectNameInput"); - this.refs.mapWidthInput = rootDocument.getElementById("mapWidthInput"); - this.refs.mapHeightInput = rootDocument.getElementById("mapHeightInput"); - this.refs.tileSizeInput = rootDocument.getElementById("tileSizeInput"); - this.refs.applyMapMetaButton = rootDocument.getElementById("applyMapMetaButton"); - - this.refs.layerList = rootDocument.getElementById("layerList"); - this.refs.newLayerNameInput = rootDocument.getElementById("newLayerNameInput"); - this.refs.addLayerButton = rootDocument.getElementById("addLayerButton"); - this.refs.removeLayerButton = rootDocument.getElementById("removeLayerButton"); - this.refs.duplicateLayerButton = rootDocument.getElementById("duplicateLayerButton"); - this.refs.moveLayerUpButton = rootDocument.getElementById("moveLayerUpButton"); - this.refs.moveLayerDownButton = rootDocument.getElementById("moveLayerDownButton"); - - this.refs.cameraXInput = rootDocument.getElementById("cameraXInput"); - this.refs.cameraYInput = rootDocument.getElementById("cameraYInput"); - this.refs.cameraReadout = rootDocument.getElementById("cameraReadout"); - - this.refs.layerNameInput = rootDocument.getElementById("layerNameInput"); - this.refs.layerDrawOrderInput = rootDocument.getElementById("layerDrawOrderInput"); - this.refs.layerOpacityInput = rootDocument.getElementById("layerOpacityInput"); - this.refs.layerVisibleSelect = rootDocument.getElementById("layerVisibleSelect"); - - this.refs.layerImageSourceInput = rootDocument.getElementById("layerImageSourceInput"); - this.refs.applyImageSourceButton = rootDocument.getElementById("applyImageSourceButton"); - this.refs.layerImageFileInput = rootDocument.getElementById("layerImageFileInput"); - - this.refs.scrollFactorXInput = rootDocument.getElementById("scrollFactorXInput"); - this.refs.scrollFactorYInput = rootDocument.getElementById("scrollFactorYInput"); - this.refs.offsetXInput = rootDocument.getElementById("offsetXInput"); - this.refs.offsetYInput = rootDocument.getElementById("offsetYInput"); - this.refs.repeatXSelect = rootDocument.getElementById("repeatXSelect"); - this.refs.repeatYSelect = rootDocument.getElementById("repeatYSelect"); - this.refs.wrapModeSelect = rootDocument.getElementById("wrapModeSelect"); - this.refs.applyLayerSettingsButton = rootDocument.getElementById("applyLayerSettingsButton"); - - this.refs.previewMeta = rootDocument.getElementById("previewMeta"); - this.refs.simulationContext = rootDocument.getElementById("simulationContext"); - this.refs.previewDetailsText = rootDocument.getElementById("previewDetailsText"); - this.refs.statusText = rootDocument.getElementById("statusText"); - this.refs.appShell = rootDocument.querySelector(".app-shell"); - this.refs.leftSidebar = rootDocument.getElementById("leftSidebar"); - this.refs.rightSidebar = rootDocument.getElementById("rightSidebar"); - this.refs.leftPanelAccordions = Array.from(rootDocument.querySelectorAll("#leftSidebar .panel-accordion")); - this.refs.rightPanelAccordions = Array.from(rootDocument.querySelectorAll("#rightSidebar .panel-accordion")); - this.refs.overlayToggleButtons = Array.from(rootDocument.querySelectorAll("[data-overlay-toggle]")); - this.refs.previewWrap = rootDocument.querySelector(".preview-wrap"); - this.refs.previewCanvas = rootDocument.getElementById("previewCanvas"); - this.refs.previewContext = this.refs.previewCanvas.getContext("2d", { alpha: false }); - } - - attachEvents() { - this.refs.newProjectButton.addEventListener("click", () => this.handleNewProject()); - this.refs.loadProjectButton.addEventListener("click", () => this.refs.loadProjectInput.click()); - this.refs.loadProjectInput.addEventListener("change", (event) => this.handleLoadProject(event)); - this.refs.saveProjectButton.addEventListener("click", () => this.handleSaveProject()); - this.refs.loadAssetRegistryButton.addEventListener("click", () => this.refs.loadAssetRegistryInput.click()); - this.refs.loadAssetRegistryInput.addEventListener("change", (event) => this.handleLoadAssetRegistry(event)); - this.refs.saveAssetRegistryButton.addEventListener("click", () => this.handleSaveAssetRegistry()); - this.refs.simulateButton.addEventListener("click", () => this.enterSimulationMode()); - this.refs.playSimulationButton.addEventListener("click", () => this.resumeSimulation()); - this.refs.pauseSimulationButton.addEventListener("click", () => this.pauseSimulation()); - this.refs.restartSimulationButton.addEventListener("click", () => this.restartSimulationPosition()); - this.refs.exitSimulationButton.addEventListener("click", () => this.exitSimulationMode()); - this.refs.exportParallaxPatchButton.addEventListener("click", () => this.handleExportTilemapPatch()); - this.refs.packageProjectButton.addEventListener("click", () => this.handlePackageProject()); - this.refs.refreshExperienceButton.addEventListener("click", () => this.refreshExperienceSnapshot()); - this.refs.refreshDebugVisualizationButton.addEventListener("click", () => this.refreshDebugVisualizationSnapshot()); - this.refs.inspectRemediationButton.addEventListener("click", () => this.inspectRemediationActions()); - this.refs.jumpToProblemButton.addEventListener("click", () => this.jumpToRemediationProblem()); - this.refs.applyRemediationButton.addEventListener("click", () => this.applyRemediationAction()); - this.refs.overlayToggleButtons.forEach((button) => { - button.addEventListener("click", () => { - const side = button.dataset.overlaySide === "left" ? "left" : "right"; - const targetId = button.dataset.overlayTarget || ""; - this.toggleOverlayPanel(side, targetId); - }); - }); - this.refs.leftPanelAccordions.forEach((panel) => { - panel.addEventListener("toggle", () => this.handleOverlayAccordionToggle("left", panel)); - }); - this.refs.rightPanelAccordions.forEach((panel) => { - panel.addEventListener("toggle", () => this.handleOverlayAccordionToggle("right", panel)); - }); - document.addEventListener("fullscreenchange", () => { - this.syncFullscreenState(); - this.refs.leftSidebar?.classList.remove("visible-overlay"); - this.refs.rightSidebar?.classList.remove("visible-overlay"); - this.syncOverlayToggleButtons(); - }); - - this.refs.applyMapMetaButton.addEventListener("click", () => this.applyMapMetaFromInputs()); - this.refs.projectNameInput.addEventListener("change", () => this.applyMapMetaFromInputs()); - - this.refs.addLayerButton.addEventListener("click", () => this.addLayer()); - this.refs.removeLayerButton.addEventListener("click", () => this.removeSelectedLayer()); - this.refs.duplicateLayerButton.addEventListener("click", () => this.duplicateSelectedLayer()); - this.refs.moveLayerUpButton.addEventListener("click", () => this.moveSelectedLayer(-1)); - this.refs.moveLayerDownButton.addEventListener("click", () => this.moveSelectedLayer(1)); - - this.refs.layerNameInput.addEventListener("change", () => this.applyBasicLayerFields()); - this.refs.layerDrawOrderInput.addEventListener("change", () => this.applyBasicLayerFields()); - this.refs.layerOpacityInput.addEventListener("change", () => this.applyBasicLayerFields()); - this.refs.layerVisibleSelect.addEventListener("change", () => this.applyBasicLayerFields()); - - this.refs.applyImageSourceButton.addEventListener("click", () => this.applyImageSourceFromInput()); - this.refs.layerImageFileInput.addEventListener("change", (event) => this.assignLocalImageFile(event)); - - this.refs.applyLayerSettingsButton.addEventListener("click", () => this.applyExtendedLayerSettings()); - - this.refs.cameraXInput.addEventListener("input", () => this.handleCameraChange()); - this.refs.cameraYInput.addEventListener("input", () => this.handleCameraChange()); - if (this.refs.previewWrap) { - this.refs.previewWrap.addEventListener("scroll", () => { - if (this.isSimulationMode) { - this.updateSimulationContextReadout(); - } - }); - } - } - - syncFullscreenState() { - document.body.classList.toggle("fullscreen-mode", Boolean(document.fullscreenElement)); - this.syncOverlayToggleButtons(); - } - - syncOverlayToggleButtons() { - const fullscreenActive = Boolean(document.fullscreenElement); - this.refs.overlayToggleButtons.forEach((button) => { - const side = button.dataset.overlaySide === "left" ? "left" : "right"; - const targetId = button.dataset.overlayTarget || ""; - const sidebar = this.getOverlaySidebar(side); - const target = targetId ? document.getElementById(targetId) : null; - const active = Boolean( - fullscreenActive - && sidebar instanceof HTMLElement - && sidebar.classList.contains("visible-overlay") - && target instanceof HTMLElement - && target.open - ); - button.setAttribute("aria-expanded", active ? "true" : "false"); - const symbol = button.querySelector(".overlay-toggle-symbol"); - if (symbol) { - symbol.textContent = active ? "-" : "+"; - } - const hideWhileOverlayOpen = Boolean( - fullscreenActive - && sidebar instanceof HTMLElement - && sidebar.classList.contains("visible-overlay") - ); - button.classList.toggle("is-hidden-while-overlay-open", hideWhileOverlayOpen); - }); - } - - getOverlaySidebar(side) { - return side === "left" ? this.refs.leftSidebar : this.refs.rightSidebar; - } - - getOverlayPanels(side) { - return side === "left" ? this.refs.leftPanelAccordions : this.refs.rightPanelAccordions; - } - - toggleOverlayPanel(side, targetId) { - const sidebar = this.getOverlaySidebar(side); - const panels = this.getOverlayPanels(side); - if (!(sidebar instanceof HTMLElement) || !Array.isArray(panels) || panels.length === 0) { - return; - } - const target = panels.find((panel) => panel.id === targetId); - if (!(target instanceof HTMLElement)) { - return; - } - - const fullscreenActive = Boolean(document.fullscreenElement); - if (!fullscreenActive) { - target.open = !target.open; - this.syncOverlayToggleButtons(); - return; - } - - const overlayVisible = sidebar.classList.contains("visible-overlay"); - if (!overlayVisible) { - sidebar.classList.add("visible-overlay"); - target.open = true; - } else { - target.open = !target.open; - } - if (!panels.some((panel) => panel.open)) { - sidebar.classList.remove("visible-overlay"); - } - - this.syncOverlayToggleButtons(); - } - - handleOverlayAccordionToggle(side, panel) { - if (!document.fullscreenElement) { - return; - } - const sidebar = this.getOverlaySidebar(side); - const panels = this.getOverlayPanels(side); - if (!(sidebar instanceof HTMLElement) || !Array.isArray(panels)) { - return; - } - if (panel.open) { - sidebar.classList.add("visible-overlay"); - } else if (!panels.some((entry) => entry.open)) { - sidebar.classList.remove("visible-overlay"); - } - this.syncOverlayToggleButtons(); - } - - getSelectedLayer() { - return this.documentModel.layers.find((layer) => layer.id === this.selectedLayerId) || this.documentModel.layers[0] || null; - } - - touchDocument() { - this.documentModel.metadata.updatedAt = new Date().toISOString(); - this.queueLivePreviewSync("document-update"); - } - - bindRuntimeStateSync() { - this.livePreviewSync.subscribe((payload, envelope) => { - if (envelope?.eventType !== "runtime-state-binding") { - return; - } - const validation = validateStateBindingPayload(payload); - if (!validation.valid || !payload?.runtimeState) { - return; - } - this.boundRuntimeState = payload.runtimeState; - const now = Date.now(); - if ((now - this.lastRuntimeBindingStatusAt) < 500) { - return; - } - this.lastRuntimeBindingStatusAt = now; - const heroX = Number(this.boundRuntimeState.heroX).toFixed(1); - const heroY = Number(this.boundRuntimeState.heroY).toFixed(1); - const cameraX = Number(this.boundRuntimeState.cameraX).toFixed(1); - const cameraY = Number(this.boundRuntimeState.cameraY).toFixed(1); - this.updateStatus(`Runtime bound: hero(${heroX}, ${heroY}) camera(${cameraX}, ${cameraY}).`); - }); - } - - publishLivePreviewSync(reason = "update") { - this.livePreviewSync.publish( - { - toolId: "parallax-editor", - reason, - parallaxDocument: cloneDeep(this.documentModel) - }, - "tool-live-preview-sync" - ); - } - - queueLivePreviewSync(reason = "update") { - this.pendingLivePreviewReason = reason; - if (this.livePreviewSyncFrame) { - return; - } - const schedule = typeof requestAnimationFrame === "function" - ? requestAnimationFrame - : (callback) => setTimeout(callback, 16); - this.livePreviewSyncFrame = schedule(() => { - this.livePreviewSyncFrame = 0; - this.publishLivePreviewSync(this.pendingLivePreviewReason); - }); - } - - updateStatus(message) { - this.refs.statusText.textContent = message; - } - - syncInputsFromDocument() { - this.refs.projectNameInput.value = this.documentModel.map.name; - this.refs.mapWidthInput.value = String(this.documentModel.map.width); - this.refs.mapHeightInput.value = String(this.documentModel.map.height); - this.refs.tileSizeInput.value = String(this.documentModel.map.tileSize); - this.updateCameraInputBounds(); - this.refs.cameraXInput.value = String(this.cameraX); - this.refs.cameraYInput.value = String(this.cameraY); - } - - updateCameraInputBounds() { - const maxHorizontal = Math.max(1024, this.documentModel.map.pixelWidth); - const maxVertical = Math.max(1024, this.documentModel.map.pixelHeight); - this.refs.cameraXInput.min = String(-maxHorizontal); - this.refs.cameraXInput.max = String(maxHorizontal); - this.refs.cameraYInput.min = String(-maxVertical); - this.refs.cameraYInput.max = String(maxVertical); - } - - refreshSimulationActionState() { - const inSimulation = this.isSimulationMode; - this.refs.simulateButton.disabled = inSimulation; - this.refs.playSimulationButton.disabled = !inSimulation || this.simulation.playing; - this.refs.pauseSimulationButton.disabled = !inSimulation || !this.simulation.playing; - this.refs.restartSimulationButton.disabled = !inSimulation; - this.refs.exitSimulationButton.disabled = !inSimulation; - this.refs.cameraXInput.disabled = inSimulation; - this.refs.cameraYInput.disabled = inSimulation; - } - - configureSimulationTraverse(playing) { - const viewportWidth = this.refs.previewCanvas.width; - const worldWidth = Math.max(1, this.documentModel.map.pixelWidth); - const startCameraX = -Math.floor(viewportWidth * 0.2); - const endCameraX = Math.max(startCameraX + 1, worldWidth - Math.floor(viewportWidth * 0.8)); - - this.simulation.startCameraX = startCameraX; - this.simulation.endCameraX = endCameraX; - this.simulation.startCameraY = 0; - this.simulation.endCameraY = 0; - this.simulation.traversalDistance = Math.max(1, Math.abs(endCameraX - startCameraX)); - this.simulation.traversalDurationMs = Math.max(9000, Math.min(24000, 9000 + (this.simulation.traversalDistance * 9))); - this.simulation.traversedDistance = 0; - this.simulation.playing = playing; - this.simulation.lastTimestamp = 0; - this.simulation.accumulatorMs = 0; - - this.cameraX = startCameraX; - this.cameraY = 0; - if (this.refs.previewWrap) { - this.refs.previewWrap.scrollTop = 0; - } - this.refs.cameraXInput.value = String(this.cameraX); - this.refs.cameraYInput.value = String(this.cameraY); - this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; - this.updateSimulationContextReadout(); - } - - getSimulationProgress() { - if (!this.isSimulationMode || this.simulation.traversalDistance <= 0) { - return 0; - } - return Math.max(0, Math.min(1, this.simulation.traversedDistance / this.simulation.traversalDistance)); - } - - updateSimulationContextReadout() { - if (!this.isSimulationMode) { - this.refs.simulationContext.textContent = ""; - return; - } - - const sortedLayers = cloneDeep(this.documentModel.layers); - sortLayersByOrder(sortedLayers); - const progress = this.getSimulationProgress(); - const progressPercent = Math.round(progress * 100); - const repeatCount = sortedLayers.filter((layer) => layer.repeatX || layer.repeatY).length; - const wrapCount = sortedLayers.filter((layer) => layer.wrapMode === "wrap").length; - const backLayer = sortedLayers[0]?.name || "none"; - const frontLayer = sortedLayers[sortedLayers.length - 1]?.name || "none"; - const mode = this.simulation.playing ? "PLAY" : "PAUSE"; - this.refs.simulationContext.textContent = `${mode} ${progressPercent}% cam:${this.cameraX},${this.cameraY} depth:${backLayer}->${frontLayer} repeat:${repeatCount}/${sortedLayers.length} wrap:${wrapCount}`; - } - - applySimulationCameraAtProgress(progress) { - const clampedProgress = Math.max(0, Math.min(1, progress)); - const nextX = this.simulation.startCameraX - + ((this.simulation.endCameraX - this.simulation.startCameraX) * clampedProgress); - - this.cameraX = Math.round(nextX); - this.cameraY = 0; - this.refs.cameraXInput.value = String(this.cameraX); - this.refs.cameraYInput.value = String(this.cameraY); - this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; - } - - enterSimulationMode() { - if (this.isSimulationMode) { - return; - } - - this.isSimulationMode = true; - this.simulation.baseCameraX = this.cameraX; - this.simulation.baseCameraY = this.cameraY; - this.configureSimulationTraverse(true); - this.refreshSimulationActionState(); - this.updateStatus("Simulation mode active. Running full-map parallax traversal."); - this.startSimulationLoop(); - this.renderPreview(); - } - - pauseSimulation() { - if (!this.isSimulationMode || !this.simulation.playing) { - return; - } - this.simulation.playing = false; - this.simulation.lastTimestamp = 0; - this.simulation.accumulatorMs = 0; - this.refreshSimulationActionState(); - this.updateSimulationContextReadout(); - this.renderPreview(); - this.updateStatus("Simulation paused."); - } - - resumeSimulation() { - if (!this.isSimulationMode || this.simulation.playing) { - return; - } - this.simulation.playing = true; - this.simulation.lastTimestamp = 0; - this.simulation.accumulatorMs = 0; - this.refreshSimulationActionState(); - this.updateSimulationContextReadout(); - this.renderPreview(); - this.updateStatus("Simulation resumed."); - } - - restartSimulationPosition() { - if (!this.isSimulationMode) { - return; - } - this.configureSimulationTraverse(this.simulation.playing); - this.refreshSimulationActionState(); - this.renderPreview(); - this.updateStatus("Simulation restarted from the initial traversal position."); - } - - exitSimulationMode() { - const wasSimulationMode = this.isSimulationMode; - if (!wasSimulationMode && !this.simulation.rafId) { - return; - } - - this.isSimulationMode = false; - if (this.simulation.rafId) { - cancelAnimationFrame(this.simulation.rafId); - this.simulation.rafId = 0; - } - - this.simulation.playing = false; - this.simulation.lastTimestamp = 0; - this.simulation.accumulatorMs = 0; - this.simulation.traversedDistance = 0; - this.simulation.traversalDistance = 0; - this.cameraX = this.simulation.baseCameraX; - this.cameraY = this.simulation.baseCameraY; - this.refs.cameraXInput.value = String(this.cameraX); - this.refs.cameraYInput.value = String(this.cameraY); - this.refs.simulationContext.textContent = ""; - this.refreshSimulationActionState(); - this.renderPreview(); - if (wasSimulationMode) { - this.updateStatus("Exited simulation mode."); - } - } - - startSimulationLoop() { - const tick = (timestamp) => { - if (!this.isSimulationMode) { - return; - } - - if (this.simulation.playing) { - if (this.simulation.lastTimestamp === 0) { - this.simulation.lastTimestamp = timestamp; - } - const deltaMs = timestamp - this.simulation.lastTimestamp; - this.simulation.lastTimestamp = timestamp; - this.advanceSimulationCamera(deltaMs); - } - - this.renderPreview(); - this.simulation.rafId = requestAnimationFrame(tick); - }; - - this.simulation.rafId = requestAnimationFrame(tick); - } - - advanceSimulationCamera(deltaMs) { - const durationMs = Math.max(1, this.simulation.traversalDurationMs); - const advanceBy = Math.max(0, deltaMs) * (this.simulation.traversalDistance / durationMs); - this.simulation.traversedDistance = Math.min( - this.simulation.traversalDistance, - this.simulation.traversedDistance + advanceBy - ); - const progress = this.getSimulationProgress(); - this.applySimulationCameraAtProgress(progress); - this.updateSimulationContextReadout(); - - if (progress >= 1) { - this.simulation.playing = false; - this.refreshSimulationActionState(); - this.updateStatus("Simulation reached the end of the full-map traverse."); - } - } - - async tryLoadPresetFromQuery() { - const searchParams = new URLSearchParams(window.location.search); - const samplePresetPath = normalizeSamplePresetPath(searchParams.get("samplePresetPath") || ""); - const launchQuery = getToolLoadQuerySnapshot(searchParams); - logToolLoadRequest({ - toolId: "parallax-editor", - sampleId: String(searchParams.get("sampleId") || "").trim(), - samplePresetPath, - requestedDataPaths: getToolLoadRequestedDataPaths(launchQuery), - launchQuery - }); - if (!samplePresetPath) { - logToolLoadWarning({ - toolId: "parallax-editor", - reason: "samplePresetPath missing", - launchQuery - }); - return; - } - - const sampleId = String(searchParams.get("sampleId") || "").trim(); - this.exitSimulationMode(); - try { - const presetUrl = new URL(samplePresetPath, window.location.href); - const presetHref = presetUrl.toString(); - logToolLoadFetch({ - toolId: "parallax-editor", - phase: "attempt", - fetchUrl: presetHref, - requestedPath: samplePresetPath, - pathSource: "tool-input:query.samplePresetPath" - }); - const response = await fetch(presetHref, { cache: "no-store" }); - logToolLoadFetch({ - toolId: "parallax-editor", - phase: "response", - fetchUrl: presetHref, - requestedPath: samplePresetPath, - pathSource: "tool-input:query.samplePresetPath", - status: response.status, - ok: response.ok - }); - if (!response.ok) { - throw new Error(`Preset request failed (${response.status}).`); - } - const rawPreset = await response.json(); - await logToolLoadLoaded({ - toolId: "parallax-editor", - toolName: "Parallax Scene Studio", - sampleId, - samplePresetPath, - requestedPath: samplePresetPath, - fetchUrl: presetHref, - loadedDocument: rawPreset, - loaded: summarizeToolLoadData(rawPreset) - }); - const toolDocument = extractParallaxDocumentFromSamplePreset(rawPreset); - this.skipExternalProjectStateUntil = Date.now() + 3000; - this.applyParallaxDocument(extractParallaxDocument(toolDocument)); - this.queueLivePreviewSync("sample-preset"); - this.updateStatus(buildPresetLoadedStatus(sampleId, samplePresetPath)); - } catch (error) { - logToolLoadWarning({ - toolId: "parallax-editor", - sampleId, - samplePresetPath, - error: error instanceof Error ? error.message : "unknown error" - }); - this.updateStatus(`Preset load failed: ${error instanceof Error ? error.message : "unknown error"}`); - } - } - - applyParallaxDocument(nextDocument) { - this.documentModel = nextDocument; - this.resolveAssetRefsFromRegistry(); - normalizeDrawOrderSequence(this.documentModel.layers); - this.selectedLayerId = this.documentModel.layers[0]?.id || ""; - this.clearTransientLayerImageSources(); - this.invalidateImageCache(); - this.cameraX = 0; - this.cameraY = 0; - this.syncInputsFromDocument(); - this.renderAll(); - } - - handleNewProject() { - this.exitSimulationMode(); - this.documentModel = createInitialParallaxDocument({ map: this.documentModel.map }); - this.selectedLayerId = this.documentModel.layers[0]?.id || ""; - this.clearTransientLayerImageSources(); - this.invalidateImageCache(); - this.cameraX = 0; - this.cameraY = 0; - this.syncInputsFromDocument(); - this.renderAll(); - this.queueLivePreviewSync("new-project"); - this.updateStatus("Created new parallax document."); - } - - syncAssetRegistryFromDocument() { - const projectName = this.documentModel?.map?.name || "parallax-map"; - this.assetRegistry = sanitizeAssetRegistry(this.assetRegistry); - this.assetRegistry.projectId = this.assetRegistry.projectId || projectName; - - const sourceIds = []; - this.documentModel.layers.forEach((layer, index) => { - const layerPath = normalizeProjectRelativePath(layer?.imageSource || ""); - if (!layerPath) { - return; - } - - const imageId = createAssetId("image", layerPath, `parallax-image-${index + 1}`); - const sourceId = layer.parallaxSourceId || createAssetId("parallax", layer.id || layer.name || `source-${index + 1}`, `source-${index + 1}`); - layer.parallaxSourceId = sourceId; - - const imageRegistration = registerAssetPipelineCandidate({ - registry: this.assetRegistry, - section: "images", - ingest: { - id: imageId, - name: layer.name || `Parallax Layer ${index + 1}`, - path: layerPath, - sourceTool: "parallax-editor" - } - }); - this.assetRegistry = imageRegistration.registry; - - const sourceRegistration = registerAssetPipelineCandidate({ - registry: this.assetRegistry, - section: "parallaxSources", - ingest: { - id: sourceId, - name: layer.name || `Parallax Layer ${index + 1}`, - path: layerPath, - sourceTool: "parallax-editor" - }, - entryFields: { - imageId - } - }); - this.assetRegistry = sourceRegistration.registry; - - sourceIds.push(sourceId); - }); - - this.documentModel.assetRefs = { - parallaxSourceIds: Array.from(new Set(sourceIds)) - }; - } - - resolveAssetRefsFromRegistry() { - this.documentModel.assetRefs = sanitizeAssetRefs(this.documentModel.assetRefs); - const sourceIds = this.documentModel.assetRefs.parallaxSourceIds; - if (!Array.isArray(sourceIds) || sourceIds.length === 0) { - return { - resolvedCount: 0, - unresolvedCount: 0 - }; - } - - let fallbackIndex = 0; - let resolvedCount = 0; - let unresolvedCount = 0; - this.documentModel.layers.forEach((layer) => { - const sourceId = layer.parallaxSourceId || sourceIds[fallbackIndex] || ""; - if (!sourceId) { - return; - } - - if (!layer.parallaxSourceId) { - layer.parallaxSourceId = sourceId; - } - - if (normalizeProjectRelativePath(layer.imageSource || "")) { - fallbackIndex += 1; - return; - } - - const sourceEntry = findRegistryEntryById(this.assetRegistry, "parallaxSources", sourceId); - const sourcePath = normalizeProjectRelativePath(sourceEntry?.path || ""); - if (sourcePath) { - layer.imageSource = sourcePath; - resolvedCount += 1; - } else { - unresolvedCount += 1; - } - fallbackIndex += 1; - }); - - return { - resolvedCount, - unresolvedCount - }; - } - - handleSaveProject() { - this.touchDocument(); - this.syncAssetRegistryFromDocument(); - const validation = this.validateProjectAssets(); - if (hasBlockingAssetValidationFindings(validation)) { - this.updateStatus(`${getBlockingAssetValidationMessage("Save Project", validation)} (${summarizeAssetValidation(validation)}).`); - return; - } - const output = addToolModeMetadata(createRegistryManagedParallaxSaveDocument(this.documentModel), { toolId: "parallax-editor" }); - const { graph, findings } = buildAssetDependencyGraph(this.assetRegistry); - this.assetDependencyGraphSnapshot = graph; - output.project = { - ...(output.project && typeof output.project === "object" ? output.project : {}), - assetDependencyGraph: graph - }; - const payload = JSON.stringify(output, null, 2); - const fileName = `${this.documentModel.map.name || "map"}.parallax.json`; - downloadTextFile(payload, fileName); - this.updateStatus(`Saved ${fileName} (${output.assetRefs.parallaxSourceIds.length} parallax asset refs, ID-based layer references).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`); - } - - handleSaveAssetRegistry() { - this.syncAssetRegistryFromDocument(); - const validation = this.validateProjectAssets(); - if (hasBlockingAssetValidationFindings(validation)) { - this.updateStatus(`${getBlockingAssetValidationMessage("Save Asset Registry", validation)} (${summarizeAssetValidation(validation)}).`); - return; - } - const { findings } = buildAssetDependencyGraph(this.assetRegistry); - const payload = createRegistryDownloadPayload(this.assetRegistry); - downloadTextFile(payload, "project.assets.json"); - this.updateStatus(`Saved project.assets.json (${this.assetRegistry.parallaxSources.length} parallax sources).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`); - } - - handleExportTilemapPatch() { - const validation = this.validateProjectAssets(); - if (hasBlockingAssetValidationFindings(validation)) { - this.updateStatus(`${getBlockingAssetValidationMessage("Export Parallax Patch", validation)} (${summarizeAssetValidation(validation)}).`); - return; - } - const patch = createTilemapParallaxPatch(createRegistryManagedParallaxSaveDocument(this.documentModel)); - const payload = JSON.stringify(patch, null, 2); - const fileName = `${this.documentModel.map.name || "map"}.tilemap-parallax.patch.json`; - downloadTextFile(payload, fileName); - this.updateStatus(`Exported ${fileName}. Validation: ${summarizeAssetValidation(validation)}.`); - } - - handlePackageProject() { - this.touchDocument(); - this.syncAssetRegistryFromDocument(); - const validation = this.validateProjectAssets(); - const persistableDocument = createRegistryManagedParallaxSaveDocument(this.documentModel); - const packageResult = buildProjectPackage({ - registry: this.assetRegistry, - validationResult: validation, - parallaxDocument: persistableDocument - }); - this.lastPackageResult = packageResult; - this.editorExperienceResult = buildEditorExperienceLayer({ - assetDependencyGraph: validation.assetDependencyGraph, - validationResult: validation, - remediationResult: this.remediationResult, - packageResult: this.lastPackageResult, - runtimeResult: this.lastRuntimeResult - }); - this.debugVisualizationResult = buildDebugVisualizationLayer({ - assetDependencyGraph: validation.assetDependencyGraph, - validationResult: validation, - remediationResult: this.remediationResult, - packageResult: this.lastPackageResult, - runtimeResult: this.lastRuntimeResult - }); - this.updateEditorExperienceUI(); - this.updateDebugVisualizationUI(); - if (packageResult.packageStatus !== "ready") { - this.updateStatus(`${summarizeProjectPackaging(packageResult)} ${packageResult.manifest.package.reports[0]?.message || ""}`.trim()); - return; - } - const fileBase = `${this.assetRegistry.projectId || this.documentModel.map.name || "parallax-project"}.package`; - downloadTextFile(`${JSON.stringify(packageResult.manifest, null, 2)}\n`, `${fileBase}.json`); - downloadTextFile(`${packageResult.reportText}\n`, `${fileBase}.report.txt`); - this.updateStatus(`${summarizeProjectPackaging(packageResult)} Manifest and report exported.`); - } - - async handleLoadProject(event) { - this.exitSimulationMode(); - const file = event.target.files?.[0]; - if (!file) { - return; - } - - try { - const text = await readFileText(file); - const raw = JSON.parse(text); - const guard = assertStandaloneToolDocument(raw, { - expectedLabel: "Parallax project", - acceptedSchemas: ["toolbox.parallax/1", "toolbox.tilemap/1"], - requiredToolId: "parallax-editor" - }); - if (!guard.ok) { - const handled = offerImportMismatchOptions(guard, { - viewerToolId: "state-inspector", - viewerPayload: raw, - sourceToolId: "parallax-editor" - }); - if (handled) { - return; - } - throw new Error(guard.reason); - } - this.documentModel = extractParallaxDocument(raw); - this.assetDependencyGraphSnapshot = raw?.project?.assetDependencyGraph || null; - const resolution = this.resolveAssetRefsFromRegistry(); - normalizeDrawOrderSequence(this.documentModel.layers); - this.selectedLayerId = this.documentModel.layers[0]?.id || ""; - this.clearTransientLayerImageSources(); - this.invalidateImageCache(); - this.cameraX = 0; - this.cameraY = 0; - this.syncInputsFromDocument(); - this.renderAll(); - this.queueLivePreviewSync("load-project"); - const validation = this.validateProjectAssets(); - if (resolution.resolvedCount > 0) { - this.updateStatus(`Loaded ${file.name} (${resolution.resolvedCount} layer image refs restored from asset registry, validation: ${summarizeAssetValidation(validation)}).`); - } else if (resolution.unresolvedCount > 0) { - this.updateStatus(`Loaded ${file.name} (${resolution.unresolvedCount} registry image refs unresolved; layer image paths are missing, validation: ${summarizeAssetValidation(validation)}).`); - } else { - this.updateStatus(`Loaded ${file.name} (validation: ${summarizeAssetValidation(validation)}).`); - } - } catch (error) { - this.updateStatus(`Load failed: ${error instanceof Error ? error.message : "invalid JSON"}`); - } - this.refs.loadProjectInput.value = ""; - } - - async handleLoadAssetRegistry(event) { - const file = event.target.files?.[0]; - if (!file) { - return; - } - - try { - const text = await readFileText(file); - const parsed = JSON.parse(text); - this.assetRegistry = mergeAssetRegistries(this.assetRegistry, parsed); - const resolution = this.resolveAssetRefsFromRegistry(); - this.invalidateImageCache(); - this.renderAll(); - const validation = this.validateProjectAssets(); - if (resolution.resolvedCount > 0) { - this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources; ${resolution.resolvedCount} layer refs restored; validation: ${summarizeAssetValidation(validation)}).`); - } else if (resolution.unresolvedCount > 0) { - this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources; ${resolution.unresolvedCount} refs still unresolved; validation: ${summarizeAssetValidation(validation)}).`); - } else { - this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources, validation: ${summarizeAssetValidation(validation)}).`); - } - } catch (error) { - this.updateStatus(`Asset registry load failed: ${error instanceof Error ? error.message : "invalid JSON"}`); - } - this.refs.loadAssetRegistryInput.value = ""; - } - - applyMapMetaFromInputs() { - const map = normalizeMapMeta({ - name: this.refs.projectNameInput.value, - width: this.refs.mapWidthInput.value, - height: this.refs.mapHeightInput.value, - tileSize: this.refs.tileSizeInput.value - }); - - this.documentModel.map = map; - this.updateCameraInputBounds(); - this.touchDocument(); - this.renderPreview(); - this.refs.previewMeta.textContent = `${map.width}x${map.height} @ ${map.tileSize}px (${map.pixelWidth}x${map.pixelHeight})`; - this.updateStatus("Updated map metadata for parallax preview."); - } - - addLayer() { - const name = this.refs.newLayerNameInput.value.trim() || `Parallax Layer ${this.documentModel.layers.length + 1}`; - const newLayer = createDefaultLayer(this.documentModel.layers.length, name); - newLayer.drawOrder = this.documentModel.layers.length; - - this.documentModel.layers.push(newLayer); - normalizeDrawOrderSequence(this.documentModel.layers); - this.selectedLayerId = newLayer.id; - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Added layer ${name}.`); - } - - removeSelectedLayer() { - if (this.documentModel.layers.length <= 1) { - this.updateStatus("Cannot remove the last parallax layer."); - return; - } - - const index = this.documentModel.layers.findIndex((layer) => layer.id === this.selectedLayerId); - if (index < 0) { - return; - } - - const removed = this.documentModel.layers[index]; - this.documentModel.layers.splice(index, 1); - normalizeDrawOrderSequence(this.documentModel.layers); - this.selectedLayerId = this.documentModel.layers[Math.max(0, index - 1)]?.id || this.documentModel.layers[0].id; - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Removed layer ${removed.name}.`); - } - - duplicateSelectedLayer() { - const selected = this.getSelectedLayer(); - if (!selected) { - return; - } - - const duplicate = normalizeLayer(cloneDeep(selected), this.documentModel.layers.length); - duplicate.id = `parallax-layer-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - duplicate.name = `${selected.name} Copy`; - duplicate.drawOrder = this.documentModel.layers.length; - - this.documentModel.layers.push(duplicate); - normalizeDrawOrderSequence(this.documentModel.layers); - this.selectedLayerId = duplicate.id; - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Duplicated layer ${selected.name}.`); - } - - moveSelectedLayer(direction) { - const currentIndex = this.documentModel.layers.findIndex((layer) => layer.id === this.selectedLayerId); - if (currentIndex < 0) { - return; - } - - const nextIndex = currentIndex + direction; - if (nextIndex < 0 || nextIndex >= this.documentModel.layers.length) { - return; - } - - const [layer] = this.documentModel.layers.splice(currentIndex, 1); - this.documentModel.layers.splice(nextIndex, 0, layer); - // Preserve the user-driven array move, then reindex drawOrder without re-sorting. - this.documentModel.layers.forEach((entry, index) => { - entry.drawOrder = index; - }); - this.selectedLayerId = layer.id; - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Moved layer ${layer.name} ${direction < 0 ? "up" : "down"}.`); - } - - applyBasicLayerFields() { - const layer = this.getSelectedLayer(); - if (!layer) { - return; - } - - layer.name = this.refs.layerNameInput.value.trim() || layer.name; - layer.drawOrder = Math.trunc(clamp(this.refs.layerDrawOrderInput.value, -999, 999, layer.drawOrder)); - layer.opacity = clamp(this.refs.layerOpacityInput.value, 0, 1, layer.opacity); - layer.visible = this.refs.layerVisibleSelect.value === "true"; - - normalizeDrawOrderSequence(this.documentModel.layers); - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Updated basic layer settings for ${layer.name}.`); - } - - applyExtendedLayerSettings() { - const layer = this.getSelectedLayer(); - if (!layer) { - return; - } - - layer.scrollFactorX = clamp(this.refs.scrollFactorXInput.value, -4, 4, layer.scrollFactorX); - layer.scrollFactorY = clamp(this.refs.scrollFactorYInput.value, -4, 4, layer.scrollFactorY); - layer.offsetX = Math.trunc(clamp(this.refs.offsetXInput.value, -4096, 4096, layer.offsetX)); - layer.offsetY = Math.trunc(clamp(this.refs.offsetYInput.value, -4096, 4096, layer.offsetY)); - layer.repeatX = this.refs.repeatXSelect.value === "true"; - layer.repeatY = this.refs.repeatYSelect.value === "true"; - layer.wrapMode = this.refs.wrapModeSelect.value === "clamp" ? "clamp" : "wrap"; - - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Applied scroll/repeat settings for ${layer.name}.`); - } - - applyImageSourceFromInput() { - const layer = this.getSelectedLayer(); - if (!layer) { - return; - } - - const source = this.refs.layerImageSourceInput.value.trim(); - if (layer.imageSource && layer.imageSource !== source) { - const previousTransient = this.getTransientLayerImageSource(layer.imageSource); - if (previousTransient) { - URL.revokeObjectURL(previousTransient); - this.transientLayerImageSourceByName.delete(layer.imageSource.trim()); - } - } - layer.imageSource = source; - - this.invalidateImageCache(); - this.touchDocument(); - this.renderAll(); - this.updateStatus(source ? `Assigned image source to ${layer.name}.` : `Cleared image source for ${layer.name}.`); - } - - assignLocalImageFile(event) { - const layer = this.getSelectedLayer(); - if (!layer) { - return; - } - - const file = event.target.files?.[0]; - if (!file) { - return; - } - - const previousSource = layer.imageSource; - const objectUrl = URL.createObjectURL(file); - layer.imageSource = file.name; - this.setTransientLayerImageSource(layer.imageSource, objectUrl); - if (previousSource && previousSource !== layer.imageSource) { - const previousTransient = this.getTransientLayerImageSource(previousSource); - if (previousTransient) { - URL.revokeObjectURL(previousTransient); - this.transientLayerImageSourceByName.delete(previousSource.trim()); - } - } - this.invalidateImageCache(); - this.touchDocument(); - this.renderAll(); - this.updateStatus(`Assigned local image file ${file.name} to ${layer.name}.`); - this.refs.layerImageFileInput.value = ""; - } - - handleCameraChange() { - if (this.isSimulationMode) { - return; - } - const maxHorizontal = Math.max(1024, this.documentModel.map.pixelWidth); - const maxVertical = Math.max(1024, this.documentModel.map.pixelHeight); - this.cameraX = Math.trunc(clamp(this.refs.cameraXInput.value, -maxHorizontal, maxHorizontal, 0)); - this.cameraY = Math.trunc(clamp(this.refs.cameraYInput.value, -maxVertical, maxVertical, 0)); - this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; - this.renderPreview(); - } - - renderAll() { - normalizeDrawOrderSequence(this.documentModel.layers); - this.renderLayerList(); - this.renderSelectedLayerFields(); - this.renderPreview(); - this.refs.previewMeta.textContent = `${this.documentModel.map.width}x${this.documentModel.map.height} @ ${this.documentModel.map.tileSize}px (${this.documentModel.map.pixelWidth}x${this.documentModel.map.pixelHeight})`; - this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; - this.refreshSimulationActionState(); - this.updateSimulationContextReadout(); - this.updateRemediationUI(); - this.updateEditorExperienceUI(); - this.updateDebugVisualizationUI(); - } - - renderLayerList() { - const list = this.refs.layerList; - list.innerHTML = ""; - - const fragment = document.createDocumentFragment(); - this.documentModel.layers.forEach((layer) => { - const item = document.createElement("li"); - item.className = `layer-item${layer.id === this.selectedLayerId ? " selected" : ""}`; - - const row = document.createElement("div"); - row.className = "layer-row"; - - const selectButton = document.createElement("button"); - selectButton.type = "button"; - selectButton.className = "layer-select-btn"; - selectButton.textContent = layer.name; - selectButton.addEventListener("click", () => { - this.selectedLayerId = layer.id; - this.renderAll(); - this.updateStatus(`Selected layer ${layer.name}.`); - }); - - const orderChip = document.createElement("span"); - orderChip.className = "layer-order-chip"; - orderChip.textContent = `order ${layer.drawOrder}`; - - row.appendChild(selectButton); - row.appendChild(orderChip); - item.appendChild(row); - fragment.appendChild(item); - }); - - list.appendChild(fragment); - } - - renderSelectedLayerFields() { - const layer = this.getSelectedLayer(); - if (!layer) { - return; - } - - this.refs.layerNameInput.value = layer.name; - this.refs.layerDrawOrderInput.value = String(layer.drawOrder); - this.refs.layerOpacityInput.value = String(layer.opacity); - this.refs.layerVisibleSelect.value = layer.visible ? "true" : "false"; - - this.refs.layerImageSourceInput.value = layer.imageSource || ""; - this.refs.scrollFactorXInput.value = String(layer.scrollFactorX); - this.refs.scrollFactorYInput.value = String(layer.scrollFactorY); - this.refs.offsetXInput.value = String(layer.offsetX); - this.refs.offsetYInput.value = String(layer.offsetY); - this.refs.repeatXSelect.value = layer.repeatX ? "true" : "false"; - this.refs.repeatYSelect.value = layer.repeatY ? "true" : "false"; - this.refs.wrapModeSelect.value = layer.wrapMode; - } - - resolveLayerImageUrl(source) { - if (!source || source.startsWith("blob:")) { - return source; - } - - try { - const url = new URL(source, window.location.href); - url.searchParams.set("_cb", String(this.imageCacheVersion)); - return url.toString(); - } catch (error) { - const separator = source.includes("?") ? "&" : "?"; - return `${source}${separator}_cb=${this.imageCacheVersion}`; - } - } - - getLayerImageRecord(layer) { - const source = this.getTransientLayerImageSource(layer.imageSource) || layer.imageSource; - if (!source) { - return null; - } - - const cacheKey = `${source}::v${this.imageCacheVersion}`; - if (this.imageCache.has(cacheKey)) { - return this.imageCache.get(cacheKey); - } - - const record = { - status: "loading", - image: new Image(), - error: null - }; - - record.image.onload = () => { - record.status = "ready"; - this.renderPreview(); - }; - record.image.onerror = () => { - record.status = "error"; - record.error = "Image failed to load."; - this.renderPreview(); - }; - - if (!source.startsWith("blob:")) { - record.image.crossOrigin = "anonymous"; - } - record.image.src = this.resolveLayerImageUrl(source); - - this.imageCache.set(cacheKey, record); - return record; - } - - drawFallbackLayer(context, layer, viewportWidth, viewportHeight, screenX, screenY) { - const color = getLayerVisualColor(layer); - context.fillStyle = color; - - if (layer.repeatX || layer.repeatY) { - context.globalAlpha = layer.opacity * 0.25; - context.fillRect(0, 0, viewportWidth, viewportHeight); - context.globalAlpha = layer.opacity * 0.6; - const stripeHeight = Math.max(24, Math.floor(viewportHeight / 10)); - for (let y = 0; y < viewportHeight; y += stripeHeight * 2) { - context.fillRect(0, y, viewportWidth, stripeHeight); - } - return; - } - - const width = Math.max(240, Math.floor(viewportWidth * 0.4)); - const height = Math.max(140, Math.floor(viewportHeight * 0.25)); - context.globalAlpha = layer.opacity * 0.65; - context.fillRect(screenX, screenY, width, height); - } - - drawSingleLayer(context, layer, viewportWidth, viewportHeight, mapOriginX, mapOriginY) { - if (!layer.visible || layer.opacity <= 0) { - return; - } - - const screenX = Math.round(mapOriginX + (-this.cameraX * layer.scrollFactorX) + layer.offsetX); - const screenY = Math.round(mapOriginY + (-this.cameraY * layer.scrollFactorY) + layer.offsetY); - - context.save(); - context.globalAlpha = layer.opacity; - - const imageRecord = this.getLayerImageRecord(layer); - if (!imageRecord || imageRecord.status === "error" || imageRecord.status === "loading") { - this.drawFallbackLayer(context, layer, viewportWidth, viewportHeight, screenX, screenY); - context.restore(); - return; - } - - const image = imageRecord.image; - const tileWidth = Math.max(1, image.naturalWidth || image.width || viewportWidth); - const tileHeight = Math.max(1, image.naturalHeight || image.height || viewportHeight); - - const xPositions = []; - const yPositions = []; - - if (layer.repeatX) { - if (layer.wrapMode === "wrap") { - let startX = mod(screenX, tileWidth) - tileWidth; - for (let x = startX; x <= viewportWidth + tileWidth; x += tileWidth) { - xPositions.push(x); - } - } else { - for (let x = screenX; x <= viewportWidth + tileWidth; x += tileWidth) { - xPositions.push(x); - } - for (let x = screenX - tileWidth; x >= -tileWidth; x -= tileWidth) { - xPositions.push(x); - } - } - } else { - xPositions.push(screenX); - } - - if (layer.repeatY) { - if (layer.wrapMode === "wrap") { - let startY = mod(screenY, tileHeight) - tileHeight; - for (let y = startY; y <= viewportHeight + tileHeight; y += tileHeight) { - yPositions.push(y); - } - } else { - for (let y = screenY; y <= viewportHeight + tileHeight; y += tileHeight) { - yPositions.push(y); - } - for (let y = screenY - tileHeight; y >= -tileHeight; y -= tileHeight) { - yPositions.push(y); - } - } - } else { - yPositions.push(screenY); - } - - for (let yi = 0; yi < yPositions.length; yi += 1) { - for (let xi = 0; xi < xPositions.length; xi += 1) { - context.drawImage(image, Math.round(xPositions[xi]), Math.round(yPositions[yi])); - } - } - - context.restore(); - } - - ensureSimulationViewportFocus(focusX) { - if (!this.isSimulationMode || !this.refs.previewWrap) { - return; - } - const wrap = this.refs.previewWrap; - const maxScrollX = Math.max(0, wrap.scrollWidth - wrap.clientWidth); - const targetLeft = Math.max(0, Math.min(maxScrollX, Math.round(focusX - (wrap.clientWidth * 0.45)))); - if (wrap.scrollLeft !== targetLeft) { - wrap.scrollLeft = targetLeft; - } - if (wrap.scrollTop !== 0) { - wrap.scrollTop = 0; - } - } - - renderPreview() { - const context = this.refs.previewContext; - const canvas = this.refs.previewCanvas; - const desiredWidth = Math.max(960, Math.min(4096, this.documentModel.map.pixelWidth + 360)); - const desiredHeight = Math.max(540, Math.min(2160, this.documentModel.map.pixelHeight + 260)); - if (canvas.width !== desiredWidth) { - canvas.width = desiredWidth; - } - if (canvas.height !== desiredHeight) { - canvas.height = desiredHeight; - } - const viewportWidth = canvas.width; - const viewportHeight = canvas.height; - const worldWidth = this.documentModel.map.pixelWidth; - const worldHeight = this.documentModel.map.pixelHeight; - const mapOriginX = Math.max(20, Math.round((viewportWidth - worldWidth) / 2)); - const mapOriginY = Math.max(20, Math.round((viewportHeight - worldHeight) / 2)); - - context.fillStyle = "#0b1830"; - context.fillRect(0, 0, viewportWidth, viewportHeight); - - context.fillStyle = "#0f2340"; - for (let y = 0; y < viewportHeight; y += 48) { - context.fillRect(0, y, viewportWidth, 24); - } - - const sortedLayers = cloneDeep(this.documentModel.layers); - sortLayersByOrder(sortedLayers); - - const progress = this.getSimulationProgress(); - const proxyX = mapOriginX + Math.round(progress * Math.max(1, worldWidth - 1)); - const proxyY = mapOriginY + Math.round(worldHeight * 0.62); - const visibleHeroLayer = sortedLayers.find((layer) => { - if (!layer.visible) { - return false; - } - const id = typeof layer.id === "string" ? layer.id.toLowerCase() : ""; - const name = typeof layer.name === "string" ? layer.name.toLowerCase() : ""; - return id.includes("hero") || name.includes("hero"); - }); - const heroDrawOrder = visibleHeroLayer ? visibleHeroLayer.drawOrder : null; - let markerDrawn = false; - const drawTraversalMarker = (includeTrackLine) => { - if (includeTrackLine) { - context.strokeStyle = "rgba(244, 244, 245, 0.35)"; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(proxyX, mapOriginY); - context.lineTo(proxyX, mapOriginY + worldHeight); - context.stroke(); - } - context.fillStyle = this.isSimulationMode ? "#f59e0b" : "#e2e8f0"; - context.beginPath(); - context.arc(proxyX, proxyY, 8, 0, Math.PI * 2); - context.fill(); - }; - - sortedLayers.forEach((layer) => { - this.drawSingleLayer(context, layer, viewportWidth, viewportHeight, mapOriginX, mapOriginY); - if (!markerDrawn && heroDrawOrder !== null && layer.drawOrder >= heroDrawOrder) { - drawTraversalMarker(false); - markerDrawn = true; - } - }); - if (!markerDrawn) { - drawTraversalMarker(true); - } - - context.strokeStyle = "rgba(255, 255, 255, 0.45)"; - context.lineWidth = 2; - context.strokeRect(mapOriginX, mapOriginY, worldWidth, worldHeight); - this.ensureSimulationViewportFocus(proxyX); - - const modeText = this.isSimulationMode - ? `SIMULATION ${this.simulation.playing ? "PLAY" : "PAUSE"}` - : "EDIT"; - const repeatCount = sortedLayers.filter((layer) => layer.repeatX || layer.repeatY).length; - const wrapCount = sortedLayers.filter((layer) => layer.wrapMode === "wrap").length; - - if (this.refs.previewDetailsText instanceof HTMLElement) { - const lines = [ - `Mode: ${modeText}`, - `Map: ${this.documentModel.map.name}`, - `Camera: ${this.cameraX}, ${this.cameraY}`, - `Traverse: ${Math.round(progress * 100)}% (${Math.round(this.simulation.traversedDistance)}/${Math.round(this.simulation.traversalDistance || 0)} px)`, - `Layers: ${sortedLayers.length} repeat=${repeatCount} wrap=${wrapCount}` - ]; - sortedLayers.slice(0, 3).forEach((layer) => { - lines.push(`${layer.drawOrder}:${layer.name} sf(${layer.scrollFactorX.toFixed(2)},${layer.scrollFactorY.toFixed(2)}) ${layer.repeatX ? "RX" : "--"}/${layer.repeatY ? "RY" : "--"} ${layer.wrapMode}`); - }); - this.refs.previewDetailsText.textContent = lines.join("\n"); - } - this.updateSimulationContextReadout(); - } - - validateProjectAssets() { - this.validationResult = validateProjectAssetState({ - registry: this.assetRegistry, - assetDependencyGraph: this.assetDependencyGraphSnapshot, - parallaxDocument: this.documentModel - }); - this.remediationResult = buildProjectAssetRemediation({ - validationResult: this.validationResult, - registry: this.assetRegistry - }); - this.editorExperienceResult = buildEditorExperienceLayer({ - assetDependencyGraph: this.validationResult.assetDependencyGraph, - validationResult: this.validationResult, - remediationResult: this.remediationResult, - packageResult: this.lastPackageResult, - runtimeResult: this.lastRuntimeResult - }); - this.debugVisualizationResult = buildDebugVisualizationLayer({ - assetDependencyGraph: this.validationResult.assetDependencyGraph, - validationResult: this.validationResult, - remediationResult: this.remediationResult, - packageResult: this.lastPackageResult, - runtimeResult: this.lastRuntimeResult - }); - return this.validationResult; - } - - updateRemediationUI() { - if (!this.refs.remediationSummaryText) { - return; - } - this.refs.remediationSummaryText.textContent = summarizeProjectAssetRemediation(this.remediationResult); - const navigateAction = getPrimaryRemediationAction(this.remediationResult, "navigate"); - const fixAction = getPrimaryRemediationAction(this.remediationResult, "confirmable-fix"); - this.refs.inspectRemediationButton.disabled = this.remediationResult?.remediation?.status !== "available"; - this.refs.jumpToProblemButton.disabled = !navigateAction; - this.refs.applyRemediationButton.disabled = !fixAction; - } - - updateEditorExperienceUI() { - if (!this.refs.experienceSummaryText || !this.refs.experienceDetailsText) { - return; - } - this.refs.experienceSummaryText.textContent = summarizeEditorExperienceLayer(this.editorExperienceResult); - this.refs.experienceDetailsText.textContent = this.editorExperienceResult?.experience?.reportText || "No experience snapshot available."; - } - - updateDebugVisualizationUI() { - if (!this.refs.debugSummaryText || !this.refs.debugDetailsText) { - return; - } - this.refs.debugSummaryText.textContent = summarizeDebugVisualizationLayer(this.debugVisualizationResult); - this.refs.debugDetailsText.textContent = this.debugVisualizationResult?.debugVisualization?.reportText || "No debug visualization available."; - } - - refreshExperienceSnapshot() { - this.validateProjectAssets(); - this.updateEditorExperienceUI(); - this.updateStatus(summarizeEditorExperienceLayer(this.editorExperienceResult)); - } - - refreshDebugVisualizationSnapshot() { - this.validateProjectAssets(); - this.updateDebugVisualizationUI(); - this.updateStatus(summarizeDebugVisualizationLayer(this.debugVisualizationResult)); - } - - inspectRemediationActions() { - this.validateProjectAssets(); - const actions = this.remediationResult?.remediation?.actions || []; - if (actions.length === 0) { - this.updateStatus("No remediation actions are currently available."); - this.updateRemediationUI(); - return false; - } - this.updateStatus(`Remediation actions: ${actions.slice(0, 3).map((action) => `${action.label}: ${action.message}`).join(" | ")}`); - this.updateRemediationUI(); - return true; - } - - jumpToRemediationProblem() { - this.validateProjectAssets(); - const action = getPrimaryRemediationAction(this.remediationResult, "navigate"); - if (!action) { - this.updateStatus("No navigation remediation action is available."); - this.updateRemediationUI(); - return false; - } - - if (action.findingCode === "UNRESOLVED_IMAGE_LINK") { - const targetLayer = this.documentModel.layers.find((layer) => layer.id === action.sourceId) || this.documentModel.layers[0] || null; - if (targetLayer) { - this.selectedLayerId = targetLayer.id; - } - this.renderAll(); - this.refs.layerImageSourceInput.focus(); - this.updateStatus(`Jumped to image assignment for ${action.sourceId || "the current layer"}: ${action.message}`); - } else if (action.findingCode === "MISSING_ASSET_ID" || action.findingCode === "DUPLICATE_REGISTRY_ID") { - this.refs.saveAssetRegistryButton.focus(); - this.updateStatus(`Jumped to project I/O: ${action.message}`); - } else { - this.updateStatus(`Inspected ${action.sourceId || "the current issue"}: ${action.message}`); - } - this.updateRemediationUI(); - return true; - } - - applyRemediationAction() { - this.validateProjectAssets(); - const action = getPrimaryRemediationAction(this.remediationResult, "confirmable-fix"); - if (!action) { - this.updateStatus("No confirmable remediation fix is available."); - this.updateRemediationUI(); - return false; - } - - const shouldApply = typeof globalThis.confirm === "function" - ? globalThis.confirm(`${action.label}? ${action.message}`) - : true; - if (!shouldApply) { - this.updateStatus("Remediation fix canceled."); - this.updateRemediationUI(); - return false; - } - - if (action.payload?.fixKind === "refresh-owned-registry-entry") { - this.syncAssetRegistryFromDocument(); - } else if (action.payload?.fixKind === "relink-reference" && action.payload.referenceField === "parallaxSourceId") { - const targetLayer = this.documentModel.layers.find((layer) => layer.id === action.sourceId) || this.getSelectedLayer(); - if (targetLayer) { - targetLayer.parallaxSourceId = action.payload.candidateId || ""; - } - this.resolveAssetRefsFromRegistry(); - } else if (action.payload?.fixKind === "refresh-graph-snapshot") { - const validation = validateProjectAssetState({ - registry: this.assetRegistry, - parallaxDocument: this.documentModel - }); - this.assetDependencyGraphSnapshot = validation.assetDependencyGraph; - } - - const validation = this.validateProjectAssets(); - this.renderAll(); - this.updateStatus(`Applied remediation: ${action.label}. Validation: ${summarizeAssetValidation(validation)}.`); - return true; - } -} - -let parallaxSceneStudioApp = null; - -function isStrictWorkspaceParallaxSnapshot(snapshot) { - if (!snapshot || typeof snapshot !== "object") { - return false; - } - const documentModel = snapshot.documentModel; - const assetRegistry = snapshot.assetRegistry; - if (!documentModel || typeof documentModel !== "object") { - return false; - } - if (documentModel.schema !== "toolbox.parallax/1") { - return false; - } - if (!documentModel.map || typeof documentModel.map !== "object") { - return false; - } - if (!Array.isArray(documentModel.layers)) { - return false; - } - if (!assetRegistry || typeof assetRegistry !== "object") { - return false; - } - return true; -} - -function bootParallaxSceneStudio() { - if (parallaxSceneStudioApp) { - window.parallaxSceneStudioApp = parallaxSceneStudioApp; - return parallaxSceneStudioApp; - } - - const initialDocument = createInitialParallaxDocument(); - const app = new ParallaxEditorApp(initialDocument); - app.init(document); - app.applyProjectSystemState = function applyProjectSystemState(snapshot) { - if (Date.now() <= Number(this.skipExternalProjectStateUntil || 0)) { - this.skipExternalProjectStateUntil = 0; - return true; - } - if (!isStrictWorkspaceParallaxSnapshot(snapshot)) { - this.updateStatus("Project state rejected: workspace snapshot is missing required parallax source data."); - return false; - } - const nextDocument = sanitizeParallaxDocument(snapshot?.documentModel); - this.documentModel = nextDocument; - this.assetRegistry = sanitizeAssetRegistry(snapshot.assetRegistry); - this.selectedLayerId = typeof snapshot?.selectedLayerId === "string" && nextDocument.layers.some((layer) => layer.id === snapshot.selectedLayerId) - ? snapshot.selectedLayerId - : nextDocument.layers[0]?.id || ""; - this.cameraX = Number.isFinite(Number(snapshot?.cameraX)) ? Number(snapshot.cameraX) : 0; - this.cameraY = Number.isFinite(Number(snapshot?.cameraY)) ? Number(snapshot.cameraY) : 0; - this.resolveAssetRefsFromRegistry(); - this.clearTransientLayerImageSources(); - this.invalidateImageCache(); - this.syncInputsFromDocument(); - this.renderAll(); - this.updateStatus(`Project state loaded for ${this.documentModel.map.name}.`); - return true; - }; - parallaxSceneStudioApp = app; - window.parallaxSceneStudioApp = parallaxSceneStudioApp; - return parallaxSceneStudioApp; -} - -registerToolBootContract("parallax-editor", { - init: bootParallaxSceneStudio, - destroy() { - return true; - }, - getApi() { - return window.parallaxSceneStudioApp || null; - } -}); - -bootParallaxSceneStudio(); diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css deleted file mode 100644 index 7aabd9a85..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css +++ /dev/null @@ -1,419 +0,0 @@ -/* -Toolbox Aid -David Quesenberry -03/30/2026 -parallaxEditor.css -*/ -.parallax-editor-page { - --parallax-border: var(--line, rgba(221, 214, 254, 0.26)); - --parallax-text: var(--text, #f7f4ff); - --parallax-muted: var(--muted, #e9ddff); - --parallax-accent: var(--accent, #ddd6fe); - margin: 0; - min-height: 100vh; - color: var(--parallax-text); -} - -.app-shell { - min-height: 100vh; - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - box-sizing: border-box; -} - -.toolbar { - display: flex; - flex-wrap: wrap; - gap: 12px; - border: 1px solid var(--parallax-border); - border-radius: 10px; - background: var(--surface-inline, rgba(43, 16, 91, 0.9)); - padding: 10px; -} - -.toolbar-group { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 8px; -} - -label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--parallax-muted); -} - -input, -select, -button { - font: inherit; -} - -input, -select { - color: var(--parallax-text); - background: var(--surface-inline, rgba(43, 16, 91, 0.9)); - border: 1px solid var(--parallax-border); - border-radius: 6px; - padding: 5px 8px; -} - -button, -.file-input-label { - color: var(--parallax-text); - background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); - border: 1px solid var(--parallax-border); - border-radius: 6px; - padding: 6px 10px; - cursor: pointer; -} - -button:hover, -.file-input-label:hover { - border-color: var(--card-hover-border, #c4b5fd); - background: color-mix(in srgb, var(--panel, rgba(76, 29, 149, 0.88)) 72%, var(--accent, #ddd6fe)); -} - -.inline-label { - display: inline-flex; - align-items: center; - justify-content: center; -} - -.hidden-file-input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; -} - -.workspace { - display: grid; - grid-template-columns: minmax(250px, 320px) minmax(420px, 1fr) minmax(260px, 360px); - gap: 12px; - flex: 1; - min-height: 0; -} - -.sidebar, -.preview-panel { - border: 1px solid var(--parallax-border); - border-radius: 10px; - background: var(--surface-inline, rgba(43, 16, 91, 0.9)); - min-height: 0; -} - -.sidebar { - padding: 10px; - overflow: auto; -} - -.panel { - border: 1px solid var(--parallax-border); - border-radius: 8px; - padding: 8px; - margin-bottom: 10px; - background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); -} - -.panel h3 { - margin: 0 0 8px; - font-size: 14px; -} - -.panel-accordion { - margin: 0 0 10px; - border: 1px solid var(--parallax-border); - border-radius: 8px; - background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); - overflow: hidden; -} - -.panel-accordion__summary { - list-style: none; - cursor: pointer; - padding: 8px; - border-bottom: 1px solid transparent; -} - -.panel-accordion__summary::-webkit-details-marker { - display: none; -} - -.panel-accordion__summary h3 { - margin: 0; - font-size: 14px; -} - -.panel-accordion[open] .panel-accordion__summary { - border-bottom-color: var(--parallax-border); -} - -.panel-accordion__body { - padding: 8px; -} - -.preview-panel { - display: flex; - flex-direction: column; - padding: 10px; -} - -.preview-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 8px; -} - -.preview-wrap { - position: relative; - flex: 1; - min-height: 260px; - border: 1px solid var(--parallax-border); - border-radius: 8px; - overflow: auto; - background: var(--surface-code, rgba(34, 12, 74, 0.92)); -} - -#previewCanvas { - image-rendering: auto; - display: block; -} - -.status-text, -.muted { - color: var(--parallax-muted); - font-size: 12px; -} - -.status-text { - margin-top: 8px; -} - -.preview-details-text { - margin: 8px 0 0; - min-height: 84px; - max-height: 180px; - overflow: auto; - border: 1px solid var(--parallax-border); - border-radius: 8px; - padding: 10px; - background: rgba(15, 23, 42, 0.72); - color: #dbeafe; - font: 12px/1.35 Consolas, Monaco, monospace; - white-space: pre-wrap; -} - -.layer-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.layer-item { - border: 1px solid var(--parallax-border); - border-radius: 6px; - background: var(--surface-inline, rgba(43, 16, 91, 0.9)); - padding: 6px; -} - -.layer-item.selected { - border-color: var(--parallax-accent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--parallax-accent) 30%, transparent) inset; -} - -.layer-row { - display: flex; - gap: 6px; - align-items: center; -} - -.layer-select-btn { - flex: 1; -} - -.layer-order-chip { - font-size: 10px; - color: var(--parallax-muted); - border: 1px solid var(--parallax-border); - border-radius: 999px; - padding: 2px 6px; - line-height: 1.2; -} - -.row { - display: flex; - gap: 6px; - align-items: center; - margin-top: 6px; -} - -.row.buttons { - flex-wrap: wrap; -} - -.usage-list { - margin: 0; - padding-left: 18px; - color: var(--parallax-muted); - font-size: 12px; -} - -.parallax-editor-page .fullscreen-overlay-button { - position: relative; - z-index: 80; - display: none; - min-height: 32px; - padding: 0 10px; -} - -.parallax-editor-page .fullscreen-overlay-toggle-group { - display: none; - position: fixed; - top: 96px; - z-index: 80; - gap: 8px; - flex-direction: column; -} - -.parallax-editor-page .fullscreen-overlay-toggle-group--left { - left: 10px; - align-items: flex-start; -} - -.parallax-editor-page .fullscreen-overlay-toggle-group--right { - right: 10px; - align-items: flex-end; -} - -.parallax-editor-page .overlay-toggle-symbol { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - font-weight: 900; - line-height: 1; -} - -body.parallax-editor-page.fullscreen-mode .app-shell { - min-height: 100vh; - min-height: 100dvh; - overflow: hidden; - padding: 8px; - gap: 8px; -} - -body.parallax-editor-page.fullscreen-mode .toolbar { - padding: 8px; - gap: 8px; -} - -body.parallax-editor-page.fullscreen-mode .toolbar-group.tools-platform-control-cluster--workflow, -body.parallax-editor-page.fullscreen-mode .toolbar-group.tools-platform-control-cluster--preview { - display: none; -} - -body.parallax-editor-page.fullscreen-mode .workspace { - grid-template-columns: minmax(0, 1fr); - height: calc(100dvh - 90px); - gap: 8px; -} - -body.parallax-editor-page.fullscreen-mode .sidebar { - display: none; -} - -body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay { - display: block; - position: fixed; - top: 70px; - bottom: 8px; - width: min(360px, calc(100vw - 24px)); - z-index: 50; - overflow: auto; - padding-top: 10px; -} - -body.parallax-editor-page.fullscreen-mode .left-sidebar.visible-overlay { - left: 12px; -} - -body.parallax-editor-page.fullscreen-mode .right-sidebar.visible-overlay { - right: 12px; -} - -body.parallax-editor-page.fullscreen-mode .preview-panel { - width: 100%; - min-width: 0; - height: 100%; - padding: 8px; -} - -body.parallax-editor-page.fullscreen-mode .preview-wrap { - overflow: hidden; - min-height: 0; - height: 100%; -} - -body.parallax-editor-page.fullscreen-mode #previewCanvas { - width: 100%; - height: 100%; -} - -body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-toggle-group { - display: flex; -} - -body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-button.is-hidden-while-overlay-open { - visibility: hidden; - pointer-events: none; -} - -body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion__summary { - display: flex; - align-items: center; - gap: 8px; -} - -body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion__summary::before { - content: "+"; - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - font-weight: 900; - line-height: 1; -} - -body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion[open] > .panel-accordion__summary::before { - content: "-"; -} - -body.parallax-editor-page.fullscreen-mode .preview-details-text { - max-height: 150px; -} - -@media (max-width: 1260px) { - .workspace { - grid-template-columns: 1fr; - } -} diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg deleted file mode 100644 index 5b50167a8..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg deleted file mode 100644 index d46c9f740..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg deleted file mode 100644 index 5ab3498d9..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg deleted file mode 100644 index 092831880..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg deleted file mode 100644 index 41f40628e..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg deleted file mode 100644 index 92c68880c..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg deleted file mode 100644 index 9ed22bed6..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg deleted file mode 100644 index 8197d1925..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg deleted file mode 100644 index b53882fa4..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg deleted file mode 100644 index f6f01325e..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg deleted file mode 100644 index df5696462..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json deleted file mode 100644 index 20255362f..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "schema": "toolbox.parallax/1", - "version": 1, - "companionEditor": "ParallaxEditor", - "map": { - "name": "mario-style-learning-level", - "width": 64, - "height": 16, - "tileSize": 24 - }, - "layers": [ - { - "id": "mario-sky", - "name": "Mario Sky", - "drawOrder": 0, - "imageSource": "./samples/mario_style_sky.svg", - "scrollFactorX": 0.08, - "scrollFactorY": 0.03, - "offsetX": 0, - "offsetY": 0, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 1, - "visible": true - }, - { - "id": "mario-clouds", - "name": "Mario Clouds", - "drawOrder": 1, - "imageSource": "./samples/mario_style_clouds.svg", - "scrollFactorX": 0.18, - "scrollFactorY": 0.06, - "offsetX": 0, - "offsetY": 36, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.95, - "visible": true - }, - { - "id": "mario-mountains-tall", - "name": "Mario Mountains Tall", - "drawOrder": 2, - "imageSource": "./samples/mario_style_mountains.svg", - "scrollFactorX": 0.34, - "scrollFactorY": 0.18, - "offsetX": 0, - "offsetY": 178, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - }, - { - "id": "mario-mountains-short", - "name": "Mario Mountains Short", - "drawOrder": 3, - "imageSource": "./samples/mario_style_hills.svg", - "scrollFactorX": 0.58, - "scrollFactorY": 0.22, - "offsetX": 0, - "offsetY": 214, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - }, - { - "id": "mario-hero-proxy", - "name": "Mario Hero Proxy", - "drawOrder": 4, - "imageSource": "./samples/images/hero-proxy.svg", - "scrollFactorX": 1, - "scrollFactorY": 1, - "offsetX": 720, - "offsetY": 258, - "repeatX": false, - "repeatY": false, - "wrapMode": "clamp", - "opacity": 1, - "visible": true - }, - { - "id": "mario-foreground-trees", - "name": "Mario Foreground Trees", - "drawOrder": 5, - "imageSource": "./samples/mario_style_bushes_foreground.svg", - "scrollFactorX": 1.08, - "scrollFactorY": 0.36, - "offsetX": 84, - "offsetY": 182, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - } - ], - "metadata": { - "createdAt": "2026-03-30T00:00:00.000Z", - "updatedAt": "2026-03-30T00:00:00.000Z", - "source": "parallax-editor-samples", - "notes": "Mario-style learning level parallax sample aligned with tilemap concept." - } -} diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg deleted file mode 100644 index 5f3db0956..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg deleted file mode 100644 index 80ef1847f..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json deleted file mode 100644 index 50c277ca5..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "schema": "toolbox.parallax/1", - "version": 1, - "companionEditor": "ParallaxEditor", - "map": { - "name": "sample-parallax-only", - "width": 64, - "height": 24, - "tileSize": 24 - }, - "layers": [ - { - "id": "sample-sky", - "name": "Sample Sky", - "drawOrder": 0, - "imageSource": "./samples/images/sky-gradient.svg", - "scrollFactorX": 0.08, - "scrollFactorY": 0.03, - "offsetX": 0, - "offsetY": 0, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 1, - "visible": true - }, - { - "id": "sample-clouds", - "name": "Sample Clouds", - "drawOrder": 1, - "imageSource": "./samples/images/cloud-bands.svg", - "scrollFactorX": 0.2, - "scrollFactorY": 0.08, - "offsetX": 0, - "offsetY": 48, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.92, - "visible": true - }, - { - "id": "sample-mountains-tall", - "name": "Sample Mountains Tall", - "drawOrder": 2, - "imageSource": "./samples/images/mountain-ridge-tall.svg", - "scrollFactorX": 0.38, - "scrollFactorY": 0.2, - "offsetX": 0, - "offsetY": 208, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.95, - "visible": true - }, - { - "id": "sample-mountains-short", - "name": "Sample Mountains Short", - "drawOrder": 3, - "imageSource": "./samples/images/mountain-ridge-short.svg", - "scrollFactorX": 0.64, - "scrollFactorY": 0.24, - "offsetX": 0, - "offsetY": 242, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.95, - "visible": true - }, - { - "id": "sample-hero", - "name": "Sample Hero Proxy", - "drawOrder": 4, - "imageSource": "./samples/images/hero-proxy.svg", - "scrollFactorX": 1, - "scrollFactorY": 1, - "offsetX": 780, - "offsetY": 350, - "repeatX": false, - "repeatY": false, - "wrapMode": "clamp", - "opacity": 1, - "visible": true - }, - { - "id": "sample-foreground-trees", - "name": "Sample Foreground Trees", - "drawOrder": 5, - "imageSource": "./samples/images/foreground-occluder.svg", - "scrollFactorX": 1.08, - "scrollFactorY": 0.4, - "offsetX": 84, - "offsetY": 188, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - } - ], - "metadata": { - "createdAt": "2026-03-30T00:00:00.000Z", - "updatedAt": "2026-03-30T00:00:00.000Z", - "source": "parallax-editor-samples" - } -} diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json deleted file mode 100644 index 00775cea6..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "schema": "toolbox.parallax-sample-manifest/1", - "version": 1, - "samples": [ - { - "id": "parallax-basic", - "label": "Parallax Sample (toolbox.parallax/1)", - "path": "./samples/parallax_sample.json" - }, - { - "id": "tilemap-embedded", - "label": "Tilemap + Parallax Sample (toolbox.tilemap/1)", - "path": "./samples/tilemap_parallax_sample.json" - }, - { - "id": "mario-style-learning", - "label": "Mario Style Learning Level (toolbox.parallax/1)", - "path": "./samples/mario_style_learning_level_parallax.json" - } - ] -} diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json deleted file mode 100644 index 6ce556238..000000000 --- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "schema": "toolbox.tilemap/1", - "version": 1, - "map": { - "name": "sample-tilemap-with-parallax", - "width": 80, - "height": 30, - "tileSize": 24 - }, - "tileset": [ - { - "id": 0, - "name": "Empty", - "color": "transparent" - } - ], - "layers": [], - "markers": [], - "parallax": { - "schema": "toolbox.parallax/1", - "companionEditor": "ParallaxEditor", - "layers": [ - { - "id": "tm-sky", - "name": "Tilemap Sky", - "drawOrder": 0, - "imageSource": "./samples/images/sky-gradient.svg", - "scrollFactorX": 0.1, - "scrollFactorY": 0.05, - "offsetX": 0, - "offsetY": 0, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 1, - "visible": true - }, - { - "id": "tm-ridge-tall", - "name": "Tilemap Ridge Tall", - "drawOrder": 1, - "imageSource": "./samples/images/mountain-ridge-tall.svg", - "scrollFactorX": 0.36, - "scrollFactorY": 0.18, - "offsetX": 0, - "offsetY": 250, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - }, - { - "id": "tm-ridge-short", - "name": "Tilemap Ridge Short", - "drawOrder": 2, - "imageSource": "./samples/images/mountain-ridge-short.svg", - "scrollFactorX": 0.88, - "scrollFactorY": 0.24, - "offsetX": 0, - "offsetY": 268, - "repeatX": true, - "repeatY": false, - "wrapMode": "wrap", - "opacity": 0.96, - "visible": true - } - ] - }, - "metadata": { - "createdAt": "2026-03-30T00:00:00.000Z", - "updatedAt": "2026-03-30T00:00:00.000Z", - "notes": "Sample for Parallax Scene Studio direct loading." - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md deleted file mode 100644 index 873ee3c42..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Object Vector Studio V2 - -Object Vector Studio V2 is the vector authoring surface for reusable gameplay entities: ships, enemies, pickups, actors, weapons, UI vector assets, and other object-level game pieces. - -Tagline: "Build the thing." - -The tool folder was copied from `tools/templates-v2/` for `PR_26132_001-add-world-object-vector-studios`. It keeps the copied template CSS and modular control structure intact while giving the new studio its own first-class tool id: `object-vector-studio-v2`. - -## Architectural Split - -Object Vector Studio V2 owns the thing being placed, animated, reused, equipped, collected, or rendered as an object. World Vector Studio V2 owns the place where those things live. - -Use Object Vector Studio V2 for: - -- Reusable gameplay entities. -- Ships. -- Enemies. -- Pickups. -- Actors. -- Weapons. -- UI vector assets. -- SVG/vector authoring. -- Primitives, shapes, and path editing. -- Animation-ready structures. - -Use World Vector Studio V2 instead for terrain, maps, tile/world geometry, layered scenes, parallax, environment composition, collision/world regions, level layout, spawn placement, and camera zones. - -## Migration Direction - -- Object Vector Studio V2 is the current vector asset authoring surface. -- World Vector Studio V2 is the current map, layer, parallax, and world layout surface. -- Current object geometry lives in Object Vector Studio V2 payloads as `objects[]`, `objects[].tags`, and `objects[].shapes`. - -## Examples - -- Asteroids ship/UFO -> Object Vector Studio V2. -- Weapon icons, pickups, HUD markers, and actor silhouettes: Object Vector Studio V2. -- Platformer level/parallax scene -> World Vector Studio V2. - -## Runtime Notes - -- The current implementation is the copied Tool Template V2 shell with Object Vector Studio V2 naming and documentation. -- JavaScript and CSS remain external. -- Paint and Stroke modes use selectable palette swatches, click/drag application, Alt-click sampling, and fill/stroke swap/default shortcuts. This model is intended to scale into shaders, gradients, patterns, neon effects, SVG export, and runtime rendering without storing palette data inside object JSON. -- The copied `docs/CONTROL_SERVICE_CONTRACTS.md` and `docs/BATCH_GUARDRAIL_CONTRACT.md` remain the local implementation contracts. -- `docs/OBJECT_VECTOR_STUDIO_V2_REQUIREMENTS.md` captures the future Object Vector Studio V2 requirements and design constraints. - -## Validation - -This documentation PR has no Playwright impact. Runtime implementation remains future work. diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md deleted file mode 100644 index ae3718606..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md +++ /dev/null @@ -1,51 +0,0 @@ -# Tool Template V2 Batch Guardrail Contract - -Batch operations process repeated work across real discovered inputs. A First-Class Tool V2 batch flow must be explicit, observable, and safe to resume or diagnose. - -## Discovery Rules - -- Discover real files and directories. -- Never assume numeric folder sequences. -- Missing discovered candidates are logged as `SKIP`, not `FAIL`, unless the missing item is the selected single input. -- Logs must identify the resolved input path or identifier for every item. - -## Per-Item Logging - -Every item must log exactly one terminal outcome through the tool logger: - -- `OK`: item completed and wrote or updated the expected output. -- `WARN`: item completed with a recoverable warning. -- `FAIL`: item failed with an actionable error. -- `SKIP`: item was intentionally skipped. - -Per-item logs must include the item identifier and the reason for `WARN`, `FAIL`, or `SKIP`. - -## Failure Isolation - -- One item failure must not stop the batch unless the failure is global. -- Global failures include missing required configuration, unavailable repo access, invalid destination root, or a cancelled run. -- Batch processors must keep processing remaining discovered items after item-level `FAIL` or `SKIP` outcomes. - -## Summary Contract - -Every batch run must finish with a summary that includes: - -- `written` -- `failed` -- `skipped` -- `warnings` - -The summary must match the per-item log outcomes. - -## Stop/Cancel Contract - -Long-running batches must support a stop or cancel pattern when applicable. - -- Stop/cancel requests must prevent new items from starting. -- Already-running item work should finish or fail safely. -- Cancelled remaining items should be logged as `SKIP` or another clearly documented non-success outcome. -- The final summary must make cancellation visible. - -## No Silent Fallback - -Batch processors must not invent default inputs, substitute fallback targets, or claim success when a fallback or partial result occurred. diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md deleted file mode 100644 index 917b12484..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md +++ /dev/null @@ -1,174 +0,0 @@ -# Tool Template V2 Control And Service Contracts - -These contracts define the required structure for First-Class Tool V2 code created from Tool Template V2. - -## Deprecated Shared Tool Surface - -`tools/shared/` is deprecated for First-Class Tool V2 surfaces. - -First-Class Tool V2 surfaces must have: - -- no imports from `tools/shared/` -- no script references to `tools/shared/` -- no CSS references to `tools/shared/` -- no runtime dependency on `tools/shared/` - -## Control Contract - -Controls own one UI control or one UI section. - -Required rules: - -- One control or section per class. -- One class per file. -- A control owns its DOM references. -- A control owns its event binding. -- A control must not reach into sibling controls directly. -- A control must not perform unrelated business logic. -- A control exposes clear methods that match its responsibility. - -Common methods: - -- `init()` or `mount()` prepares the control. -- `bindEvents()` wires DOM events when event wiring is separate from initialization. -- `getValue()` returns the current control value when applicable. -- `setValue(value)` updates the control value when applicable. -- `validate()` returns validation status and messages when applicable. -- `setEnabled(isEnabled)` updates enabled state when applicable. -- `clear()` clears visible state when applicable. - -Controls communicate through injected callbacks, injected services, or the app/root coordinator. A control must not directly edit another control's DOM or internal state. - -## Panel Role Contract - -First-Class Tool V2 surfaces use a consistent three-column layout contract: - -- Left panel owns user input, setup, and intent controls only. -- Center panel owns the primary work surface, editor, canvas, or preview. -- Right panel owns generated output, summaries, diagnostics, logs, and status. -- Status and log controls belong at the bottom of the right panel unless a PR explicitly justifies a different placement. - -Controls should be placed according to these panel roles before adding new sections. A control that mixes input and output responsibilities should be split into separate controls. - -## Service Contract - -Services contain non-DOM logic. - -Required rules: - -- Services must not query or manipulate UI directly. -- Services must not write to status text, textareas, headers, buttons, or panels. -- Services return data, status objects, or errors for controls, the app/root coordinator, or logger to display. -- Services must avoid hidden defaults and silent fallback data. -- Services should be deterministic for the same input unless they explicitly own I/O. - -Recommended result shape: - -```js -{ - ok: true, - value: {}, - warnings: [] -} -``` - -Recommended error shape: - -```js -{ - ok: false, - code: "VALIDATION_FAILED", - message: "Explain the actionable failure.", - details: {} -} -``` - -## App/Root Contract - -The app/root class is a coordinator only. - -Required rules: - -- The app/root class wires controls and services. -- The app/root class coordinates flows between controls and services. -- The app/root class does not own DOM references except for constructing controls when unavoidable. -- The app/root class does not own business logic. -- The app/root class does not write directly to log/status UI. -- The app/root class does not reach into control internals. - -Allowed responsibilities: - -- instantiate controls and services -- inject dependencies -- connect callbacks -- coordinate high-level flows such as load, validate, render, export -- route service results to the owning control or logger - -## Logger Contract - -Each tool has one status/log writer. - -Required rules: - -- Logger is the single writer for status/log output. -- Controls, services, and app/root code must route log output through the logger. -- Logger owns status/log DOM writes. -- Logger supports these levels: - - `OK` - - `WARN` - - `FAIL` - - `SKIP` - - `INFO` -- Logger entries must be clear and actionable. -- Batch operations must include the item identifier in per-item log lines. - -Recommended logger methods: - -- `ok(message)` -- `warn(message)` -- `fail(message)` -- `skip(message)` -- `info(message)` -- `clear()` - -## Batch Processor Contract - -Batch processors handle repeated work across discovered inputs. - -See `BATCH_GUARDRAIL_CONTRACT.md` for the full batch guardrail contract. - -Required rules: - -- Process real discovered inputs only. -- Never assume numeric sequences. -- Log every item through the logger with `OK`, `WARN`, `FAIL`, or `SKIP`. -- One item failure must not stop the batch unless the failure is global. -- Missing inputs are `SKIP` during batch processing. -- Batch failures identify the exact item and underlying error. -- Batch summaries include written, failed, skipped, and warnings. -- Long-running batches support stop or cancel when applicable. - -Recommended summary shape: - -```js -{ - written: 0, - failed: 0, - skipped: 0, - warnings: 0, - items: [] -} -``` - -## Review Checklist - -Before a First-Class Tool V2 PR is ready: - -- Every control or section has its own class. -- Every service is DOM-free. -- The app/root class coordinates only. -- All logging goes through the logger. -- Batch work logs per item and summarizes counts. -- No inline event handlers exist in HTML. -- No inline ` - - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html deleted file mode 100644 index ce7748b53..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html +++ /dev/null @@ -1,392 +0,0 @@ - - - - - - Object Vector Studio V2 - - - - - - - -
- Hide Header and Details -
-
-
-
-
-
-
-

Object Vector Studio V2

-

First-Class Tools Surface V2

-
-
-
Build vector assets for ships, enemies, pickups, actors, UI elements, and reusable game objects.
-
-
-
-

Author reusable vector gameplay objects for actors, collectibles, hazards, and entity-driven scenes.

-
-
-
-
-
-
- - - - - -
- - -
-
- -
- -
-
-
- - -
- - - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js deleted file mode 100644 index cc0d32253..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js +++ /dev/null @@ -1,9178 +0,0 @@ -import { CANONICAL_WORLD_TO_SCREEN_SCALE } from '../../../src/engine/rendering/WorldScreenTransform.js'; -import { combineObjectVectorBounds, inverseTransformObjectVectorShapePoint, normalizeObjectVectorOrigin, normalizeRotationDegrees as normalizeSharedRotationDegrees, objectVectorBoundsCornerPoints, objectVectorSvgTransformAttribute, rotatePointAround as rotateSharedPointAround, transformedObjectVectorShapeBounds, transformObjectVectorShapePoint } from '../../../src/engine/rendering/OrientationTransform.js'; -import ObjectVectorRuntimeAssetService from '../../../src/engine/rendering/ObjectVectorRuntimeAssetService.js'; -import { downloadTextFile, readFileText } from '../../../src/engine/persistence/FilePersistenceService.js'; -import { isPlainObject } from "../../../src/shared/object/objects.js"; - -const WORKSPACE_TOOL_SESSION_KEY = "workspace.tools.object-vector-studio-v2"; -const WORKSPACE_PALETTE_SESSION_KEY = "workspace.tools.palette-manager-v2"; -const RUNTIME_PALETTE_SESSION_KEY = "object-vector-studio-v2.runtimePalette"; -const TOOL_DISPLAY_MODE_KEY = "object-vector-studio-v2.toolDisplayMode"; -const SNAP_MODE_SESSION_KEY = "object-vector-studio-v2.snapMode"; -const ANGLE_SNAP_SESSION_KEY = "object-vector-studio-v2.angleSnap"; -const ANGLE_SNAP_STEP_SESSION_KEY = "object-vector-studio-v2.angleSnapStep"; -const GRID_RENDER_SESSION_KEY = "object-vector-studio-v2.gridRender"; -const CENTER_ORIGIN_SESSION_KEY = "object-vector-studio-v2.centerOrigin"; -const SVG_NS = "http://www.w3.org/2000/svg"; -const DEFAULT_VIEWPORT = Object.freeze({ height: 2200, width: 3200, x: 0, y: 0, zoom: 1.0 }); -const GRID_STEP = 10; -const OBJECT_PREVIEW_DRAWING_SCALE = GRID_STEP; -const MAX_ZOOM = 10.0; -const MIN_ZOOM = 0.1; -const ZOOM_STEP = 0.1; -const TRANSPARENT_STYLE_COLOR = "#00000000"; -const PREVIEW_HISTORY_LIMIT = 50; -const PREVIEW_DRAG_START_THRESHOLD_PX = 5; -const SNAP_MODES = Object.freeze(["grid", "point", "none"]); -const ANGLE_SNAP_STEPS = Object.freeze([15, 30, 45, 90]); -const POINT_SNAP_RADIUS = 1.5; -const SNAP_MODE_DETAILS = Object.freeze({ - grid: { - iconKey: "snapGrid", - label: "Snap Grid", - title: "Snap drawing and point dragging to grid intersections" - }, - none: { - iconKey: "snapNone", - label: "Snap None", - title: "Disable drawing and point-drag snapping" - }, - point: { - iconKey: "snapPoint", - label: "Snap Point", - title: "Snap drawing and point dragging to visible shape points" - } -}); - -const objectVectorStudioIcon = (name, glyph) => Object.freeze({ glyph, name }); - -const OBJECT_VECTOR_STUDIO_ICON_GLYPHS = Object.freeze({ - add: objectVectorStudioIcon("nf-fa-plus", "\uf067"), - angle: objectVectorStudioIcon("nf-md-angle_acute", "\u{f0937}"), - annulusRing: objectVectorStudioIcon("nf-md-circle_double", "\u{f0e95}"), - arc: objectVectorStudioIcon("nf-md-vector_radius", "\u{f074a}"), - bri: objectVectorStudioIcon("nf-fa-sun_o", "\uf185"), - bringForward: objectVectorStudioIcon("nf-fa-arrow_up", "\uf062"), - bringFront: objectVectorStudioIcon("nf-fa-angle_double_up", "\uf102"), - capsuleStadium: objectVectorStudioIcon("nf-md-rounded_corner", "\u{f0607}"), - center: objectVectorStudioIcon("nf-fa-dot_circle_o", "\uf192"), - circle: objectVectorStudioIcon("nf-md-vector_circle_variant", "\u{f0557}"), - copy: objectVectorStudioIcon("nf-fa-copy", "\uf0c5"), - delete: objectVectorStudioIcon("nf-md-trash_can_outline", "\u{f0a7a}"), - duplicate: objectVectorStudioIcon("nf-fa-copy", "\uf0c5"), - edit: objectVectorStudioIcon("nf-fa-pencil_square_o", "\uf044"), - ellipse: objectVectorStudioIcon("nf-md-vector_ellipse", "\u{f0893}"), - eye: objectVectorStudioIcon("nf-fa-eye", "\uf06e"), - eyeOff: objectVectorStudioIcon("nf-fa-eye_slash", "\uf070"), - grid: objectVectorStudioIcon("nf-md-grid", "\u{f02c1}"), - gridOff: objectVectorStudioIcon("nf-md-grid_off", "\u{f02c2}"), - polyline: objectVectorStudioIcon("nf-md-vector_polyline", "\u{f0561}"), - kite: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), - lune: objectVectorStudioIcon("nf-md-moon_waning_crescent", "\u{f0f64}"), - parallelogram: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), - snapGrid: objectVectorStudioIcon("nf-md-grid_large", "\u{f0758}"), - snapNone: objectVectorStudioIcon("nf-fa-not_equal", "\u{efcb}"), - snapPoint: objectVectorStudioIcon("nf-md-vector_point", "\u{f055f}"), - group: objectVectorStudioIcon("nf-fa-object_group", "\uf247"), - hue: objectVectorStudioIcon("nf-fa-eyedropper", "\uf1fb"), - line: objectVectorStudioIcon("nf-md-vector_line", "\u{f055e}"), - lock: objectVectorStudioIcon("nf-fa-lock", "\uf023"), - move: objectVectorStudioIcon("nf-fa-arrows", "\uf047"), - name: objectVectorStudioIcon("nf-fa-font", "\uf031"), - paint: objectVectorStudioIcon("nf-fa-paint_brush", "\uf1fc"), - panDown: objectVectorStudioIcon("nf-fa-arrow_down", "\uf063"), - panLeft: objectVectorStudioIcon("nf-fa-arrow_left", "\uf060"), - panRight: objectVectorStudioIcon("nf-fa-arrow_right", "\uf061"), - panUp: objectVectorStudioIcon("nf-fa-arrow_up", "\uf062"), - paste: objectVectorStudioIcon("nf-oct-paste", "\uf0ea"), - picker: objectVectorStudioIcon("nf-fa-eye_dropper", "\uf1fb"), - polygon: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), - rectangle: objectVectorStudioIcon("nf-md-vector_rectangle", "\u{f05c6}"), - redo: objectVectorStudioIcon("nf-md-redo", "\u{f044e}"), - reset: objectVectorStudioIcon("nf-fa-undo", "\uf0e2"), - resize: objectVectorStudioIcon("nf-md-resize", "\u{f0a68}"), - rhombus: objectVectorStudioIcon("nf-md-rhombus_outline", "\u{f070b}"), - rotate: objectVectorStudioIcon("nf-fa-repeat", "\uf01e"), - sat: objectVectorStudioIcon("nf-fa-tint", "\uf043"), - scale: objectVectorStudioIcon("nf-fa-scale_unbalanced", "\u{eddf}"), - sector: objectVectorStudioIcon("nf-md-chart_pie", "\u{f012b}"), - select: objectVectorStudioIcon("nf-md-select", "\u{f0485}"), - segment: objectVectorStudioIcon("nf-md-circle_slice_4", "\u{f0a9e}"), - sendBack: objectVectorStudioIcon("nf-fa-angle_double_down", "\uf103"), - sendBackward: objectVectorStudioIcon("nf-fa-arrow_down", "\uf063"), - stroke: objectVectorStudioIcon("nf-fa-pencil", "\uf040"), - square: objectVectorStudioIcon("nf-fa-vector_square", "\u{ee92}"), - squircle: objectVectorStudioIcon("nf-md-rounded_corner", "\u{f0607}"), - starPolygon: objectVectorStudioIcon("nf-fa-star_o", "\uf006"), - tag: objectVectorStudioIcon("nf-fa-tag", "\uf02b"), - text: objectVectorStudioIcon("nf-fa-font", "\uf031"), - trapezoidTrapezium: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), - triangle: objectVectorStudioIcon("nf-md-vector_triangle", "\u{f0563}"), - ungroup: objectVectorStudioIcon("nf-fa-object_ungroup", "\uf248"), - undo: objectVectorStudioIcon("nf-md-undo", "\u{f054c}"), - unlock: objectVectorStudioIcon("nf-fa-unlock", "\uf09c"), - zoomIn: objectVectorStudioIcon("nf-oct-zoom_in", "\u{f531}"), - zoomOut: objectVectorStudioIcon("nf-oct-zoom_out", "\u{f532}") -}); - -const OBJECT_VECTOR_STUDIO_STATIC_ICON_TARGETS = Object.freeze([ - ["#objectVectorStudioV2AddTagButton", "add"], - ["#objectVectorStudioV2AddObjectButton", "add"], - ["#objectVectorStudioV2RenameObjectButton", "edit"], - ["#objectVectorStudioV2DuplicateObjectButton", "duplicate"], - ["#objectVectorStudioV2PaintModeButton", "paint"], - ["#objectVectorStudioV2StrokeModeButton", "stroke"], - [".object-vector-studio-v2__palette-sort [data-palette-sort='hue']", "hue"], - [".object-vector-studio-v2__palette-sort [data-palette-sort='sat']", "sat"], - [".object-vector-studio-v2__palette-sort [data-palette-sort='bri']", "bri"], - [".object-vector-studio-v2__palette-sort [data-palette-sort='name']", "name"], - [".object-vector-studio-v2__palette-sort [data-palette-sort='tag']", "tag"], - ["#objectVectorStudioV2ZoomOutButton", "zoomOut"], - ["#objectVectorStudioV2ZoomInButton", "zoomIn"], - ["#objectVectorStudioV2PanUpButton", "panUp"], - ["#objectVectorStudioV2PanDownButton", "panDown"], - ["#objectVectorStudioV2PanLeftButton", "panLeft"], - ["#objectVectorStudioV2PanRightButton", "panRight"], - ["#objectVectorStudioV2ResetViewButton", "reset"], - ["#objectVectorStudioV2CenterDotButton", "center"], - ["#objectVectorStudioV2FrameLeftButton", "panLeft"], - ["#objectVectorStudioV2FrameEarlierButton", "panLeft"], - ["#objectVectorStudioV2DuplicateFrameButton", "duplicate"], - ["#objectVectorStudioV2FrameLaterButton", "panRight"], - ["#objectVectorStudioV2FrameRightButton", "panRight"], - ["#objectVectorStudioV2DeleteFrameButton", "delete"], - ["#objectVectorStudioV2PreviewUndoButton", "undo"], - ["#objectVectorStudioV2PreviewRedoButton", "redo"], - ["#objectVectorStudioV2PreviewCopyButton", "copy"], - ["#objectVectorStudioV2PreviewPasteButton", "paste"], - ["#objectVectorStudioV2AngleSnapButton", "angle"], - ["#objectVectorStudioV2GridRenderButton", "grid"], - [".object-vector-studio-v2__shape-icon--select", "select"], - [".object-vector-studio-v2__shape-icon--annulus-ring", "annulusRing"], - [".object-vector-studio-v2__shape-icon--triangle", "triangle"], - [".object-vector-studio-v2__shape-icon--rectangle", "rectangle"], - [".object-vector-studio-v2__shape-icon--square", "square"], - [".object-vector-studio-v2__shape-icon--circle", "circle"], - [".object-vector-studio-v2__shape-icon--capsule-stadium", "capsuleStadium"], - [".object-vector-studio-v2__shape-icon--ellipse", "ellipse"], - [".object-vector-studio-v2__shape-icon--kite", "kite"], - [".object-vector-studio-v2__shape-icon--line", "line"], - [".object-vector-studio-v2__shape-icon--lune", "lune"], - [".object-vector-studio-v2__shape-icon--parallelogram", "parallelogram"], - [".object-vector-studio-v2__shape-icon--picker", "picker"], - [".object-vector-studio-v2__shape-icon--polygon", "polygon"], - [".object-vector-studio-v2__shape-icon--polyline", "polyline"], - [".object-vector-studio-v2__shape-icon--rhombus", "rhombus"], - [".object-vector-studio-v2__shape-icon--arc", "arc"], - [".object-vector-studio-v2__shape-icon--sector", "sector"], - [".object-vector-studio-v2__shape-icon--segment", "segment"], - [".object-vector-studio-v2__shape-icon--squircle", "squircle"], - [".object-vector-studio-v2__shape-icon--star-polygon", "starPolygon"], - [".object-vector-studio-v2__shape-icon--text", "text"], - [".object-vector-studio-v2__shape-icon--trapezoid-trapezium", "trapezoidTrapezium"], - [".object-vector-studio-v2__z-icon--bring-forward", "bringForward"], - [".object-vector-studio-v2__z-icon--send-backward", "sendBackward"], - [".object-vector-studio-v2__z-icon--bring-front", "bringFront"], - [".object-vector-studio-v2__z-icon--send-back", "sendBack"], - [".object-vector-studio-v2__z-icon--group", "group"], - [".object-vector-studio-v2__z-icon--ungroup", "ungroup"] -]); - -const OBJECT_STATE_IDS = Object.freeze(["idle", "move", "active", "inactive", "damaged", "destroyed"]); - -const OBJECT_STATE_LABELS = Object.freeze({ - active: "Active", - damaged: "Damaged", - destroyed: "Destroyed", - idle: "Idle", - inactive: "Inactive", - move: "Move" -}); - -const OBJECT_STATE_HELP = Object.freeze({ - active: ["Object is enabled and participating in gameplay.", "Typically the default active runtime state."], - damaged: ["Object is visually damaged but still active."], - destroyed: ["Object destruction/death state.", "Usually final or transitional before removal."], - idle: ["Default stationary state.", "No movement or action animation active."], - inactive: ["Object is disabled, hidden, sleeping, or not participating in gameplay."], - move: ["Movement/action state.", "Used for thrusting, walking, flying, or active movement visuals."] -}); - -const GROUP_COLOR_SWATCHES = Object.freeze(["#22d3ee", "#a78bfa", "#fb7185", "#34d399", "#fbbf24", "#60a5fa", "#f472b6", "#4ade80"]); - -const SHAPE_TYPE_DETAILS = Object.freeze({ - arc: "Arc primitive metadata with center, radius, and angle span.", - circle: "Circle primitive metadata with center point and radius.", - ellipse: "Ellipse primitive metadata with center point and radius pair.", - line: "Line primitive metadata with start and end points.", - polygon: "Polygon primitive metadata with ordered point ownership.", - polyline: "Polyline primitive metadata with ordered open point ownership.", - rectangle: "Rectangle primitive metadata with position and dimensions.", - square: "Square tool metadata backed by rectangle geometry with equal width and height.", - text: "Text primitive metadata with position, font size, and content." -}); - -const PRIMITIVE_TOOLS = Object.freeze(["triangle", "rectangle", "square", "circle", "ellipse", "line", "polygon", "polyline", "arc", "text"]); -const POINT_STYLE_VALUES = Object.freeze(["round", "square"]); -const OPEN_POINT_STYLE_TOOLS = Object.freeze(new Set(["line", "polyline", "arc"])); -const CLOSED_POINT_STYLE_TOOLS = Object.freeze(new Set(["polygon", "rectangle"])); -const OBJECT_ID_SIZE_WORDS = Object.freeze(new Set(["large", "medium", "small"])); -const OBJECT_ID_ORDERED_NOUNS = Object.freeze(new Set(["asteroid", "ufo"])); - -function shapeTool(shape) { - return String(shape?.tool || "").trim().toLowerCase(); -} - -function shapeGeometryTool(shape) { - const tool = shapeTool(shape); - if (tool === "triangle") { - return "polygon"; - } - if (tool === "square") { - return "rectangle"; - } - return tool; -} - -function shapeTypeLabel(shapeOrTool) { - const tool = typeof shapeOrTool === "string" ? shapeOrTool : shapeTool(shapeOrTool); - return tool.replace(/(^|-)([a-z])/g, (match) => match.toUpperCase()).replaceAll("-", " "); -} - -function shapeDisplayLabel(shape) { - return shapeTypeLabel(shape); -} - -function shapeCountLabel(count) { - return `${count} ${count === 1 ? "shape" : "shapes"}`; -} - -function normalizeShapeIndex(value) { - const index = Number(value); - return Number.isInteger(index) && index >= 0 ? index : -1; -} - -function linePoint(geometry, pointKey) { - return geometry?.[pointKey]; -} - -function tagList(value) { - if (Array.isArray(value)) { - return Array.from(new Set(value.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean))).sort(); - } - return Array.from(new Set(String(value || "") - .split(",") - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean))).sort(); -} - -function slugifyObjectName(name) { - const slug = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug || "object"; -} - -function canonicalObjectSlug(name, gameKey = "") { - const gameWords = slugifyObjectName(gameKey).split("-").filter(Boolean); - let words = slugifyObjectName(name).split("-").filter(Boolean); - if (gameWords.length && gameWords.every((word, index) => words[index] === word)) { - words = words.slice(gameWords.length); - } - words = words.filter((word, index) => index === 0 || word !== words[index - 1]); - if (words.length === 2 && OBJECT_ID_ORDERED_NOUNS.has(words[0]) && OBJECT_ID_SIZE_WORDS.has(words[1])) { - words = [words[1], words[0]]; - } - if (words.length > 2 && words.at(-1) === "1" && OBJECT_ID_ORDERED_NOUNS.has(words.at(-2))) { - words = words.slice(0, -1); - } - return words.join("-") || "object"; -} - -function objectGameSegment(objectId) { - const match = /^object\.([a-z0-9-]+)\./.exec(String(objectId || "")); - return match?.[1] || ""; -} - -function payloadGameSlugFromName(name) { - const slug = slugifyObjectName(name || "game") - .replace(/-(object-vector-assets|object-vector-asset|object-assets|object-set|payload|assets)$/u, ""); - return slug || "game"; -} - -function sortedShapes(object) { - return [...(object?.shapes || [])].sort((left, right) => left.order - right.order); -} - -function sortedFrames(state) { - return [...(state?.frames || [])].sort((left, right) => left.order - right.order); -} - -function swatchColor(swatch) { - return swatch?.value || swatch?.hex || swatch?.color || ""; -} - -function colorMetrics(color) { - const hex = String(color || "").trim().replace(/^#/, ""); - if (!/^[0-9a-f]{6}$/i.test(hex)) { - return { bri: 0, hue: 0, sat: 0 }; - } - const red = parseInt(hex.slice(0, 2), 16) / 255; - const green = parseInt(hex.slice(2, 4), 16) / 255; - const blue = parseInt(hex.slice(4, 6), 16) / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - let hue = 0; - if (delta !== 0) { - if (max === red) { - hue = ((green - blue) / delta) % 6; - } else if (max === green) { - hue = (blue - red) / delta + 2; - } else { - hue = (red - green) / delta + 4; - } - hue = Math.round(hue * 60); - if (hue < 0) { - hue += 360; - } - } - return { - bri: max, - hue, - sat: max === 0 ? 0 : delta / max - }; -} - -function shapeBounds(shape) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "rectangle") { - return { - height: shape.geometry.height, - width: shape.geometry.width, - x: shape.geometry.x, - y: shape.geometry.y - }; - } - if (geometryTool === "circle") { - return { - height: shape.geometry.r * 2, - width: shape.geometry.r * 2, - x: shape.geometry.cx - shape.geometry.r, - y: shape.geometry.cy - shape.geometry.r - }; - } - if (geometryTool === "ellipse") { - return { - height: shape.geometry.ry * 2, - width: shape.geometry.rx * 2, - x: shape.geometry.cx - shape.geometry.rx, - y: shape.geometry.cy - shape.geometry.ry - }; - } - if (geometryTool === "line") { - const point1 = linePoint(shape.geometry, "point1"); - const point2 = linePoint(shape.geometry, "point2"); - const x = Math.min(point1.x, point2.x); - const y = Math.min(point1.y, point2.y); - return { - height: Math.max(1, Math.abs(point2.y - point1.y)), - width: Math.max(1, Math.abs(point2.x - point1.x)), - x, - y - }; - } - if (geometryTool === "polygon" || geometryTool === "polyline") { - const xValues = shape.geometry.points.map((point) => point.x); - const yValues = shape.geometry.points.map((point) => point.y); - const minX = Math.min(...xValues); - const minY = Math.min(...yValues); - return { - height: Math.max(1, Math.max(...yValues) - minY), - width: Math.max(1, Math.max(...xValues) - minX), - x: minX, - y: minY - }; - } - if (geometryTool === "arc") { - return { - height: shape.geometry.r * 2, - width: shape.geometry.r * 2, - x: shape.geometry.cx - shape.geometry.r, - y: shape.geometry.cy - shape.geometry.r - }; - } - if (geometryTool === "text") { - return { - height: shape.geometry.fontSize, - width: Math.max(24, shape.geometry.text.length * shape.geometry.fontSize * 0.6), - x: shape.geometry.x, - y: shape.geometry.y - shape.geometry.fontSize - }; - } - throw new Error(`unsupported shape bounds for ${shapeTool(shape)}`); -} - -function ellipseSamplePoints(cx, cy, rx, ry, sampleCount = 64) { - return Array.from({ length: sampleCount }, (_, index) => { - const radians = (index / sampleCount) * Math.PI * 2; - return { - x: cx + Math.cos(radians) * rx, - y: cy + Math.sin(radians) * ry - }; - }); -} - -function arcSamplePoints(geometry, sampleCount = 32) { - const startAngle = geometry.startAngle; - const endAngle = geometry.endAngle; - const span = endAngle - startAngle; - const steps = Math.max(1, Math.ceil(Math.abs(span) / (360 / sampleCount))); - - return Array.from({ length: steps + 1 }, (_, index) => { - const angle = startAngle + (span * index) / steps; - const radians = ((angle - 90) * Math.PI) / 180; - return { - x: geometry.cx + Math.cos(radians) * geometry.r, - y: geometry.cy + Math.sin(radians) * geometry.r - }; - }); -} - -function shapeBoundsPoints(shape) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "line") { - return [linePoint(shape.geometry, "point1"), linePoint(shape.geometry, "point2")].filter(Boolean); - } - if (geometryTool === "polygon" || geometryTool === "polyline") { - return Array.isArray(shape.geometry.points) ? shape.geometry.points : []; - } - if (geometryTool === "circle") { - return ellipseSamplePoints(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.r); - } - if (geometryTool === "ellipse") { - return ellipseSamplePoints(shape.geometry.cx, shape.geometry.cy, shape.geometry.rx, shape.geometry.ry); - } - if (geometryTool === "arc") { - return arcSamplePoints(shape.geometry); - } - return objectVectorBoundsCornerPoints(shapeBounds(shape)); -} - -function defaultShapeTransform() { - return { - rotation: 0, - scaleX: 1, - scaleY: 1, - x: 0, - y: 0 - }; -} - -export class ToolStarterApp { - constructor({ accordions, actionNav, elements, runtimeAssetService = null, schemaService, shell, statusLog, windowRef = window }) { - this.accordions = accordions; - this.actionNav = actionNav; - this.elements = elements; - this.schemaService = schemaService; - this.shell = shell; - this.statusLog = statusLog; - this.runtimeAssetService = runtimeAssetService || new ObjectVectorRuntimeAssetService({ logger: statusLog }); - this.window = windowRef; - this.currentPayload = null; - this.runtimePalette = null; - this.runtimePaletteSource = ""; - this.selectedObjectId = ""; - this.selectedShapeIndex = -1; - this.selectedShapeIndexes = new Set(); - this.directSelectedShapeIndexes = new Set(); - this.selectedStateId = ""; - this.selectedFrameId = ""; - this.playbackTimerId = 0; - this.isAnimationPlaying = false; - this.activeTool = "select"; - this.paletteTarget = "paint"; - this.paletteSortMode = "name"; - this.paletteSortDirection = "asc"; - this.selectedFillColor = "#ffffff"; - this.selectedStrokeColor = "#000000"; - this.selectedFillLabel = "default fill"; - this.selectedStrokeLabel = "default stroke"; - this.selectedFillOpacity = 1; - this.selectedStrokeOpacity = 1; - this.isPaintDragging = false; - this.activeDrawing = null; - this.drawingPreviewPoint = null; - this.drawingHintClientPoint = null; - this.previewClipboardShape = null; - this.previewUndoStack = []; - this.previewRedoStack = []; - this.previewPointerEdit = null; - this.transformInputValues = new Map(); - this.stateControlStateId = ""; - this.pendingAddObjectClick = false; - this.hiddenObjectIds = new Set(); - this.lockedObjectIds = new Set(); - this.snapMode = this.readSnapMode(); - this.angleSnapEnabled = false; - this.angleSnapStep = 15; - this.gridRenderEnabled = true; - this.centerOriginVisible = this.window.sessionStorage?.getItem(CENTER_ORIGIN_SESSION_KEY) !== "0"; - this.schemaReady = false; - this.viewportPan = null; - this.viewport = { ...DEFAULT_VIEWPORT }; - } - - async start() { - this.window.__objectVectorStudioV2App = this; - this.shell.mount(); - this.accordions.forEach((accordion) => accordion.mount()); - this.actionNav.mount({ - onCopyJson: () => { - void this.copyJson(); - }, - onExportJson: () => this.exportJson(), - onImportJson: (file) => { - void this.importJson(file); - }, - onReturnToWorkspace: (url) => { - this.window.location.href = url; - } - }); - this.statusLog.mount(); - this.applyNerdFontIcons(); - this.bindObjectActions(); - this.bindToolToggles(); - this.bindSnapControls(); - this.bindPaletteControls(); - this.bindViewportControls(); - this.bindPreviewEditActions(); - this.bindObjectFilters(); - this.bindAnimationControls(); - this.bindJsonDetailsActions(); - this.bindKeyboardShortcuts(); - this.applyToolDisplayMode(this.window.sessionStorage?.getItem(TOOL_DISPLAY_MODE_KEY) || "words", false); - this.applySnapState(); - this.updateViewport(); - this.actionNav.setImportEnabled(false); - this.renderEmptyState("Object Vector Studio V2 schema contract is loading."); - this.statusLog.write("OK Object Vector Studio V2 layout shell ready."); - this.statusLog.write("INFO Schema-only loading is idle. Import JSON or launch with workspace toolState data. Runtime palette is required before rendering."); - this.statusLog.write("INFO Tools primitive buttons enter drawing mode; use the canvas to commit schema-valid geometry."); - this.statusLog.write("INFO Disabled controls stay inactive until a schema-valid payload, runtime palette, selected object, or active frame is available."); - this.statusLog.write("INFO Object identity uses object.game.name ids."); - this.statusLog.write(`INFO Object Vector Studio V2 editor zoom is viewport-only; runtime/world scale remains ${CANONICAL_WORLD_TO_SCREEN_SCALE}:1 through the shared world-to-screen transform.`); - this.statusLog.write("INFO Paint and stroke selection is structured to scale later into shaders, gradients, patterns, neon, SVG export, and runtime rendering."); - await this.schemaService.loadSchema(); - this.schemaReady = true; - this.statusLog.write(`OK Object Vector Studio V2 schema contract loaded from ${this.schemaService.schemaPath}.`); - await this.loadRuntimePaletteFromSources(); - this.renderEmptyState("Schema-only loading is idle. Import JSON or launch with workspace toolState data. Runtime palette is required before rendering."); - if (this.actionNav.isWorkspaceLaunch()) { - this.loadWorkspaceToolState(); - } - } - - async loadRuntimePaletteFromSources() { - const params = new URLSearchParams(this.window.location.search); - const paletteSourcePath = params.get("paletteSourcePath") || ""; - if (paletteSourcePath) { - await this.loadPaletteFromUrl(paletteSourcePath); - if (this.runtimePalette) { - return; - } - } - - if (this.actionNav.isWorkspaceLaunch() && this.loadPaletteFromWorkspaceSession()) { - return; - } - - if (this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY)) { - return; - } - this.loadPaletteFromWorkspaceSession(); - } - - async loadPaletteFromUrl(paletteSourcePath) { - let paletteUrl; - try { - paletteUrl = new URL(paletteSourcePath, this.window.location.href); - const response = await this.window.fetch(paletteUrl, { cache: "no-store" }); - if (!response.ok) { - this.statusLog.write(`FAIL Runtime palette load failed from ${paletteSourcePath}: ${response.status}.`); - return; - } - const palette = await response.json(); - this.acceptRuntimePalette(palette, `paletteSourcePath:${paletteSourcePath}`, true); - } catch (error) { - this.statusLog.write(`FAIL Runtime palette load failed from ${paletteSourcePath}: ${error.message}`); - } - } - - loadPaletteFromSessionKey(sessionKey) { - const storedPalette = this.window.sessionStorage?.getItem(sessionKey) || ""; - if (!storedPalette) { - return false; - } - try { - const palette = JSON.parse(storedPalette); - return this.acceptRuntimePalette(palette, sessionKey, sessionKey !== RUNTIME_PALETTE_SESSION_KEY); - } catch (error) { - this.statusLog.write(`FAIL Runtime palette load failed from ${sessionKey}: ${error.message}`); - return false; - } - } - - loadPaletteFromWorkspaceSession() { - const storedSession = this.window.sessionStorage?.getItem(WORKSPACE_PALETTE_SESSION_KEY) || ""; - if (!storedSession) { - return false; - } - try { - const session = JSON.parse(storedSession); - const palette = session?.data && typeof session.data === "object" ? session.data : session; - return this.acceptRuntimePalette(palette, `${WORKSPACE_PALETTE_SESSION_KEY}.data`, true); - } catch (error) { - this.statusLog.write(`FAIL Runtime palette load failed from ${WORKSPACE_PALETTE_SESSION_KEY}: ${error.message}`); - return false; - } - } - - acceptRuntimePalette(palette, sourceLabel, persistToRuntimeSession) { - const validation = this.schemaService.validateRuntimePalette(palette, sourceLabel); - if (!validation.ok) { - this.runtimePalette = null; - this.runtimePaletteSource = ""; - this.statusLog.write(`FAIL Runtime palette validation failed from ${sourceLabel}: ${validation.errors.join(" ")}`); - return false; - } - this.runtimePalette = validation.palette; - this.runtimePaletteSource = sourceLabel; - const firstColor = validation.palette.swatches.map((swatch) => swatchColor(swatch)).find(Boolean); - if (firstColor) { - this.selectedFillColor = firstColor; - this.selectedStrokeColor = firstColor; - const firstSwatch = validation.palette.swatches.find((swatch) => swatchColor(swatch) === firstColor); - const firstLabel = firstSwatch?.name || firstSwatch?.id || firstSwatch?.symbol || "first swatch"; - this.selectedFillLabel = firstLabel; - this.selectedStrokeLabel = firstLabel; - } - if (persistToRuntimeSession) { - this.window.sessionStorage?.setItem(RUNTIME_PALETTE_SESSION_KEY, JSON.stringify(validation.palette)); - } - this.statusLog.write(`OK Runtime palette loaded from ${sourceLabel}: ${validation.palette.swatches.length} swatches.`); - this.statusLog.write("INFO Runtime palette is read-only session/workspace data; Object Vector JSON remains palette-free."); - return true; - } - - bindObjectActions() { - this.elements.addObjectButton.addEventListener("pointerdown", () => { - this.pendingAddObjectClick = true; - }); - this.elements.addObjectButton.addEventListener("click", () => this.addObject()); - this.elements.addTagButton.addEventListener("click", () => this.addTagToSelectedObject()); - this.elements.objectNameInput.addEventListener("input", () => this.updateObjectPreviewFooterFromNameInput()); - this.elements.renameObjectButton.addEventListener("click", () => this.renameSelectedObject()); - this.elements.duplicateObjectButton.addEventListener("click", () => this.duplicateSelectedObject()); - this.elements.deleteObjectButton?.addEventListener("click", () => this.deleteSelectedObject()); - this.elements.exportSvgButton.addEventListener("click", () => this.exportSelectedObjectSvg()); - } - - applyNerdFontIcons() { - OBJECT_VECTOR_STUDIO_STATIC_ICON_TARGETS.forEach(([selector, iconKey]) => { - this.window.document.querySelectorAll(selector).forEach((element) => this.applyIconGlyph(element, iconKey)); - }); - } - - applyIconGlyph(element, iconKey) { - const icon = OBJECT_VECTOR_STUDIO_ICON_GLYPHS[iconKey]; - if (!element || !icon?.glyph) { - return; - } - element.dataset.ovsIcon = icon.glyph; - element.dataset.ovsIconKey = iconKey; - element.dataset.ovsIconName = icon.name; - element.classList.add("object-vector-studio-v2__nerd-icon"); - } - - bindAnimationControls() { - this.elements.deleteFrameButton.addEventListener("click", () => this.deleteSelectedFrame()); - this.elements.duplicateFrameButton.addEventListener("click", () => this.duplicateSelectedFrame()); - this.elements.frameLeftButton.addEventListener("click", () => this.moveSelectedFrame("earlier", "left")); - this.elements.frameEarlierButton.addEventListener("click", () => this.moveSelectedFrame("earlier", "earlier")); - this.elements.frameLaterButton.addEventListener("click", () => this.moveSelectedFrame("later", "later")); - this.elements.frameRightButton.addEventListener("click", () => this.moveSelectedFrame("later", "right")); - this.elements.playButton.addEventListener("click", () => this.playAnimation()); - this.elements.pauseButton.addEventListener("click", () => this.pauseAnimation()); - this.elements.stopButton.addEventListener("click", () => this.stopAnimation()); - this.elements.runtimePreviewButton.addEventListener("click", () => { - void this.previewRuntimeAsset(); - }); - this.elements.onionSkinToggle.addEventListener("change", () => { - this.renderWorkSurface(); - this.statusLog.write(`OK Onion-skin preview ${this.elements.onionSkinToggle.checked ? "enabled" : "disabled"}.`); - }); - } - - bindToolToggles() { - this.elements.toolLabelModeButton.addEventListener("click", () => { - const nextMode = this.elements.toolToggleGrid.classList.contains("is-icon-only") ? "words" : "icons"; - this.applyToolDisplayMode(nextMode, true); - }); - - this.elements.toolToggles.forEach((button) => { - button.addEventListener("click", () => { - const tool = button.dataset.shapeTool || "unknown"; - this.setActiveToolButton(button); - this.setPaletteTarget("stroke", false); - if (PRIMITIVE_TOOLS.includes(tool)) { - this.startDrawingMode(tool); - return; - } - this.cancelActiveDrawing("tool switch", { log: false }); - this.activeTool = tool; - if (tool === "select" && this.selectedShapeIndex >= 0) { - const clearedShape = this.selectedShapeIndex; - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - this.renderObjectTiles(); - this.renderSelectedObject(); - this.renderWorkSurface(); - this.updateObjectActionState(); - this.statusLog.write(`OK Select tool cleared selected shape row ${clearedShape}.`); - return; - } - this.statusLog.write(`OK Tools mode selected: ${tool}.`); - }); - }); - } - - bindSnapControls() { - this.elements.snapModeButton.addEventListener("click", () => this.cycleSnapMode()); - this.elements.angleSnapButton.addEventListener("click", () => { - this.angleSnapEnabled = !this.angleSnapEnabled; - this.window.sessionStorage?.setItem(ANGLE_SNAP_SESSION_KEY, this.angleSnapEnabled ? "1" : "0"); - this.applySnapState(); - this.statusLog.write(`OK Snap angle ${this.angleSnapEnabled ? "enabled" : "disabled"}: Rotate action ${this.angleSnapEnabled ? `uses dropdown values in ${this.angleSnapStep} degree increments` : "uses raw numeric textbox values"}.`); - }); - this.elements.gridRenderButton.addEventListener("click", () => { - this.gridRenderEnabled = !this.gridRenderEnabled; - this.window.sessionStorage?.setItem(GRID_RENDER_SESSION_KEY, this.gridRenderEnabled ? "1" : "0"); - this.applySnapState(); - this.renderWorkSurface(); - this.statusLog.write(`OK Grid rendering ${this.gridRenderEnabled ? "enabled" : "disabled"}.`); - }); - this.elements.centerDotButton.addEventListener("click", () => this.toggleCenterOriginMarker()); - } - - bindPaletteControls() { - this.elements.paintModeButton.addEventListener("click", () => this.setPaletteTarget("paint")); - this.elements.strokeModeButton.addEventListener("click", () => this.setPaletteTarget("stroke")); - this.elements.strokeWidth.addEventListener("change", () => { - const width = Number(this.elements.strokeWidth.value); - if (!Number.isFinite(width) || width <= 0) { - this.elements.strokeWidth.value = "2"; - this.statusLog.write("WARN Stroke width reset to 2: value must be greater than 0."); - return; - } - this.statusLog.write(`OK Palette stroke width set to ${width}.`); - }); - [ - this.elements.fillOpacity, - this.elements.strokeOpacity - ].forEach((input) => input.addEventListener("input", () => this.clearInputValidity(input))); - this.elements.fillOpacity.addEventListener("change", () => this.changePaletteOpacity("fill")); - this.elements.strokeOpacity.addEventListener("change", () => this.changePaletteOpacity("stroke")); - this.elements.paletteSortButtons.forEach((button) => { - button.addEventListener("click", () => { - const nextSortMode = button.dataset.paletteSort || "name"; - if (this.paletteSortMode === nextSortMode) { - this.paletteSortDirection = this.paletteSortDirection === "asc" ? "desc" : "asc"; - } else { - this.paletteSortMode = nextSortMode; - this.paletteSortDirection = "asc"; - } - this.updatePaletteSortButtons(); - if (this.runtimePalette) { - this.renderPalette(); - } - this.statusLog.write(`OK Palette sort set to ${this.paletteSortMode} ${this.paletteSortDirection}.`); - }); - }); - this.setPaletteTarget("paint", false); - this.updatePaletteSortButtons(); - this.updatePaletteModeSwatches(); - } - - bindJsonDetailsActions() { - this.elements.copyJsonDetailsButton.addEventListener("click", (event) => { - event.stopPropagation(); - void this.copyJson(); - }); - } - - setPaletteTarget(target, shouldLog = true) { - this.paletteTarget = target === "stroke" ? "stroke" : "paint"; - this.elements.paintModeButton.setAttribute("aria-pressed", String(this.paletteTarget === "paint")); - this.elements.strokeModeButton.setAttribute("aria-pressed", String(this.paletteTarget === "stroke")); - this.elements.paintModeButton.classList.toggle("is-active", this.paletteTarget === "paint"); - this.elements.strokeModeButton.classList.toggle("is-active", this.paletteTarget === "stroke"); - this.updatePaletteModeSwatches(); - if (this.runtimePalette) { - this.renderPalette(); - } - if (shouldLog) { - this.cancelActiveDrawing("palette mode change", { log: false }); - this.activeTool = this.paletteTarget === "stroke" ? "stroke" : "paint"; - this.setActiveToolButton(null); - this.statusLog.write(`OK Palette target set to ${this.paletteTarget === "stroke" ? "Stroke" : "Paint"}.`); - } - } - - updatePaletteSortButtons() { - this.elements.paletteSortButtons.forEach((button) => { - const isActive = button.dataset.paletteSort === this.paletteSortMode; - const baseLabel = button.dataset.paletteSortLabel || button.textContent.replace(/[▲▼]\s*$/u, "").trim() || shapeTypeLabel(button.dataset.paletteSort || "name"); - button.dataset.paletteSortLabel = baseLabel; - button.dataset.sortDirection = isActive ? this.paletteSortDirection : ""; - button.textContent = ""; - const label = document.createElement("span"); - label.textContent = baseLabel; - button.append(label); - const caret = document.createElement("span"); - caret.className = "object-vector-studio-v2__palette-sort-caret"; - caret.setAttribute("aria-hidden", "true"); - caret.textContent = isActive ? (this.paletteSortDirection === "asc" ? "▲" : "▼") : "◇"; - button.append(caret); - button.setAttribute("aria-pressed", String(isActive)); - button.setAttribute("aria-label", `${baseLabel} sort ${isActive ? this.paletteSortDirection : "inactive"}`); - button.title = isActive ? `Sort ${baseLabel} ${this.paletteSortDirection === "asc" ? "ascending" : "descending"}` : `Sort ${baseLabel} ascending`; - button.classList.toggle("is-active", isActive); - }); - } - - updatePaletteModeSwatches() { - this.elements.paintModeButton.style.setProperty("--object-vector-studio-v2-mode-swatch", this.selectedFillColor); - this.elements.paintModeButton.style.setProperty("--object-vector-studio-v2-mode-opacity", String(this.selectedFillOpacity)); - this.elements.strokeModeButton.style.setProperty("--object-vector-studio-v2-mode-swatch", this.selectedStrokeColor); - this.elements.strokeModeButton.style.setProperty("--object-vector-studio-v2-mode-opacity", String(this.selectedStrokeOpacity)); - this.elements.paintModeButton.dataset.paletteModeColor = this.selectedFillColor; - this.elements.paintModeButton.dataset.paletteModeOpacity = String(this.selectedFillOpacity); - this.elements.strokeModeButton.dataset.paletteModeColor = this.selectedStrokeColor; - this.elements.strokeModeButton.dataset.paletteModeOpacity = String(this.selectedStrokeOpacity); - } - - pointStyleValue(value) { - return POINT_STYLE_VALUES.includes(value) ? value : "square"; - } - - strokeLinejoinValue(value = "round") { - return this.pointStyleValue(value) === "square" ? "miter" : "round"; - } - - supportsOpenPointStyle(shape) { - return OPEN_POINT_STYLE_TOOLS.has(shapeGeometryTool(shape)); - } - - supportsClosedPointStyle(shape) { - return CLOSED_POINT_STYLE_TOOLS.has(shapeGeometryTool(shape)); - } - - shapeSupportsPointRoundingControls(shape) { - const geometryTool = shapeGeometryTool(shape); - return !["circle", "ellipse", "text"].includes(geometryTool) && this.shapeGeometryPointCount(shape) > 0; - } - - shapeSupportsRoundingRadiusControl(shape) { - return ["polygon", "polyline", "rectangle"].includes(shapeGeometryTool(shape)) && this.shapeGeometryPointCount(shape) > 0; - } - - shapeRoundingRadius(shape) { - const value = Number(shape?.style?.roundingRadius); - if (Number.isFinite(value) && value >= 0) { - return value; - } - return 4; - } - - shapeUnifiedPointStyle(shape) { - const pointStyles = this.shapePointStyleValues(shape); - if (pointStyles.length) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "polygon" || geometryTool === "polyline" || geometryTool === "rectangle") { - return "square"; - } - return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap); - } - return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap); - } - - shapeStartPointStyle(shape) { - const pointStyles = this.shapePointStyleValues(shape); - if (pointStyles.length) { - return pointStyles[0]; - } - return this.pointStyleValue(shape?.style?.startPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle); - } - - shapeEndPointStyle(shape) { - const pointStyles = this.shapePointStyleValues(shape); - if (pointStyles.length) { - return pointStyles.at(-1); - } - return this.pointStyleValue(shape?.style?.endPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle); - } - - openShapeUsesSplitPointStyles(shape) { - return this.supportsOpenPointStyle(shape) && this.shapeStartPointStyle(shape) !== this.shapeEndPointStyle(shape); - } - - shapeGeometryPointCount(shape) { - return this.shapeGeometryPoints(shape).length; - } - - shapeGeometryPoints(shape) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "line") { - return [linePoint(shape.geometry, "point1"), linePoint(shape.geometry, "point2")]; - } - if (geometryTool === "arc") { - return [ - this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.startAngle), - this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.endAngle) - ]; - } - if (geometryTool === "rectangle") { - const x = Number(shape.geometry.x) || 0; - const y = Number(shape.geometry.y) || 0; - const width = Number(shape.geometry.width) || 0; - const height = Number(shape.geometry.height) || 0; - return [ - { x, y }, - { x: x + width, y }, - { x: x + width, y: y + height }, - { x, y: y + height } - ]; - } - if ((geometryTool === "polygon" || geometryTool === "polyline") && Array.isArray(shape?.geometry?.points)) { - return shape.geometry.points; - } - return []; - } - - shapePointRoundingValues(shape) { - const pointCount = this.shapeGeometryPointCount(shape); - if (!pointCount) { - return []; - } - const explicit = Array.isArray(shape?.style?.pointRounding) ? shape.style.pointRounding : null; - if (explicit) { - return Array.from({ length: pointCount }, (_, index) => explicit[index] === true); - } - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "polygon" || geometryTool === "rectangle") { - return Array.from({ length: pointCount }, () => false); - } - return Array.from({ length: pointCount }, (_, index) => { - if (geometryTool === "line" || geometryTool === "polyline") { - if (index === 0) { - return this.pointStyleValue(shape?.style?.startPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle) === "round"; - } - if (index === pointCount - 1) { - return this.pointStyleValue(shape?.style?.endPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle) === "round"; - } - if (geometryTool === "polyline") { - return false; - } - } - return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap) === "round"; - }); - } - - shapePointStyleValues(shape) { - return this.shapePointRoundingValues(shape).map((isRounded) => isRounded ? "round" : "square"); - } - - shapeJoinPointIndexes(shape, pointCount = this.shapeGeometryPointCount(shape)) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "polyline") { - return Array.from({ length: Math.max(0, pointCount - 2) }, (_, index) => index + 1); - } - if (geometryTool === "polygon") { - return Array.from({ length: pointCount }, (_, index) => index); - } - return []; - } - - bindViewportControls() { - this.elements.zoomInButton.addEventListener("click", () => this.zoomViewportByStep(ZOOM_STEP)); - this.elements.zoomOutButton.addEventListener("click", () => this.zoomViewportByStep(-ZOOM_STEP)); - this.elements.panUpButton.addEventListener("click", () => this.panViewport(0, -20)); - this.elements.panDownButton.addEventListener("click", () => this.panViewport(0, 20)); - this.elements.panLeftButton.addEventListener("click", () => this.panViewport(-20, 0)); - this.elements.panRightButton.addEventListener("click", () => this.panViewport(20, 0)); - this.elements.resetViewButton.addEventListener("click", () => this.resetViewport()); - this.elements.renderSurface.addEventListener("mousemove", (event) => this.updateCoordinateDisplay(event)); - this.elements.renderSurface.addEventListener("pointerdown", (event) => this.handleRenderSurfacePointerDown(event)); - this.elements.renderSurface.addEventListener("contextmenu", (event) => event.preventDefault()); - this.elements.renderSurface.addEventListener("dblclick", (event) => this.finishMultiPointDrawing("double-click", event)); - this.window.addEventListener("pointermove", (event) => this.updatePreviewPointerEdit(event)); - this.window.addEventListener("pointermove", (event) => this.updateDrawingPreview(event)); - this.window.addEventListener("pointermove", (event) => this.updateViewportPan(event)); - this.elements.renderSurface.addEventListener("wheel", (event) => { - event.preventDefault(); - this.zoomViewportByStepAtPointer(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP, event); - }, { passive: false }); - this.window.addEventListener("pointerup", (event) => { - this.isPaintDragging = false; - this.finishViewportPan(event); - this.finishPreviewPointerEdit(event); - }); - } - - bindPreviewEditActions() { - this.elements.previewUndoButton.addEventListener("click", () => this.undoPreviewEdit()); - this.elements.previewRedoButton.addEventListener("click", () => this.redoPreviewEdit()); - this.elements.previewCopyButton.addEventListener("click", () => this.copyPreviewSelection()); - this.elements.previewPasteButton.addEventListener("click", () => this.pastePreviewClipboard()); - } - - bindObjectFilters() { - this.elements.tagFilter.addEventListener("change", () => { - this.renderObjectTiles(); - this.statusLog.write(`OK Object tag filter set to ${this.elements.tagFilter.value}.`); - }); - this.elements.searchFilter.addEventListener("input", () => { - this.renderObjectTiles(); - }); - } - - bindKeyboardShortcuts() { - const handleShortcut = (event) => { - if (event.defaultPrevented) { - return; - } - if (event.key === "Enter" && this.activeDrawing) { - event.preventDefault(); - this.finishMultiPointDrawing("Enter", event); - return; - } - const tagName = event.target?.tagName || ""; - if (["INPUT", "SELECT", "TEXTAREA"].includes(tagName)) { - return; - } - if (event.key === "Delete" || event.key === "Backspace") { - event.preventDefault(); - this.deleteSelectedShape("keyboard"); - return; - } - const key = event.key.toLowerCase(); - if (key === "v") { - event.preventDefault(); - this.activateToolMode("select", "keyboard"); - } else if (key === "f") { - event.preventDefault(); - this.setPaletteTarget("paint"); - } else if (key === "s") { - event.preventDefault(); - this.setPaletteTarget("stroke"); - } else if (key === "i") { - event.preventDefault(); - this.activateToolMode("picker", "keyboard"); - } else if (key === "x") { - event.preventDefault(); - this.swapFillStrokeColors(); - } else if (key === "d") { - event.preventDefault(); - this.restoreDefaultColors(); - } - }; - this.elements.renderSurface.addEventListener("keydown", handleShortcut); - this.window.document.addEventListener("keydown", handleShortcut); - } - - applySnapState() { - this.snapMode = this.readSnapMode(); - this.angleSnapEnabled = this.window.sessionStorage?.getItem(ANGLE_SNAP_SESSION_KEY) === "1"; - this.angleSnapStep = this.readAngleSnapStep(); - this.gridRenderEnabled = this.window.sessionStorage?.getItem(GRID_RENDER_SESSION_KEY) !== "0"; - this.centerOriginVisible = this.window.sessionStorage?.getItem(CENTER_ORIGIN_SESSION_KEY) !== "0"; - const snapDetails = SNAP_MODE_DETAILS[this.snapMode] || SNAP_MODE_DETAILS.grid; - this.elements.snapModeButton.textContent = snapDetails.label; - this.elements.snapModeButton.dataset.snapMode = this.snapMode; - this.elements.snapModeButton.setAttribute("aria-label", snapDetails.label); - this.elements.snapModeButton.setAttribute("aria-pressed", String(this.snapMode !== "none")); - this.elements.snapModeButton.title = snapDetails.title; - this.applyIconGlyph(this.elements.snapModeButton, snapDetails.iconKey); - this.elements.renderSurface.classList.toggle("is-snap-point-mode", this.snapMode === "point"); - this.elements.angleSnapButton.setAttribute("aria-pressed", String(this.angleSnapEnabled)); - this.elements.angleSnapButton.textContent = "Snap Angle"; - this.elements.angleSnapButton.setAttribute("aria-label", "Snap Angle for Shape/Object Rotate"); - this.elements.angleSnapButton.title = "Snap Angle switches Rotate to a constrained dropdown using the selected 15, 30, 45, or 90 degree step."; - this.elements.gridRenderButton.setAttribute("aria-pressed", String(this.gridRenderEnabled)); - this.applyIconGlyph(this.elements.gridRenderButton, this.gridRenderEnabled ? "grid" : "gridOff"); - this.elements.centerDotButton.setAttribute("aria-pressed", String(this.centerOriginVisible)); - this.elements.renderSurface.classList.toggle("is-grid-visible", this.gridRenderEnabled); - this.updateRotateSnapControls(); - } - - updateRotateSnapControls(scope = this.window.document) { - const root = scope || this.window.document; - [ - { - inputId: "objectVectorStudioV2RotateInput", - selectId: "objectVectorStudioV2RotateSnapSelect", - stepId: "objectVectorStudioV2SnapAngleStepSelect" - }, - { - inputId: "objectVectorStudioV2ObjectRotateInput", - selectId: "objectVectorStudioV2ObjectRotateSnapSelect", - stepId: "objectVectorStudioV2ObjectSnapAngleStepSelect" - } - ].forEach(({ inputId, selectId, stepId }) => { - const numericInput = root.querySelector?.(`#${inputId}`) || this.window.document.getElementById(inputId); - const snapSelect = root.querySelector?.(`#${selectId}`) || this.window.document.getElementById(selectId); - const stepSelect = root.querySelector?.(`#${stepId}`) || this.window.document.getElementById(stepId); - if (!numericInput && !snapSelect && !stepSelect) { - return; - } - const row = (numericInput || snapSelect || stepSelect)?.closest?.(".object-vector-studio-v2__transform-control-row--rotate") || null; - const panelDisabled = row?.closest?.(".object-vector-studio-v2__shape-panel")?.classList.contains("is-disabled") === true; - row?.classList.toggle("is-angle-snap-enabled", this.angleSnapEnabled); - if (stepSelect) { - stepSelect.value = String(this.angleSnapStep); - stepSelect.disabled = panelDisabled || !this.angleSnapEnabled; - stepSelect.hidden = !this.angleSnapEnabled; - const stepField = stepSelect.closest(".object-vector-studio-v2__snap-angle-step-field"); - if (stepField) { - stepField.hidden = !this.angleSnapEnabled; - } - } - if (snapSelect) { - const preferred = snapSelect.value || numericInput?.value || this.transformInputValue(snapSelect.id, "15"); - this.populateRotateSnapSelect(snapSelect, this.angleSnapStep, preferred); - snapSelect.disabled = panelDisabled || !this.angleSnapEnabled; - snapSelect.hidden = !this.angleSnapEnabled; - } - if (numericInput) { - numericInput.disabled = panelDisabled || this.angleSnapEnabled; - numericInput.hidden = this.angleSnapEnabled; - } - }); - } - - populateRotateSnapSelect(select, step, preferredValue = "15") { - if (!select) { - return; - } - const normalizedStep = ANGLE_SNAP_STEPS.includes(Number(step)) ? Number(step) : 15; - const previous = Number(preferredValue); - const normalizedPreferred = Number.isFinite(previous) - ? this.normalizeRotationDegrees(Math.round(previous / normalizedStep) * normalizedStep) - : normalizedStep; - select.replaceChildren(); - for (let value = 0; value < 360; value += normalizedStep) { - const option = document.createElement("option"); - option.value = String(value); - option.textContent = String(value); - select.append(option); - } - select.value = Array.from(select.options).some((option) => option.value === String(normalizedPreferred)) - ? String(normalizedPreferred) - : "0"; - this.transformInputValues.set(select.id, select.value); - } - - readSnapMode() { - const storedMode = this.window.sessionStorage?.getItem(SNAP_MODE_SESSION_KEY) || "grid"; - return SNAP_MODES.includes(storedMode) ? storedMode : "grid"; - } - - readAngleSnapStep() { - const storedStep = Number(this.window.sessionStorage?.getItem(ANGLE_SNAP_STEP_SESSION_KEY) || 15); - return ANGLE_SNAP_STEPS.includes(storedStep) ? storedStep : 15; - } - - setSnapMode(mode, label = "") { - if (!SNAP_MODES.includes(mode)) { - this.statusLog.write(`WARN Snap mode skipped: ${mode || "unknown"} is not supported.`); - return; - } - this.snapMode = mode; - this.window.sessionStorage?.setItem(SNAP_MODE_SESSION_KEY, mode); - this.applySnapState(); - this.renderWorkSurface(); - this.statusLog.write(`OK ${label || shapeTypeLabel(mode)} mode selected for drawing and point dragging.`); - } - - cycleSnapMode() { - const currentIndex = SNAP_MODES.indexOf(this.snapMode); - const nextMode = SNAP_MODES[(currentIndex + 1) % SNAP_MODES.length] || "grid"; - this.setSnapMode(nextMode, SNAP_MODE_DETAILS[nextMode]?.label); - } - - applyToolDisplayMode(mode, shouldLog) { - const isCompact = mode === "icons"; - this.elements.toolToggleGrid.classList.toggle("is-icon-only", isCompact); - this.elements.toolLabelModeButton.setAttribute("aria-pressed", String(isCompact)); - this.elements.toolLabelModeButton.textContent = isCompact ? "Words" : "Icons"; - this.window.sessionStorage?.setItem(TOOL_DISPLAY_MODE_KEY, isCompact ? "icons" : "words"); - if (shouldLog) { - this.statusLog.write(`OK Tools display mode set to ${isCompact ? "compact icons" : "words and icons"}.`); - } - } - - setActiveToolButton(button) { - this.elements.toolToggles.forEach((candidate) => { - candidate.classList.toggle("is-active", candidate === button); - candidate.setAttribute("aria-pressed", String(candidate === button)); - }); - } - - activateToolMode(tool, sourceLabel) { - if (!PRIMITIVE_TOOLS.includes(tool)) { - this.cancelActiveDrawing("tool mode change", { log: false }); - } - this.activeTool = tool; - const button = this.elements.toolToggles.find((candidate) => candidate.dataset.shapeTool === tool) || null; - this.setActiveToolButton(button); - if (tool === "paint") { - this.setPaletteTarget("paint", false); - } else if (tool === "stroke") { - this.setPaletteTarget("stroke", false); - } else { - this.setPaletteTarget("stroke", false); - } - this.statusLog.write(`OK Tools mode selected from ${sourceLabel}: ${tool}.`); - } - - renderEmptyState(message) { - this.currentPayload = null; - this.selectedObjectId = ""; - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - this.activeDrawing = null; - this.drawingPreviewPoint = null; - this.activeDrawing = null; - this.drawingPreviewPoint = null; - this.clearPreviewEditState({ clipboard: true }); - this.selectedStateId = ""; - this.stateControlStateId = ""; - this.selectedFrameId = ""; - this.stopPlaybackTimer(); - this.updateObjectsHeader(0, 0); - this.updatePaletteHeader(this.runtimePalette?.swatches?.length || 0); - this.elements.objectNameInput.value = ""; - this.elements.objectTagInput.value = ""; - this.renderObjectTagList(null); - this.elements.paletteSummary.textContent = this.runtimePalette ? "" : "Palette required before render."; - this.elements.shapeGeometryDetails.textContent = "No object selected."; - this.updateShapeGeometryHeader(null); - this.elements.objectTransform.textContent = "No object selected."; - this.elements.shapeTransform.textContent = "No shape selected."; - this.elements.objectPreviewFooter.textContent = "Object ID: none"; - this.elements.jsonDetails.textContent = "{}"; - this.renderFrameTimeline(); - this.updateViewport(); - this.elements.renderSurface.replaceChildren(); - this.renderSvgGrid(); - this.renderCenterOriginMarker(); - this.elements.objectTiles.replaceChildren(this.createEmptyObjectTile()); - this.renderDependencyGraph(); - this.actionNav.setJsonPayloadActionsEnabled(false); - this.elements.exportSvgButton.disabled = true; - this.actionNav.setImportEnabled(this.schemaReady && !this.actionNav.isWorkspaceLaunch()); - this.updateObjectActionState(); - } - - updateObjectsHeader(objectCount, shapeCount) { - const stateCount = this.objectStates(this.selectedObject()).length; - this.elements.objectsCount.textContent = `(${objectCount} obj, states ${stateCount}, ${shapeCount} ${shapeCount === 1 ? "shape" : "shapes"})`; - } - - updateShapeGeometryHeader(shape) { - this.elements.shapeGeometrySummary.textContent = ""; - if (this.elements.shapeGeometryName) { - this.elements.shapeGeometryName.textContent = ""; - } - } - - updatePaletteHeader(swatchCount) { - this.elements.paletteSwatchCount.textContent = `(${swatchCount} ${swatchCount === 1 ? "swatch" : "swatches"})`; - } - - createEmptyObjectTile() { - const tile = document.createElement("article"); - tile.className = "object-vector-studio-v2__object-tile object-vector-studio-v2__object-tile--empty"; - tile.textContent = "No objects loaded. Import schema-valid Object Vector Studio V2 JSON and provide a runtime palette to show object tiles."; - return tile; - } - - async importJson(file) { - if (!file) { - this.statusLog.write("FAIL Import JSON blocked: choose an Object Vector Studio V2 JSON file."); - return; - } - - let payload; - try { - payload = JSON.parse(await readFileText(file)); - } catch (error) { - this.statusLog.write(`FAIL Import JSON blocked: ${file.name || "selected file"} is invalid JSON: ${error.message}`); - return; - } - - this.loadPayload(payload, `import:${file.name || "selected file"}`); - } - - loadWorkspaceToolState() { - const storedPayload = this.window.sessionStorage?.getItem(WORKSPACE_TOOL_SESSION_KEY) || ""; - if (!storedPayload) { - const message = `Workspace toolState loading blocked: ${WORKSPACE_TOOL_SESSION_KEY} is missing. A valid Object Vector Studio V2 payload is required.`; - this.renderEmptyState(message); - this.statusLog.write(`FAIL ${message}`); - return; - } - - let payload; - try { - const session = JSON.parse(storedPayload); - payload = session?.data && typeof session.data === "object" ? session.data : session; - } catch (error) { - const message = `Workspace toolState loading blocked: ${WORKSPACE_TOOL_SESSION_KEY} is invalid JSON: ${error.message}`; - this.renderEmptyState(message); - this.statusLog.write(`FAIL ${message}`); - return; - } - - this.loadPayload(payload, WORKSPACE_TOOL_SESSION_KEY); - } - - loadPayload(payload, sourceLabel) { - const validation = this.schemaService.validatePayload(payload); - if (!validation.ok) { - const message = `Object Vector Studio V2 schema validation failed from ${sourceLabel}: ${validation.errors.join(" ")}`; - this.renderEmptyState(message); - this.statusLog.write(`FAIL ${message}`); - return; - } - - this.currentPayload = validation.payload; - this.applyLoadedRuntimeState(sourceLabel); - this.actionNav.setJsonPayloadActionsEnabled(true); - this.actionNav.setImportEnabled(!this.actionNav.isWorkspaceLaunch()); - this.renderPayload(); - this.statusLog.write(`OK Loaded Object Vector Studio V2 schema payload from ${sourceLabel}: ${this.currentPayload.objects.length} objects.`); - } - - applyLoadedRuntimeState(sourceLabel) { - const selectedObject = this.currentPayload.objects[0] || null; - this.selectedObjectId = selectedObject?.id || ""; - const selectedShape = sortedShapes(selectedObject)[0] || null; - this.selectedShapeIndex = selectedShape ? 0 : -1; - this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - const selectedState = this.objectStates(selectedObject)[0] || null; - this.selectedStateId = selectedState?.id || ""; - this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; - this.selectedFrameId = selectedState ? sortedFrames(selectedState)[0]?.id || "" : ""; - this.hiddenObjectIds.clear(); - this.lockedObjectIds.clear(); - this.clearPreviewEditState({ clipboard: true }); - this.activeDrawing = null; - this.drawingPreviewPoint = null; - this.viewport = { ...DEFAULT_VIEWPORT }; - this.updateViewport(); - if (sourceLabel) { - this.statusLog.write(`OK Runtime selection initialized from ${sourceLabel}: ${this.selectedObjectId || "no object selected"}.`); - } - } - - renderPayload(options = {}) { - if (!this.currentPayload) { - return; - } - - const validation = this.schemaService.validatePayload(this.currentPayload); - if (!validation.ok) { - const message = `Render blocked by Object Vector Studio V2 schema validation: ${validation.errors.join(" ")}`; - this.renderEmptyState(message); - this.statusLog.write(`FAIL ${message}`); - return; - } - - this.currentPayload = validation.payload; - if (!this.runtimePalette) { - this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY) || this.loadPaletteFromWorkspaceSession(); - } - const paletteValidation = this.schemaService.validateRuntimePalette(this.runtimePalette, "runtime palette"); - if (!paletteValidation.ok) { - const message = `Render blocked: runtime palette is required separately from Object Vector Studio V2 JSON. ${paletteValidation.errors.join(" ")}`; - this.renderPaletteRequiredState(message); - this.statusLog.write(`FAIL ${message}`); - return; - } - this.runtimePalette = paletteValidation.palette; - const preserveEmptyShapeSelection = options.preserveEmptyShapeSelection === true - && this.selectedObject() - && this.selectedShapeIndex < 0 - && !this.selectedShapeIndexes.size - && !this.directSelectedShapeIndexes.size; - if (!preserveEmptyShapeSelection) { - this.ensureSelectedShape(); - } - this.ensureSelectedFrame(); - if (options.syncPaletteSelection !== false) { - this.syncPaletteSelectionFromCurrentShape({ render: false }); - } - if (this.runtimePalette) { - this.renderPalette(); - } - this.renderTagFilter(); - this.renderObjectTiles(); - this.renderDependencyGraph(); - this.renderSelectedObject(); - this.renderWorkSurface(); - this.updateObjectActionState(); - } - - renderPaletteRequiredState(message) { - this.selectedObjectId = ""; - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - this.selectedStateId = ""; - this.stateControlStateId = ""; - this.selectedFrameId = ""; - this.stopPlaybackTimer(); - this.updateObjectsHeader(0, 0); - this.updatePaletteHeader(0); - this.elements.paletteSummary.textContent = message; - this.elements.objectNameInput.value = ""; - this.elements.objectTagInput.value = ""; - this.renderObjectTagList(null); - this.elements.shapeGeometryDetails.textContent = "Runtime palette required before object render."; - this.updateShapeGeometryHeader(null); - this.elements.objectTransform.textContent = "Runtime palette required before object transform."; - this.elements.shapeTransform.textContent = "Runtime palette required before shape transform."; - this.elements.objectPreviewFooter.textContent = "Object ID: none"; - this.elements.jsonDetails.textContent = "{}"; - this.renderFrameTimeline(); - this.elements.renderSurface.replaceChildren(); - this.renderSvgGrid(); - this.renderCenterOriginMarker(); - const tile = this.createEmptyObjectTile(); - tile.textContent = "Runtime palette required before rendering object tiles."; - this.elements.objectTiles.replaceChildren(tile); - this.renderDependencyGraph(); - this.actionNav.setJsonPayloadActionsEnabled(Boolean(this.currentPayload)); - this.elements.exportSvgButton.disabled = true; - this.actionNav.setImportEnabled(this.schemaReady && !this.actionNav.isWorkspaceLaunch()); - this.updateObjectActionState(); - } - - renderPalette() { - const swatchCount = this.runtimePalette.swatches.length; - this.updatePaletteModeSwatches(); - this.updatePaletteHeader(swatchCount); - this.elements.paletteSummary.replaceChildren(); - this.sortedPaletteSwatches().forEach((swatch, index) => { - const label = swatch?.name || swatch?.id || swatch?.symbol || `swatch-${index + 1}`; - const color = swatchColor(swatch); - const item = document.createElement("button"); - item.className = "object-vector-studio-v2__palette-swatch"; - item.type = "button"; - item.dataset.paletteColor = color; - item.dataset.paletteLabel = label; - item.dataset.paletteTags = tagList(swatch?.tags).join(", "); - item.dataset.paletteDetails = `${label}\n${color || "value unavailable"}`; - item.title = item.dataset.paletteDetails; - item.setAttribute("aria-label", `Palette swatch ${label} ${color || "value unavailable"}`); - item.classList.toggle("is-selected", color && color === this.currentTargetColor()); - if (color) { - item.style.setProperty("--object-vector-studio-v2-swatch", color); - } - item.addEventListener("click", () => this.selectPaletteColor(color, label)); - this.elements.paletteSummary.append(item); - }); - } - - sortedPaletteSwatches() { - const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); - return [...this.runtimePalette.swatches].sort((left, right) => { - const leftLabel = left?.name || left?.id || left?.symbol || ""; - const rightLabel = right?.name || right?.id || right?.symbol || ""; - let result = 0; - if (this.paletteSortMode === "name") { - result = collator.compare(leftLabel, rightLabel); - } else if (this.paletteSortMode === "tag") { - const leftTags = tagList(left?.tags).join(", "); - const rightTags = tagList(right?.tags).join(", "); - if (leftTags && !rightTags) { - return -1; - } - if (!leftTags && rightTags) { - return 1; - } - result = collator.compare(leftTags, rightTags) || collator.compare(leftLabel, rightLabel); - } else { - const leftMetrics = colorMetrics(swatchColor(left)); - const rightMetrics = colorMetrics(swatchColor(right)); - result = (leftMetrics[this.paletteSortMode] - rightMetrics[this.paletteSortMode]) || collator.compare(leftLabel, rightLabel); - } - return this.paletteSortDirection === "desc" ? -result : result; - }); - } - - paletteDisplayName() { - return this.runtimePalette?.id || this.runtimePalette?.name || this.runtimePaletteSource || "runtime palette"; - } - - renderObjectTiles() { - const previousScrollTop = this.elements.objectsContent.scrollTop; - this.updateObjectsHeader(this.currentPayload.objects.length, this.selectedObject()?.shapes.length || 0); - this.elements.objectTiles.replaceChildren(); - if (!this.currentPayload.objects.length) { - this.elements.objectTiles.append(this.createEmptyObjectTile()); - this.restoreObjectsScroll(previousScrollTop); - return; - } - - const filteredObjects = this.filteredObjects(); - if (!filteredObjects.length) { - const tile = this.createEmptyObjectTile(); - tile.textContent = "No objects match the current tag or search filter."; - this.elements.objectTiles.append(tile); - this.restoreObjectsScroll(previousScrollTop); - return; - } - - filteredObjects.forEach((object) => { - const tile = document.createElement("article"); - tile.className = "object-vector-studio-v2__object-tile"; - tile.dataset.objectId = object.id; - tile.classList.toggle("is-selected", object.id === this.selectedObjectId); - tile.classList.toggle("is-hidden", this.isObjectHidden(object.id)); - tile.classList.toggle("is-locked", this.isObjectLocked(object.id)); - tile.setAttribute("aria-pressed", String(object.id === this.selectedObjectId)); - - const selectButton = document.createElement("button"); - selectButton.className = "object-vector-studio-v2__object-select"; - selectButton.type = "button"; - selectButton.title = `Select object ${object.name}`; - selectButton.setAttribute("aria-pressed", String(object.id === this.selectedObjectId)); - - const thumbnail = this.createObjectThumbnail(object); - const name = document.createElement("span"); - name.className = "object-vector-studio-v2__object-tile-name"; - name.textContent = object.name; - - const meta = document.createElement("span"); - meta.className = "object-vector-studio-v2__object-tile-meta"; - const tags = tagList(object.tags); - const inheritedText = object.baseObjectId ? ` - inherits ${object.baseObjectId}` : ""; - const gameSegment = objectGameSegment(object.id) || this.payloadGameKey(); - meta.textContent = `object > ${gameSegment} > ${object.name} | ${shapeCountLabel(object.shapes.length)}${tags.length ? ` | ${tags.join(", ")}` : ""}${inheritedText}`; - - const copy = document.createElement("span"); - copy.className = "object-vector-studio-v2__object-tile-copy"; - copy.append(name, meta); - - selectButton.append(thumbnail, copy); - selectButton.addEventListener("click", () => { - this.selectObject(object.id, "tile"); - }); - - const controls = document.createElement("div"); - controls.className = "object-vector-studio-v2__object-tile-controls"; - controls.append( - this.createObjectTileControl(object, "visibility"), - this.createObjectTileControl(object, "lock"), - this.createObjectTileControl(object, "delete") - ); - tile.append(selectButton, controls); - tile.addEventListener("click", () => { - this.selectObject(object.id, "tile"); - }); - if (object.id === this.selectedObjectId) { - tile.append(this.createObjectStatePanel(object), this.createObjectTileShapeList(object)); - } - this.elements.objectTiles.append(tile); - }); - this.restoreObjectsScroll(previousScrollTop); - } - - renderShapeTiles(object = this.selectedObject()) { - void object; - } - - restoreObjectsScroll(scrollTop) { - this.restoreElementScrollTop(this.elements.objectsContent, scrollTop); - } - - captureLeftPanelScrollState() { - return { - leftPanelScrollTop: this.elements.leftPanel.scrollTop, - objectsScrollTop: this.elements.objectsContent.scrollTop - }; - } - - restoreLeftPanelScrollState(scrollState) { - this.restoreElementScrollTop(this.elements.leftPanel, scrollState.leftPanelScrollTop); - this.restoreElementScrollTop(this.elements.objectsContent, scrollState.objectsScrollTop); - } - - restoreElementScrollTop(element, scrollTop) { - const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); - element.scrollTop = Math.min(scrollTop, maxScrollTop); - } - - createObjectTileControl(object, kind) { - const button = document.createElement("button"); - button.className = "object-vector-studio-v2__object-tile-control"; - button.type = "button"; - const isVisibility = kind === "visibility"; - const isDelete = kind === "delete"; - const isLocked = this.isObjectLocked(object.id); - const isActive = isVisibility ? !this.isObjectHidden(object.id) : !isDelete && isLocked; - button.dataset.objectControl = kind; - button.dataset.objectControlId = object.id; - if (isDelete) { - button.classList.add("object-vector-studio-v2__object-tile-control--delete"); - button.setAttribute("aria-label", `Delete object ${object.name}`); - button.append(this.createIconSpan("delete", true)); - button.title = isLocked ? "Unlock object before deleting" : "Delete this object"; - button.disabled = isLocked; - } else { - button.setAttribute("aria-pressed", String(isActive)); - button.setAttribute("aria-label", isVisibility ? `${isActive ? "Hide" : "Show"} object ${object.name}` : `${isActive ? "Unlock" : "Lock"} object ${object.name}`); - button.append(this.createIconSpan(isVisibility ? "eye" : "lock", isActive)); - button.title = isVisibility ? "Toggle object visibility" : "Toggle runtime object lock"; - } - button.addEventListener("click", (event) => { - event.stopPropagation(); - if (isVisibility) { - this.toggleObjectVisibility(object.id); - } else if (isDelete) { - this.deleteObjectById(object.id, "object tile delete"); - } else { - this.toggleObjectLock(object.id); - } - }); - return button; - } - - createIconSpan(kind, isActive = true) { - const icon = document.createElement("span"); - icon.className = `object-vector-studio-v2__tile-icon object-vector-studio-v2__tile-icon--${kind}`; - icon.classList.toggle("is-off", !isActive); - icon.setAttribute("aria-hidden", "true"); - const iconKey = kind === "eye" && !isActive ? "eyeOff" : kind === "lock" && !isActive ? "unlock" : kind; - this.applyIconGlyph(icon, iconKey); - return icon; - } - - createObjectStatePanel(object) { - const panel = document.createElement("div"); - panel.className = "object-vector-studio-v2__object-state-panel"; - panel.addEventListener("click", (event) => event.stopPropagation()); - - const controls = document.createElement("div"); - controls.className = "object-vector-studio-v2__object-state-controls"; - controls.setAttribute("aria-label", "Object state controls"); - - const selectedStateId = this.stateControlSelectionId(object); - const stateExists = this.objectStates(object).some((state) => state.id === selectedStateId); - const isLocked = this.isObjectLocked(object.id); - - const addButton = document.createElement("button"); - addButton.type = "button"; - addButton.dataset.objectStateAction = "add"; - addButton.textContent = "Add"; - addButton.title = stateExists ? "Selected state already exists." : `Add ${selectedStateId} state`; - addButton.disabled = isLocked || stateExists || !OBJECT_STATE_IDS.includes(selectedStateId); - addButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.createSelectedState(selectedStateId); - }); - - const select = document.createElement("select"); - select.dataset.objectStateSelect = object.id; - select.setAttribute("aria-label", "State"); - OBJECT_STATE_IDS.forEach((stateId) => { - const option = document.createElement("option"); - option.value = stateId; - option.textContent = stateId; - select.append(option); - }); - select.value = selectedStateId; - select.addEventListener("change", (event) => { - event.stopPropagation(); - this.stateControlStateId = select.value; - const nextState = this.objectStates(this.selectedObject()).find((state) => state.id === select.value); - if (nextState) { - this.selectState(nextState.id, "state dropdown"); - return; - } - this.renderPayload(); - this.statusLog.write(`INFO State ${select.value} is ready to add for ${object.name}.`); - }); - - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.dataset.objectStateAction = "delete"; - deleteButton.textContent = "Delete"; - deleteButton.title = stateExists ? `Delete ${selectedStateId} state` : "Selected state has not been added."; - deleteButton.disabled = isLocked || !stateExists || this.objectStates(object).length <= 1; - deleteButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.deleteSelectedState(selectedStateId); - }); - - const helpButton = document.createElement("button"); - helpButton.className = "object-vector-studio-v2__object-state-help"; - helpButton.type = "button"; - helpButton.textContent = "?"; - const allHelpText = this.allStateHelpText(); - helpButton.title = allHelpText; - helpButton.dataset.objectStateHelp = "all"; - helpButton.setAttribute("aria-label", `State help for all states: ${allHelpText.replace(/\s+/gu, " ")}`); - - controls.append(select, addButton, deleteButton, helpButton); - panel.append(controls, this.createObjectStateTileList(object)); - return panel; - } - - createObjectStateTileList(object) { - const states = this.objectStates(object); - const list = document.createElement("div"); - list.className = "object-vector-studio-v2__object-state-tiles"; - list.setAttribute("aria-label", "Object states"); - if (!states.length) { - const empty = document.createElement("span"); - empty.className = "object-vector-studio-v2__shape-list-empty"; - empty.textContent = "No states."; - list.append(empty); - return list; - } - - states.forEach((state) => { - const button = document.createElement("button"); - button.type = "button"; - button.dataset.objectStateTile = state.id; - button.textContent = state.id; - button.title = `Select ${state.id} state`; - button.setAttribute("aria-pressed", String(state.id === this.selectedStateId)); - button.classList.toggle("is-selected", state.id === this.selectedStateId); - button.addEventListener("click", (event) => { - event.stopPropagation(); - this.selectState(state.id, "state tile"); - }); - list.append(button); - }); - return list; - } - - stateControlSelectionId(object) { - if (OBJECT_STATE_IDS.includes(this.stateControlStateId)) { - return this.stateControlStateId; - } - if (OBJECT_STATE_IDS.includes(this.selectedStateId)) { - return this.selectedStateId; - } - const firstState = this.objectStates(object).find((state) => OBJECT_STATE_IDS.includes(state.id)); - return firstState?.id || OBJECT_STATE_IDS[0]; - } - - createObjectTileShapeList(object) { - const list = document.createElement("div"); - list.className = "object-vector-studio-v2__object-tile-shapes"; - const displayedShapes = sortedShapes(object) - .map((shape, shapeIndex) => ({ shape, shapeIndex })) - .reverse(); - displayedShapes.forEach(({ shape, shapeIndex }) => { - const displayShape = object.id === this.selectedObjectId ? this.effectiveShape(shape, shapeIndex) : shape; - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__object-tile-shape-row"; - row.classList.toggle("is-selected", this.selectedShapeIndexes.has(shapeIndex)); - const selectButton = document.createElement("button"); - selectButton.type = "button"; - selectButton.className = "object-vector-studio-v2__shape-select"; - selectButton.dataset.objectTileShapeIndex = String(shapeIndex); - selectButton.setAttribute("aria-pressed", String(this.selectedShapeIndexes.has(shapeIndex))); - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__shape-select-label"; - label.textContent = `${shapeIndex}. ${shapeDisplayLabel(shape)}`; - selectButton.append(label); - selectButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.selectShape(shapeIndex, "object tile shape list", { additive: event.shiftKey || event.ctrlKey || event.metaKey }); - }); - const actions = document.createElement("div"); - actions.className = "object-vector-studio-v2__shape-inline-actions"; - const groupButton = this.createShapeGroupButton(shape, shapeIndex); - const visibilityButton = document.createElement("button"); - visibilityButton.type = "button"; - visibilityButton.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-eye-inline"; - visibilityButton.dataset.shapeVisibilityIndex = String(shapeIndex); - visibilityButton.setAttribute("aria-label", `${displayShape.visible ? "Hide" : "Show"} shape ${shapeDisplayLabel(shape)} in the selected state/frame`); - visibilityButton.title = "Toggle state/frame shape visibility"; - visibilityButton.append(this.createIconSpan("eye", displayShape.visible)); - visibilityButton.addEventListener("pointerdown", (event) => { - event.stopPropagation(); - }); - visibilityButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.toggleShapeVisibilityByIndex(shapeIndex, object.id); - }); - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-delete-inline"; - deleteButton.dataset.shapeDeleteIndex = String(shapeIndex); - deleteButton.dataset.shapeDeleteObjectId = object.id; - deleteButton.setAttribute("aria-label", `Delete shape ${shapeDisplayLabel(shape)}`); - deleteButton.title = "Delete this base shape from every state"; - deleteButton.append(this.createIconSpan("delete", true)); - deleteButton.addEventListener("pointerdown", (event) => { - event.stopPropagation(); - }); - deleteButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.deleteShapeByIndex(shapeIndex, "object tile shape delete", object.id); - }); - if (groupButton) { - actions.append(groupButton); - } - actions.append(visibilityButton, deleteButton); - row.append(selectButton, actions); - list.append(row); - }); - if (!list.children.length) { - const empty = document.createElement("div"); - empty.className = "object-vector-studio-v2__shape-list-empty"; - empty.textContent = "No shapes."; - list.append(empty); - } - list.append(this.createSelectedShapeActions(object)); - return list; - } - - createShapeGroupButton(shape, shapeIndex) { - const groupId = String(shape.groupId || "").trim(); - if (!groupId) { - return null; - } - const button = document.createElement("button"); - button.type = "button"; - button.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-group-button"; - button.dataset.shapeGroupId = groupId; - button.setAttribute("aria-label", `Shape group ${groupId}`); - button.title = `Shape group ${groupId}`; - button.addEventListener("pointerdown", (event) => { - event.stopPropagation(); - }); - button.addEventListener("click", (event) => { - event.stopPropagation(); - this.selectShapeGroup(groupId, shapeIndex, "shape group icon"); - }); - button.append(this.createGroupIndicator(groupId)); - return button; - } - - createGroupIndicator(groupId) { - const icon = document.createElement("span"); - icon.className = "object-vector-studio-v2__shape-group-indicator object-vector-studio-v2__tile-icon object-vector-studio-v2__tile-icon--group"; - icon.style.setProperty("--object-vector-studio-v2-shape-group-color", this.groupColor(groupId)); - this.applyIconGlyph(icon, "group"); - return icon; - } - - createSelectedShapeActions(object) { - const actions = document.createElement("div"); - actions.className = "object-vector-studio-v2__shape-list-actions object-vector-studio-v2__z-order-actions is-icon-only"; - actions.setAttribute("aria-label", "Selected shape move and group controls"); - const shape = this.selectedShape(); - const isLocked = this.isObjectLocked(object.id); - const noShapeReason = "Disabled until a schema-valid shape is selected."; - [ - { action: "back", iconClass: "send-back", iconKey: "sendBack", label: "Send To Back", title: "Send selected shape to the back." }, - { action: "backward", iconClass: "send-backward", iconKey: "sendBackward", label: "Move Backward", title: "Move selected shape backward one row." }, - { action: "forward", iconClass: "bring-forward", iconKey: "bringForward", label: "Move Forward", title: "Move selected shape forward one row." }, - { action: "front", iconClass: "bring-front", iconKey: "bringFront", label: "Bring To Front", title: "Bring selected shape to the front." } - ].forEach((definition) => { - const button = this.createShapeActionButton(definition.label, definition.iconClass, definition.iconKey, definition.title, () => this.changeSelectedShapeOrder(definition.action)); - this.setControlDisabled(button, !shape || isLocked, isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : noShapeReason, definition.title); - actions.append(button); - }); - - const groupButton = this.createShapeActionButton("Group", "group", "group", "Group selected shapes. Shift-click shapes to select more than one.", () => this.groupSelectedShapes()); - this.setControlDisabled( - groupButton, - this.selectedShapeIndexes.size < 2 || isLocked, - isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : "Disabled until two or more shapes are selected. Shift-click shapes in the preview or object shape list to build a group selection.", - "Group selected shapes. Shift-click shapes to select more than one." - ); - actions.append(groupButton); - - const ungroupButton = this.createShapeActionButton("Ungroup", "ungroup", "ungroup", "Ungroup selected shapes.", () => this.ungroupSelectedShapes()); - const hasValidGroup = this.shapeBelongsToValidGroup(object, this.selectedShapeIndex); - this.setControlDisabled( - ungroupButton, - !shape || isLocked || !hasValidGroup, - isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : "Disabled until the selected shape belongs to a group with at least two shapes.", - "Ungroup selected shapes." - ); - actions.append(ungroupButton); - return actions; - } - - createShapeActionButton(labelText, iconClass, iconKey, title, onClick) { - const button = document.createElement("button"); - button.className = "object-vector-studio-v2__z-action"; - button.type = "button"; - button.dataset.shapeListAction = labelText.toLowerCase().replace(/\s+/gu, "-"); - button.setAttribute("aria-label", labelText); - button.title = title; - const icon = document.createElement("span"); - icon.className = `object-vector-studio-v2__z-icon object-vector-studio-v2__z-icon--${iconClass}`; - icon.setAttribute("aria-hidden", "true"); - this.applyIconGlyph(icon, iconKey); - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__z-label"; - label.textContent = labelText; - button.append(icon, label); - button.addEventListener("click", (event) => { - event.stopPropagation(); - onClick(); - }); - return button; - } - - isObjectHidden(objectId) { - return this.hiddenObjectIds.has(objectId); - } - - isObjectLocked(objectId) { - return this.lockedObjectIds.has(objectId); - } - - toggleObjectVisibility(objectId) { - const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId); - if (!object) { - this.statusLog.write(`WARN Object visibility skipped: object id ${objectId || "unknown"} is not available.`); - return; - } - if (this.hiddenObjectIds.has(objectId)) { - this.hiddenObjectIds.delete(objectId); - } else { - this.hiddenObjectIds.add(objectId); - } - this.renderPayload(); - this.statusLog.write(`OK Object ${object.name} visibility set to ${this.isObjectHidden(objectId) ? "hidden" : "visible"} for this runtime session.`); - } - - toggleObjectLock(objectId) { - const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId); - if (!object) { - this.statusLog.write(`WARN Object lock skipped: object id ${objectId || "unknown"} is not available.`); - return; - } - if (this.lockedObjectIds.has(objectId)) { - this.lockedObjectIds.delete(objectId); - } else { - this.lockedObjectIds.add(objectId); - } - this.renderPayload(); - this.statusLog.write(`OK Object ${object.name} lock set to ${this.isObjectLocked(objectId) ? "locked" : "unlocked"} for this runtime session.`); - } - - filteredObjects() { - const tag = this.elements.tagFilter.value || "all"; - const query = this.elements.searchFilter.value.trim().toLowerCase(); - return this.currentPayload.objects.filter((object) => { - const matchesTag = tag === "all" || tagList(object.tags).includes(tag); - const haystack = `${object.id} ${object.name} ${objectGameSegment(object.id)} ${(object.tags || []).join(" ")} ${object.baseObjectId || ""}`.toLowerCase(); - return matchesTag && (!query || haystack.includes(query)); - }); - } - - renderTagFilter() { - const selectedValue = this.elements.tagFilter.value || "all"; - this.elements.tagFilter.replaceChildren(); - const allOption = document.createElement("option"); - allOption.value = "all"; - allOption.textContent = "All"; - this.elements.tagFilter.append(allOption); - this.availableObjectTags().forEach((tag) => { - const option = document.createElement("option"); - option.value = tag; - option.textContent = tag; - this.elements.tagFilter.append(option); - }); - this.elements.tagFilter.value = Array.from(this.elements.tagFilter.options).some((option) => option.value === selectedValue) ? selectedValue : "all"; - } - - availableObjectTags() { - return Array.from(new Set((this.currentPayload?.objects || []).flatMap((object) => tagList(object.tags)))).sort(); - } - - renderDependencyGraph() { - if (!this.currentPayload) { - this.elements.dependencyGraph.textContent = "No dependency graph loaded."; - return; - } - const usageLines = this.currentPayload.objects.map((object) => `${object.id}: ${object.name}${tagList(object.tags).length ? ` [${tagList(object.tags).join(", ")}]` : ""}`); - const graphLines = this.currentPayload.objects - .map((object) => `${object.id}${object.baseObjectId ? ` inherits ${object.baseObjectId}` : " has no base object"}`); - this.elements.dependencyGraph.textContent = [ - "Dependency details:", - graphLines.join("\n") || "No objects loaded.", - "", - "Object tags:", - usageLines.join("\n") || "No object tags." - ].join("\n"); - } - - createObjectThumbnail(object) { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.classList.add("object-vector-studio-v2__object-thumbnail"); - svg.dataset.objectThumbnail = object.id; - svg.setAttribute("aria-label", `${object.name} thumbnail`); - svg.setAttribute("role", "img"); - - const bounds = this.objectBounds(object, { includeInvisible: false }); - const transformOrigin = this.objectTransformOrigin(object); - const padding = 12; - svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); - sortedShapes(object).filter((shape) => shape.visible).forEach((shape, shapeIndex) => { - try { - const element = this.createSvgShape(shape, { transformOrigin }); - element.classList.add("object-vector-studio-v2__object-thumbnail-shape"); - svg.append(element); - this.appendShapePointStyleCaps(svg, shape, { transformOrigin }); - } catch (error) { - this.statusLog.write(`FAIL Thumbnail render failed for ${object.id}/shape-${shapeIndex} (${shapeTool(shape)}): ${error.message}`); - } - }); - if (!svg.children.length) { - const empty = document.createElementNS(SVG_NS, "rect"); - empty.setAttribute("x", bounds.x); - empty.setAttribute("y", bounds.y); - empty.setAttribute("width", bounds.width); - empty.setAttribute("height", bounds.height); - empty.classList.add("object-vector-studio-v2__object-thumbnail-empty"); - svg.append(empty); - } - return svg; - } - - renderSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.elements.objectNameInput.value = ""; - this.elements.objectTagInput.value = ""; - this.renderObjectTagList(null); - this.updateObjectsHeader(this.currentPayload?.objects.length || 0, 0); - this.elements.shapeGeometryDetails.textContent = "No object selected."; - this.elements.objectTransform.textContent = "No object selected."; - this.elements.shapeTransform.textContent = "No shape selected."; - this.updateShapeGeometryHeader(null); - this.elements.objectPreviewFooter.textContent = "Object ID: none"; - this.elements.jsonDetails.textContent = "{}"; - this.renderFrameTimeline(); - return; - } - - const shape = this.selectedShape(); - this.elements.objectNameInput.value = selected.name; - this.renderObjectTagList(selected); - this.updateObjectsHeader(this.currentPayload.objects.length, selected.shapes.length); - this.elements.shapeGeometryDetails.replaceChildren(this.createShapeGeometryDetails(selected, shape)); - this.elements.objectTransform.replaceChildren(this.createObjectTransformDetails(selected)); - this.elements.shapeTransform.replaceChildren(this.createShapeTransformDetails(shape)); - this.updateShapeGeometryHeader(shape); - this.elements.objectPreviewFooter.textContent = `Object ID: ${selected.id}`; - this.updateSelectedObjectJsonDetails(); - this.renderFrameTimeline(); - } - - updateSelectedObjectJsonDetails() { - const selected = this.selectedObject(); - if (!selected) { - this.elements.jsonDetails.textContent = "{}"; - return; - } - const shape = this.selectedShape(); - this.elements.jsonDetails.textContent = JSON.stringify({ - object: selected, - selectedFrame: this.activeFrame(), - selectedShape: shape, - selectedShapeIndexes: Array.from(this.selectedShapeIndexes), - selectedState: this.selectedState() - }, null, 2); - } - - updateTransformSummaryText() { - const objectSummary = this.elements.objectTransform.querySelector(".object-vector-studio-v2__transform-summary"); - const object = this.selectedObject(); - if (objectSummary && object) { - objectSummary.textContent = this.formatObjectTransformSummary(object); - } - const summary = this.elements.shapeTransform.querySelector(".object-vector-studio-v2__transform-summary"); - const shape = this.selectedShape(); - if (!summary || !shape) { - return; - } - summary.textContent = this.formatTransformSummary(this.shapeTransform(this.effectiveShape(shape))); - } - - updateObjectPreviewFooterFromNameInput() { - const selected = this.selectedObject(); - if (!selected) { - this.elements.objectPreviewFooter.textContent = "Object ID: none"; - return; - } - - const name = this.elements.objectNameInput.value.trim(); - if (!name) { - this.elements.objectPreviewFooter.textContent = `Object ID: ${selected.id}`; - return; - } - - const siblingObjects = this.currentPayload.objects.filter((object) => object.id !== selected.id); - const previewId = this.uniqueObjectId(name, siblingObjects, objectGameSegment(selected.id) || this.payloadGameKey()); - this.elements.objectPreviewFooter.textContent = `Object ID: ${previewId}`; - } - - renderObjectTagList(object) { - this.elements.objectTagList.replaceChildren(); - if (!object) { - const empty = document.createElement("span"); - empty.className = "object-vector-studio-v2__shape-list-empty"; - empty.textContent = "No selected object tags."; - this.elements.objectTagList.append(empty); - return; - } - const tags = tagList(object.tags); - if (!tags.length) { - const empty = document.createElement("span"); - empty.className = "object-vector-studio-v2__shape-list-empty"; - empty.textContent = "No tags."; - this.elements.objectTagList.append(empty); - return; - } - tags.forEach((tag) => { - const button = document.createElement("button"); - button.type = "button"; - button.dataset.objectTag = tag; - button.textContent = tag; - button.title = `Remove tag ${tag}`; - button.addEventListener("click", () => this.removeTagFromSelectedObject(tag)); - this.elements.objectTagList.append(button); - }); - } - - renderFrameTimeline() { - this.elements.frameTimeline.replaceChildren(); - const object = this.selectedObject(); - const state = this.selectedState(); - if (!object || !state) { - const empty = document.createElement("p"); - empty.className = "tool-starter__hint"; - empty.textContent = "No state timeline selected."; - this.elements.frameTimeline.append(empty); - this.updateAnimationActionState(); - return; - } - - sortedFrames(state).forEach((frame) => { - const button = document.createElement("div"); - button.className = "object-vector-studio-v2__frame-tile"; - button.dataset.stateId = state.id; - button.dataset.frameId = frame.id; - button.title = `Select frame ${frame.id} in state ${state.id}`; - button.setAttribute("aria-pressed", String(frame.id === this.selectedFrameId)); - button.setAttribute("role", "button"); - button.tabIndex = 0; - button.classList.toggle("is-selected", frame.id === this.selectedFrameId); - button.append(this.createFrameThumbnail(object, frame)); - const label = document.createElement("span"); - label.textContent = `${frame.order}. ${frame.id}`; - button.append(label); - button.addEventListener("click", () => this.selectFrame(frame.id, "timeline")); - button.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") { - return; - } - event.preventDefault(); - this.selectFrame(frame.id, "timeline"); - }); - this.elements.frameTimeline.append(button); - }); - this.updateAnimationActionState(); - } - - stateHelpText(stateId) { - const helpLines = OBJECT_STATE_HELP[stateId] || [`No contextual help is available for state ${stateId || "unknown"}.`]; - return helpLines.join("\n"); - } - - allStateHelpText() { - return OBJECT_STATE_IDS - .map((stateId) => `${stateId}\n${this.stateHelpText(stateId)}`) - .join("\n\n"); - } - - createFrameThumbnail(object, frame) { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.classList.add("object-vector-studio-v2__frame-thumbnail"); - svg.dataset.frameThumbnail = frame.id; - const bounds = this.objectBoundsForFrame(object, frame); - const transformOrigin = this.objectTransformOrigin(object); - const padding = 12; - svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); - sortedShapes(object).forEach((shape, shapeIndex) => { - const renderShape = this.effectiveShapeForFrame(shape, frame, shapeIndex); - if (!renderShape.visible) { - return; - } - try { - svg.append(this.createSvgShape(renderShape, { transformOrigin })); - this.appendShapePointStyleCaps(svg, renderShape, { transformOrigin }); - } catch (error) { - this.statusLog.write(`FAIL Frame thumbnail render failed for ${object.id}/${frame.id}/shape-${shapeIndex}: ${error.message}`); - } - }); - return svg; - } - - createShapeGeometryDetails(object, shape) { - const wrapper = document.createElement("div"); - wrapper.className = "object-vector-studio-v2__object-detail-stack"; - if (!shape) { - const shapePanel = document.createElement("section"); - shapePanel.className = "object-vector-studio-v2__shape-panel"; - const empty = document.createElement("p"); - empty.textContent = "No shape selected. Create a primitive from Tools."; - shapePanel.append(empty); - wrapper.append(shapePanel); - return wrapper; - } - - wrapper.append(this.createShapeGeometryControls(shape), this.createSelectedShapeSummary(shape)); - if (!this.canUseShapeGeometry()) { - this.setGeometryPanelDisabled(wrapper, "Shape Geometry is disabled until exactly one shape is selected."); - } - return wrapper; - } - - createSelectedShapeSummary(shape) { - const shapePanel = document.createElement("section"); - shapePanel.className = "object-vector-studio-v2__shape-panel"; - const groupRow = document.createElement("div"); - groupRow.className = "object-vector-studio-v2__shape-group-summary"; - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__detail-label"; - label.textContent = "Group"; - const value = document.createElement("span"); - value.className = "object-vector-studio-v2__detail-value object-vector-studio-v2__shape-group-summary-value"; - if (shape.groupId) { - value.append(this.createGroupIndicator(shape.groupId), document.createTextNode(shape.groupId)); - } else { - value.textContent = "None"; - } - groupRow.append(label, value); - shapePanel.append(groupRow); - return shapePanel; - } - - shapeSummaryTypeLabel(shape) { - return shapeTool(shape); - } - - isTriangleShape(shape) { - return this.shapeSummaryTypeLabel(shape) === "triangle"; - } - - isEditablePolygonShape(shape) { - const geometryTool = shapeGeometryTool(shape); - return (geometryTool === "polygon" && !this.isTriangleShape(shape)) || geometryTool === "polyline"; - } - - shapeGeometryHeadingLabel(shape) { - return shapeTypeLabel(shape); - } - - createObjectTransformDetails(object) { - const wrapper = document.createElement("div"); - wrapper.className = "object-vector-studio-v2__object-detail-stack"; - if (!object) { - const empty = document.createElement("p"); - empty.textContent = "No object selected."; - wrapper.append(empty); - return wrapper; - } - - const transformPanel = document.createElement("section"); - transformPanel.className = "object-vector-studio-v2__shape-panel"; - const summary = document.createElement("p"); - summary.className = "object-vector-studio-v2__transform-summary"; - summary.dataset.transformSummary = "object"; - summary.textContent = this.formatObjectTransformSummary(object); - transformPanel.append(this.createObjectTransformControls(object), summary); - wrapper.append(transformPanel); - return wrapper; - } - - createShapeTransformDetails(shape) { - const wrapper = document.createElement("div"); - wrapper.className = "object-vector-studio-v2__object-detail-stack"; - if (!shape) { - const empty = document.createElement("p"); - empty.textContent = "No shape selected. Create a primitive from Tools."; - wrapper.append(empty); - return wrapper; - } - - const transformPanel = document.createElement("section"); - transformPanel.className = "object-vector-studio-v2__shape-panel"; - const transform = this.shapeTransform(this.effectiveShape(shape)); - const summary = document.createElement("p"); - summary.className = "object-vector-studio-v2__transform-summary"; - summary.dataset.transformSummary = "shape"; - summary.textContent = this.formatTransformSummary(transform); - transformPanel.append(this.createShapeTransformControls(shape), summary); - if (!this.canUseShapeTransform()) { - this.setTransformPanelDisabled(transformPanel, "Shape Transform is disabled until exactly one shape is selected."); - } - wrapper.append(transformPanel); - return wrapper; - } - - canUseShapeTransform() { - return this.selectedShapeIndex >= 0 && this.selectedShapeIndexes.size === 1; - } - - canUseShapeGeometry() { - return this.selectedShapeIndex >= 0 && this.selectedShapeIndexes.size === 1; - } - - setGeometryPanelDisabled(panel, reason) { - panel.classList.add("is-disabled"); - panel.querySelectorAll("input, select, button").forEach((control) => { - control.disabled = true; - control.dataset.disabledReason = reason; - control.title = reason; - }); - } - - setTransformPanelDisabled(panel, reason) { - panel.classList.add("is-disabled"); - panel.querySelectorAll("input, select, button").forEach((control) => { - control.disabled = true; - control.dataset.disabledReason = reason; - control.title = reason; - }); - } - - formatTransformSummary(transform) { - const rotation = this.normalizeRotationDegrees(transform.rotation); - const scaleText = transform.scaleX === transform.scaleY - ? String(transform.scaleX) - : `${transform.scaleX} x ${transform.scaleY}`; - return `x ${transform.x}, y ${transform.y}, rot ${rotation}, scale ${scaleText}`; - } - - formatObjectTransformSummary(object) { - const shapes = sortedShapes(object); - const origin = this.objectTransformOrigin(object); - const objectScale = object?.id === this.selectedObjectId ? this.objectScalePreviewValue() : 1; - if (!shapes.length) { - return `origin ${origin.x}, ${origin.y}, rot 0, scale ${this.formatScaleInputValue(objectScale)}`; - } - const frame = object?.id === this.selectedObjectId ? this.activeFrame() : null; - const transforms = shapes.map((shape, shapeIndex) => this.shapeTransform(this.effectiveShapeForFrame(shape, frame, shapeIndex))); - const sameNumber = (key) => transforms.every((transform) => Math.abs(transform[key] - transforms[0][key]) < 0.001); - const sameScale = transforms.every((transform) => Math.abs(transform.scaleX - transforms[0].scaleX) < 0.001 && Math.abs(transform.scaleY - transforms[0].scaleY) < 0.001); - let scaleText = "mixed"; - if (Math.abs(objectScale - 1) >= 0.001) { - scaleText = this.formatScaleInputValue(objectScale); - } else if (sameScale) { - scaleText = Math.abs(transforms[0].scaleX - transforms[0].scaleY) < 0.001 - ? String(this.formatViewportNumber(transforms[0].scaleX)) - : `${this.formatViewportNumber(transforms[0].scaleX)} x ${this.formatViewportNumber(transforms[0].scaleY)}`; - } - const rotationText = sameNumber("rotation") ? String(this.normalizeRotationDegrees(transforms[0].rotation)) : "mixed"; - return `origin ${origin.x}, ${origin.y}, rot ${rotationText}, scale ${scaleText}`; - } - - createDetailGrid(entries) { - const grid = document.createElement("div"); - grid.className = "object-vector-studio-v2__object-detail-grid"; - entries.forEach(([label, value]) => { - const labelElement = document.createElement("span"); - labelElement.className = "object-vector-studio-v2__detail-label"; - labelElement.textContent = label; - const valueElement = document.createElement("span"); - valueElement.className = "object-vector-studio-v2__detail-value"; - valueElement.textContent = value; - grid.append(labelElement, valueElement); - }); - return grid; - } - - createShapeGeometryControls(shape) { - const section = document.createElement("section"); - const geometryTool = shapeGeometryTool(shape); - section.className = `object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--geometry object-vector-studio-v2__edit-panel--${geometryTool}`; - if (geometryTool === "polygon" || geometryTool === "polyline") { - section.classList.add("object-vector-studio-v2__edit-panel--polygon"); - } - const heading = document.createElement("h4"); - heading.textContent = `${this.shapeGeometryHeadingLabel(shape)} Geometry`; - const grid = document.createElement("div"); - grid.className = geometryTool === "polygon" || geometryTool === "polyline" - ? "object-vector-studio-v2__polygon-point-list" - : `object-vector-studio-v2__edit-grid object-vector-studio-v2__edit-grid--${geometryTool}${geometryTool === "ellipse" ? " object-vector-studio-v2__edit-grid--ellipse" : ""}`; - const editablePolygon = this.isEditablePolygonShape(shape); - if (geometryTool === "polygon" || geometryTool === "polyline") { - const pointRounding = this.shapePointRoundingValues(shape); - grid.append(this.createPointRowHeader({ addable: editablePolygon, deletable: editablePolygon })); - shape.geometry.points.forEach((point, index) => { - grid.append(this.createPolygonPointRow(point, index, { - addable: editablePolygon, - deletable: editablePolygon, - rounded: pointRounding[index] === true, - selectable: false - })); - }); - } else { - this.shapeGeometryFields(shape).forEach((field) => { - const label = document.createElement("label"); - label.className = "object-vector-studio-v2__edit-field"; - if (field.wide) { - label.classList.add("object-vector-studio-v2__edit-field--wide"); - } - const caption = document.createElement("span"); - caption.textContent = field.label; - const input = document.createElement("input"); - input.dataset.shapeGeometryField = field.key; - input.type = field.kind; - input.value = field.kind === "number" - ? (field.coordinate ? this.formatCoordinateInputValue(field.value) : String(this.formatViewportNumber(field.value))) - : String(field.value); - this.bindGeometryAutoApplyInput(input); - label.append(caption, input); - grid.append(label); - }); - } - section.append(heading); - const roundingRadiusControl = this.createRoundingRadiusControl(shape); - if (roundingRadiusControl) { - section.append(roundingRadiusControl); - } - section.append(grid); - if (geometryTool !== "polygon" && geometryTool !== "polyline" && this.shapeSupportsPointRoundingControls(shape)) { - section.append(this.createShapePointRoundingControls(shape)); - } - return section; - } - - createRoundingRadiusControl(shape) { - if (!this.shapeSupportsRoundingRadiusControl(shape)) { - return null; - } - const label = document.createElement("label"); - label.className = "object-vector-studio-v2__rounding-radius-field"; - const caption = document.createElement("span"); - caption.textContent = "Rounding Radius"; - const input = document.createElement("input"); - input.type = "number"; - input.min = "0"; - input.step = "0.1"; - input.value = String(this.formatViewportNumber(this.shapeRoundingRadius(shape))); - input.dataset.shapeRoundingRadius = "true"; - input.setAttribute("aria-label", "Rounding radius"); - input.addEventListener("input", () => this.clearInputValidity(input)); - input.addEventListener("change", () => { - const shapeIndex = this.selectedShapeIndex; - this.window.setTimeout(() => { - if (this.selectedShapeIndex !== shapeIndex) { - return; - } - this.updateSelectedShapeRoundingRadius(input); - }, 0); - }); - label.append(caption, input); - return label; - } - - updateSelectedShapeRoundingRadius(input) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Rounding radius update skipped: no shape is selected."); - return; - } - if (!this.canUseShapeGeometry()) { - this.statusLog.write("WARN Rounding radius update skipped: select exactly one shape."); - return; - } - if (!this.shapeSupportsPointRoundingControls(selected)) { - this.statusLog.write(`WARN Rounding radius update skipped: ${shapeTypeLabel(selected)} does not use rounded points.`); - return; - } - const rawValue = String(input?.value ?? "").trim(); - const value = Number(rawValue); - if (rawValue === "" || !Number.isFinite(value) || value < 0) { - const error = "Rounding Radius must be a finite number greater than or equal to 0."; - this.markInputInvalid(input, error); - this.statusLog.write(`FAIL Invalid rounding radius rejected for shape row ${this.selectedShapeIndex}: ${error}`); - return; - } - if (this.guardSelectedObjectMutation("Rounding radius update")) { - return; - } - this.clearInputValidity(input); - const roundedValue = Number(value.toFixed(3)); - const nextPayload = this.cloneCurrentPayload(); - const nextShape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - if (!nextShape) { - this.statusLog.write(`WARN Rounding radius update skipped: selected shape row ${this.selectedShapeIndex} was not found.`); - return; - } - nextShape.style = { - ...nextShape.style, - roundingRadius: roundedValue - }; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Updated rounding radius to ${roundedValue} for shape row ${this.selectedShapeIndex}.`, "Rounding radius update failed schema validation"); - } - - updateSelectedShapePointRounding(pointIndex, isRounded) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Point rounding update skipped: no shape is selected."); - return; - } - if (!this.canUseShapeGeometry()) { - this.statusLog.write("WARN Point rounding update skipped: select exactly one shape."); - return; - } - if (!this.shapeSupportsPointRoundingControls(selected)) { - this.statusLog.write(`WARN Point rounding update skipped: ${shapeTypeLabel(selected)} does not use editable point rows.`); - return; - } - if (this.guardSelectedObjectMutation("Point rounding update")) { - return; - } - const objectIndex = this.currentPayload.objects.findIndex((object) => object.id === this.selectedObjectId); - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects[objectIndex]; - const nextShape = sortedShapes(nextObject)[this.selectedShapeIndex]; - if (!nextShape) { - this.statusLog.write(`WARN Point rounding update skipped: selected shape row ${this.selectedShapeIndex} was not found.`); - return; - } - const pointCount = this.shapeGeometryPointCount(nextShape); - if (!Number.isInteger(pointIndex) || pointIndex < 0 || pointIndex >= pointCount) { - this.statusLog.write(`WARN Point rounding update skipped: point ${pointIndex + 1} is not available.`); - return; - } - const pointRounding = this.normalizedPointRounding(nextShape, pointCount); - pointRounding[pointIndex] = isRounded === true; - this.applyPointRoundingToShape(nextShape, pointRounding); - const pointStyle = isRounded ? "round" : "square"; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Updated point ${pointIndex + 1} rounding to ${pointStyle} for shape row ${this.selectedShapeIndex}.`, "Point rounding update failed schema validation"); - } - - bindGeometryAutoApplyInput(input) { - input.addEventListener("input", () => this.clearInputValidity(input)); - input.addEventListener("change", () => { - const shapeIndex = this.selectedShapeIndex; - this.window.setTimeout(() => { - if (this.selectedShapeIndex !== shapeIndex) { - return; - } - this.applyShapeGeometryEdits({ - okMessage: `OK Auto-applied geometry edits to shape row ${this.selectedShapeIndex}.` - }); - }, 0); - }); - } - - createPointRowHeader({ addable = false, deletable = false } = {}) { - const header = document.createElement("div"); - header.className = "object-vector-studio-v2__polygon-point-header"; - header.setAttribute("aria-hidden", "true"); - ["", "", "", "Round", "", ""].forEach((label) => { - const cell = document.createElement("span"); - cell.textContent = label; - header.append(cell); - }); - return header; - } - - createPolygonPointRow(point, index, { addable = true, deletable = true, rounded = false, selectable = true } = {}) { - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__polygon-point-field"; - row.dataset.polygonPointIndex = String(index); - if (selectable) { - row.dataset.polygonPointSelectable = "true"; - row.setAttribute("role", "button"); - row.setAttribute("tabindex", "0"); - row.setAttribute("aria-pressed", "false"); - row.title = "Select this point row for Add Point."; - row.addEventListener("click", (event) => this.handlePolygonPointRowSelection(event, row)); - row.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - this.togglePolygonPointRowSelection(row, event); - } - }); - } - const caption = document.createElement("span"); - caption.className = "object-vector-studio-v2__polygon-point-label"; - caption.textContent = `Point ${index + 1}`; - row.append(caption); - [ - ["x", "X", point.x], - ["y", "Y", point.y] - ].forEach(([axis, labelText, value]) => { - const label = document.createElement("label"); - label.className = "object-vector-studio-v2__polygon-point-axis"; - const axisLabel = document.createElement("span"); - axisLabel.textContent = labelText; - const input = document.createElement("input"); - input.dataset.shapeGeometryField = "points"; - input.dataset.polygonPointAxis = axis; - input.dataset.polygonPointIndex = String(index); - input.inputMode = "decimal"; - input.type = "text"; - input.value = this.formatCoordinateInputValue(value); - this.bindGeometryAutoApplyInput(input); - label.append(axisLabel, input); - row.append(label); - }); - const roundLabel = document.createElement("label"); - roundLabel.className = "object-vector-studio-v2__polygon-point-rounding"; - const roundCaption = document.createElement("span"); - roundCaption.textContent = "Round"; - const roundCheckbox = document.createElement("input"); - roundCheckbox.type = "checkbox"; - roundCheckbox.checked = rounded === true; - roundCheckbox.dataset.polygonPointRound = "true"; - roundCheckbox.dataset.polygonPointIndex = String(index); - roundCheckbox.setAttribute("aria-label", `Round point ${index + 1}`); - roundCheckbox.addEventListener("click", (event) => event.stopPropagation()); - roundCheckbox.addEventListener("change", () => this.updateSelectedShapePointRounding(index, roundCheckbox.checked)); - roundLabel.append(roundCaption, roundCheckbox); - row.append(roundLabel); - if (addable) { - const addButton = document.createElement("button"); - addButton.type = "button"; - addButton.className = "object-vector-studio-v2__polygon-point-add"; - addButton.dataset.polygonPointAdd = "true"; - addButton.dataset.polygonPointIndex = String(index); - addButton.setAttribute("aria-label", `Add point after point ${index + 1}`); - addButton.title = `Add point after point ${index + 1}`; - this.applyIconGlyph(addButton, "add"); - addButton.addEventListener("pointerdown", (event) => event.stopPropagation()); - addButton.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.addPolygonPointAfterRow(index, addButton); - }); - row.append(addButton); - } - if (deletable) { - const deleteButton = document.createElement("button"); - deleteButton.type = "button"; - deleteButton.className = "object-vector-studio-v2__polygon-point-delete"; - deleteButton.dataset.polygonPointDelete = "true"; - deleteButton.dataset.polygonPointIndex = String(index); - deleteButton.setAttribute("aria-label", `Delete point ${index + 1}`); - deleteButton.title = `Delete point ${index + 1}`; - this.applyIconGlyph(deleteButton, "delete"); - deleteButton.addEventListener("pointerdown", (event) => event.stopPropagation()); - deleteButton.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - this.deletePolygonPointRow(index, deleteButton); - }); - row.append(deleteButton); - } - return row; - } - - createShapePointRoundingControls(shape) { - const panel = document.createElement("div"); - panel.className = "object-vector-studio-v2__point-rounding-panel"; - const heading = document.createElement("h4"); - heading.textContent = "Point Rounding"; - const list = document.createElement("div"); - list.className = "object-vector-studio-v2__polygon-point-list object-vector-studio-v2__point-rounding-list"; - const points = this.shapeGeometryPoints(shape); - const pointRounding = this.shapePointRoundingValues(shape); - list.append(this.createPointRowHeader()); - points.forEach((point, index) => { - list.append(this.createReadonlyPointRoundingRow(shape, point, index, pointRounding[index] === true)); - }); - panel.append(heading, list); - return panel; - } - - createReadonlyPointRoundingRow(shape, point, index, rounded = false) { - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__polygon-point-field object-vector-studio-v2__polygon-point-field--rounding-only"; - row.dataset.polygonPointIndex = String(index); - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__polygon-point-label"; - const geometryTool = shapeGeometryTool(shape); - label.textContent = OPEN_POINT_STYLE_TOOLS.has(geometryTool) - ? (index === 0 ? "Start" : "End") - : `Point ${index + 1}`; - row.append(label); - [ - ["X", point.x], - ["Y", point.y] - ].forEach(([axis, value]) => { - const field = document.createElement("span"); - field.className = "object-vector-studio-v2__polygon-point-axis object-vector-studio-v2__polygon-point-axis--readonly"; - const axisLabel = document.createElement("span"); - axisLabel.textContent = axis; - const valueElement = document.createElement("span"); - valueElement.className = "object-vector-studio-v2__polygon-point-value"; - valueElement.textContent = this.formatCoordinateInputValue(value); - field.append(axisLabel, valueElement); - row.append(field); - }); - const roundLabel = document.createElement("label"); - roundLabel.className = "object-vector-studio-v2__polygon-point-rounding"; - const roundCaption = document.createElement("span"); - roundCaption.textContent = "Round"; - const roundCheckbox = document.createElement("input"); - roundCheckbox.type = "checkbox"; - roundCheckbox.checked = rounded === true; - roundCheckbox.dataset.polygonPointRound = "true"; - roundCheckbox.dataset.polygonPointIndex = String(index); - roundCheckbox.setAttribute("aria-label", `Round ${label.textContent.toLowerCase()} point`); - roundCheckbox.addEventListener("change", () => this.updateSelectedShapePointRounding(index, roundCheckbox.checked)); - roundLabel.append(roundCaption, roundCheckbox); - row.append(roundLabel); - return row; - } - - handlePolygonPointRowSelection(event, row) { - if (event.target.closest("input, button, select, textarea, label")) { - return; - } - this.togglePolygonPointRowSelection(row, event); - } - - togglePolygonPointRowSelection(row, event = null) { - if (!row?.dataset || row.dataset.polygonPointSelectable !== "true") { - return; - } - const shouldExtend = Boolean(event?.ctrlKey || event?.metaKey || event?.shiftKey); - if (!shouldExtend) { - this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-action-selected='true']").forEach((candidate) => { - if (candidate !== row) { - delete candidate.dataset.polygonPointActionSelected; - candidate.classList.remove("is-action-selected"); - candidate.setAttribute("aria-pressed", "false"); - } - }); - } - const isSelected = row.dataset.polygonPointActionSelected === "true"; - if (isSelected) { - delete row.dataset.polygonPointActionSelected; - row.classList.remove("is-action-selected"); - row.setAttribute("aria-pressed", "false"); - return; - } - row.dataset.polygonPointActionSelected = "true"; - row.classList.add("is-action-selected"); - row.setAttribute("aria-pressed", "true"); - } - - createShapeTransformControls(shape) { - const section = document.createElement("section"); - section.className = "object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--transform"; - const transform = this.shapeTransform(this.effectiveShape(shape)); - section.append( - this.createMoveControlRow(), - this.createRotateControlRow(), - this.createScaleControlRow(transform) - ); - return section; - } - - createObjectTransformControls(object) { - const section = document.createElement("section"); - section.className = "object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--transform object-vector-studio-v2__edit-panel--object-transform"; - const origin = this.objectTransformOrigin(object); - const objectScale = this.objectScaleControlValue(object); - section.append( - this.createObjectOriginControlRow(origin), - this.createObjectRotateControlRow(), - this.createScaleControlRow({ scaleX: objectScale, scaleY: objectScale }, { - downLargeId: "objectVectorStudioV2ObjectScaleDownLargeButton", - downSmallId: "objectVectorStudioV2ObjectScaleDownSmallButton", - inputId: "objectVectorStudioV2ObjectScaleInput", - inputLabel: "Object Scale", - onInput: (input) => this.applyObjectScaleInputLive(input), - onReset: () => this.resetSelectedObjectScale(), - onResize: () => this.resizeSelectedObject(), - onStep: (delta) => this.adjustSelectedObjectScale(delta), - resetButtonId: "objectVectorStudioV2ObjectResetScaleButton", - resizeButtonId: "objectVectorStudioV2ObjectResizeButton", - upLargeId: "objectVectorStudioV2ObjectScaleUpLargeButton", - upSmallId: "objectVectorStudioV2ObjectScaleUpSmallButton" - }) - ); - return section; - } - - objectScaleControlValue(object) { - return this.objectScalePreviewValueForObject(object); - } - - objectScalePreviewValue() { - const value = Number(this.transformInputValue("objectVectorStudioV2ObjectScaleInput", "1")); - return Number.isFinite(value) && value > 0 ? this.formatViewportNumber(value) : 1; - } - - objectScalePreviewValueForObject(object) { - return object?.id === this.selectedObjectId ? this.objectScalePreviewValue() : 1; - } - - shapeWithObjectScalePreview(shape, scale = this.objectScalePreviewValue()) { - if (!shape || Math.abs(scale - 1) < 0.001) { - return shape; - } - const transform = this.shapeTransform(shape); - return { - ...shape, - transform: { - ...transform, - scaleX: this.formatViewportNumber(transform.scaleX * scale), - scaleY: this.formatViewportNumber(transform.scaleY * scale), - x: this.formatViewportNumber(transform.x * scale), - y: this.formatViewportNumber(transform.y * scale) - } - }; - } - - createMoveControlRow() { - return this.createTransformAxisControlRow({ - action: () => this.moveSelectedShape(), - buttonId: "objectVectorStudioV2MoveShapeButton", - buttonLabel: "Move", - iconKey: "move", - label: "Move", - rowType: "move", - xInput: { id: "objectVectorStudioV2MoveXInput", label: "Move X", value: "10" }, - yInput: { id: "objectVectorStudioV2MoveYInput", label: "Move Y", value: "0" } - }); - } - - createObjectOriginControlRow(origin) { - return this.createTransformAxisControlRow({ - action: () => this.applySelectedObjectOrigin(), - buttonId: "objectVectorStudioV2ObjectApplyOriginButton", - buttonLabel: "Apply", - buttonTitle: "Apply Object Origin", - label: "Origin", - rowType: "origin", - extraButtons: [ - this.createTransformActionButton({ - handler: () => this.autoOriginSelectedObjectPivot(), - id: "objectVectorStudioV2ObjectAutoOriginButton", - label: "Auto", - title: "Auto Origin" - }) - ], - xInput: { id: "objectVectorStudioV2ObjectOriginXInput", label: "Object Origin X", value: String(this.formatViewportNumber(origin.x)) }, - yInput: { id: "objectVectorStudioV2ObjectOriginYInput", label: "Object Origin Y", value: String(this.formatViewportNumber(origin.y)) } - }); - } - - createRotateControlRow() { - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--rotate"; - row.dataset.transformControlRow = "rotate"; - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__transform-control-label"; - label.textContent = "Rotate"; - const input = this.createTransformNumberInput("objectVectorStudioV2RotateInput", "Rotate", "15", { - max: "359", - min: "-359" - }); - const snapSelect = this.createRotateSnapSelect(); - const stepField = this.createAngleSnapStepField(); - const button = this.createTransformActionButton({ - handler: () => this.rotateSelectedShape(), - iconKey: "rotate", - id: "objectVectorStudioV2RotateShapeButton", - label: "Rotate" - }); - row.append(label, input, snapSelect, stepField, button); - this.updateRotateSnapControls(row); - return row; - } - - createObjectRotateControlRow() { - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--rotate object-vector-studio-v2__transform-control-row--object-rotate"; - row.dataset.transformControlRow = "rotate"; - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__transform-control-label"; - label.textContent = "Rotate"; - const input = this.createTransformNumberInput("objectVectorStudioV2ObjectRotateInput", "Object Rotate", "15", { - max: "359", - min: "-359" - }); - const snapSelect = this.createRotateSnapSelect({ - id: "objectVectorStudioV2ObjectRotateSnapSelect", - label: "Object Rotate Snap Angle" - }); - const stepField = this.createAngleSnapStepField({ - id: "objectVectorStudioV2ObjectSnapAngleStepSelect", - label: "Object Snap Angle Step" - }); - const button = this.createTransformActionButton({ - handler: () => this.rotateSelectedObject(), - iconKey: "rotate", - id: "objectVectorStudioV2ObjectRotateButton", - label: "Rotate" - }); - row.append(label, input, snapSelect, stepField, button); - this.updateRotateSnapControls(row); - return row; - } - - createRotateSnapSelect(options = {}) { - const { - id = "objectVectorStudioV2RotateSnapSelect", - label = "Rotate Snap Angle" - } = options; - const select = document.createElement("select"); - select.id = id; - select.className = "object-vector-studio-v2__rotate-snap-select"; - select.setAttribute("aria-label", label); - select.title = "Valid Rotate values generated by the selected Snap Angle step."; - this.populateRotateSnapSelect(select, this.angleSnapStep, this.transformInputValue(select.id, "15")); - select.addEventListener("change", () => { - this.transformInputValues.set(select.id, select.value); - }); - return select; - } - - createAngleSnapStepField(options = {}) { - const { - id = "objectVectorStudioV2SnapAngleStepSelect", - label: labelText = "Snap Angle Step" - } = options; - const labelElement = document.createElement("label"); - labelElement.className = "object-vector-studio-v2__snap-angle-step-field"; - const text = document.createElement("span"); - text.textContent = "Step"; - const select = document.createElement("select"); - select.id = id; - select.setAttribute("aria-label", labelText); - select.title = "Choose the angle increment used to generate Rotate dropdown values."; - ANGLE_SNAP_STEPS.forEach((step) => { - const option = document.createElement("option"); - option.value = String(step); - option.textContent = String(step); - select.append(option); - }); - select.value = String(this.angleSnapStep); - select.addEventListener("change", () => { - const nextStep = Number(select.value); - this.angleSnapStep = ANGLE_SNAP_STEPS.includes(nextStep) ? nextStep : 15; - this.window.sessionStorage?.setItem(ANGLE_SNAP_STEP_SESSION_KEY, String(this.angleSnapStep)); - this.updateRotateSnapControls(); - this.statusLog.write(`OK Snap angle step set to ${this.angleSnapStep} degrees.`); - }); - labelElement.append(text, select); - return labelElement; - } - - createTransformAxisControlRow({ action, buttonId, buttonLabel, buttonTitle, extraButtons = [], iconKey, label, rowType, xInput, yInput }) { - const row = document.createElement("div"); - row.className = `object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--${rowType}`; - row.dataset.transformControlRow = rowType; - const rowLabel = document.createElement("span"); - rowLabel.className = "object-vector-studio-v2__transform-control-label"; - rowLabel.textContent = label; - const actionButton = this.createTransformActionButton({ - handler: action, - iconKey, - id: buttonId, - label: buttonLabel, - title: buttonTitle - }); - row.append( - rowLabel, - this.createTransformAxisField("X", xInput), - this.createTransformAxisField("Y", yInput), - actionButton, - ...extraButtons - ); - return row; - } - - createTransformAxisField(axisLabel, inputOptions) { - const label = document.createElement("label"); - label.className = "object-vector-studio-v2__transform-axis-field"; - const axis = document.createElement("span"); - axis.textContent = axisLabel; - label.append(axis, this.createTransformNumberInput(inputOptions.id, inputOptions.label, inputOptions.value)); - return label; - } - - createTransformNumberInput(id, label, value, options = {}) { - const input = document.createElement("input"); - input.id = id; - input.type = "number"; - input.step = "0.1"; - const initialValue = this.transformInputValue(id, value); - const numericInitialValue = Number(initialValue); - input.value = String(initialValue).trim() !== "" && Number.isFinite(numericInitialValue) - ? String(this.formatViewportNumber(numericInitialValue)) - : initialValue; - input.setAttribute("aria-label", label); - if (options.min !== undefined) { - input.min = options.min; - } - if (options.max !== undefined) { - input.max = options.max; - } - input.addEventListener("input", () => { - this.transformInputValues.set(id, input.value); - this.clearInputValidity(input); - }); - return input; - } - - createTransformActionButton({ handler, iconKey, id, label, title }) { - const button = document.createElement("button"); - button.id = id; - button.type = "button"; - button.textContent = label; - if (title) { - button.title = title; - button.setAttribute("aria-label", title); - } - this.applyIconGlyph(button, iconKey); - button.addEventListener("click", handler); - return button; - } - - createScaleControlRow(transform, options = {}) { - const { - downLargeId = "objectVectorStudioV2ScaleDownLargeButton", - downSmallId = "objectVectorStudioV2ScaleDownSmallButton", - inputId = "objectVectorStudioV2ScaleInput", - inputLabel = "Scale", - onInput = (input) => this.applyScaleInputLive(input), - onReset = () => this.resetSelectedShapeScale(), - onResize = () => this.resizeSelectedShape(), - onStep = (delta) => this.adjustSelectedShapeScale(delta), - resetButtonId = "objectVectorStudioV2ResetShapeScaleButton", - resizeButtonId = "objectVectorStudioV2ResizeShapeButton", - upLargeId = "objectVectorStudioV2ScaleUpLargeButton", - upSmallId = "objectVectorStudioV2ScaleUpSmallButton" - } = options; - const row = document.createElement("div"); - row.className = "object-vector-studio-v2__scale-control-row"; - const label = document.createElement("span"); - label.className = "object-vector-studio-v2__scale-control-label"; - label.textContent = "Scale"; - row.append(label); - - [ - [downLargeId, "--", -0.1], - [downSmallId, "-", -0.01] - ].forEach(([id, text, delta]) => { - row.append(this.createScaleStepButton(id, text, delta, onStep)); - }); - - const input = document.createElement("input"); - input.id = inputId; - input.className = "object-vector-studio-v2__scale-input"; - input.type = "number"; - input.min = "0.01"; - input.step = "0.01"; - input.value = this.formatScaleInputValue(transform.scaleX); - input.setAttribute("aria-label", inputLabel); - input.addEventListener("input", () => { - this.transformInputValues.set(input.id, input.value); - onInput(input); - }); - row.append(input); - - [ - [upSmallId, "+", 0.01], - [upLargeId, "++", 0.1] - ].forEach(([id, text, delta]) => { - row.append(this.createScaleStepButton(id, text, delta, onStep)); - }); - - const resizeButton = document.createElement("button"); - resizeButton.id = resizeButtonId; - resizeButton.className = "object-vector-studio-v2__scale-resize-button"; - resizeButton.type = "button"; - resizeButton.textContent = "Resize"; - resizeButton.title = "Resize Geometry"; - resizeButton.setAttribute("aria-label", "Resize Geometry"); - this.applyIconGlyph(resizeButton, "resize"); - resizeButton.addEventListener("click", onResize); - row.append(resizeButton); - - const resetButton = document.createElement("button"); - resetButton.id = resetButtonId; - resetButton.className = "object-vector-studio-v2__scale-reset-button"; - resetButton.type = "button"; - resetButton.textContent = "X"; - resetButton.title = "Reset Scale to 1.0"; - resetButton.setAttribute("aria-label", `${inputLabel} Reset Scale to 1.0`); - resetButton.addEventListener("click", onReset); - row.append(resetButton); - return row; - } - - createScaleStepButton(id, text, delta, onStep = (scaleDelta) => this.adjustSelectedShapeScale(scaleDelta)) { - const button = document.createElement("button"); - button.id = id; - button.type = "button"; - button.dataset.scaleStep = String(delta); - button.textContent = text; - button.title = `Scale ${delta > 0 ? "up" : "down"} ${Math.abs(delta).toFixed(2)}`; - button.addEventListener("click", () => onStep(delta)); - return button; - } - - transformInputValue(id, defaultValue) { - return this.transformInputValues.has(id) ? this.transformInputValues.get(id) : defaultValue; - } - - shapeGeometryFields(shape) { - const geometryTool = shapeGeometryTool(shape); - if (shapeTool(shape) === "square") { - return [ - { key: "x", kind: "number", label: "x", value: shape.geometry.x }, - { key: "y", kind: "number", label: "y", value: shape.geometry.y }, - { key: "size", kind: "number", label: "Size", value: shape.geometry.width, wide: true } - ]; - } - if (geometryTool === "rectangle") { - return ["x", "y", "width", "height"].map((key) => ({ key, kind: "number", label: key, value: shape.geometry[key] })); - } - if (geometryTool === "circle") { - return ["cx", "cy", "r"].map((key) => ({ key, kind: "number", label: key, value: shape.geometry[key], wide: key === "r" })); - } - if (geometryTool === "ellipse") { - return [ - { key: "cx", kind: "number", label: "Cx", value: shape.geometry.cx }, - { key: "cy", kind: "number", label: "Cy", value: shape.geometry.cy }, - { key: "rx", kind: "number", label: "Rx", value: shape.geometry.rx }, - { key: "ry", kind: "number", label: "Ry", value: shape.geometry.ry } - ]; - } - if (geometryTool === "line") { - const point1 = linePoint(shape.geometry, "point1"); - const point2 = linePoint(shape.geometry, "point2"); - return [ - { coordinate: true, key: "point1.x", kind: "number", label: "Point 1 X", value: point1.x }, - { coordinate: true, key: "point1.y", kind: "number", label: "Point 1 Y", value: point1.y }, - { coordinate: true, key: "point2.x", kind: "number", label: "Point 2 X", value: point2.x }, - { coordinate: true, key: "point2.y", kind: "number", label: "Point 2 Y", value: point2.y } - ]; - } - if (geometryTool === "arc") { - return ["cx", "cy", "r", "startAngle", "endAngle"].map((key) => ({ - key, - kind: "number", - label: key, - value: shape.geometry[key], - wide: key === "r" - })); - } - if (geometryTool === "text") { - return [ - { key: "x", kind: "number", label: "x", value: shape.geometry.x }, - { key: "y", kind: "number", label: "y", value: shape.geometry.y }, - { key: "fontSize", kind: "number", label: "fontSize", value: shape.geometry.fontSize, wide: true }, - { key: "text", kind: "text", label: "text", value: shape.geometry.text, wide: true } - ]; - } - return [ - { - key: "points", - kind: "text", - label: "points", - value: shape.geometry.points.map((point) => `${point.x},${point.y}`).join(" ") - } - ]; - } - - renderWorkSurface() { - const object = this.selectedObject(); - this.elements.renderSurface.classList.toggle("is-drawing-mode", Boolean(this.activeDrawing)); - this.updateRenderSurfaceCursorState(); - this.removeDrawingHint(); - this.elements.renderSurface.replaceChildren(); - this.renderSvgGrid(); - if (!object) { - this.renderCenterOriginMarker(); - return; - } - if (this.isObjectHidden(object.id)) { - this.renderCenterOriginMarker(); - this.statusLog.write(`OK Render mode svg-work-surface: object ${object.name} is hidden for this runtime session; capture mode none.`); - return; - } - - if (this.elements.onionSkinToggle.checked) { - this.renderOnionSkin(object); - } - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - let renderedCount = 0; - const hitLayer = document.createElementNS(SVG_NS, "g"); - hitLayer.classList.add("object-vector-studio-v2__shape-hit-layer"); - this.elements.renderSurface.append(hitLayer); - sortedShapes(object).forEach((shape, shapeIndex) => { - const renderShape = this.shapeWithObjectScalePreview(this.effectiveShape(shape, shapeIndex), objectScale); - if (!renderShape.visible) { - return; - } - try { - const element = this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); - element.dataset.shapeIndex = String(shapeIndex); - element.dataset.renderShapeTool = shapeTool(shape); - element.classList.add("object-vector-studio-v2__shape"); - element.classList.toggle("is-selected", this.selectedShapeIndexes.has(shapeIndex)); - element.setAttribute("tabindex", "0"); - this.bindRenderedShapePointerEvents(element, shape, shapeIndex); - this.elements.renderSurface.append(element); - this.appendShapePointStyleCaps(this.elements.renderSurface, renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); - hitLayer.append(this.createShapeHitArea(renderShape, shape, shapeIndex, transformOrigin)); - renderedCount += 1; - } catch (error) { - this.statusLog.write(`FAIL Render mode svg-work-surface failed for shape-${shapeIndex} (${shapeTool(shape)}): ${error.message}`); - } - }); - if (!hitLayer.childNodes.length) { - hitLayer.remove(); - } - this.renderObjectBounds(object); - this.renderObjectOriginMarker(object); - this.renderSnapPointTargets(object); - this.renderDrawingPreview(); - this.renderDrawingHint(); - this.renderSelectionOverlay(object); - this.renderCenterOriginMarker(); - - const frame = this.activeFrame(); - const message = `Render mode svg-work-surface: rendered ${object.name} with ${renderedCount} visible shapes${frame ? ` for ${this.selectedStateId}/${frame.id}` : ""}; capture mode none.`; - this.statusLog.write(`OK ${message}`); - } - - bindRenderedShapePointerEvents(element, shape, shapeIndex) { - element.addEventListener("pointerdown", (event) => { - event.stopPropagation(); - if (event.button === 2) { - event.preventDefault(); - return; - } - if (this.activeDrawing) { - this.handleDrawingPointerDown(event); - return; - } - if (this.activeTool === "paint" || this.activeTool === "stroke") { - this.isPaintDragging = true; - return; - } - this.startPreviewShapeMove(event, shapeIndex); - }); - element.addEventListener("click", (event) => { - event.stopPropagation(); - if (this.activeDrawing) { - return; - } - this.handleShapePointer(event, shape, shapeIndex, { source: "render surface click" }); - }); - element.addEventListener("contextmenu", (event) => this.handleShapeContextMenu(event, shape, shapeIndex)); - element.addEventListener("pointerenter", (event) => { - if (this.isPaintDragging) { - event.stopPropagation(); - this.handleShapePointer(event, shape, shapeIndex, { source: "render surface drag" }); - } - }); - } - - createShapeHitArea(renderShape, sourceShape, shapeIndex, transformOrigin = { x: 0, y: 0 }) { - const hitArea = this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); - const geometryTool = shapeGeometryTool(sourceShape); - hitArea.classList.add("object-vector-studio-v2__shape-hit-area"); - hitArea.dataset.shapeHitIndex = String(shapeIndex); - hitArea.dataset.shapeHitTool = shapeTool(sourceShape); - hitArea.removeAttribute("tabindex"); - hitArea.setAttribute("fill", ["arc", "line", "polyline"].includes(geometryTool) ? "none" : "transparent"); - hitArea.setAttribute("stroke", "transparent"); - hitArea.setAttribute("stroke-width", String(Math.max(12, Number(renderShape.style?.strokeWidth) || 1))); - hitArea.setAttribute("pointer-events", "all"); - hitArea.setAttribute("aria-hidden", "true"); - this.bindRenderedShapePointerEvents(hitArea, sourceShape, shapeIndex); - return hitArea; - } - - renderSvgGrid() { - if (!this.gridRenderEnabled) { - return; - } - const width = DEFAULT_VIEWPORT.width / this.viewport.zoom; - const height = DEFAULT_VIEWPORT.height / this.viewport.zoom; - const minX = this.viewport.x - width / 2; - const maxX = this.viewport.x + width / 2; - const minY = this.viewport.y - height / 2; - const maxY = this.viewport.y + height / 2; - const group = document.createElementNS(SVG_NS, "g"); - group.classList.add("object-vector-studio-v2__svg-grid"); - group.dataset.gridRendered = "true"; - for (let x = Math.floor(minX / GRID_STEP) * GRID_STEP; x <= maxX; x += GRID_STEP) { - const line = document.createElementNS(SVG_NS, "line"); - line.setAttribute("x1", this.formatViewportNumber(x)); - line.setAttribute("x2", this.formatViewportNumber(x)); - line.setAttribute("y1", this.formatViewportNumber(minY)); - line.setAttribute("y2", this.formatViewportNumber(maxY)); - group.append(line); - } - for (let y = Math.floor(minY / GRID_STEP) * GRID_STEP; y <= maxY; y += GRID_STEP) { - const line = document.createElementNS(SVG_NS, "line"); - line.setAttribute("x1", this.formatViewportNumber(minX)); - line.setAttribute("x2", this.formatViewportNumber(maxX)); - line.setAttribute("y1", this.formatViewportNumber(y)); - line.setAttribute("y2", this.formatViewportNumber(y)); - group.append(line); - } - this.elements.renderSurface.append(group); - } - - handleShapePointer(event, shape, shapeIndex, options = {}) { - if (event.altKey || this.activeTool === "picker") { - this.sampleShapeStyle(shapeIndex); - return; - } - if (event.shiftKey) { - this.selectShape(shapeIndex, "render surface additive", { additive: true }); - return; - } - const isStrokeMode = this.activeTool === "stroke"; - const isPaintMode = this.activeTool === "paint"; - if (isStrokeMode || isPaintMode) { - if (options.dragStart) { - this.isPaintDragging = true; - } - this.applySelectedPaletteColorToShape(shapeIndex, isStrokeMode ? "stroke" : "fill", options.source || (options.dragStart ? "render surface click" : "render surface drag")); - return; - } - if (this.preserveSelectedShapeClick(shapeIndex, options.source || "render surface")) { - return; - } - this.selectShape(shapeIndex, "render surface"); - } - - preserveSelectedShapeClick(shapeIndex, sourceLabel) { - const object = this.selectedObject(); - const normalizedIndex = normalizeShapeIndex(shapeIndex); - const shapes = sortedShapes(object); - if (normalizedIndex < 0 || normalizedIndex >= shapes.length || !this.selectedShapeIndexes.has(normalizedIndex)) { - return false; - } - - const scrollState = this.captureLeftPanelScrollState(); - this.selectedShapeIndex = normalizedIndex; - this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); - this.setPaletteTarget("stroke", false); - this.renderPayload(); - this.restoreLeftPanelScrollState(scrollState); - const shape = this.selectedShape(); - this.statusLog.write(`OK Preserved selected shape from ${sourceLabel}: row ${this.selectedShapeIndex} (${shapeTool(shape)}). Multi-select count: ${this.selectedShapeIndexes.size}.`); - return true; - } - - handleShapeContextMenu(event, shape, shapeIndex) { - event.preventDefault(); - if (this.activeDrawing || this.previewPointerEdit) { - return; - } - this.applyTransparentStyleToShape(shapeIndex, "fill", "right-click"); - } - - renderOnionSkin(object) { - const previousFrame = this.previousFrame(); - if (!previousFrame) { - return; - } - const group = document.createElementNS(SVG_NS, "g"); - group.classList.add("object-vector-studio-v2__onion-skin"); - group.dataset.onionSkinFrame = previousFrame.id; - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - sortedShapes(object).forEach((shape, shapeIndex) => { - const renderShape = this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(shape, previousFrame, shapeIndex), objectScale); - if (!renderShape.visible) { - return; - } - try { - group.append(this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin })); - this.appendShapePointStyleCaps(group, renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); - } catch (error) { - this.statusLog.write(`FAIL Onion-skin render failed for ${object.name}/shape-${shapeIndex}: ${error.message}`); - } - }); - this.elements.renderSurface.append(group); - } - - renderObjectBounds(object) { - const bounds = this.objectBounds(object, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, includeInvisible: false }); - const box = document.createElementNS(SVG_NS, "rect"); - box.classList.add("object-vector-studio-v2__object-bounds"); - box.dataset.objectBounds = object.id; - box.setAttribute("x", bounds.x - 6); - box.setAttribute("y", bounds.y - 6); - box.setAttribute("width", bounds.width + 12); - box.setAttribute("height", bounds.height + 12); - this.elements.renderSurface.append(box); - } - - renderObjectOriginMarker(object) { - if (!object) { - return; - } - const origin = this.objectTransformOrigin(object); - const x = this.scaleDrawingValue(origin.x, OBJECT_PREVIEW_DRAWING_SCALE); - const y = this.scaleDrawingValue(origin.y, OBJECT_PREVIEW_DRAWING_SCALE); - const marker = document.createElementNS(SVG_NS, "g"); - marker.classList.add("object-vector-studio-v2__object-origin"); - marker.dataset.objectOrigin = object.id; - marker.setAttribute("role", "img"); - marker.setAttribute("aria-label", "Object origin/pivot marker for object rotation and scale"); - const markerTitle = document.createElementNS(SVG_NS, "title"); - markerTitle.textContent = "Object Origin/Pivot: rotate and scale pivot for the selected object."; - const horizontal = document.createElementNS(SVG_NS, "line"); - horizontal.setAttribute("x1", x - 8); - horizontal.setAttribute("x2", x + 8); - horizontal.setAttribute("y1", y); - horizontal.setAttribute("y2", y); - const vertical = document.createElementNS(SVG_NS, "line"); - vertical.setAttribute("x1", x); - vertical.setAttribute("x2", x); - vertical.setAttribute("y1", y - 8); - vertical.setAttribute("y2", y + 8); - marker.append(markerTitle, horizontal, vertical); - this.elements.renderSurface.append(marker); - } - - renderCenterOriginMarker() { - if (!this.centerOriginVisible) { - return; - } - const marker = document.createElementNS(SVG_NS, "circle"); - marker.classList.add("object-vector-studio-v2__center-origin-dot"); - marker.dataset.centerOrigin = "0,0"; - marker.setAttribute("cx", "0"); - marker.setAttribute("cy", "0"); - marker.setAttribute("r", "9"); - marker.setAttribute("aria-label", "Canvas origin 0,0"); - this.elements.renderSurface.append(marker); - } - - renderSnapPointTargets(object) { - if (this.snapMode !== "point") { - return; - } - const points = this.visibleSnapPoints(object); - if (!points.length) { - return; - } - const group = document.createElementNS(SVG_NS, "g"); - group.classList.add("object-vector-studio-v2__snap-targets"); - points.forEach((point, index) => { - const target = document.createElementNS(SVG_NS, "circle"); - target.classList.add("object-vector-studio-v2__snap-target"); - target.dataset.snapTargetIndex = String(index); - target.setAttribute("cx", this.scaleDrawingValue(point.x, OBJECT_PREVIEW_DRAWING_SCALE)); - target.setAttribute("cy", this.scaleDrawingValue(point.y, OBJECT_PREVIEW_DRAWING_SCALE)); - target.setAttribute("r", 4); - group.append(target); - }); - this.elements.renderSurface.append(group); - } - - renderDrawingPreview() { - if (!this.activeDrawing) { - return; - } - const previewShape = this.previewShapeFromDrawing(); - if (!previewShape) { - return; - } - try { - this.renderDrawingSnapLine(previewShape); - const element = this.createSvgShape(previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); - element.classList.add("object-vector-studio-v2__drawing-preview"); - element.dataset.drawingPreviewTool = this.activeDrawing.tool; - this.applyDrawingPreviewPresentation(element, previewShape); - this.elements.renderSurface.append(element); - this.appendShapePointStyleCaps(this.elements.renderSurface, previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); - } catch (error) { - this.statusLog.write(`FAIL Drawing preview failed for ${this.activeDrawing.tool}: ${error.message}`); - } - } - - renderDrawingSnapLine(previewShape) { - if (this.snapMode === "none") { - return; - } - const element = this.createSvgShape(previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); - element.classList.add("object-vector-studio-v2__snap-line"); - element.dataset.snapLineMode = this.snapMode; - element.dataset.snapLineTool = this.activeDrawing?.tool || shapeTool(previewShape); - element.setAttribute("aria-hidden", "true"); - this.applyDrawingPreviewPresentation(element, previewShape); - element.style.strokeDasharray = "none"; - this.elements.renderSurface.append(element); - } - - renderDrawingHint() { - const drawing = this.activeDrawing; - if (!drawing || !["polygon", "polyline"].includes(drawing.tool) || !this.drawingPreviewPoint) { - this.removeDrawingHint(); - return; - } - const workArea = this.elements.renderSurface.closest(".object-vector-studio-v2__work-area"); - if (!workArea) { - return; - } - const clientPoint = this.drawingHintClientPoint || this.clientPointFromDrawingPoint(this.drawingPreviewPoint); - if (!clientPoint) { - this.removeDrawingHint(); - return; - } - const workAreaBounds = workArea.getBoundingClientRect(); - const hint = document.createElement("div"); - hint.className = "object-vector-studio-v2__drawing-hint"; - hint.dataset.drawingHintTool = drawing.tool; - hint.textContent = "Double-click / Enter to complete"; - hint.style.left = `${Math.round(clientPoint.x - workAreaBounds.left + 12)}px`; - hint.style.top = `${Math.round(clientPoint.y - workAreaBounds.top + 16)}px`; - workArea.append(hint); - } - - removeDrawingHint() { - this.elements.renderSurface.closest(".object-vector-studio-v2__work-area") - ?.querySelector(".object-vector-studio-v2__drawing-hint") - ?.remove(); - } - - clientPointFromDrawingPoint(point) { - const bounds = this.elements.renderSurface.getBoundingClientRect(); - if (!bounds.width || !bounds.height) { - return null; - } - const viewWidth = DEFAULT_VIEWPORT.width / this.viewport.zoom; - const viewHeight = DEFAULT_VIEWPORT.height / this.viewport.zoom; - const drawingX = point.x * OBJECT_PREVIEW_DRAWING_SCALE; - const drawingY = point.y * OBJECT_PREVIEW_DRAWING_SCALE; - return { - x: bounds.left + ((drawingX - this.viewport.x + viewWidth / 2) / viewWidth) * bounds.width, - y: bounds.top + ((drawingY - this.viewport.y + viewHeight / 2) / viewHeight) * bounds.height - }; - } - - applyDrawingPreviewPresentation(element, previewShape) { - if (shapeGeometryTool(previewShape) === "text") { - element.style.strokeDasharray = "none"; - return; - } - element.style.strokeDasharray = this.drawingPreviewDashArray(previewShape.style.strokeWidth); - const previewPointStyle = this.supportsOpenPointStyle(previewShape) - ? (this.openShapeUsesSplitPointStyles(previewShape) ? "butt" : this.shapeStartPointStyle(previewShape)) - : this.shapeUnifiedPointStyle(previewShape); - element.style.strokeLinecap = previewPointStyle; - element.style.strokeLinejoin = this.strokeLinejoinValue(this.shapeUnifiedPointStyle(previewShape)); - } - - drawingPreviewDashArray(strokeWidth) { - const normalizedWidth = Number(strokeWidth); - const width = Number.isFinite(normalizedWidth) && normalizedWidth > 0 ? normalizedWidth : 2; - const unitsPerPixel = this.svgUnitsPerCssPixel(); - const dashPixels = Math.max(12, Math.min(24, width * 1.1)); - const gapPixels = Math.max(8, Math.min(18, width * 0.75)); - return `${this.formatViewportNumber(dashPixels * unitsPerPixel)} ${this.formatViewportNumber(gapPixels * unitsPerPixel)}`; - } - - svgUnitsPerCssPixel() { - const bounds = this.elements.renderSurface.getBoundingClientRect(); - const viewBox = this.elements.renderSurface.viewBox?.baseVal; - if (!bounds.width || !viewBox?.width) { - return 1; - } - return viewBox.width / bounds.width; - } - - visibleSnapPoints(object = this.selectedObject(), options = {}) { - if (!object) { - return []; - } - const points = []; - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - sortedShapes(object).forEach((shape, shapeIndex) => { - if (normalizeShapeIndex(options.excludeShapeIndex) === shapeIndex) { - return; - } - const effective = this.shapeWithObjectScalePreview(this.effectiveShape(shape, shapeIndex), objectScale); - if (!effective.visible) { - return; - } - const transform = this.shapeTransform(effective); - const definitions = this.geometryPointHandleDefinitions(effective); - const shapePoints = definitions.length - ? definitions.map((definition) => definition.point) - : boundsCornerPoints(shapeBounds(effective)); - shapePoints.forEach((point) => points.push(this.transformedPoint(point, transform, transformOrigin))); - }); - return points; - } - - nearestVisibleSnapPoint(point, options = {}) { - const candidates = this.visibleSnapPoints(this.selectedObject(), options); - let nearest = null; - candidates.forEach((candidate) => { - const distance = Math.hypot(candidate.x - point.x, candidate.y - point.y); - if (distance <= POINT_SNAP_RADIUS && (!nearest || distance < nearest.distance)) { - nearest = { distance, point: candidate }; - } - }); - return nearest?.point || null; - } - - normalizeDrawingPoint(point) { - return { - x: this.formatViewportNumber(point?.x || 0), - y: this.formatViewportNumber(point?.y || 0) - }; - } - - snapCanvasPoint(point, options = {}) { - if (this.snapMode === "grid") { - return { - x: Math.round(point.x), - y: Math.round(point.y) - }; - } - if (this.snapMode === "point") { - const target = this.nearestVisibleSnapPoint(point, options); - if (target) { - return { - x: this.formatViewportNumber(target.x), - y: this.formatViewportNumber(target.y) - }; - } - } - return this.normalizeDrawingPoint(point); - } - - supportsDrawingAngleSnap(tool) { - return ["line", "polygon", "polyline"].includes(tool); - } - - drawingAngleSnapAnchor(drawing) { - if (!drawing || !this.supportsDrawingAngleSnap(drawing.tool)) { - return null; - } - if (drawing.flow === "two-click") { - return drawing.start || null; - } - if (drawing.flow === "points") { - return drawing.points.at(-1) || null; - } - return null; - } - - snapPointToDrawingAngle(anchor, point) { - const normalizedAnchor = this.normalizeDrawingPoint(anchor); - const normalizedPoint = this.normalizeDrawingPoint(point); - const dx = normalizedPoint.x - normalizedAnchor.x; - const dy = normalizedPoint.y - normalizedAnchor.y; - const distance = Math.hypot(dx, dy); - if (!distance) { - return normalizedPoint; - } - const degrees = Math.atan2(dy, dx) * 180 / Math.PI; - const snappedDegrees = this.snapAngle(degrees); - const radians = snappedDegrees * Math.PI / 180; - return { - x: this.formatViewportNumber(normalizedAnchor.x + Math.cos(radians) * distance), - y: this.formatViewportNumber(normalizedAnchor.y + Math.sin(radians) * distance) - }; - } - - snapDrawingPoint(point, drawing, options = {}) { - const snappedPoint = this.snapCanvasPoint(point, options); - if (!this.angleSnapEnabled) { - return this.normalizeDrawingPoint(snappedPoint); - } - const anchor = this.drawingAngleSnapAnchor(drawing); - if (!anchor) { - return this.normalizeDrawingPoint(snappedPoint); - } - return this.snapPointToDrawingAngle(anchor, snappedPoint); - } - - createSvgShape(shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "rectangle") { - const roundedPath = this.roundedPointPath(shape, { closed: true, drawingScale }); - const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "rect"); - if (roundedPath) { - element.setAttribute("d", roundedPath); - element.dataset.roundedPointRender = "path"; - element.setAttribute("points", this.svgPointList(this.shapeGeometryPoints(shape), drawingScale)); - } - element.setAttribute("x", this.scaleDrawingValue(shape.geometry.x, drawingScale)); - element.setAttribute("y", this.scaleDrawingValue(shape.geometry.y, drawingScale)); - element.setAttribute("width", this.scaleDrawingValue(shape.geometry.width, drawingScale)); - element.setAttribute("height", this.scaleDrawingValue(shape.geometry.height, drawingScale)); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "circle") { - const element = document.createElementNS(SVG_NS, "circle"); - element.setAttribute("cx", this.scaleDrawingValue(shape.geometry.cx, drawingScale)); - element.setAttribute("cy", this.scaleDrawingValue(shape.geometry.cy, drawingScale)); - element.setAttribute("r", this.scaleDrawingValue(shape.geometry.r, drawingScale)); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "ellipse") { - const element = document.createElementNS(SVG_NS, "ellipse"); - element.setAttribute("cx", this.scaleDrawingValue(shape.geometry.cx, drawingScale)); - element.setAttribute("cy", this.scaleDrawingValue(shape.geometry.cy, drawingScale)); - element.setAttribute("rx", this.scaleDrawingValue(shape.geometry.rx, drawingScale)); - element.setAttribute("ry", this.scaleDrawingValue(shape.geometry.ry, drawingScale)); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "line") { - const element = document.createElementNS(SVG_NS, "line"); - element.setAttribute("x1", this.scaleDrawingValue(shape.geometry.point1.x, drawingScale)); - element.setAttribute("y1", this.scaleDrawingValue(shape.geometry.point1.y, drawingScale)); - element.setAttribute("x2", this.scaleDrawingValue(shape.geometry.point2.x, drawingScale)); - element.setAttribute("y2", this.scaleDrawingValue(shape.geometry.point2.y, drawingScale)); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "polygon") { - const roundedPath = this.roundedPointPath(shape, { closed: true, drawingScale }); - const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "polygon"); - const points = this.svgPointList(shape.geometry.points, drawingScale); - if (roundedPath) { - element.setAttribute("d", roundedPath); - element.dataset.roundedPointRender = "path"; - } - element.setAttribute("points", points); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "polyline") { - const roundedPath = this.roundedPointPath(shape, { closed: false, drawingScale }); - const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "polyline"); - const points = this.svgPointList(shape.geometry.points, drawingScale); - if (roundedPath) { - element.setAttribute("d", roundedPath); - element.dataset.roundedPointRender = "path"; - } - element.setAttribute("points", points); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "arc") { - const element = document.createElementNS(SVG_NS, "path"); - element.setAttribute("d", this.arcPath({ - ...shape.geometry, - cx: this.scaleDrawingValue(shape.geometry.cx, drawingScale), - cy: this.scaleDrawingValue(shape.geometry.cy, drawingScale), - r: this.scaleDrawingValue(shape.geometry.r, drawingScale) - })); - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - if (geometryTool === "text") { - const element = document.createElementNS(SVG_NS, "text"); - element.setAttribute("x", this.scaleDrawingValue(shape.geometry.x, drawingScale)); - element.setAttribute("y", this.scaleDrawingValue(shape.geometry.y, drawingScale)); - element.setAttribute("font-size", this.scaleDrawingValue(shape.geometry.fontSize, drawingScale)); - element.textContent = shape.geometry.text; - this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); - return element; - } - throw new Error(`unsupported shape tool ${shapeTool(shape)}`); - } - - svgPointList(points, drawingScale = 1) { - return points.map((point) => `${this.scaleDrawingValue(point.x, drawingScale)},${this.scaleDrawingValue(point.y, drawingScale)}`).join(" "); - } - - roundedPointPath(shape, { closed, drawingScale = 1 } = {}) { - const geometryTool = shapeGeometryTool(shape); - if (!["polygon", "polyline", "rectangle"].includes(geometryTool)) { - return ""; - } - const sourcePoints = geometryTool === "rectangle" ? this.shapeGeometryPoints(shape) : shape.geometry.points; - if (!Array.isArray(sourcePoints)) { - return ""; - } - const pointCount = sourcePoints.length; - if (pointCount < (closed ? 3 : 3)) { - return ""; - } - const pointRounding = this.shapePointRoundingValues(shape); - const roundedIndexes = pointRounding - .map((isRounded, index) => isRounded === true ? index : -1) - .filter((index) => index >= 0 && (closed || (index > 0 && index < pointCount - 1))); - if (!roundedIndexes.length) { - return ""; - } - const points = sourcePoints.map((point) => ({ - x: Number(this.scaleDrawingValue(point.x, drawingScale)), - y: Number(this.scaleDrawingValue(point.y, drawingScale)) - })); - const preferredRadius = this.shapeRoundingRadius(shape) * drawingScale; - if (preferredRadius <= 0) { - return ""; - } - const roundedVertex = (index) => { - if (pointRounding[index] !== true || (!closed && (index === 0 || index === pointCount - 1))) { - return null; - } - const previous = points[index === 0 ? pointCount - 1 : index - 1]; - const current = points[index]; - const next = points[index === pointCount - 1 ? 0 : index + 1]; - if (!previous || !current || !next) { - return null; - } - const previousDistance = Math.hypot(previous.x - current.x, previous.y - current.y); - const nextDistance = Math.hypot(next.x - current.x, next.y - current.y); - if (previousDistance <= 0 || nextDistance <= 0) { - return null; - } - const radius = Math.min(preferredRadius, previousDistance / 2, nextDistance / 2); - if (radius <= 0) { - return null; - } - return { - after: { - x: current.x + (next.x - current.x) / nextDistance * radius, - y: current.y + (next.y - current.y) / nextDistance * radius - }, - before: { - x: current.x + (previous.x - current.x) / previousDistance * radius, - y: current.y + (previous.y - current.y) / previousDistance * radius - }, - current - }; - }; - const commandPoint = (point) => `${this.formatViewportNumber(point.x)} ${this.formatViewportNumber(point.y)}`; - const appendVertex = (commands, index) => { - const rounded = roundedVertex(index); - if (!rounded) { - commands.push(`L ${commandPoint(points[index])}`); - return; - } - commands.push(`L ${commandPoint(rounded.before)}`); - commands.push(`Q ${commandPoint(rounded.current)} ${commandPoint(rounded.after)}`); - }; - if (closed) { - const start = roundedVertex(0)?.after || points[0]; - const commands = [`M ${commandPoint(start)}`]; - for (let index = 1; index < pointCount; index += 1) { - appendVertex(commands, index); - } - appendVertex(commands, 0); - commands.push("Z"); - return commands.join(" "); - } - const commands = [`M ${commandPoint(points[0])}`]; - for (let index = 1; index < pointCount - 1; index += 1) { - appendVertex(commands, index); - } - commands.push(`L ${commandPoint(points[pointCount - 1])}`); - return commands.join(" "); - } - - applySvgStyle(element, shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { - const geometryTool = shapeGeometryTool(shape); - element.setAttribute("fill", shape.style.fill); - element.setAttribute("stroke", shape.style.stroke); - element.setAttribute("stroke-width", shape.style.strokeWidth); - element.setAttribute("fill-opacity", shape.style.fillOpacity); - element.setAttribute("stroke-opacity", shape.style.strokeOpacity); - if (OPEN_POINT_STYLE_TOOLS.has(geometryTool)) { - const lineCap = this.openShapeUsesSplitPointStyles(shape) ? "butt" : this.shapeStartPointStyle(shape); - element.setAttribute("stroke-linecap", lineCap); - element.setAttribute("stroke-linejoin", this.strokeLinejoinValue(this.shapeUnifiedPointStyle(shape))); - element.dataset.pointStyle = this.shapeUnifiedPointStyle(shape); - element.dataset.startPointStyle = this.shapeStartPointStyle(shape); - element.dataset.endPointStyle = this.shapeEndPointStyle(shape); - } else if (CLOSED_POINT_STYLE_TOOLS.has(geometryTool)) { - const pointStyle = this.shapeUnifiedPointStyle(shape); - element.setAttribute("stroke-linecap", pointStyle); - element.setAttribute("stroke-linejoin", this.strokeLinejoinValue(pointStyle)); - element.dataset.pointStyle = pointStyle; - } - const transform = this.scaledDrawingTransform(this.shapeTransform(shape), drawingScale); - element.setAttribute("transform", this.svgTransformAttribute(transform, this.scaledDrawingPoint(transformOrigin, drawingScale))); - } - - appendShapePointStyleCaps(parent, shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { - const endpoints = this.openShapeEndpointPoints(shape); - const pointMarkers = this.shapePointStyleMarkers(shape); - if (!this.openShapeUsesSplitPointStyles(shape) && !pointMarkers.length) { - return; - } - const group = document.createElementNS(SVG_NS, "g"); - group.classList.add("object-vector-studio-v2__point-style-caps"); - group.dataset.pointStyleCaps = shapeTool(shape); - group.setAttribute("aria-hidden", "true"); - group.setAttribute("pointer-events", "none"); - group.setAttribute("transform", this.svgTransformAttribute( - this.scaledDrawingTransform(this.shapeTransform(shape), drawingScale), - this.scaledDrawingPoint(transformOrigin, drawingScale) - )); - if (this.openShapeUsesSplitPointStyles(shape) && endpoints) { - [ - ["start", endpoints.start.point, this.shapeStartPointStyle(shape), endpoints.start.angle], - ["end", endpoints.end.point, this.shapeEndPointStyle(shape), endpoints.end.angle] - ].forEach(([endpoint, point, pointStyle, angle]) => { - group.append(this.createPointStyleCapMarker(point, pointStyle, shape, endpoint, drawingScale, angle)); - }); - } - pointMarkers.forEach(({ angle = 0, markerId, point, pointStyle }) => { - group.append(this.createPointStyleCapMarker(point, pointStyle, shape, markerId, drawingScale, angle)); - }); - parent.append(group); - } - - shapePointStyleMarkers(shape) { - const geometryTool = shapeGeometryTool(shape); - if (!["polygon", "polyline", "rectangle"].includes(geometryTool)) { - return []; - } - const points = this.shapeGeometryPoints(shape); - const styles = this.shapePointStyleValues(shape); - return points - .map((point, index) => ({ index, point, pointStyle: styles[index] })) - .filter(({ index, pointStyle }) => pointStyle === "round" - && !(geometryTool === "polyline" && (index === 0 || index === points.length - 1))) - .map(({ index, point, pointStyle }) => ({ - markerId: `point-${index}`, - point, - pointStyle - })); - } - - createPointStyleCapMarker(point, pointStyle, shape, endpoint, drawingScale, angle = 0) { - const strokeWidth = Math.max(1, Number(shape.style?.strokeWidth) || 1); - const size = strokeWidth; - const x = this.scaleDrawingValue(point.x, drawingScale); - const y = this.scaleDrawingValue(point.y, drawingScale); - const marker = pointStyle === "square" - ? document.createElementNS(SVG_NS, "rect") - : document.createElementNS(SVG_NS, "circle"); - marker.classList.add("object-vector-studio-v2__point-style-cap"); - marker.dataset.pointStyleCap = endpoint; - marker.dataset.pointStyle = pointStyle; - marker.setAttribute("fill", shape.style.stroke); - marker.setAttribute("fill-opacity", shape.style.strokeOpacity); - marker.setAttribute("stroke", "none"); - if (pointStyle === "square") { - marker.setAttribute("x", x - size / 2); - marker.setAttribute("y", y - size / 2); - marker.setAttribute("width", size); - marker.setAttribute("height", size); - marker.setAttribute("transform", `rotate(${this.formatViewportNumber(angle)} ${this.formatViewportNumber(x)} ${this.formatViewportNumber(y)})`); - return marker; - } - marker.setAttribute("cx", x); - marker.setAttribute("cy", y); - marker.setAttribute("r", size / 2); - return marker; - } - - openShapeEndpointPoints(shape) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "line") { - const start = linePoint(shape.geometry, "point1"); - const end = linePoint(shape.geometry, "point2"); - const angle = this.angleBetweenPoints(start, end); - return { - end: { angle, point: end }, - start: { angle, point: start } - }; - } - if (geometryTool === "polyline" && Array.isArray(shape.geometry?.points) && shape.geometry.points.length >= 2) { - const points = shape.geometry.points; - return { - end: { angle: this.angleBetweenPoints(points.at(-2), points.at(-1)), point: points.at(-1) }, - start: { angle: this.angleBetweenPoints(points[0], points[1]), point: points[0] } - }; - } - if (geometryTool === "arc") { - const start = this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.startAngle); - const end = this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.endAngle); - return { - end: { angle: Number(shape.geometry.endAngle) + 90, point: end }, - start: { angle: Number(shape.geometry.startAngle) + 90, point: start } - }; - } - return null; - } - - angleBetweenPoints(start, end) { - if (!start || !end) { - return 0; - } - return Math.atan2(Number(end.y) - Number(start.y), Number(end.x) - Number(start.x)) * 180 / Math.PI; - } - - shapeTransform(shape) { - return this.ensureShapeTransform(shape || {}); - } - - effectiveShape(shape, shapeIndex = this.selectedShapeIndex) { - return this.effectiveShapeForFrame(shape, this.activeFrame(), shapeIndex); - } - - effectiveShapeForFrame(shape, frame, shapeIndex) { - const effective = JSON.parse(JSON.stringify(shape)); - const override = frame?.shapeOverrides?.find((entry) => entry.shapeIndex === shapeIndex) || null; - if (override && Object.prototype.hasOwnProperty.call(override, "visible")) { - effective.visible = override.visible; - } - if (override?.transform) { - effective.transform = { ...override.transform }; - } - return effective; - } - - svgTransformAttribute(transform, origin = { x: 0, y: 0 }) { - return objectVectorSvgTransformAttribute(transform, origin); - } - - scaleDrawingValue(value, drawingScale = 1) { - return drawingScale === 1 ? value : this.formatViewportNumber(value * drawingScale); - } - - scaledDrawingTransform(transform, drawingScale = 1) { - if (drawingScale === 1) { - return transform; - } - return { - ...transform, - x: this.scaleDrawingValue(transform.x, drawingScale), - y: this.scaleDrawingValue(transform.y, drawingScale) - }; - } - - scaledDrawingPoint(point, drawingScale = 1) { - if (drawingScale === 1) { - return point; - } - return { - x: this.scaleDrawingValue(point.x, drawingScale), - y: this.scaleDrawingValue(point.y, drawingScale) - }; - } - - scaledDrawingBounds(bounds, drawingScale = 1) { - if (drawingScale === 1) { - return bounds; - } - return { - height: this.scaleDrawingValue(bounds.height, drawingScale), - originX: this.scaleDrawingValue(bounds.originX, drawingScale), - originY: this.scaleDrawingValue(bounds.originY, drawingScale), - width: this.scaleDrawingValue(bounds.width, drawingScale), - x: this.scaleDrawingValue(bounds.x, drawingScale), - y: this.scaleDrawingValue(bounds.y, drawingScale) - }; - } - - transformedPoint(point, transform, origin = { x: 0, y: 0 }) { - return transformObjectVectorShapePoint(point, transform, origin); - } - - localPointFromTransformedPoint(point, transform, origin = { x: 0, y: 0 }) { - const localPoint = inverseTransformObjectVectorShapePoint(point, transform, origin); - return { - x: this.formatViewportNumber(localPoint.x), - y: this.formatViewportNumber(localPoint.y) - }; - } - - transformedBounds(shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { - return this.scaledDrawingBounds( - transformedObjectVectorShapeBounds(shapeBoundsPoints(shape), this.shapeTransform(shape), transformOrigin), - drawingScale - ); - } - - objectBounds(object, { drawingScale = 1, includeInvisible = true } = {}) { - const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - const shapes = sortedShapes(object) - .map((shape, shapeIndex) => ({ shape, shapeIndex })) - .map(({ shape, shapeIndex }) => ({ - shape: this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(shape, activeFrame, shapeIndex), objectScale), - shapeIndex - })) - .filter((entry) => includeInvisible || entry.shape.visible !== false); - if (!shapes.length) { - return { - height: 80, - width: 120, - x: -60, - y: -40 - }; - } - - return combineObjectVectorBounds(shapes.map(({ shape }) => this.transformedBounds(shape, { drawingScale, transformOrigin }))); - } - - rawVisibleObjectGeometryBounds(object) { - const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; - const bounds = sortedShapes(object) - .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, activeFrame, shapeIndex)) - .filter((shape) => shape.visible !== false) - .map((shape) => shapeBounds(shape)); - if (!bounds.length) { - return null; - } - return combineObjectVectorBounds(bounds); - } - - objectBoundsForFrame(object, frame) { - const transformOrigin = this.objectTransformOrigin(object); - const shapes = sortedShapes(object) - .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, frame, shapeIndex)) - .filter((shape) => shape.visible); - if (!shapes.length) { - return { - height: 80, - width: 120, - x: -60, - y: -40 - }; - } - return combineObjectVectorBounds(shapes.map((shape) => this.transformedBounds(shape, { transformOrigin }))); - } - - shapeSetBounds(object, shapeIndexes, { drawingScale = 1, includeInvisible = false } = {}) { - const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; - const objectShapes = sortedShapes(object); - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - const targetIndexes = Array.from(new Set(Array.from(shapeIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) - .sort((left, right) => left - right); - const shapes = targetIndexes - .map((shapeIndex) => this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex), objectScale)) - .filter((shape) => includeInvisible || shape.visible !== false); - if (!shapes.length) { - return null; - } - return combineObjectVectorBounds(shapes.map((shape) => this.transformedBounds(shape, { drawingScale, transformOrigin }))); - } - - renderSelectionOverlay(object) { - const selectedShape = this.selectedShape(); - if (!selectedShape || selectedShape.visible === false) { - return; - } - try { - const selectedIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - const bounds = selectedIndexes.length > 1 - ? this.shapeSetBounds(object, selectedIndexes, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }) - : this.transformedBounds(this.shapeWithObjectScalePreview(this.effectiveShape(selectedShape), objectScale), { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); - if (!bounds) { - return; - } - const dragArea = document.createElementNS(SVG_NS, "rect"); - dragArea.classList.add("object-vector-studio-v2__selection-drag-area"); - dragArea.dataset.selectionDragBounds = selectedIndexes.join(","); - dragArea.setAttribute("x", bounds.x - 4); - dragArea.setAttribute("y", bounds.y - 4); - dragArea.setAttribute("width", bounds.width + 8); - dragArea.setAttribute("height", bounds.height + 8); - dragArea.addEventListener("pointerdown", (event) => this.startPreviewSelectionBoundsMove(event)); - dragArea.addEventListener("click", (event) => this.handleSelectionDragAreaClick(event)); - dragArea.addEventListener("contextmenu", (event) => this.handleSelectionDragAreaContextMenu(event)); - this.elements.renderSurface.insertBefore(dragArea, this.elements.renderSurface.querySelector(".object-vector-studio-v2__shape")); - - const box = document.createElementNS(SVG_NS, "rect"); - box.classList.add("object-vector-studio-v2__selection-box"); - box.dataset.selectionBounds = String(this.selectedShapeIndex); - box.setAttribute("x", bounds.x - 4); - box.setAttribute("y", bounds.y - 4); - box.setAttribute("width", bounds.width + 8); - box.setAttribute("height", bounds.height + 8); - this.elements.renderSurface.append(box); - - [ - [bounds.x - 4, bounds.y - 4, "nw"], - [bounds.x + bounds.width + 4, bounds.y - 4, "ne"], - [bounds.x - 4, bounds.y + bounds.height + 4, "sw"], - [bounds.x + bounds.width + 4, bounds.y + bounds.height + 4, "se"] - ].forEach(([cx, cy, handle]) => { - const point = document.createElementNS(SVG_NS, "rect"); - point.classList.add("object-vector-studio-v2__resize-handle"); - point.dataset.resizeHandle = handle; - point.dataset.resizeShapeIndex = String(this.selectedShapeIndex); - if (shapeGeometryTool(selectedShape) === "rectangle") { - point.classList.add("object-vector-studio-v2__geometry-point-handle"); - point.dataset.geometryPointHandle = `rectangle-${handle}`; - point.dataset.geometryPointKind = "rectangle-corner"; - } - const handleSize = shapeGeometryTool(selectedShape) === "circle" ? 5 : 3; - point.setAttribute("x", cx - handleSize / 2); - point.setAttribute("y", cy - handleSize / 2); - point.setAttribute("width", handleSize); - point.setAttribute("height", handleSize); - point.addEventListener("pointerdown", (event) => this.startPreviewHandleEdit(event, this.selectedShapeIndex, { handle, mode: "resize" })); - this.elements.renderSurface.append(point); - }); - - this.renderGeometryPointHandles(selectedShape); - } catch (error) { - this.statusLog.write(`FAIL Selection overlay render failed for ${object.name}/shape-${this.selectedShapeIndex} (${shapeTool(selectedShape)}): ${error.message}`); - } - } - - selectedPreviewDragIndexes(object, preferredShapeIndex = this.selectedShapeIndex) { - const objectShapes = sortedShapes(object); - const preferredIndex = normalizeShapeIndex(preferredShapeIndex); - const selectedIndexes = Array.from(this.selectedShapeIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length && objectShapes[shapeIndex]?.visible !== false); - if (selectedIndexes.length) { - return Array.from(new Set(selectedIndexes)).sort((left, right) => left - right); - } - if (preferredIndex >= 0 && preferredIndex < objectShapes.length && objectShapes[preferredIndex]?.visible !== false) { - return [preferredIndex]; - } - return []; - } - - selectedPreviewDragGroupId(objectShapes, targetIndexes) { - if (!Array.isArray(targetIndexes) || targetIndexes.length < 2) { - return ""; - } - const groupIds = new Set(targetIndexes - .map((shapeIndex) => String(objectShapes[shapeIndex]?.groupId || "").trim()) - .filter(Boolean)); - return groupIds.size === 1 ? Array.from(groupIds)[0] : ""; - } - - selectedPreviewHitShapeIndex(object, shapeIndexes, point) { - const objectShapes = sortedShapes(object); - const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; - const transformOrigin = this.objectTransformOrigin(object); - const objectScale = this.objectScalePreviewValueForObject(object); - return Array.from(shapeIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length) - .reverse() - .find((shapeIndex) => { - const effectiveShape = this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex), objectScale); - if (!effectiveShape || effectiveShape.visible === false) { - return false; - } - const bounds = this.transformedBounds(effectiveShape, { transformOrigin }); - return point.x >= bounds.x - 4 - && point.x <= bounds.x + bounds.width + 4 - && point.y >= bounds.y - 4 - && point.y <= bounds.y + bounds.height + 4; - }); - } - - handleSelectionDragAreaContextMenu(event) { - const object = this.selectedObject(); - const targetIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); - const shapeIndex = this.selectedPreviewHitShapeIndex(object, targetIndexes, this.pointerPreviewPoint(event)); - if (!Number.isInteger(shapeIndex)) { - event.preventDefault(); - return; - } - this.handleShapeContextMenu(event, sortedShapes(object)[shapeIndex], shapeIndex); - } - - handleSelectionDragAreaClick(event) { - event.stopPropagation(); - if (Date.now() < (this.previewClickSuppressedUntil || 0)) { - event.preventDefault(); - return; - } - const object = this.selectedObject(); - const targetIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); - const shapeIndex = this.selectedPreviewHitShapeIndex(object, targetIndexes, this.pointerPreviewPoint(event)); - if (!Number.isInteger(shapeIndex)) { - return; - } - this.handleShapePointer(event, sortedShapes(object)[shapeIndex], shapeIndex, { source: "selection bounds click" }); - } - - renderGeometryPointHandles(shape) { - const effectiveShape = this.effectiveShape(shape); - const renderShape = this.shapeWithObjectScalePreview(effectiveShape, this.objectScalePreviewValueForObject(this.selectedObject())); - const renderTransform = this.shapeTransform(renderShape); - const transformOrigin = this.objectTransformOrigin(this.selectedObject()); - this.geometryPointHandleDefinitions(effectiveShape).forEach((definition) => { - const position = this.transformedPoint(definition.point, renderTransform, transformOrigin); - const point = document.createElementNS(SVG_NS, "rect"); - point.classList.add("object-vector-studio-v2__resize-handle", "object-vector-studio-v2__geometry-point-handle"); - if (definition.className) { - point.classList.add(definition.className); - } - point.dataset.geometryPointHandle = definition.id; - point.dataset.geometryPointKind = definition.kind; - point.dataset.resizeShapeIndex = String(this.selectedShapeIndex); - if (definition.endpoint) { - point.dataset.lineEndpoint = definition.endpoint; - } - point.setAttribute("x", this.scaleDrawingValue(position.x, OBJECT_PREVIEW_DRAWING_SCALE) - 2); - point.setAttribute("y", this.scaleDrawingValue(position.y, OBJECT_PREVIEW_DRAWING_SCALE) - 2); - point.setAttribute("width", 4); - point.setAttribute("height", 4); - point.setAttribute("aria-label", definition.label); - point.addEventListener("pointerdown", (event) => this.startPreviewHandleEdit(event, this.selectedShapeIndex, definition.editOptions)); - this.elements.renderSurface.append(point); - }); - } - - geometryPointHandleDefinitions(shape) { - const geometryTool = shapeGeometryTool(shape); - const geometry = shape.geometry; - if (geometryTool === "polygon" || geometryTool === "polyline") { - return geometry.points.map((point, index) => ({ - editOptions: { - mode: "geometry-point", - point: { ...point }, - pointIndex: index - }, - id: `${geometryTool}-${index}`, - kind: this.isTriangleShape(shape) ? "triangle-point" : `${geometryTool}-point`, - label: `Move point ${index + 1}`, - point - })); - } - if (geometryTool === "line") { - return [ - ["start", "point1", linePoint(geometry, "point1")], - ["end", "point2", linePoint(geometry, "point2")] - ].map(([endpoint, key, point], index) => ({ - className: "object-vector-studio-v2__line-endpoint-handle", - editOptions: { - endpoint, - mode: "line-endpoint", - point: { ...point } - }, - endpoint, - id: `line-${endpoint}`, - kind: key, - label: `Move line point ${index + 1}`, - point - })); - } - if (geometryTool === "circle") { - return [ - { - control: "center", - id: "circle-center", - kind: "center", - label: "Move circle center", - point: { x: geometry.cx, y: geometry.cy } - }, - { - control: "radius", - id: "circle-radius", - kind: "radius", - label: "Adjust circle radius", - point: { x: geometry.cx + geometry.r, y: geometry.cy } - } - ].map((definition) => ({ - ...definition, - editOptions: { - control: definition.control, - mode: "geometry-point", - point: { ...definition.point } - } - })); - } - if (geometryTool === "ellipse") { - return [ - { - control: "center", - id: "ellipse-center", - kind: "center", - label: "Move ellipse center", - point: { x: geometry.cx, y: geometry.cy } - }, - { - control: "rx", - id: "ellipse-rx", - kind: "rx", - label: "Adjust ellipse rx", - point: { x: geometry.cx + geometry.rx, y: geometry.cy } - }, - { - control: "ry", - id: "ellipse-ry", - kind: "ry", - label: "Adjust ellipse ry", - point: { x: geometry.cx, y: geometry.cy + geometry.ry } - } - ].map((definition) => ({ - ...definition, - editOptions: { - control: definition.control, - mode: "geometry-point", - point: { ...definition.point } - } - })); - } - if (geometryTool === "arc") { - return [ - { - control: "center", - id: "arc-center", - kind: "center", - label: "Move arc center", - point: { x: geometry.cx, y: geometry.cy } - }, - { - control: "start", - id: "arc-start", - kind: "startAngle", - label: "Move arc start point", - point: this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.startAngle) - }, - { - control: "end", - id: "arc-end", - kind: "endAngle", - label: "Move arc end point", - point: this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.endAngle) - } - ].map((definition) => ({ - ...definition, - editOptions: { - control: definition.control, - mode: "geometry-point", - point: { ...definition.point } - } - })); - } - if (geometryTool === "text") { - const point = { x: geometry.x, y: geometry.y }; - return [ - { - editOptions: { - control: "anchor", - mode: "geometry-point", - point: { ...point } - }, - id: "text-anchor", - kind: "anchor", - label: "Move text anchor", - point - } - ]; - } - return []; - } - - arcPath(geometry) { - const start = this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.startAngle); - const end = this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.endAngle); - const largeArc = Math.abs(geometry.endAngle - geometry.startAngle) > 180 ? 1 : 0; - return `M ${start.x} ${start.y} A ${geometry.r} ${geometry.r} 0 ${largeArc} 1 ${end.x} ${end.y}`; - } - - polarPoint(cx, cy, radius, degrees) { - const radians = (degrees - 90) * Math.PI / 180; - return { - x: Number((cx + radius * Math.cos(radians)).toFixed(3)), - y: Number((cy + radius * Math.sin(radians)).toFixed(3)) - }; - } - - updateViewport() { - const width = DEFAULT_VIEWPORT.width / this.viewport.zoom; - const height = DEFAULT_VIEWPORT.height / this.viewport.zoom; - const viewX = this.formatViewportNumber(this.viewport.x - width / 2); - const viewY = this.formatViewportNumber(this.viewport.y - height / 2); - this.elements.renderSurface.setAttribute("viewBox", `${viewX} ${viewY} ${this.formatViewportNumber(width)} ${this.formatViewportNumber(height)}`); - this.elements.coordinateDisplay.textContent = `Origin: ${this.formatLogicalCoordinate(this.viewport.x)}, ${this.formatLogicalCoordinate(this.viewport.y)} | ${this.canvasOriginDisplayText()} | Zoom ${this.formatZoomPercentage()}%`; - } - - formatViewportNumber(value) { - const normalized = Number(Number(value).toFixed(3)); - return Object.is(normalized, -0) ? 0 : normalized; - } - - formatCoordinateInputValue(value) { - return this.formatViewportNumber(value).toFixed(3); - } - - canvasOriginDisplayText() { - const x = this.formatLogicalCoordinate(-this.viewport.x); - const y = this.formatLogicalCoordinate(-this.viewport.y); - if (x === 0 && y === 0) { - return "Canvas origin 0,0 centered"; - } - return `Canvas origin ${x},${y} from center`; - } - - formatLogicalCoordinate(value) { - return this.formatViewportNumber(value / GRID_STEP); - } - - formatLogicalPointerCoordinate(value) { - return Math.round(this.formatLogicalCoordinate(value)); - } - - formatZoomPercentage() { - return Math.round(this.viewport.zoom * 100); - } - - zoomViewportByStep(step) { - const nextZoom = Number((this.viewport.zoom + step).toFixed(3)); - this.zoomViewport(nextZoom); - } - - zoomViewportByStepAtPointer(step, event) { - const nextZoom = Number((this.viewport.zoom + step).toFixed(3)); - this.zoomViewportAtPointer(nextZoom, event); - } - - zoomViewport(nextZoom) { - if (!Number.isFinite(nextZoom)) { - this.statusLog.write("WARN Viewport zoom skipped: zoom value is invalid."); - return; - } - this.viewport.zoom = this.clampedViewportZoom(nextZoom); - this.updateViewport(); - this.renderWorkSurface(); - this.statusLog.write(`OK Viewport zoom set to ${this.formatZoomPercentage()}%.`); - } - - zoomViewportAtPointer(nextZoom, event) { - if (!Number.isFinite(nextZoom)) { - this.statusLog.write("WARN Viewport zoom skipped: zoom value is invalid."); - return; - } - const anchor = this.viewportPointFromEvent(event); - if (!anchor) { - this.zoomViewport(nextZoom); - return; - } - const zoom = this.clampedViewportZoom(nextZoom); - const viewWidth = DEFAULT_VIEWPORT.width / zoom; - const viewHeight = DEFAULT_VIEWPORT.height / zoom; - this.viewport.zoom = zoom; - this.viewport.x = Number((anchor.x - (anchor.ratioX - 0.5) * viewWidth).toFixed(6)); - this.viewport.y = Number((anchor.y - (anchor.ratioY - 0.5) * viewHeight).toFixed(6)); - this.updateViewport(); - this.renderWorkSurface(); - this.updateCoordinateDisplay(event); - this.statusLog.write(`OK Viewport zoom set to ${this.formatZoomPercentage()}%.`); - } - - clampedViewportZoom(zoom) { - return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(zoom.toFixed(3)))); - } - - panViewport(x, y) { - this.viewport.x = Number((this.viewport.x + x).toFixed(3)); - this.viewport.y = Number((this.viewport.y + y).toFixed(3)); - this.updateViewport(); - this.statusLog.write(`OK Viewport pan set to ${this.viewport.x}, ${this.viewport.y}.`); - } - - toggleCenterOriginMarker() { - this.centerOriginVisible = !this.centerOriginVisible; - this.window.sessionStorage?.setItem(CENTER_ORIGIN_SESSION_KEY, this.centerOriginVisible ? "1" : "0"); - this.applySnapState(); - this.renderWorkSurface(); - this.statusLog.write(`OK Center dot ${this.centerOriginVisible ? "visible" : "hidden"}.`); - } - - resetViewport() { - this.viewport = { ...DEFAULT_VIEWPORT }; - this.updateViewport(); - this.statusLog.write(`OK Viewport reset to ${this.formatZoomPercentage()}% at origin 0,0.`); - } - - updateCoordinateDisplay(event) { - const point = this.viewportPointFromEvent(event); - if (!point) { - return; - } - this.elements.coordinateDisplay.textContent = `Pointer ${this.formatLogicalPointerCoordinate(point.x)}, ${this.formatLogicalPointerCoordinate(point.y)} | ${this.canvasOriginDisplayText()} | Zoom ${this.formatZoomPercentage()}%`; - } - - viewportPointFromEvent(event) { - return this.viewportPointFromClient(event.clientX, event.clientY); - } - - viewportPointFromClient(clientX, clientY, zoom = this.viewport.zoom, viewport = this.viewport) { - const bounds = this.elements.renderSurface.getBoundingClientRect(); - if (!bounds.width || !bounds.height) { - return null; - } - const ratioX = (clientX - bounds.left) / bounds.width; - const ratioY = (clientY - bounds.top) / bounds.height; - const viewWidth = DEFAULT_VIEWPORT.width / zoom; - const viewHeight = DEFAULT_VIEWPORT.height / zoom; - return { - ratioX, - ratioY, - x: viewport.x - viewWidth / 2 + ratioX * viewWidth, - y: viewport.y - viewHeight / 2 + ratioY * viewHeight - }; - } - - pointerPreviewPoint(event) { - const point = this.viewportPointFromEvent(event); - if (!point) { - return { x: 0, y: 0 }; - } - return { - x: this.formatViewportNumber(point.x / OBJECT_PREVIEW_DRAWING_SCALE), - y: this.formatViewportNumber(point.y / OBJECT_PREVIEW_DRAWING_SCALE) - }; - } - - startDrawingMode(tool) { - if (!PRIMITIVE_TOOLS.includes(tool)) { - this.statusLog.write(`WARN Drawing mode skipped: ${tool || "unknown"} is not a drawable shape tool.`); - return; - } - this.activeTool = tool; - this.previewPointerEdit = null; - this.activeDrawing = this.createDrawingState(tool); - this.drawingPreviewPoint = null; - this.drawingHintClientPoint = null; - this.renderWorkSurface(); - const objectHint = this.selectedObject() ? "" : " Select a schema-valid object before committing geometry."; - const drawingHelp = ["polygon", "polyline"].includes(tool) - ? "Click to add points.\n\nDouble-click to finish." - : "Click once to start, move to preview, then click again to finish."; - this.statusLog.write(`OK Drawing mode selected: ${shapeTypeLabel(tool)}. ${drawingHelp}${objectHint}`); - } - - createDrawingState(tool) { - const flow = ["polygon", "polyline"].includes(tool) - ? "points" - : "two-click"; - return { - flow, - points: [], - preview: null, - start: null, - style: this.drawingStyleFromActivePalette(), - tool - }; - } - - drawingStyleFromActivePalette() { - const { pointStyle, startPointStyle, endPointStyle, strokeLinecap, ...styleDefault } = this.schemaDefault("style"); - const strokeWidth = Number(this.elements.strokeWidth?.value); - return { - ...styleDefault, - fill: TRANSPARENT_STYLE_COLOR, - fillOpacity: this.selectedFillOpacity, - stroke: this.selectedStrokeColor || this.currentTargetColor() || "#ffffff", - strokeOpacity: this.selectedStrokeOpacity, - strokeWidth: Number.isFinite(strokeWidth) && strokeWidth > 0 ? strokeWidth : styleDefault.strokeWidth - }; - } - - cancelActiveDrawing(sourceLabel = "cancel", { log = true } = {}) { - if (!this.activeDrawing) { - return; - } - const tool = this.activeDrawing.tool; - this.activeDrawing = null; - this.drawingPreviewPoint = null; - this.drawingHintClientPoint = null; - this.activeTool = "select"; - const selectButton = this.elements.toolToggles.find((candidate) => candidate.dataset.shapeTool === "select") || null; - this.setActiveToolButton(selectButton); - this.renderWorkSurface(); - if (log) { - this.statusLog.write(`OK Canceled ${shapeTypeLabel(tool)} drawing from ${sourceLabel}; no invalid geometry was committed.`); - } - } - - handleRenderSurfacePointerDown(event) { - if (this.activeDrawing) { - this.handleDrawingPointerDown(event); - return; - } - if (event.button !== 0 || this.previewPointerEdit) { - return; - } - if (this.isEmptyCanvasPointerTarget(event.target)) { - if (this.startPreviewObjectMove(event)) { - return; - } - if (this.startViewportPan(event)) { - return; - } - this.deselectShape("empty canvas"); - } - } - - isEmptyCanvasPointerTarget(target) { - if (target === this.elements.renderSurface) { - return true; - } - return Boolean(target?.classList?.contains("object-vector-studio-v2__svg-grid") - || target?.closest?.(".object-vector-studio-v2__svg-grid") - || target?.classList?.contains("object-vector-studio-v2__center-origin-dot")); - } - - canDragSelectedObjectFromEmptyCanvas() { - const object = this.selectedObject(); - if (!object || this.activeDrawing || this.activeTool !== "select") { - return false; - } - if (this.selectedShapeIndex >= 0 || this.selectedShapeIndexes.size || this.directSelectedShapeIndexes.size) { - return false; - } - const targetIndexes = this.selectedObjectShapeIndexes(object); - if (!targetIndexes.length || this.isObjectLocked(object.id)) { - return false; - } - return !targetIndexes.some((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); - } - - canPanViewportFromEmptyCanvas() { - return !this.activeDrawing - && !this.previewPointerEdit - && this.activeTool === "select" - && this.selectedShapeIndex < 0 - && !this.selectedShapeIndexes.size - && !this.directSelectedShapeIndexes.size - && !this.canDragSelectedObjectFromEmptyCanvas(); - } - - updateRenderSurfaceCursorState() { - const draggingObject = this.previewPointerEdit?.mode === "move-object"; - const panningViewport = Boolean(this.viewportPan); - this.elements.renderSurface.classList.toggle("is-empty-canvas-object-drag-ready", this.canDragSelectedObjectFromEmptyCanvas()); - this.elements.renderSurface.classList.toggle("is-empty-canvas-object-dragging", draggingObject); - this.elements.renderSurface.classList.toggle("is-canvas-pan-ready", this.canPanViewportFromEmptyCanvas()); - this.elements.renderSurface.classList.toggle("is-canvas-panning", panningViewport); - } - - deselectShape(sourceLabel = "selection") { - if (this.selectedShapeIndex < 0 && !this.selectedShapeIndexes.size && !this.directSelectedShapeIndexes.size) { - return; - } - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - this.renderObjectTiles(); - this.renderSelectedObject(); - this.renderWorkSurface(); - this.updateObjectActionState(); - this.statusLog.write(`OK Deselected shape from ${sourceLabel}.`); - } - - handleDrawingPointerDown(event) { - const drawing = this.activeDrawing; - if (!drawing || event.button !== 0) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const point = this.snapDrawingPoint(this.pointerPreviewPoint(event), drawing); - this.drawingPreviewPoint = point; - this.drawingHintClientPoint = { x: event.clientX, y: event.clientY }; - if (drawing.flow === "points") { - if (!drawing.points.length) { - drawing.style = this.drawingStyleFromActivePalette(); - } - drawing.points.push(point); - drawing.preview = point; - this.renderWorkSurface(); - this.statusLog.write(`OK Added ${shapeTypeLabel(drawing.tool)} point ${drawing.points.length}.`); - return; - } - if (drawing.flow === "two-click") { - if (!drawing.start) { - drawing.style = this.drawingStyleFromActivePalette(); - drawing.start = point; - drawing.preview = null; - this.renderWorkSurface(); - this.statusLog.write(`OK Started ${shapeTypeLabel(drawing.tool)} drawing.`); - return; - } - drawing.preview = point; - const geometry = this.geometryFromDrawingPoints(drawing.tool, [drawing.start, point], point); - this.commitDrawnShape(drawing.tool, geometry, "canvas click"); - return; - } - this.renderWorkSurface(); - } - - updateDrawingPreview(event) { - const drawing = this.activeDrawing; - if (!drawing) { - return; - } - if (drawing.flow === "two-click" && !drawing.start) { - return; - } - const point = this.snapDrawingPoint(this.pointerPreviewPoint(event), drawing); - if (drawing.preview && Math.abs(point.x - drawing.preview.x) < 0.001 && Math.abs(point.y - drawing.preview.y) < 0.001) { - return; - } - drawing.preview = point; - this.drawingPreviewPoint = point; - this.drawingHintClientPoint = { x: event.clientX, y: event.clientY }; - this.renderWorkSurface(); - } - - finishMultiPointDrawing(sourceLabel = "finish", event = null) { - const drawing = this.activeDrawing; - if (!drawing || drawing.flow !== "points" || !["polygon", "polyline"].includes(drawing.tool)) { - return; - } - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - const geometry = this.geometryFromDrawingPoints(drawing.tool, drawing.points, drawing.preview || drawing.points.at(-1)); - this.commitDrawnShape(drawing.tool, geometry, sourceLabel); - } - - previewShapeFromDrawing() { - const drawing = this.activeDrawing; - if (!drawing) { - return null; - } - const points = [...drawing.points]; - if (drawing.flow === "points" && drawing.preview && points.length) { - points.push(drawing.preview); - } - if (drawing.flow === "two-click" && drawing.start && drawing.preview) { - points.push(drawing.start, drawing.preview); - } - if (!points.length) { - return null; - } - const geometry = this.geometryFromDrawingPoints(drawing.tool === "polygon" && points.length < 4 ? "polyline" : drawing.tool, points, drawing.preview); - if (!geometry) { - return null; - } - const tool = drawing.tool === "polygon" && points.length < 4 ? "polyline" : drawing.tool; - const shape = { - geometry, - locked: false, - order: 0, - style: { - ...drawing.style - }, - tool, - transform: defaultShapeTransform(), - visible: true - }; - return shape; - } - - geometryFromDrawingPoints(tool, points, previewPoint = null) { - const cleanPoints = points - .filter(Boolean) - .map((point) => ({ - x: this.formatViewportNumber(point.x), - y: this.formatViewportNumber(point.y) - })); - if (tool === "line") { - if (cleanPoints.length < 2) { - return null; - } - return { point1: cleanPoints[0], point2: cleanPoints[1] }; - } - if (tool === "polygon" || tool === "polyline") { - return { points: cleanPoints }; - } - if (tool === "rectangle" || tool === "square") { - if (cleanPoints.length < 2) { - return null; - } - const [start, end] = cleanPoints; - const width = Math.abs(end.x - start.x); - const height = Math.abs(end.y - start.y); - if (tool === "square") { - const size = Math.max(width, height); - return { - height: size, - width: size, - x: end.x < start.x ? start.x - size : start.x, - y: end.y < start.y ? start.y - size : start.y - }; - } - return { - height, - width, - x: Math.min(start.x, end.x), - y: Math.min(start.y, end.y) - }; - } - if (tool === "triangle") { - if (cleanPoints.length < 2) { - return null; - } - const [start, end] = cleanPoints; - const minX = Math.min(start.x, end.x); - const maxX = Math.max(start.x, end.x); - const minY = Math.min(start.y, end.y); - const maxY = Math.max(start.y, end.y); - return { - points: [ - { x: Number(((minX + maxX) / 2).toFixed(3)), y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY } - ] - }; - } - if (tool === "circle") { - if (cleanPoints.length < 2) { - return null; - } - const [center, edge] = cleanPoints; - return { - cx: center.x, - cy: center.y, - r: this.radiusFromControlPoint(center, edge) - }; - } - if (tool === "ellipse") { - if (cleanPoints.length < 2) { - return null; - } - const [start, end] = cleanPoints; - return { - cx: Number(((start.x + end.x) / 2).toFixed(3)), - cy: Number(((start.y + end.y) / 2).toFixed(3)), - rx: Number((Math.max(0, Math.abs(end.x - start.x)) / 2).toFixed(3)), - ry: Number((Math.max(0, Math.abs(end.y - start.y)) / 2).toFixed(3)) - }; - } - if (tool === "arc") { - if (cleanPoints.length < 2) { - return null; - } - const [center, edge] = cleanPoints; - return { - cx: center.x, - cy: center.y, - endAngle: 135, - r: this.radiusFromControlPoint(center, edge), - startAngle: -135 - }; - } - if (tool === "text") { - if (cleanPoints.length < 2) { - return null; - } - const [start, end] = cleanPoints; - const width = Math.abs(end.x - start.x); - const height = Math.abs(end.y - start.y); - if (width <= 0 && height <= 0) { - return null; - } - const fontSize = Number(Math.max(1, height || width * 0.5).toFixed(3)); - return { - fontSize, - text: "Text", - x: Math.min(start.x, end.x), - y: Number((Math.min(start.y, end.y) + fontSize).toFixed(3)) - }; - } - return null; - } - - validateDrawnGeometry(tool, geometry) { - if (!geometry) { - return `${shapeTypeLabel(tool)} drawing has no geometry.`; - } - if (tool === "line") { - const distance = Math.hypot(geometry.point2.x - geometry.point1.x, geometry.point2.y - geometry.point1.y); - return distance > 0 ? "" : "line requires two distinct points."; - } - if (tool === "polyline") { - return geometry.points.length >= 2 ? "" : "polyline requires at least two points."; - } - if (tool === "polygon") { - return geometry.points.length >= 4 ? "" : "polygon requires at least four points."; - } - if (tool === "triangle") { - return geometry.points.length === 3 ? "" : "triangle requires exactly three points."; - } - if (tool === "rectangle" || tool === "square") { - return geometry.width > 0 && geometry.height > 0 ? "" : `${shapeTypeLabel(tool)} requires non-zero width and height.`; - } - if (tool === "circle" || tool === "arc") { - return geometry.r > 0 ? "" : `${shapeTypeLabel(tool)} requires a non-zero radius.`; - } - if (tool === "ellipse") { - return geometry.rx > 0 && geometry.ry > 0 ? "" : "ellipse requires non-zero rx and ry."; - } - if (tool === "text") { - return geometry.text && geometry.fontSize > 0 ? "" : "text requires two distinct points, content, and a positive font size."; - } - return ""; - } - - commitDrawnShape(type, geometry, sourceLabel) { - const geometryError = this.validateDrawnGeometry(type, geometry); - if (geometryError) { - this.statusLog.write(`FAIL Create ${type} blocked: ${geometryError}`); - return; - } - this.createShape(type, { geometry, sourceLabel, style: this.activeDrawing?.style || null }); - } - - startPreviewObjectMove(event) { - if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || !this.canDragSelectedObjectFromEmptyCanvas()) { - return false; - } - const object = this.selectedObject(); - const objectShapes = sortedShapes(object); - const targetIndexes = this.selectedObjectShapeIndexes(object); - const activeFrame = this.activeFrame(); - event.preventDefault(); - event.stopPropagation(); - this.previewPointerEdit = { - ...this.previewPointerEditStartMetadata(event), - mode: "move-object", - objectId: object.id, - objectName: object.name, - originalTransforms: Object.fromEntries(targetIndexes.map((shapeIndex) => { - const effectiveShape = this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex); - return [shapeIndex, this.ensureShapeTransform(effectiveShape)]; - })), - shapeIndex: -1, - start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: -1 }), - targetIndexes - }; - this.updateRenderSurfaceCursorState(); - return true; - } - - startViewportPan(event) { - if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || !this.canPanViewportFromEmptyCanvas()) { - return false; - } - event.preventDefault(); - event.stopPropagation(); - this.viewportPan = { - dragThresholdMet: false, - pointerId: event.pointerId, - startClient: { - x: Number(event.clientX) || 0, - y: Number(event.clientY) || 0 - }, - startViewport: { ...this.viewport } - }; - try { - this.elements.renderSurface.setPointerCapture?.(event.pointerId); - } catch { - // Pointer capture is best-effort for synthetic validation events. - } - this.updateRenderSurfaceCursorState(); - return true; - } - - updateViewportPan(event) { - const pan = this.viewportPan; - if (!pan || event.buttons !== 1) { - return; - } - const clientX = Number(event.clientX) || 0; - const clientY = Number(event.clientY) || 0; - const distance = Math.hypot(clientX - pan.startClient.x, clientY - pan.startClient.y); - if (!pan.dragThresholdMet && distance < PREVIEW_DRAG_START_THRESHOLD_PX) { - return; - } - pan.dragThresholdMet = true; - const bounds = this.elements.renderSurface.getBoundingClientRect(); - if (!bounds.width || !bounds.height) { - return; - } - const viewWidth = DEFAULT_VIEWPORT.width / pan.startViewport.zoom; - const viewHeight = DEFAULT_VIEWPORT.height / pan.startViewport.zoom; - const viewportX = Number((pan.startViewport.x - ((clientX - pan.startClient.x) / bounds.width) * viewWidth).toFixed(3)); - const viewportY = Number((pan.startViewport.y - ((clientY - pan.startClient.y) / bounds.height) * viewHeight).toFixed(3)); - if (viewportX === this.viewport.x && viewportY === this.viewport.y) { - return; - } - this.viewport.x = viewportX; - this.viewport.y = viewportY; - this.updateViewport(); - this.updateCoordinateDisplay(event); - } - - finishViewportPan(event) { - const pan = this.viewportPan; - if (!pan) { - return false; - } - this.viewportPan = null; - try { - this.elements.renderSurface.releasePointerCapture?.(pan.pointerId); - } catch { - // Pointer capture is best-effort for synthetic validation events. - } - this.updateRenderSurfaceCursorState(); - if (!pan.dragThresholdMet) { - this.deselectShape("empty canvas"); - return true; - } - this.updateCoordinateDisplay(event); - this.statusLog.write(`OK Viewport drag-pan set to ${this.viewport.x}, ${this.viewport.y}.`); - return true; - } - - startPreviewSelectionBoundsMove(event) { - this.startPreviewShapeMove(event, this.selectedShapeIndex, { fromSelectionBounds: true }); - } - - startPreviewShapeMove(event, shapeIndex, options = {}) { - const normalizedIndex = normalizeShapeIndex(shapeIndex); - if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { - return; - } - const object = this.selectedObject(); - const objectShapes = sortedShapes(object); - const targetIndexes = this.selectedPreviewDragIndexes(object, normalizedIndex); - const dragAnchorIndex = options.fromSelectionBounds ? this.selectedShapeIndex : normalizedIndex; - if (!targetIndexes.includes(dragAnchorIndex)) { - return; - } - const selected = objectShapes[dragAnchorIndex] || null; - if (!selected || selected.visible === false) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const groupId = this.selectedPreviewDragGroupId(objectShapes, targetIndexes); - if (targetIndexes.length > 1) { - const lockedIndex = targetIndexes.find((targetIndex) => objectShapes[targetIndex]?.locked); - if (Number.isInteger(lockedIndex)) { - this.statusLog.write(`WARN Drag selection skipped: shape row ${lockedIndex} is locked.`); - return; - } - const activeFrame = this.activeFrame(); - const directSelectedShapeIndexes = this.directSelectedShapeIndexes.size - ? new Set(Array.from(this.directSelectedShapeIndexes).filter((targetIndex) => targetIndexes.includes(targetIndex))) - : new Set(targetIndexes); - this.selectedShapeIndexes = new Set(targetIndexes); - this.directSelectedShapeIndexes = directSelectedShapeIndexes.size ? directSelectedShapeIndexes : new Set(targetIndexes); - this.previewPointerEdit = { - ...this.previewPointerEditStartMetadata(event), - mode: "move-group", - groupId, - directSelectedShapeIndexes: new Set(this.directSelectedShapeIndexes), - originalTransforms: Object.fromEntries(targetIndexes.map((targetIndex) => { - const effectiveShape = this.effectiveShapeForFrame(objectShapes[targetIndex], activeFrame, targetIndex); - return [targetIndex, this.ensureShapeTransform(effectiveShape)]; - })), - shapeIndex: dragAnchorIndex, - start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: dragAnchorIndex }), - targetIndexes - }; - return; - } - if (this.selectedShapeIndex !== dragAnchorIndex) { - return; - } - this.previewPointerEdit = { - ...this.previewPointerEditStartMetadata(event), - mode: "move", - originalGeometry: JSON.parse(JSON.stringify(selected.geometry)), - originalTransform: { ...this.shapeTransform(selected) }, - shapeIndex: normalizedIndex, - start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: normalizedIndex }) - }; - } - - startPreviewHandleEdit(event, shapeIndex, options) { - const normalizedIndex = normalizeShapeIndex(shapeIndex); - if (event.button !== 0) { - return; - } - const selected = this.selectedShape(); - if (!selected || this.selectedShapeIndex !== normalizedIndex) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const effectiveSelected = this.effectiveShape(selected, normalizedIndex); - const originalTransform = { ...this.shapeTransform(effectiveSelected) }; - const transformOrigin = this.objectTransformOrigin(this.selectedObject()); - const rawPointer = this.pointerPreviewPoint(event); - const transformedPoint = options?.point - ? this.transformedPoint(options.point, originalTransform, transformOrigin) - : null; - this.previewPointerEdit = { - ...this.previewPointerEditStartMetadata(event), - ...options, - originalGeometry: JSON.parse(JSON.stringify(selected.geometry)), - originalTransform, - transformOrigin, - pointerOffset: transformedPoint - ? { - x: this.formatViewportNumber(rawPointer.x - transformedPoint.x), - y: this.formatViewportNumber(rawPointer.y - transformedPoint.y) - } - : null, - shapeIndex: normalizedIndex, - start: transformedPoint || this.snapCanvasPoint(rawPointer, { excludeShapeIndex: normalizedIndex }) - }; - } - - previewPointerEditStartMetadata(event) { - return { - dragThresholdMet: false, - historyRecorded: false, - historySnapshot: this.cloneCurrentPayload(), - lastDelta: { x: 0, y: 0 }, - startClient: { - x: Number(event.clientX) || 0, - y: Number(event.clientY) || 0 - } - }; - } - - previewPointerEditPastDragThreshold(edit, event) { - if (edit.dragThresholdMet) { - return true; - } - if (!edit.startClient) { - edit.dragThresholdMet = true; - return true; - } - const distance = Math.hypot((Number(event.clientX) || 0) - edit.startClient.x, (Number(event.clientY) || 0) - edit.startClient.y); - if (distance < PREVIEW_DRAG_START_THRESHOLD_PX) { - return false; - } - edit.dragThresholdMet = true; - return true; - } - - previewPointerEditDelta(edit, event) { - const end = this.previewPointerEditTargetPoint(edit, event); - edit.currentTargetPoint = end; - return { - x: this.formatViewportNumber(end.x - edit.start.x), - y: this.formatViewportNumber(end.y - edit.start.y) - }; - } - - previewPointerEditTargetPoint(edit, event) { - const rawEnd = this.pointerPreviewPoint(event); - const target = edit.pointerOffset - ? { - x: this.formatViewportNumber(rawEnd.x - edit.pointerOffset.x), - y: this.formatViewportNumber(rawEnd.y - edit.pointerOffset.y) - } - : rawEnd; - return this.snapCanvasPoint(target, { excludeShapeIndex: edit.shapeIndex }); - } - - previewPointerEditLocalTarget(edit) { - const target = edit.currentTargetPoint || edit.start; - if (!edit.originalTransform) { - return this.normalizeDrawingPoint(target); - } - return this.localPointFromTransformedPoint(target, edit.originalTransform, edit.transformOrigin || { x: 0, y: 0 }); - } - - updatePreviewPointerEdit(event) { - const edit = this.previewPointerEdit; - if (!edit || event.buttons !== 1) { - return; - } - if (!this.previewPointerEditPastDragThreshold(edit, event)) { - return; - } - const delta = this.previewPointerEditDelta(edit, event); - if (Math.abs(delta.x - edit.lastDelta.x) < 0.001 && Math.abs(delta.y - edit.lastDelta.y) < 0.001) { - return; - } - this.recordPreviewPointerEditStart(edit); - edit.lastDelta = delta; - edit.didMove = true; - this.applyPreviewPointerEdit(edit, delta, { live: true }); - } - - finishPreviewPointerEdit(event) { - const edit = this.previewPointerEdit; - if (!edit) { - return; - } - this.previewPointerEdit = null; - this.updateRenderSurfaceCursorState(); - if (!edit.dragThresholdMet && !this.previewPointerEditPastDragThreshold(edit, event)) { - return; - } - const delta = this.previewPointerEditDelta(edit, event); - if (Math.abs(delta.x) < 0.001 && Math.abs(delta.y) < 0.001) { - return; - } - this.previewClickSuppressedUntil = Date.now() + 500; - this.recordPreviewPointerEditStart(edit); - this.applyPreviewPointerEdit(edit, delta, { live: false }); - } - - recordPreviewPointerEditStart(edit) { - if (edit.historyRecorded || !edit.historySnapshot) { - return; - } - this.previewUndoStack.push(this.clonePayloadValue(edit.historySnapshot)); - if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { - this.previewUndoStack.shift(); - } - this.previewRedoStack = []; - edit.historyRecorded = true; - this.updatePreviewEditActionState(); - } - - applyPreviewPointerEdit(edit, delta, { live = false } = {}) { - if (edit.mode === "move-object") { - this.applyPreviewObjectMove(edit, delta, { live }); - return; - } - - if (edit.mode === "move") { - this.updateSelectedShapeTransform("preview move", (shape) => { - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((edit.originalTransform.x + delta.x).toFixed(3)); - shape.transform.y = Number((edit.originalTransform.y + delta.y).toFixed(3)); - }, `OK ${live ? "Live " : ""}Dragged shape row ${edit.shapeIndex} by ${delta.x}, ${delta.y}.`, { skipPreviewHistory: true }); - return; - } - - if (edit.mode === "move-group") { - this.applyPreviewGroupMove(edit, delta, { live }); - return; - } - - if (edit.mode === "line-endpoint") { - this.updateSelectedShapeGeometry("preview line endpoint", (shape) => { - shape.geometry = this.previewLineEndpointGeometry(shape, edit, delta); - }, `OK ${live ? "Live " : ""}Moved line ${edit.endpoint} for shape row ${edit.shapeIndex}.`, { skipPreviewHistory: true }); - return; - } - - if (edit.mode === "geometry-point") { - this.updateSelectedShapeGeometry("preview point handle", (shape) => { - shape.geometry = this.previewGeometryPointGeometry(shape, edit, delta); - }, `OK ${live ? "Live " : ""}Moved geometry point ${edit.control || edit.pointIndex + 1 || "handle"} for shape row ${edit.shapeIndex}.`, { skipPreviewHistory: true }); - return; - } - - if (edit.mode === "resize") { - this.updateSelectedShapeGeometry("preview resize", (shape) => { - shape.geometry = this.previewResizeGeometry(shape, edit, delta); - }, `OK ${live ? "Live " : ""}Resized shape row ${edit.shapeIndex} with ${edit.handle} handle.`, { skipPreviewHistory: true }); - } - } - - applyPreviewObjectMove(edit, delta, { live = false } = {}) { - const object = this.selectedObject(); - const objectShapes = sortedShapes(object); - const targetIndexes = Array.from(new Set((edit.targetIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) - .sort((left, right) => left - right); - if (!object || object.id !== edit.objectId || !targetIndexes.length) { - this.statusLog.write("WARN Drag object skipped: selected object is not available."); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const activeFrame = this.activeFrame(); - try { - targetIndexes.forEach((shapeIndex) => { - const originalTransform = edit.originalTransforms?.[shapeIndex]; - if (!originalTransform) { - throw new Error(`shape row ${shapeIndex} original transform is not available.`); - } - const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); - const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); - const shape = override - ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) - : baseShape; - if (!shape) { - throw new Error(`shape row ${shapeIndex} is not available.`); - } - shape.transform = this.ensureShapeTransform({ - transform: { - ...originalTransform, - x: Number((originalTransform.x + delta.x).toFixed(3)), - y: Number((originalTransform.y + delta.y).toFixed(3)) - } - }); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - }); - } catch (error) { - this.statusLog.write(`FAIL Invalid object drag rejected for ${object.name}: ${error.message}`); - return; - } - - this.commitPayloadUpdate(nextPayload, object.id, -1, `OK ${live ? "Live " : ""}Dragged object ${object.name} by ${delta.x}, ${delta.y}.`, "Drag object failed schema validation", { - directSelectedShapeIndexes: new Set(), - preserveEmptyShapeSelection: true, - selectedShapeIndexes: new Set(), - skipPreviewHistory: true - }); - } - - applyPreviewGroupMove(edit, delta, { live = false } = {}) { - const object = this.selectedObject(); - const objectShapes = sortedShapes(object); - const targetIndexes = Array.from(new Set((edit.targetIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) - .sort((left, right) => left - right); - if (!object || targetIndexes.length < 2) { - this.statusLog.write("WARN Drag selection skipped: selected shape set is not available."); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const activeFrame = this.activeFrame(); - try { - targetIndexes.forEach((shapeIndex) => { - const originalTransform = edit.originalTransforms?.[shapeIndex]; - if (!originalTransform) { - throw new Error(`shape row ${shapeIndex} original transform is not available.`); - } - const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); - const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); - const shape = override - ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) - : baseShape; - if (!shape) { - throw new Error(`shape row ${shapeIndex} is not available.`); - } - shape.transform = this.ensureShapeTransform({ - transform: { - ...originalTransform, - x: Number((originalTransform.x + delta.x).toFixed(3)), - y: Number((originalTransform.y + delta.y).toFixed(3)) - } - }); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - }); - } catch (error) { - this.statusLog.write(`FAIL Invalid drag rejected for ${edit.groupId ? `group ${edit.groupId}` : "selected shapes"}: ${error.message}`); - return; - } - - const dragSubject = edit.groupId ? `group ${edit.groupId}` : "selected shapes"; - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK ${live ? "Live " : ""}Dragged ${dragSubject} (${targetIndexes.length} shapes) by ${delta.x}, ${delta.y}.`, "Drag selection failed schema validation", { - directSelectedShapeIndexes: edit.directSelectedShapeIndexes || new Set([edit.shapeIndex]), - selectedShapeIndexes: new Set(targetIndexes), - skipPreviewHistory: true - }); - } - - previewLineEndpointGeometry(shape, edit, delta) { - const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); - if (shapeGeometryTool(shape) !== "line") { - return geometry; - } - const movedPoint = this.previewPointerEditLocalTarget(edit); - if (edit.endpoint === "start") { - geometry.point1 = movedPoint; - } else { - geometry.point2 = movedPoint; - } - return geometry; - } - - previewResizeGeometry(shape, edit, delta) { - const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "rectangle") { - if (edit.handle.includes("w")) { - geometry.x = Number((edit.originalGeometry.x + delta.x).toFixed(3)); - geometry.width = Number((edit.originalGeometry.width - delta.x).toFixed(3)); - } - if (edit.handle.includes("e")) { - geometry.width = Number((edit.originalGeometry.width + delta.x).toFixed(3)); - } - if (edit.handle.includes("n")) { - geometry.y = Number((edit.originalGeometry.y + delta.y).toFixed(3)); - geometry.height = Number((edit.originalGeometry.height - delta.y).toFixed(3)); - } - if (edit.handle.includes("s")) { - geometry.height = Number((edit.originalGeometry.height + delta.y).toFixed(3)); - } - return geometry; - } - if (geometryTool === "ellipse") { - geometry.rx = Number(Math.max(1, edit.originalGeometry.rx + (edit.handle.includes("e") ? delta.x : -delta.x)).toFixed(3)); - geometry.ry = Number(Math.max(1, edit.originalGeometry.ry + (edit.handle.includes("s") ? delta.y : -delta.y)).toFixed(3)); - return geometry; - } - if (geometryTool === "circle") { - const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); - const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); - const diameter = Math.max(1, Math.min(Math.abs(nextBounds.width), Math.abs(nextBounds.height))); - geometry.cx = Number((nextBounds.x + nextBounds.width / 2).toFixed(3)); - geometry.cy = Number((nextBounds.y + nextBounds.height / 2).toFixed(3)); - geometry.r = Number((diameter / 2).toFixed(3)); - return geometry; - } - if (geometryTool === "polygon" || geometryTool === "polyline") { - const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); - const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); - geometry.points = edit.originalGeometry.points.map((point) => this.mapPointBetweenBounds(point, bounds, nextBounds)); - return geometry; - } - if (geometryTool === "line") { - const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); - const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); - geometry.point1 = this.mapPointBetweenBounds(edit.originalGeometry.point1, bounds, nextBounds); - geometry.point2 = this.mapPointBetweenBounds(edit.originalGeometry.point2, bounds, nextBounds); - return geometry; - } - if (geometryTool === "arc") { - const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); - const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); - geometry.cx = Number((nextBounds.x + nextBounds.width / 2).toFixed(3)); - geometry.cy = Number((nextBounds.y + nextBounds.height / 2).toFixed(3)); - geometry.r = Number((Math.max(1, Math.min(nextBounds.width, nextBounds.height) / 2)).toFixed(3)); - return geometry; - } - if (geometryTool === "text") { - const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); - const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); - const anchor = this.mapPointBetweenBounds({ x: edit.originalGeometry.x, y: edit.originalGeometry.y }, bounds, nextBounds); - const scaleY = nextBounds.height / Math.max(1, bounds.height); - geometry.x = anchor.x; - geometry.y = anchor.y; - geometry.fontSize = Number(Math.max(1, edit.originalGeometry.fontSize * scaleY).toFixed(3)); - return geometry; - } - return geometry; - } - - resizedGeometryBounds(bounds, handle, delta) { - const next = { ...bounds }; - if (handle.includes("w")) { - next.x = Number((bounds.x + delta.x).toFixed(3)); - next.width = Number((bounds.width - delta.x).toFixed(3)); - } - if (handle.includes("e")) { - next.width = Number((bounds.width + delta.x).toFixed(3)); - } - if (handle.includes("n")) { - next.y = Number((bounds.y + delta.y).toFixed(3)); - next.height = Number((bounds.height - delta.y).toFixed(3)); - } - if (handle.includes("s")) { - next.height = Number((bounds.height + delta.y).toFixed(3)); - } - if (next.width <= 0 || next.height <= 0) { - throw new Error("resize would invert or collapse geometry bounds."); - } - return next; - } - - mapPointBetweenBounds(point, bounds, nextBounds) { - const xRatio = bounds.width <= 0 ? 0 : (point.x - bounds.x) / bounds.width; - const yRatio = bounds.height <= 0 ? 0 : (point.y - bounds.y) / bounds.height; - return { - x: Number((nextBounds.x + xRatio * nextBounds.width).toFixed(3)), - y: Number((nextBounds.y + yRatio * nextBounds.height).toFixed(3)) - }; - } - - previewGeometryPointGeometry(shape, edit, delta) { - const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); - const geometryTool = shapeGeometryTool(shape); - const movedPoint = this.previewPointerEditLocalTarget(edit); - if (geometryTool === "polygon" || geometryTool === "polyline") { - const pointIndex = normalizeShapeIndex(edit.pointIndex); - if (pointIndex >= 0 && pointIndex < geometry.points.length) { - geometry.points[pointIndex] = movedPoint; - } - return geometry; - } - if (geometryTool === "circle") { - if (edit.control === "center") { - geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); - geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); - return geometry; - } - if (edit.control === "radius") { - geometry.r = this.radiusFromControlPoint({ x: edit.originalGeometry.cx, y: edit.originalGeometry.cy }, movedPoint); - } - return geometry; - } - if (geometryTool === "ellipse") { - if (edit.control === "center") { - geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); - geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); - return geometry; - } - if (edit.control === "rx") { - geometry.rx = this.axisRadiusFromControlPoint(edit.originalGeometry.cx, movedPoint.x); - } - if (edit.control === "ry") { - geometry.ry = this.axisRadiusFromControlPoint(edit.originalGeometry.cy, movedPoint.y); - } - return geometry; - } - if (geometryTool === "arc") { - if (edit.control === "center") { - geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); - geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); - return geometry; - } - if (edit.control === "start" || edit.control === "end") { - const center = { x: edit.originalGeometry.cx, y: edit.originalGeometry.cy }; - geometry.r = this.radiusFromControlPoint(center, movedPoint); - geometry[edit.control === "start" ? "startAngle" : "endAngle"] = this.angleFromControlPoint(center, movedPoint); - } - return geometry; - } - if (geometryTool === "text") { - geometry.x = movedPoint.x; - geometry.y = movedPoint.y; - return geometry; - } - return geometry; - } - - radiusFromControlPoint(center, point) { - return Number(Math.max(1, Math.hypot(point.x - center.x, point.y - center.y)).toFixed(3)); - } - - axisRadiusFromControlPoint(centerValue, pointValue) { - return Number(Math.max(1, Math.abs(pointValue - centerValue)).toFixed(3)); - } - - angleFromControlPoint(center, point) { - const degrees = (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI + 90; - return this.normalizeRotationDegrees(degrees); - } - - selectObject(objectId, sourceLabel) { - if (!this.currentPayload?.objects.some((object) => object.id === objectId)) { - this.statusLog.write(`WARN Select object skipped: object id ${objectId || "unknown"} is not available.`); - return; - } - - const scrollState = this.captureLeftPanelScrollState(); - this.selectedObjectId = objectId; - this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", "1"); - const selectedObject = this.selectedObject(); - this.selectedShapeIndex = sortedShapes(selectedObject).length ? 0 : -1; - this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - const selectedState = this.objectStates(selectedObject)[0] || null; - this.selectedStateId = selectedState?.id || ""; - this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; - this.selectedFrameId = selectedState ? sortedFrames(selectedState)[0]?.id || "" : ""; - this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); - this.setPaletteTarget("stroke", false); - this.renderPayload(); - this.restoreLeftPanelScrollState(scrollState); - const selected = this.selectedObject(); - this.statusLog.write(`OK Selected object from ${sourceLabel}: ${selected.name}.`); - } - - selectShape(shapeIndex, sourceLabel, options = {}) { - const object = this.selectedObject(); - const normalizedIndex = normalizeShapeIndex(shapeIndex); - const shapes = sortedShapes(object); - if (normalizedIndex < 0 || normalizedIndex >= shapes.length) { - this.statusLog.write(`WARN Select shape skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); - return; - } - - const scrollState = this.captureLeftPanelScrollState(); - this.selectedShapeIndex = normalizedIndex; - if (options.additive) { - if (this.selectedShapeIndexes.has(normalizedIndex) && this.selectedShapeIndexes.size > 1) { - this.selectedShapeIndexes.delete(normalizedIndex); - this.directSelectedShapeIndexes = new Set(this.selectedShapeIndexes); - this.selectedShapeIndex = Array.from(this.selectedShapeIndexes).at(-1) ?? -1; - } else { - this.selectedShapeIndexes.add(normalizedIndex); - this.directSelectedShapeIndexes = new Set(this.selectedShapeIndexes); - } - } else { - this.selectedShapeIndexes = new Set([normalizedIndex]); - this.directSelectedShapeIndexes = new Set([normalizedIndex]); - } - this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); - this.setPaletteTarget("stroke", false); - this.renderPayload(); - this.restoreLeftPanelScrollState(scrollState); - const shape = this.selectedShape(); - this.statusLog.write(`OK Selected shape from ${sourceLabel}: row ${this.selectedShapeIndex} (${shapeTool(shape)}). Multi-select count: ${this.selectedShapeIndexes.size}.`); - } - - selectShapeGroup(groupId, shapeIndex, sourceLabel = "shape group") { - const object = this.selectedObject(); - const shapes = sortedShapes(object); - const normalizedGroupId = String(groupId || "").trim(); - if (!normalizedGroupId) { - this.statusLog.write("WARN Select group skipped: shape group id is not available."); - return; - } - const groupIndexes = shapes - .map((shape, index) => String(shape?.groupId || "").trim() === normalizedGroupId ? index : -1) - .filter((index) => index >= 0); - if (groupIndexes.length < 2) { - this.statusLog.write(`WARN Select group skipped: group ${normalizedGroupId} has fewer than two shapes.`); - return; - } - - const normalizedIndex = normalizeShapeIndex(shapeIndex); - const nextSelectedIndex = groupIndexes.includes(normalizedIndex) ? normalizedIndex : groupIndexes[0]; - const scrollState = this.captureLeftPanelScrollState(); - this.selectedShapeIndex = nextSelectedIndex; - this.selectedShapeIndexes = new Set(groupIndexes); - this.directSelectedShapeIndexes = new Set(groupIndexes); - this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); - this.setPaletteTarget("stroke", false); - this.renderPayload(); - this.restoreLeftPanelScrollState(scrollState); - this.statusLog.write(`OK Selected group ${normalizedGroupId} from ${sourceLabel}: ${groupIndexes.length} shapes.`); - } - - shapeSelectionGroupIndexes(shapes, shapeIndex) { - const shape = shapes[shapeIndex]; - const groupId = String(shape?.groupId || "").trim(); - if (!groupId) { - return [shapeIndex]; - } - return shapes - .map((candidate, index) => String(candidate?.groupId || "").trim() === groupId ? index : -1) - .filter((index) => index >= 0); - } - - shapeBelongsToValidGroup(object, shapeIndex) { - const shapes = sortedShapes(object); - const shape = shapes[normalizeShapeIndex(shapeIndex)]; - const groupId = String(shape?.groupId || "").trim(); - if (!groupId) { - return false; - } - return shapes.filter((candidate) => String(candidate?.groupId || "").trim() === groupId).length >= 2; - } - - selectState(stateId, sourceLabel) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN State selection skipped: no object is selected."); - return; - } - if (!stateId) { - this.selectedStateId = ""; - this.stateControlStateId = ""; - this.selectedFrameId = ""; - this.stopPlaybackTimer(); - this.renderPayload(); - this.statusLog.write(`OK State selection cleared from ${sourceLabel}.`); - return; - } - const state = this.objectStates(object).find((candidate) => candidate.id === stateId); - if (!state) { - this.statusLog.write(`WARN State selection pending: ${stateId} is not created for ${object.name}. Use Add to create it.`); - this.selectedStateId = ""; - this.stateControlStateId = stateId; - this.selectedFrameId = ""; - this.renderFrameTimeline(); - this.updateAnimationActionState(); - return; - } - this.selectedStateId = state.id; - this.stateControlStateId = state.id; - this.selectedFrameId = sortedFrames(state)[0]?.id || ""; - this.stopPlaybackTimer(); - this.renderPayload(); - this.statusLog.write(`OK Selected state ${OBJECT_STATE_LABELS[state.id] || state.id} from ${sourceLabel}; active object remains ${object.name}.`); - } - - selectFrame(frameId, sourceLabel) { - const state = this.selectedState(); - if (!state) { - this.statusLog.write("WARN Frame selection skipped: no state is selected."); - return; - } - const frame = sortedFrames(state).find((candidate) => candidate.id === frameId); - if (!frame) { - this.statusLog.write(`WARN Frame selection skipped: frame id ${frameId || "unknown"} is not available.`); - return; - } - this.selectedFrameId = frame.id; - this.renderPayload(); - this.statusLog.write(`OK Selected frame ${frame.id} from ${sourceLabel}.`); - } - - createSelectedState(stateId = this.stateControlSelectionId(this.selectedObject())) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Create state skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Create state")) { - return; - } - if (!OBJECT_STATE_IDS.includes(stateId)) { - this.statusLog.write(`FAIL Create state blocked: choose ${OBJECT_STATE_IDS.join(", ")}.`); - return; - } - if (this.objectStates(object).some((state) => state.id === stateId)) { - this.statusLog.write(`WARN Create state skipped: ${OBJECT_STATE_LABELS[stateId] || stateId} already exists for ${object.name}.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - nextObject.states = this.objectStates(nextObject); - const frame = this.createFrameSnapshot(nextObject, stateId, "frame-1", 1); - nextObject.states.push({ - frames: [frame], - id: stateId, - name: OBJECT_STATE_LABELS[stateId] || stateId - }); - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Created state ${OBJECT_STATE_LABELS[stateId] || stateId} with frame ${frame.id}.`, "Create state failed schema validation", { - selectedFrameId: frame.id, - selectedStateId: stateId - }); - } - - deleteSelectedState(stateId = this.selectedStateId) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Delete state skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Delete state")) { - return; - } - const states = this.objectStates(object); - const stateIndex = states.findIndex((state) => state.id === stateId); - if (stateIndex < 0) { - this.statusLog.write(`WARN Delete state skipped: ${stateId || "unknown"} is not created for ${object.name}.`); - return; - } - if (states.length <= 1) { - this.statusLog.write(`WARN Delete state skipped: ${stateId} is the only state for ${object.name}.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - nextObject.states = this.objectStates(nextObject).filter((state) => state.id !== stateId); - const nextState = nextObject.states[Math.min(stateIndex, nextObject.states.length - 1)] || nextObject.states[0] || null; - const nextFrame = sortedFrames(nextState)[0] || null; - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted state ${stateId} from ${object.name}.`, "Delete state failed schema validation", { - selectedFrameId: nextFrame?.id || "", - selectedStateId: nextState?.id || "" - }); - } - - duplicateSelectedFrame() { - const object = this.selectedObject(); - const state = this.selectedState(); - const frame = this.activeFrame(); - if (!object || !state || !frame) { - this.statusLog.write("WARN Duplicate frame skipped: select an object state and frame first."); - return; - } - if (this.guardSelectedObjectMutation("Duplicate frame")) { - return; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); - const frameCopy = JSON.parse(JSON.stringify(frame)); - frameCopy.id = this.uniqueFrameId(nextState); - frameCopy.order = sortedFrames(nextState).length + 1; - nextState.frames.push(frameCopy); - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Duplicated frame ${frame.id} as ${frameCopy.id}.`, "Duplicate frame failed schema validation", { - selectedFrameId: frameCopy.id, - selectedStateId: state.id - }); - } - - deleteSelectedFrame() { - const object = this.selectedObject(); - const state = this.selectedState(); - const frame = this.activeFrame(); - if (!object || !state || !frame) { - this.statusLog.write("WARN Delete frame skipped: select an object state and frame first."); - return; - } - if (this.guardSelectedObjectMutation("Delete frame")) { - return; - } - const frames = sortedFrames(state); - if (frames.length <= 1) { - this.statusLog.write(`WARN Delete frame skipped: frame ${frame.id} is the only frame in ${OBJECT_STATE_LABELS[state.id] || state.id}.`); - return; - } - const index = frames.findIndex((candidate) => candidate.id === frame.id); - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); - const nextFrames = sortedFrames(nextState).filter((candidate) => candidate.id !== frame.id); - nextFrames.forEach((candidate, frameIndex) => { - candidate.order = frameIndex + 1; - }); - nextState.frames = nextFrames; - const nextSelectedFrame = nextFrames[Math.min(index, nextFrames.length - 1)] || nextFrames[0]; - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted frame ${frame.id} from ${OBJECT_STATE_LABELS[state.id] || state.id}.`, "Delete frame failed schema validation", { - selectedFrameId: nextSelectedFrame.id, - selectedStateId: state.id - }); - } - - moveSelectedFrame(direction, controlLabel = direction) { - const object = this.selectedObject(); - const state = this.selectedState(); - const frame = this.activeFrame(); - if (!object || !state || !frame) { - this.statusLog.write(`WARN Move frame ${controlLabel} skipped: select an object state and frame first.`); - return; - } - if (this.guardSelectedObjectMutation("Move frame")) { - return; - } - const frames = sortedFrames(state); - const index = frames.findIndex((candidate) => candidate.id === frame.id); - const nextIndex = direction === "earlier" ? index - 1 : index + 1; - if (nextIndex < 0 || nextIndex >= frames.length) { - this.statusLog.write(`WARN Move frame ${controlLabel} skipped: frame ${frame.id} cannot move ${controlLabel}.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); - const nextFrames = sortedFrames(nextState); - const [movedFrame] = nextFrames.splice(index, 1); - nextFrames.splice(nextIndex, 0, movedFrame); - nextFrames.forEach((candidate, frameIndex) => { - candidate.order = frameIndex + 1; - }); - nextState.frames = nextFrames; - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Moved frame ${frame.id} ${controlLabel}.`, "Move frame failed schema validation", { - selectedFrameId: frame.id, - selectedStateId: state.id - }); - } - - playAnimation() { - const state = this.selectedState(); - const frames = sortedFrames(state); - if (!state || !frames.length) { - this.statusLog.write("FAIL Playback blocked: create and select an animation state with at least one frame."); - return; - } - const fps = Number(this.elements.fpsInput.value); - if (!Number.isInteger(fps) || fps < 1 || fps > 60) { - this.statusLog.write("FAIL Playback blocked: FPS must be an integer from 1 to 60."); - return; - } - this.stopPlaybackTimer(false); - this.isAnimationPlaying = true; - this.updateAnimationActionState(); - this.playbackTimerId = this.window.setInterval(() => this.advancePlaybackFrame(), Math.round(1000 / fps)); - this.statusLog.write(`OK Playback started for state ${OBJECT_STATE_LABELS[state.id] || state.id} at ${fps} FPS.`); - } - - pauseAnimation() { - if (!this.isAnimationPlaying) { - this.statusLog.write("WARN Playback pause skipped: animation is not playing."); - return; - } - this.stopPlaybackTimer(false); - this.statusLog.write(`OK Playback paused at frame ${this.selectedFrameId || "none"}.`); - } - - stopAnimation() { - const state = this.selectedState(); - this.stopPlaybackTimer(false); - if (state) { - this.selectedFrameId = sortedFrames(state)[0]?.id || ""; - this.renderPayload(); - } - this.statusLog.write("OK Playback stopped."); - } - - stopPlaybackTimer(shouldRender = true) { - if (this.playbackTimerId) { - this.window.clearInterval(this.playbackTimerId); - this.playbackTimerId = 0; - } - this.isAnimationPlaying = false; - if (shouldRender) { - this.updateAnimationActionState(); - } - } - - advancePlaybackFrame() { - const state = this.selectedState(); - const frames = sortedFrames(state); - if (!state || !frames.length) { - this.stopPlaybackTimer(); - this.statusLog.write("FAIL Playback stopped: selected state or frame timeline is missing."); - return; - } - const index = Math.max(0, frames.findIndex((frame) => frame.id === this.selectedFrameId)); - const nextFrame = frames[index + 1]; - if (nextFrame) { - this.selectedFrameId = nextFrame.id; - this.renderPayload(); - return; - } - if (this.elements.loopToggle.checked) { - this.selectedFrameId = frames[0].id; - this.renderPayload(); - return; - } - this.stopPlaybackTimer(); - this.statusLog.write(`OK Playback completed for state ${OBJECT_STATE_LABELS[state.id] || state.id}.`); - } - - addObject() { - const name = this.elements.objectNameInput.value.trim(); - this.pendingAddObjectClick = false; - if (!this.currentPayload) { - this.statusLog.write("FAIL Add object blocked: load a schema-valid Object Vector Studio V2 payload before adding objects."); - return; - } - if (!name) { - this.statusLog.write("FAIL Add object blocked: enter an object name."); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const id = this.uniqueObjectId(name, nextPayload.objects); - let objectDefault; - try { - objectDefault = this.schemaDefault("object"); - } catch (error) { - this.statusLog.write(`FAIL Add object blocked: ${error.message}`); - return; - } - nextPayload.objects.push({ - ...objectDefault, - id, - name, - tags: tagList(this.elements.objectTagInput.value) - }); - this.commitPayloadUpdate(nextPayload, id, "", `OK Added object ${name} with object/game/name id ${id}.`, "Add object failed schema validation"); - } - - addTagToSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Tag add skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Tag add")) { - return; - } - const tags = tagList(this.elements.objectTagInput.value); - if (!tags.length) { - this.statusLog.write("FAIL Tag add blocked: enter a tag name."); - return; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((object) => object.id === selected.id); - const nextTags = tagList(nextObject.tags); - const addedTags = []; - tags.forEach((tag) => { - const nextTag = this.uniqueTag(tag, nextTags); - nextTags.push(nextTag); - addedTags.push(nextTag); - }); - nextObject.tags = nextTags.sort(); - this.elements.objectTagInput.value = ""; - this.commitPayloadUpdate(nextPayload, selected.id, this.selectedShapeIndex, `OK Added tag ${addedTags.join(", ")} to ${selected.name}.`, "Object tag update failed schema validation"); - } - - removeTagFromSelectedObject(tag) { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Tag remove skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Tag remove")) { - return; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((object) => object.id === selected.id); - nextObject.tags = tagList(nextObject.tags).filter((candidate) => candidate !== tag); - this.commitPayloadUpdate(nextPayload, selected.id, this.selectedShapeIndex, `OK Removed tag ${tag} from ${selected.name}.`, "Object tag update failed schema validation"); - } - - renameSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Rename object skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Rename object")) { - return; - } - - const name = this.elements.objectNameInput.value.trim(); - if (!name) { - this.statusLog.write("FAIL Rename object blocked: enter an object name."); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((object) => object.id === selected.id); - const oldId = nextObject.id; - const siblingObjects = nextPayload.objects.filter((object) => object.id !== oldId); - const nextId = this.uniqueObjectId(name, siblingObjects, objectGameSegment(oldId) || this.payloadGameKey()); - nextPayload.objects.forEach((object) => { - if (object.baseObjectId === oldId) { - object.baseObjectId = nextId; - } - }); - nextObject.id = nextId; - nextObject.name = name; - this.commitPayloadUpdate(nextPayload, nextId, this.selectedShapeIndex, `OK Renamed object ${oldId} to ${name} and updated object/game/name id to ${nextId}.`, "Rename object failed schema validation"); - } - - duplicateSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Duplicate object skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Duplicate object")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const objectCopy = JSON.parse(JSON.stringify(selected)); - objectCopy.id = this.uniqueObjectId(`${selected.name} Copy`, nextPayload.objects, objectGameSegment(selected.id) || this.payloadGameKey()); - objectCopy.name = `${selected.name} Copy`; - nextPayload.objects.push(objectCopy); - const selectedShapeIndex = sortedShapes(objectCopy).length ? 0 : -1; - this.commitPayloadUpdate(nextPayload, objectCopy.id, selectedShapeIndex, `OK Duplicated object ${selected.name} as ${objectCopy.name}.`, "Duplicate object failed schema validation"); - } - - deleteSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Delete object skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Delete object")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - nextPayload.objects = nextPayload.objects.filter((object) => object.id !== selected.id); - this.removeDeletedObjectReferences(nextPayload, selected.id); - this.removeDanglingShapeOverrideReferences(nextPayload); - const selectedObjectId = nextPayload.objects[0]?.id || ""; - const selectedShapeIndex = nextPayload.objects[0] && sortedShapes(nextPayload.objects[0]).length ? 0 : -1; - this.commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, `OK Deleted object ${selected.name}.`, "Delete object failed schema validation"); - } - - deleteObjectById(objectId, sourceLabel) { - const selected = this.currentPayload?.objects.find((object) => object.id === objectId); - if (!selected) { - this.statusLog.write(`WARN Delete object skipped: object id ${objectId || "unknown"} is not available.`); - return; - } - if (this.isObjectLocked(selected.id)) { - this.statusLog.write(`WARN Delete object blocked: object ${selected.name} is locked for this runtime session.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - nextPayload.objects = nextPayload.objects.filter((object) => object.id !== selected.id); - this.removeDeletedObjectReferences(nextPayload, selected.id); - this.removeDanglingShapeOverrideReferences(nextPayload); - const selectedObjectStillExists = nextPayload.objects.some((object) => object.id === this.selectedObjectId); - const nextSelectedObject = selectedObjectStillExists - ? nextPayload.objects.find((object) => object.id === this.selectedObjectId) - : nextPayload.objects[0] || null; - const selectedObjectId = nextSelectedObject?.id || ""; - const selectedShapeStillExists = Number.isInteger(this.selectedShapeIndex) && this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(nextSelectedObject).length; - const selectedShapeIndex = selectedShapeStillExists ? this.selectedShapeIndex : nextSelectedObject && sortedShapes(nextSelectedObject).length ? 0 : -1; - this.hiddenObjectIds.delete(selected.id); - this.lockedObjectIds.delete(selected.id); - this.commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, `OK Deleted object ${selected.name} from ${sourceLabel}.`, "Delete object failed schema validation"); - } - - removeDeletedObjectReferences(payload, objectId) { - payload.objects.forEach((object) => { - if (object.baseObjectId === objectId) { - delete object.baseObjectId; - } - }); - this.removeDirectReferenceEntries(payload, "objectId", objectId); - } - - removeDirectReferenceEntries(value, referenceKey, targetId) { - if (!value || typeof value !== "object") { - return; - } - if (Array.isArray(value)) { - for (let index = value.length - 1; index >= 0; index -= 1) { - const item = value[index]; - if (item && typeof item === "object" && item[referenceKey] === targetId) { - value.splice(index, 1); - } else { - this.removeDirectReferenceEntries(item, referenceKey, targetId); - } - } - return; - } - Object.entries(value).forEach(([key, child]) => { - if (key === referenceKey && child === targetId) { - delete value[key]; - return; - } - this.removeDirectReferenceEntries(child, referenceKey, targetId); - }); - } - - removeDanglingShapeOverrideReferences(payload) { - const objectsById = new Map(payload.objects.map((object) => [object.id, object])); - const collectShapeCount = (object, seen = new Set()) => { - if (!object || seen.has(object.id)) { - return 0; - } - seen.add(object.id); - const shapeByOrder = object.baseObjectId - ? new Map(collectShapeRows(objectsById.get(object.baseObjectId), seen)) - : new Map(); - object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); - return shapeByOrder.size; - }; - const collectShapeRows = (object, seen = new Set()) => { - if (!object || seen.has(object.id)) { - return []; - } - seen.add(object.id); - const shapeByOrder = object.baseObjectId - ? new Map(collectShapeRows(objectsById.get(object.baseObjectId), seen)) - : new Map(); - object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); - return Array.from(shapeByOrder.entries()); - }; - payload.objects.forEach((object) => { - const shapeCount = collectShapeCount(object); - this.objectStates(object).forEach((state) => { - state.frames.forEach((frame) => { - frame.shapeOverrides = frame.shapeOverrides.filter((override) => override.shapeIndex < shapeCount); - }); - }); - }); - } - - flattenSelectedObject() { - const selected = this.selectedObject(); - if (!selected) { - this.statusLog.write("WARN Flatten object skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Flatten object")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((object) => object.id === selected.id); - if (!nextObject.shapes.length) { - this.statusLog.write(`WARN Flatten object skipped: ${selected.name} has no shapes to flatten.`); - return; - } - const rotatedShape = sortedShapes(nextObject).find((shape) => this.shapeTransform(shape).rotation !== 0); - if (rotatedShape) { - this.statusLog.write(`FAIL Flatten object blocked: shape ${rotatedShape.id} has rotation ${this.shapeTransform(rotatedShape).rotation}; rotate-safe flatten is not available in this schema pass.`); - return; - } - let flattenedCount = 0; - const origin = this.objectTransformOrigin(nextObject); - nextObject.shapes = sortedShapes(nextObject).map((shape, index) => { - const flattened = this.flattenShapeTransform(shape, origin); - flattened.order = index + 1; - flattenedCount += 1; - return flattened; - }); - const selectedShapeIndex = sortedShapes(nextObject).length ? 0 : -1; - this.commitPayloadUpdate(nextPayload, selected.id, selectedShapeIndex, `OK Flattened object ${selected.name}: baked transforms into ${flattenedCount} shapes.`, "Flatten object failed schema validation"); - } - - schemaDefault(definitionName) { - if (typeof this.schemaService.getDefinitionDefault !== "function") { - throw new Error("Object Vector Studio V2 schema default reader is unavailable."); - } - return this.schemaService.getDefinitionDefault(definitionName); - } - - createShapeStyleDefault(type, color, styleOverride = null) { - const { pointStyle, startPointStyle, endPointStyle, strokeLinecap, ...style } = this.schemaDefault("style"); - const strokeColor = styleOverride?.stroke || this.selectedStrokeColor || color; - return { - ...style, - ...(styleOverride || {}), - fill: TRANSPARENT_STYLE_COLOR, - fillOpacity: styleOverride?.fillOpacity ?? this.selectedFillOpacity, - stroke: strokeColor, - strokeOpacity: styleOverride?.strokeOpacity ?? this.selectedStrokeOpacity, - strokeWidth: styleOverride?.strokeWidth ?? style.strokeWidth - }; - } - - createShapeTransformDefault() { - const transform = this.schemaDefault("transform"); - return { - ...defaultShapeTransform(), - ...transform - }; - } - - createShape(type, options = {}) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write(`FAIL Create ${type} blocked: select a schema-valid object first.`); - return; - } - if (this.guardSelectedObjectMutation(`Create ${type}`)) { - return; - } - - const color = this.firstPaletteColor(); - if (!color) { - this.statusLog.write(`FAIL Create ${type} blocked: palette swatches do not provide a usable color value.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const order = nextObject.shapes.length ? Math.max(...nextObject.shapes.map((shape) => shape.order)) + 1 : 1; - let shape; - try { - shape = this.createPrimitiveShape(type, order, color, options.geometry, options.style); - } catch (error) { - this.statusLog.write(`FAIL Create ${type} blocked: ${error.message}`); - return; - } - nextObject.shapes.push(shape); - this.activeDrawing = PRIMITIVE_TOOLS.includes(type) ? this.createDrawingState(type) : null; - this.drawingPreviewPoint = null; - this.commitPayloadUpdate(nextPayload, nextObject.id, sortedShapes(nextObject).length - 1, `OK Created ${type} shape on ${nextObject.name} from ${options.sourceLabel || "canvas drawing"}.`, `Create ${type} failed schema validation`); - } - - createPrimitiveShape(type, order, color, geometry = null, styleOverride = null) { - if (!PRIMITIVE_TOOLS.includes(type)) { - throw new Error(`unsupported shape tool ${type}.`); - } - if (!geometry) { - throw new Error(`${shapeTypeLabel(type)} requires committed canvas placement geometry.`); - } - - const base = this.schemaDefault("shapeCommon"); - const shape = { - ...base, - geometry: JSON.parse(JSON.stringify(geometry)), - order, - style: this.createShapeStyleDefault(type, color, styleOverride), - tool: type, - visible: base.visible - }; - if (type === "square") { - const size = Math.min(shape.geometry.width, shape.geometry.height); - shape.geometry.width = size; - shape.geometry.height = size; - } - shape.transform = this.createShapeTransformDefault(); - return shape; - } - - flattenShapeTransform(shape, origin = { x: 0, y: 0 }) { - const nextShape = JSON.parse(JSON.stringify(shape)); - const transform = this.shapeTransform(nextShape); - const applyX = (value) => Number((transform.x + origin.x + (value - origin.x) * transform.scaleX).toFixed(3)); - const applyY = (value) => Number((transform.y + origin.y + (value - origin.y) * transform.scaleY).toFixed(3)); - const geometryTool = shapeGeometryTool(nextShape); - if (geometryTool === "rectangle") { - nextShape.geometry.x = applyX(nextShape.geometry.x); - nextShape.geometry.y = applyY(nextShape.geometry.y); - nextShape.geometry.width = Number((nextShape.geometry.width * transform.scaleX).toFixed(3)); - nextShape.geometry.height = Number((nextShape.geometry.height * transform.scaleY).toFixed(3)); - } else if (geometryTool === "circle") { - nextShape.geometry.cx = applyX(nextShape.geometry.cx); - nextShape.geometry.cy = applyY(nextShape.geometry.cy); - nextShape.geometry.r = Number((nextShape.geometry.r * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); - } else if (geometryTool === "ellipse") { - nextShape.geometry.cx = applyX(nextShape.geometry.cx); - nextShape.geometry.cy = applyY(nextShape.geometry.cy); - nextShape.geometry.rx = Number((nextShape.geometry.rx * transform.scaleX).toFixed(3)); - nextShape.geometry.ry = Number((nextShape.geometry.ry * transform.scaleY).toFixed(3)); - } else if (geometryTool === "line") { - nextShape.geometry.point1.x = applyX(nextShape.geometry.point1.x); - nextShape.geometry.point1.y = applyY(nextShape.geometry.point1.y); - nextShape.geometry.point2.x = applyX(nextShape.geometry.point2.x); - nextShape.geometry.point2.y = applyY(nextShape.geometry.point2.y); - } else if (geometryTool === "polygon" || geometryTool === "polyline") { - nextShape.geometry.points = nextShape.geometry.points.map((point) => ({ - x: applyX(point.x), - y: applyY(point.y) - })); - } else if (geometryTool === "arc") { - nextShape.geometry.cx = applyX(nextShape.geometry.cx); - nextShape.geometry.cy = applyY(nextShape.geometry.cy); - nextShape.geometry.r = Number((nextShape.geometry.r * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); - } else if (geometryTool === "text") { - nextShape.geometry.x = applyX(nextShape.geometry.x); - nextShape.geometry.y = applyY(nextShape.geometry.y); - nextShape.geometry.fontSize = Number((nextShape.geometry.fontSize * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); - } - nextShape.transform = defaultShapeTransform(); - return nextShape; - } - - currentTargetColor() { - return this.paletteTarget === "stroke" ? this.selectedStrokeColor : this.selectedFillColor; - } - - paletteSwatchForColor(color) { - const normalized = String(color || "").trim().toLowerCase(); - if (!normalized || !this.runtimePalette?.swatches?.length) { - return null; - } - return this.runtimePalette.swatches.find((swatch) => String(swatchColor(swatch)).trim().toLowerCase() === normalized) || null; - } - - directStyleColorLabel(color) { - const normalized = String(color || "").trim().toLowerCase(); - if (normalized === TRANSPARENT_STYLE_COLOR || normalized === "transparent") { - return "transparent"; - } - if (normalized === "none") { - return "none"; - } - return ""; - } - - paletteLabelForColor(color, fallbackLabel) { - const swatch = this.paletteSwatchForColor(color); - return swatch?.name || swatch?.id || swatch?.symbol || this.directStyleColorLabel(color) || fallbackLabel; - } - - syncPaletteSelectionFromCurrentShape(options = {}) { - this.syncPaletteSelectionFromShape(this.selectedShape(), options); - } - - syncPaletteSelectionFromShape(shape, options = {}) { - if (!shape || !this.runtimePalette) { - return; - } - const { logMissing = false, render = true } = options; - const effectiveShape = this.effectiveShape(shape); - let changed = false; - const syncTarget = (target, color) => { - const normalizedColor = String(color || "").trim(); - if (!normalizedColor || normalizedColor === "none" || this.directStyleColorLabel(normalizedColor)) { - return; - } - const swatch = this.paletteSwatchForColor(normalizedColor); - if (!swatch) { - if (logMissing) { - this.statusLog.write(`WARN Palette sync skipped for selected ${shapeTool(shape)} ${target} color ${normalizedColor}: color is not in the loaded palette.`); - } - return; - } - const label = swatch.name || swatch.id || swatch.symbol || shapeDisplayLabel(shape); - const paletteColor = swatchColor(swatch) || normalizedColor; - if (target === "stroke") { - changed = changed || this.selectedStrokeColor !== paletteColor || this.selectedStrokeLabel !== label; - this.selectedStrokeColor = paletteColor; - this.selectedStrokeLabel = label; - return; - } - changed = changed || this.selectedFillColor !== paletteColor || this.selectedFillLabel !== label; - this.selectedFillColor = paletteColor; - this.selectedFillLabel = label; - }; - syncTarget("paint", effectiveShape.style?.fill); - syncTarget("stroke", effectiveShape.style?.stroke); - if (changed && render) { - this.renderPalette(); - } - } - - opacityInputValue(target) { - const element = target === "stroke" ? this.elements.strokeOpacity : this.elements.fillOpacity; - const rawValue = element?.value?.trim() || ""; - const value = Number(rawValue); - if (!Number.isInteger(value) || value < 0 || value > 255) { - const error = `${target === "stroke" ? "Stroke" : "Fill"} opacity must be a whole number between 0 and 255.`; - this.markInputInvalid(element, error); - return { ok: false, error }; - } - this.clearInputValidity(element); - return { ok: true, value: this.opacityFromInputByte(value) }; - } - - opacityFromInputByte(value) { - return Number((value / 255).toFixed(3)); - } - - opacityInputDisplayValue(value) { - if (!Number.isFinite(value)) { - return "255"; - } - const clamped = Math.min(1, Math.max(0, value)); - return String(Math.round(clamped * 255)); - } - - changePaletteOpacity(target) { - const normalizedTarget = target === "stroke" ? "stroke" : "fill"; - const opacity = this.opacityInputValue(normalizedTarget); - if (!opacity.ok) { - this.statusLog.write(`FAIL Palette ${normalizedTarget} opacity rejected: ${opacity.error}`); - return; - } - if (normalizedTarget === "stroke") { - this.selectedStrokeOpacity = opacity.value; - } else { - this.selectedFillOpacity = opacity.value; - } - this.updatePaletteModeSwatches(); - this.statusLog.write(`OK Selected ${normalizedTarget} opacity ${opacity.value}.`); - } - - selectPaletteColor(color, label) { - if (!color) { - this.statusLog.write(`FAIL Palette color selection blocked: swatch ${label} has no usable color value.`); - return false; - } - const swatch = this.paletteSwatchForColor(color); - if (!swatch) { - this.statusLog.write(`FAIL Palette color selection rejected: ${color} is not in the loaded palette.`); - return false; - } - const paletteLabel = swatch.name || swatch.id || swatch.symbol || label; - if (this.paletteTarget === "stroke") { - this.selectedStrokeColor = color; - this.selectedStrokeLabel = paletteLabel; - } else { - this.selectedFillColor = color; - this.selectedFillLabel = paletteLabel; - } - if (this.runtimePalette) { - this.renderPalette(); - } - this.statusLog.write(`OK Selected ${this.paletteTarget === "stroke" ? "stroke" : "paint"} color ${color} from ${paletteLabel}.`); - return true; - } - - applyPaletteColor(color, label) { - this.selectPaletteColor(color, label); - } - - applySelectedPaletteColorToShape(shapeIndex, target, sourceLabel) { - const selectedIndex = normalizeShapeIndex(shapeIndex); - const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; - if (!selected) { - this.statusLog.write("FAIL Palette color application blocked: select a shape first."); - return; - } - if (selected.locked) { - this.statusLog.write(`WARN Palette color application skipped: shape row ${selectedIndex} is locked.`); - return; - } - if (this.guardSelectedObjectMutation("Palette color application")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const shape = this.findShapeInPayload(nextPayload, selectedIndex); - const strokeWidth = Number(this.elements.strokeWidth.value); - const shouldApplyStroke = target === "stroke"; - const color = shouldApplyStroke ? this.selectedStrokeColor : this.selectedFillColor; - const label = shouldApplyStroke ? this.selectedStrokeLabel : this.selectedFillLabel; - if (!color) { - this.statusLog.write(`FAIL Palette color application blocked: no ${shouldApplyStroke ? "stroke" : "paint"} color is selected.`); - return; - } - const swatch = this.paletteSwatchForColor(color); - const directLabel = this.directStyleColorLabel(color); - if (!swatch && !directLabel) { - this.statusLog.write(`FAIL Palette color application rejected: ${color} is not in the loaded palette.`); - return; - } - const paletteLabel = swatch?.name || swatch?.id || swatch?.symbol || directLabel || label; - if (shouldApplyStroke) { - shape.style.stroke = color; - shape.style.strokeWidth = Number.isFinite(strokeWidth) && strokeWidth > 0 ? strokeWidth : 2; - shape.style.strokeOpacity = this.selectedStrokeOpacity; - } else { - shape.style.fill = color; - shape.style.fillOpacity = this.selectedFillOpacity; - } - const targetLabel = shouldApplyStroke ? `Target: stroke width ${shape.style.strokeWidth}, opacity ${shape.style.strokeOpacity}.` : `Target: paint opacity ${shape.style.fillOpacity}.`; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, selectedIndex, `OK Applied palette color ${color} from ${paletteLabel} to shape row ${selectedIndex} by ${sourceLabel}. ${targetLabel}`, "Palette color application failed schema validation", { - syncPaletteSelection: false - }); - } - - applyTransparentStyleToShape(shapeIndex, target, sourceLabel) { - const selectedIndex = normalizeShapeIndex(shapeIndex); - const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; - if (!selected) { - this.statusLog.write(`WARN Transparent style skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); - return; - } - if (selected.locked) { - this.statusLog.write(`WARN Transparent style skipped: shape row ${selectedIndex} is locked.`); - return; - } - if (this.guardSelectedObjectMutation("Transparent style application")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const shape = this.findShapeInPayload(nextPayload, selectedIndex); - const shouldApplyStroke = target === "stroke"; - if (shouldApplyStroke) { - shape.style.stroke = TRANSPARENT_STYLE_COLOR; - } else { - shape.style.fill = TRANSPARENT_STYLE_COLOR; - } - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Applied transparent ${shouldApplyStroke ? "stroke" : "fill"} to shape row ${selectedIndex} by ${sourceLabel}.`, "Transparent style application failed schema validation", { - syncPaletteSelection: false - }); - } - - sampleShapeStyle(shapeIndex) { - const selectedIndex = normalizeShapeIndex(shapeIndex); - const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; - if (!selected) { - this.statusLog.write(`WARN Picker skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); - return; - } - const effectiveShape = this.effectiveShape(selected, selectedIndex); - const style = effectiveShape.style || {}; - if (style.fill) { - this.selectedFillColor = style.fill; - this.selectedFillLabel = this.paletteLabelForColor(style.fill, shapeDisplayLabel(selected)); - } - if (style.stroke) { - this.selectedStrokeColor = style.stroke; - this.selectedStrokeLabel = this.paletteLabelForColor(style.stroke, shapeDisplayLabel(selected)); - } - if (Number.isFinite(style.fillOpacity)) { - this.selectedFillOpacity = style.fillOpacity; - this.elements.fillOpacity.value = this.opacityInputDisplayValue(style.fillOpacity); - this.clearInputValidity(this.elements.fillOpacity); - } - if (Number.isFinite(style.strokeOpacity)) { - this.selectedStrokeOpacity = style.strokeOpacity; - this.elements.strokeOpacity.value = this.opacityInputDisplayValue(style.strokeOpacity); - this.clearInputValidity(this.elements.strokeOpacity); - } - if (Number.isFinite(style.strokeWidth) && style.strokeWidth > 0) { - this.elements.strokeWidth.value = String(style.strokeWidth); - } - this.updatePaletteModeSwatches(); - if (this.runtimePalette) { - this.renderPalette(); - } - this.statusLog.write(`OK Picker sampled shape row ${selectedIndex}: fill ${style.fill || "none"}, stroke ${style.stroke || "none"}, fill opacity ${Number.isFinite(style.fillOpacity) ? style.fillOpacity : "n/a"}, stroke opacity ${Number.isFinite(style.strokeOpacity) ? style.strokeOpacity : "n/a"}, stroke width ${Number.isFinite(style.strokeWidth) ? style.strokeWidth : "n/a"}.`); - } - - swapFillStrokeColors() { - const nextFill = this.selectedStrokeColor; - const nextFillLabel = this.selectedStrokeLabel; - this.selectedStrokeColor = this.selectedFillColor; - this.selectedStrokeLabel = this.selectedFillLabel; - this.selectedFillColor = nextFill; - this.selectedFillLabel = nextFillLabel; - if (this.runtimePalette) { - this.renderPalette(); - } - this.statusLog.write(`OK Swapped fill and stroke colors: fill ${this.selectedFillColor}, stroke ${this.selectedStrokeColor}.`); - } - - restoreDefaultColors() { - const fillColor = this.firstPaletteColor(); - const strokeColor = this.secondPaletteColor(fillColor); - if (!fillColor || !strokeColor) { - this.statusLog.write("FAIL Default colors blocked: a loaded palette color is required."); - return; - } - this.selectedFillColor = fillColor; - this.selectedStrokeColor = strokeColor; - this.selectedFillLabel = this.paletteLabelForColor(fillColor, "default fill"); - this.selectedStrokeLabel = this.paletteLabelForColor(strokeColor, "default stroke"); - this.renderPalette(); - this.statusLog.write(`OK Restored default paint/stroke colors from loaded palette: fill ${fillColor}, stroke ${strokeColor}.`); - } - - toggleSelectedShapeVisibility() { - this.toggleShapeVisibilityByIndex(this.selectedShapeIndex, this.selectedObjectId); - } - - toggleShapeVisibilityByIndex(shapeIndex, objectId = this.selectedObjectId) { - const normalizedIndex = normalizeShapeIndex(shapeIndex); - const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId) || null; - const shape = sortedShapes(object)[normalizedIndex] || null; - if (!object || !shape) { - this.statusLog.write("WARN Shape visibility skipped: no shape is selected."); - return; - } - if (shape.locked) { - this.statusLog.write(`WARN Shape visibility skipped: shape row ${normalizedIndex} is locked.`); - return; - } - if (this.isObjectLocked(object.id)) { - this.statusLog.write(`WARN Shape visibility blocked: object ${object.name} is locked for this runtime session.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const targetIsSelectedObject = object.id === this.selectedObjectId; - const state = targetIsSelectedObject ? this.objectStates(nextObject).find((candidate) => candidate.id === this.selectedStateId) : null; - const frame = targetIsSelectedObject ? sortedFrames(state).find((candidate) => candidate.id === this.selectedFrameId) : null; - if (frame && !Array.isArray(frame.shapeOverrides)) { - frame.shapeOverrides = []; - } - let override = frame?.shapeOverrides.find((entry) => entry.shapeIndex === normalizedIndex) || null; - if (frame) { - const baseShape = sortedShapes(nextObject)[normalizedIndex]; - if (!override) { - override = { - shapeIndex: normalizedIndex, - transform: this.shapeTransform(baseShape), - visible: baseShape.visible - }; - frame.shapeOverrides.push(override); - } - const nextVisible = !this.effectiveShapeForFrame(shape, this.activeFrame(), normalizedIndex).visible; - override.visible = nextVisible; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK State ${this.selectedStateId} frame ${this.selectedFrameId} shape row ${normalizedIndex} visibility set to ${nextVisible ? "visible" : "hidden"}.`, "State frame visibility failed schema validation", { - directSelectedShapeIndexes: this.directSelectedShapeIndexes, - selectedShapeIndexes: this.selectedShapeIndexes - }); - return; - } - const nextShape = sortedShapes(nextObject)[normalizedIndex]; - nextShape.visible = !nextShape.visible; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Shape row ${normalizedIndex} visibility set to ${nextShape.visible ? "visible" : "hidden"}.`, "Shape visibility failed schema validation", { - directSelectedShapeIndexes: this.directSelectedShapeIndexes, - selectedShapeIndexes: this.selectedShapeIndexes - }); - } - - toggleSelectedShapeLock() { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Shape lock skipped: no shape is selected."); - return; - } - if (this.guardSelectedObjectMutation("Shape lock")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const shape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - shape.locked = !shape.locked; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Shape row ${this.selectedShapeIndex} lock set to ${shape.locked ? "locked" : "unlocked"}.`, "Shape lock failed schema validation"); - } - - changeSelectedShapeOrder(action) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Shape order skipped: no shape is selected."); - return; - } - if (this.guardSelectedObjectMutation("Shape order")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); - const shapes = sortedShapes(object); - const selectedIndexes = Array.from(new Set(Array.from(this.selectedShapeIndexes || []) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < shapes.length))) - .sort((left, right) => left - right); - if (!selectedIndexes.length && this.selectedShapeIndex >= 0 && this.selectedShapeIndex < shapes.length) { - selectedIndexes.push(this.selectedShapeIndex); - } - const lockedIndex = selectedIndexes.find((shapeIndex) => shapes[shapeIndex]?.locked); - if (Number.isInteger(lockedIndex)) { - this.statusLog.write(`WARN Shape order skipped: shape row ${lockedIndex} is locked.`); - return; - } - if (shapes.length < 2) { - this.statusLog.write(`WARN Shape order skipped: shape row ${this.selectedShapeIndex} is the only shape in ${object.name}.`); - return; - } - - const selectedIndexSet = new Set(selectedIndexes); - const oldIndexByShape = new Map(shapes.map((shape, shapeIndex) => [shape, shapeIndex])); - const reorderedShapes = shapes.slice(); - if (action === "back") { - reorderedShapes.sort((left, right) => { - const leftIndex = oldIndexByShape.get(left); - const rightIndex = oldIndexByShape.get(right); - const leftSelected = selectedIndexSet.has(leftIndex); - const rightSelected = selectedIndexSet.has(rightIndex); - if (leftSelected === rightSelected) { - return leftIndex - rightIndex; - } - return leftSelected ? -1 : 1; - }); - } else if (action === "front") { - reorderedShapes.sort((left, right) => { - const leftIndex = oldIndexByShape.get(left); - const rightIndex = oldIndexByShape.get(right); - const leftSelected = selectedIndexSet.has(leftIndex); - const rightSelected = selectedIndexSet.has(rightIndex); - if (leftSelected === rightSelected) { - return leftIndex - rightIndex; - } - return leftSelected ? 1 : -1; - }); - } else if (action === "backward") { - for (let index = 1; index < reorderedShapes.length; index += 1) { - const currentOldIndex = oldIndexByShape.get(reorderedShapes[index]); - const previousOldIndex = oldIndexByShape.get(reorderedShapes[index - 1]); - if (selectedIndexSet.has(currentOldIndex) && !selectedIndexSet.has(previousOldIndex)) { - [reorderedShapes[index - 1], reorderedShapes[index]] = [reorderedShapes[index], reorderedShapes[index - 1]]; - } - } - } else if (action === "forward") { - for (let index = reorderedShapes.length - 2; index >= 0; index -= 1) { - const currentOldIndex = oldIndexByShape.get(reorderedShapes[index]); - const nextOldIndex = oldIndexByShape.get(reorderedShapes[index + 1]); - if (selectedIndexSet.has(currentOldIndex) && !selectedIndexSet.has(nextOldIndex)) { - [reorderedShapes[index], reorderedShapes[index + 1]] = [reorderedShapes[index + 1], reorderedShapes[index]]; - } - } - } else { - this.statusLog.write(`WARN Shape z-order skipped: unsupported action ${action}.`); - return; - } - - const oldShapes = shapes.slice(); - if (oldShapes.every((shape, shapeIndex) => reorderedShapes[shapeIndex] === shape)) { - const subject = selectedIndexes.length > 1 ? `selected shape rows ${selectedIndexes.join(", ")}` : `shape row ${this.selectedShapeIndex}`; - this.statusLog.write(`WARN Shape z-order skipped: ${subject} cannot move ${action}.`); - return; - } - const oldIndexToNextIndex = new Map(); - oldShapes.forEach((shape, oldIndex) => { - oldIndexToNextIndex.set(oldIndex, reorderedShapes.indexOf(shape)); - }); - reorderedShapes.forEach((shape, shapeIndex) => { - shape.order = shapeIndex + 1; - }); - object.shapes = reorderedShapes; - this.remapShapeOverrideIndexes(object, oldShapes, reorderedShapes); - const selectedShapeIndexes = new Set(selectedIndexes - .map((shapeIndex) => oldIndexToNextIndex.get(shapeIndex)) - .filter((shapeIndex) => Number.isInteger(shapeIndex) && shapeIndex >= 0)); - const directSelectedShapeIndexes = new Set(Array.from(this.directSelectedShapeIndexes || []) - .map((shapeIndex) => oldIndexToNextIndex.get(normalizeShapeIndex(shapeIndex))) - .filter((shapeIndex) => Number.isInteger(shapeIndex) && shapeIndex >= 0)); - if (!directSelectedShapeIndexes.size) { - selectedShapeIndexes.forEach((shapeIndex) => directSelectedShapeIndexes.add(shapeIndex)); - } - const nextSelectedShapeIndex = oldIndexToNextIndex.get(this.selectedShapeIndex); - const primaryShapeIndex = Number.isInteger(nextSelectedShapeIndex) && nextSelectedShapeIndex >= 0 - ? nextSelectedShapeIndex - : Math.min(...selectedShapeIndexes); - const okMessage = selectedIndexes.length > 1 - ? `OK Shape rows ${selectedIndexes.join(", ")} z-order ${action}.` - : `OK Shape row ${this.selectedShapeIndex} z-order ${action}.`; - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, primaryShapeIndex, okMessage, "Shape z-order failed schema validation", { - directSelectedShapeIndexes, - selectedShapeIndexes - }); - } - - objectTransformOrigin(object) { - const origin = normalizeObjectVectorOrigin(object?.objectOrigin); - return { - x: this.formatViewportNumber(origin.x), - y: this.formatViewportNumber(origin.y) - }; - } - - updateSelectedObjectOrigin(origin, okMessage) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Object Transform origin skipped: no object is selected."); - return false; - } - if (this.guardSelectedObjectMutation("Object Transform origin")) { - return false; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - nextObject.objectOrigin = { - x: Number(origin.x.toFixed(3)), - y: Number(origin.y.toFixed(3)) - }; - return this.commitPayloadUpdate( - nextPayload, - object.id, - this.selectedShapeIndex, - okMessage, - "Object Transform origin failed schema validation", - { - directSelectedShapeIndexes: this.directSelectedShapeIndexes, - selectedShapeIndexes: this.selectedShapeIndexes - } - ); - } - - selectedObjectShapeIndexes(object = this.selectedObject()) { - return sortedShapes(object).map((shape, shapeIndex) => shapeIndex); - } - - readObjectOriginInputs() { - const originX = this.numberInputValue("objectVectorStudioV2ObjectOriginXInput", "Object Origin X"); - const originY = this.numberInputValue("objectVectorStudioV2ObjectOriginYInput", "Object Origin Y"); - if (!originX.ok || !originY.ok) { - return { - error: originX.error || originY.error, - ok: false, - value: { x: 0, y: 0 } - }; - } - return { - error: "", - ok: true, - value: { - x: Number(originX.value.toFixed(3)), - y: Number(originY.value.toFixed(3)) - } - }; - } - - updateSelectedObjectTransforms(operation, updater, okMessage, failPrefix = `Object Transform ${operation} failed schema validation`, options = {}) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write(`WARN Object Transform ${operation} skipped: no object is selected.`); - return false; - } - if (this.guardSelectedObjectMutation(`Object Transform ${operation}`)) { - return false; - } - - const targetIndexes = this.selectedObjectShapeIndexes(object); - if (!targetIndexes.length) { - this.statusLog.write(`WARN Object Transform ${operation} skipped: object ${object.name} has no shapes.`); - return false; - } - const lockedIndex = targetIndexes.find((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); - if (Number.isInteger(lockedIndex)) { - this.statusLog.write(`WARN Object Transform ${operation} skipped: shape row ${lockedIndex} is locked.`); - return false; - } - - const nextPayload = this.cloneCurrentPayload(); - const activeFrame = this.activeFrame(); - try { - targetIndexes.forEach((shapeIndex) => { - const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); - const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); - const shape = override - ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) - : baseShape; - if (!shape) { - throw new Error(`shape row ${shapeIndex} is not available.`); - } - updater(shape, shapeIndex); - shape.transform = this.ensureShapeTransform(shape); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - }); - } catch (error) { - this.statusLog.write(`FAIL Invalid object transform rejected for ${object.name}: ${error.message}`); - return false; - } - - return this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, okMessage, failPrefix, { - directSelectedShapeIndexes: this.directSelectedShapeIndexes, - dirtyReason: options.dirtyReason, - selectedShapeIndexes: this.selectedShapeIndexes - }); - } - - rotateSelectedObject() { - const input = this.objectRotateInputValue(); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); - return; - } - const origin = this.readObjectOriginInputs(); - if (!origin.ok) { - this.statusLog.write(`FAIL Invalid object transform rejected: ${origin.error}`); - return; - } - const rotation = this.snapAngle(input.value); - const snapAngleStatus = this.formatSnapAngleRotateStatus(input.value, rotation); - const object = this.selectedObject(); - const activeFrame = this.activeFrame(); - const targetIndexes = this.selectedObjectShapeIndexes(object); - const nextTransforms = new Map(targetIndexes.map((shapeIndex) => { - const effectiveShape = this.effectiveShapeForFrame(sortedShapes(object)[shapeIndex], activeFrame, shapeIndex); - const transform = this.ensureShapeTransform(effectiveShape); - const translatedOrigin = { - x: this.formatViewportNumber(origin.value.x + transform.x), - y: this.formatViewportNumber(origin.value.y + transform.y) - }; - const rotatedTranslatedOrigin = this.rotatePointAround(translatedOrigin, origin.value, rotation); - return [shapeIndex, { - ...transform, - rotation: this.normalizeRotationDegrees(transform.rotation + rotation), - x: this.formatViewportNumber(rotatedTranslatedOrigin.x - origin.value.x), - y: this.formatViewportNumber(rotatedTranslatedOrigin.y - origin.value.y) - }]; - })); - this.updateSelectedObjectTransforms("rotate", (shape, shapeIndex) => { - shape.transform = this.ensureShapeTransform({ transform: nextTransforms.get(shapeIndex) }); - }, `OK Rotated object ${object?.name || "selected object"} (${targetIndexes.length} shapes) by ${rotation} degrees around origin ${origin.value.x}, ${origin.value.y}.${snapAngleStatus}`); - } - - applySelectedObjectOrigin() { - const origin = this.readObjectOriginInputs(); - if (!origin.ok) { - this.statusLog.write(`FAIL Invalid object transform rejected: ${origin.error}`); - return; - } - const object = this.selectedObject(); - this.updateSelectedObjectOrigin(origin.value, `OK Updated object ${object?.name || "selected object"} origin/pivot to ${origin.value.x}, ${origin.value.y}.`); - } - - autoOriginSelectedObjectPivot() { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Auto Origin skipped: no object is selected."); - return; - } - const visibleShapes = sortedShapes(object) - .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, this.activeFrame(), shapeIndex)) - .filter((shape) => shape.visible !== false); - if (!visibleShapes.length) { - this.statusLog.write(`FAIL Auto Origin blocked: object ${object.name} has no visible geometry.`); - return; - } - const bounds = this.rawVisibleObjectGeometryBounds(object); - if (!bounds) { - this.statusLog.write(`FAIL Auto Origin blocked: object ${object.name} has no raw visible geometry bounds.`); - return; - } - const balancedCenter = { - x: this.formatViewportNumber(bounds.x + bounds.width / 2), - y: this.formatViewportNumber(bounds.y + bounds.height / 2) - }; - this.transformInputValues.set("objectVectorStudioV2ObjectOriginXInput", String(balancedCenter.x)); - this.transformInputValues.set("objectVectorStudioV2ObjectOriginYInput", String(balancedCenter.y)); - this.updateSelectedObjectOrigin(balancedCenter, `OK Auto Origin updated object ${object.name} origin/pivot from raw visible geometry bounds ${balancedCenter.x}, ${balancedCenter.y}.`); - } - - moveSelectedShape() { - if (!this.canUseShapeTransform()) { - this.statusLog.write("WARN Shape Transform move skipped: select exactly one shape."); - return; - } - const x = this.numberInputValue("objectVectorStudioV2MoveXInput", "Move X"); - const y = this.numberInputValue("objectVectorStudioV2MoveYInput", "Move Y"); - if (!x.ok || !y.ok) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${x.error || y.error}`); - return; - } - const moveX = this.snapDistance(x.value); - const moveY = this.snapDistance(y.value); - const object = this.selectedObject(); - if (this.shapeBelongsToValidGroup(object, this.selectedShapeIndex)) { - const targetIndexes = this.shapeSelectionGroupIndexes(sortedShapes(object), this.selectedShapeIndex); - const groupId = String(sortedShapes(object)[this.selectedShapeIndex]?.groupId || "").trim(); - this.moveSelectedShapeGroup(targetIndexes, moveX, moveY, groupId); - return; - } - this.updateSelectedShapeTransform("move", (shape) => { - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); - shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); - }, `OK Moved shape row ${this.selectedShapeIndex} by ${moveX}, ${moveY}.`); - } - - moveSelectedShapeGroup(shapeIndexes, moveX, moveY, groupId) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Transform move skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Transform move")) { - return; - } - - const objectShapes = sortedShapes(object); - const targetIndexes = Array.from(new Set(shapeIndexes - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) - .sort((left, right) => left - right); - if (targetIndexes.length < 2) { - this.updateSelectedShapeTransform("move", (shape) => { - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); - shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); - }, `OK Moved shape row ${this.selectedShapeIndex} by ${moveX}, ${moveY}.`); - return; - } - - const lockedIndex = targetIndexes.find((shapeIndex) => objectShapes[shapeIndex]?.locked); - if (Number.isInteger(lockedIndex)) { - this.statusLog.write(`WARN Transform move skipped: shape row ${lockedIndex} is locked.`); - return; - } - - this.selectedShapeIndexes = new Set(targetIndexes); - const directShapeIndexes = this.directSelectedShapeIndexes.size - ? new Set(Array.from(this.directSelectedShapeIndexes).filter((shapeIndex) => targetIndexes.includes(shapeIndex))) - : new Set([this.selectedShapeIndex]); - if (!directShapeIndexes.size && this.selectedShapeIndex >= 0) { - directShapeIndexes.add(this.selectedShapeIndex); - } - - const nextPayload = this.cloneCurrentPayload(); - try { - targetIndexes.forEach((shapeIndex) => { - const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); - const shape = override - ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, shapeIndex), this.activeFrame(), shapeIndex) - : this.findShapeInPayload(nextPayload, shapeIndex); - if (!shape) { - throw new Error(`shape row ${shapeIndex} is not available.`); - } - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); - shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - }); - } catch (error) { - this.statusLog.write(`FAIL Invalid transform rejected for group ${groupId || "selected group"}: ${error.message}`); - return; - } - - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Moved group ${groupId || "selected group"} (${targetIndexes.length} shapes) by ${moveX}, ${moveY}.`, "Transform move failed schema validation", { - directSelectedShapeIndexes: directShapeIndexes, - selectedShapeIndexes: new Set(targetIndexes) - }); - } - - rotateSelectedShape() { - if (!this.canUseShapeTransform()) { - this.statusLog.write("WARN Shape Transform rotate skipped: select exactly one shape."); - return; - } - const input = this.rotateInputValue(); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); - return; - } - const rotation = this.snapAngle(input.value); - const snapAngleStatus = this.formatSnapAngleRotateStatus(input.value, rotation); - const object = this.selectedObject(); - const origin = this.objectTransformOrigin(object); - this.updateSelectedShapeGeometry("rotate", (shape) => { - this.rotateShapeGeometryAroundOrigin(shape, origin, rotation); - shape.transform = { - ...this.ensureShapeTransform(shape), - rotation: 0 - }; - }, `OK Rotated shape row ${this.selectedShapeIndex} geometry by ${rotation} degrees around object origin ${origin.x}, ${origin.y}.${snapAngleStatus}`); - } - - formatSnapAngleRotateStatus(rawRotation, appliedRotation) { - if (this.angleSnapEnabled) { - return ` Snap Angle active: ${rawRotation} -> ${appliedRotation}.`; - } - return " Snap Angle disabled: raw rotation applied."; - } - - rotateInputValue() { - if (!this.angleSnapEnabled) { - const input = this.numberInputValue("objectVectorStudioV2RotateInput", "Rotate"); - return { ...input, rawValue: String(input.value) }; - } - const select = this.window.document.getElementById("objectVectorStudioV2RotateSnapSelect"); - const rawValue = select?.value?.trim() || ""; - const value = Number(rawValue); - if (!select || rawValue === "" || !Number.isFinite(value)) { - const error = "Rotate Snap Angle must be a valid dropdown value."; - this.markInputInvalid(select, error); - return { error, ok: false, rawValue, value: 0 }; - } - this.clearInputValidity(select); - return { error: "", ok: true, rawValue, value }; - } - - objectRotateInputValue() { - if (!this.angleSnapEnabled) { - const input = this.numberInputValue("objectVectorStudioV2ObjectRotateInput", "Object Rotate"); - return { ...input, rawValue: String(input.value) }; - } - const select = this.window.document.getElementById("objectVectorStudioV2ObjectRotateSnapSelect"); - const rawValue = select?.value?.trim() || ""; - const value = Number(rawValue); - if (!select || rawValue === "" || !Number.isFinite(value)) { - const error = "Object Rotate Snap Angle must be a valid dropdown value."; - this.markInputInvalid(select, error); - return { error, ok: false, rawValue, value: 0 }; - } - this.clearInputValidity(select); - return { error: "", ok: true, rawValue, value }; - } - - rotatePointAround(point, pivot, rotation) { - return rotateSharedPointAround(point, pivot, rotation, "degrees"); - } - - rotateGeometryPoint(point, origin, rotation) { - const rotated = this.rotatePointAround(point, origin, rotation); - return { - x: this.formatViewportNumber(rotated.x), - y: this.formatViewportNumber(rotated.y) - }; - } - - rotateShapeGeometryAroundOrigin(shape, origin, rotation) { - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "polygon" || geometryTool === "polyline") { - shape.geometry.points = shape.geometry.points.map((point) => this.rotateGeometryPoint(point, origin, rotation)); - return; - } - if (geometryTool === "line") { - shape.geometry.point1 = this.rotateGeometryPoint(shape.geometry.point1, origin, rotation); - shape.geometry.point2 = this.rotateGeometryPoint(shape.geometry.point2, origin, rotation); - return; - } - if (geometryTool === "rectangle") { - const center = this.rotateGeometryPoint({ - x: shape.geometry.x + shape.geometry.width / 2, - y: shape.geometry.y + shape.geometry.height / 2 - }, origin, rotation); - shape.geometry.x = this.formatViewportNumber(center.x - shape.geometry.width / 2); - shape.geometry.y = this.formatViewportNumber(center.y - shape.geometry.height / 2); - return; - } - if (geometryTool === "circle") { - const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); - shape.geometry.cx = center.x; - shape.geometry.cy = center.y; - return; - } - if (geometryTool === "ellipse") { - const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); - shape.geometry.cx = center.x; - shape.geometry.cy = center.y; - return; - } - if (geometryTool === "arc") { - const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); - shape.geometry.cx = center.x; - shape.geometry.cy = center.y; - shape.geometry.startAngle = this.normalizeRotationDegrees(shape.geometry.startAngle + rotation); - shape.geometry.endAngle = this.normalizeRotationDegrees(shape.geometry.endAngle + rotation); - return; - } - if (geometryTool === "text") { - const anchor = this.rotateGeometryPoint({ x: shape.geometry.x, y: shape.geometry.y }, origin, rotation); - shape.geometry.x = anchor.x; - shape.geometry.y = anchor.y; - } - } - - normalizeRotationDegrees(value) { - return normalizeSharedRotationDegrees(value); - } - - formatScaleInputValue(value) { - return String(Number(Number(value).toFixed(3))); - } - - readScaleInput(inputId = "objectVectorStudioV2ScaleInput", label = "Scale") { - const input = this.numberInputValue(inputId, label); - if (!input.ok) { - return input; - } - if (input.value <= 0) { - this.markInputInvalid(this.window.document.getElementById(inputId), `${label} must be greater than 0.`); - return { error: "scale must be greater than 0.", ok: false, value: input.value }; - } - return { error: "", ok: true, value: Number(input.value.toFixed(3)) }; - } - - applyScaleInputLive(inputElement) { - const rawValue = inputElement?.value?.trim() || ""; - if (!rawValue) { - this.markInputInvalid(inputElement, "Scale must be a finite number."); - return; - } - const input = this.readScaleInput(); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); - return; - } - this.applySelectedShapeScaleValue(input.value, { - okMessage: `OK Scale preview set to ${this.formatScaleInputValue(input.value)} for shape row ${this.selectedShapeIndex}.`, - renderControls: false - }); - } - - adjustSelectedShapeScale(delta) { - const input = this.readScaleInput(); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); - return; - } - const nextScale = Number((input.value + delta).toFixed(3)); - if (nextScale <= 0) { - this.markInputInvalid(this.window.document.getElementById("objectVectorStudioV2ScaleInput"), "Scale must be greater than 0."); - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: scale must be greater than 0.`); - return; - } - const inputElement = this.window.document.getElementById("objectVectorStudioV2ScaleInput"); - if (inputElement) { - inputElement.value = this.formatScaleInputValue(nextScale); - this.transformInputValues.set(inputElement.id, inputElement.value); - } - this.applySelectedShapeScaleValue(nextScale, { - okMessage: `OK Scale preview set to ${this.formatScaleInputValue(nextScale)} for shape row ${this.selectedShapeIndex}.`, - renderControls: false - }); - } - - resetSelectedShapeScale() { - const inputElement = this.window.document.getElementById("objectVectorStudioV2ScaleInput"); - if (inputElement) { - inputElement.value = "1"; - this.transformInputValues.set(inputElement.id, "1"); - this.clearInputValidity(inputElement); - } - this.applySelectedShapeScaleValue(1, { - okMessage: `OK Scale reset to 1 for shape row ${this.selectedShapeIndex}.`, - renderControls: false - }); - } - - applySelectedShapeScaleValue(scale, { okMessage, renderControls = false } = {}) { - if (!this.canUseShapeTransform()) { - this.statusLog.write("WARN Transform scale skipped: select exactly one shape."); - return false; - } - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Transform scale skipped: no shape is selected."); - return false; - } - if (selected.locked) { - this.statusLog.write(`WARN Transform scale skipped: shape row ${this.selectedShapeIndex} is locked.`); - return false; - } - if (this.guardSelectedObjectMutation("Transform scale")) { - return false; - } - - const nextPayload = this.cloneCurrentPayload(); - const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: true }); - const shape = override - ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, this.selectedShapeIndex), this.activeFrame(), this.selectedShapeIndex) - : this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - shape.transform = this.ensureShapeTransform(shape); - shape.transform.scaleX = Number(scale.toFixed(3)); - shape.transform.scaleY = Number(scale.toFixed(3)); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); - return false; - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - - const validation = this.schemaService.validatePayload(nextPayload); - if (!validation.ok) { - this.statusLog.write(`FAIL Transform scale failed schema validation: ${validation.errors.join(" ")}`); - return false; - } - const hasPayloadChange = this.hasPayloadChanged(validation.payload); - const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { - changedKeys: ["data.objects"], - reason: "object-vector-transform-scale" - }); - if (!workspaceSync.ok) { - this.statusLog.write(`FAIL Transform scale failed schema validation: ${workspaceSync.message}`); - return false; - } - - this.recordPreviewHistorySnapshot(hasPayloadChange); - this.currentPayload = validation.payload; - this.actionNav.setJsonPayloadActionsEnabled(true); - this.ensureSelectedFrame(); - if (renderControls) { - this.renderPayload(); - } else { - this.renderObjectTiles(); - this.renderShapeTiles(); - this.renderWorkSurface(); - this.updateTransformSummaryText(); - this.updateSelectedObjectJsonDetails(); - this.updateObjectActionState(); - } - if (workspaceSync.changed) { - this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); - } - if (okMessage) { - this.statusLog.write(okMessage); - } - return true; - } - - applyObjectScaleInputLive(inputElement) { - const rawValue = inputElement?.value?.trim() || ""; - if (!rawValue) { - this.markInputInvalid(inputElement, "Object Scale must be a finite number."); - return; - } - const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); - return; - } - this.applySelectedObjectScaleValue(input.value, { - okMessage: `OK Object resize scale set to ${this.formatScaleInputValue(input.value)} for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` - }); - } - - adjustSelectedObjectScale(delta) { - const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); - if (!input.ok) { - this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); - return; - } - const nextScale = Number((input.value + delta).toFixed(3)); - if (nextScale <= 0) { - this.markInputInvalid(this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"), "Object Scale must be greater than 0."); - this.statusLog.write("FAIL Invalid object transform rejected: scale must be greater than 0."); - return; - } - const inputElement = this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"); - if (inputElement) { - inputElement.value = this.formatScaleInputValue(nextScale); - this.transformInputValues.set(inputElement.id, inputElement.value); - } - this.applySelectedObjectScaleValue(nextScale, { - okMessage: `OK Object resize scale set to ${this.formatScaleInputValue(nextScale)} for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` - }); - } - - resetSelectedObjectScale() { - const inputElement = this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"); - if (inputElement) { - inputElement.value = "1"; - this.transformInputValues.set(inputElement.id, "1"); - this.clearInputValidity(inputElement); - } - this.applySelectedObjectScaleValue(1, { - okMessage: `OK Object resize scale reset to 1 for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` - }); - } - - applySelectedObjectScaleValue(scale, { okMessage } = {}) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Object scale skipped: no object is selected."); - return false; - } - this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", this.formatScaleInputValue(scale)); - this.renderWorkSurface(); - this.updateTransformSummaryText(); - this.updateSelectedObjectJsonDetails(); - if (okMessage) { - this.statusLog.write(okMessage); - } else { - this.statusLog.write(`OK Object resize scale set to ${this.formatScaleInputValue(scale)} for ${object.name}; object transform scale remains 1 until Resize rewrites geometry.`); - } - return true; - } - - resizeSelectedObject() { - const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); - if (!input.ok) { - this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${input.error}`); - return; - } - const origin = this.readObjectOriginInputs(); - if (!origin.ok) { - this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${origin.error}`); - return; - } - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Resize Geometry skipped: no object is selected."); - return; - } - if (this.guardSelectedObjectMutation("Resize Geometry")) { - return; - } - - const targetIndexes = this.selectedObjectShapeIndexes(object); - const lockedIndex = targetIndexes.find((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); - if (Number.isInteger(lockedIndex)) { - this.statusLog.write(`WARN Resize Geometry skipped: shape row ${lockedIndex} is locked.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - try { - targetIndexes.forEach((shapeIndex) => { - const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); - const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: false }); - const transformTarget = override?.transform ? { transform: override.transform, geometry: baseShape.geometry } : baseShape; - const transform = this.ensureShapeTransform(transformTarget); - transform.scaleX = input.value; - transform.scaleY = input.value; - this.resizeShapeGeometryByTransformScale(baseShape, transform, origin.value); - transform.scaleX = 1; - transform.scaleY = 1; - if (override?.transform) { - override.transform = transform; - } else { - baseShape.transform = transform; - } - const transformErrors = this.validateShapeForTransform(baseShape); - if (transformErrors.length) { - throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); - } - }); - } catch (error) { - this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${error.message}`); - return; - } - - this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", "1"); - this.commitPayloadUpdate( - nextPayload, - object.id, - this.selectedShapeIndex, - `OK Resize Geometry applied scale ${this.formatScaleInputValue(input.value)} to object ${object.name} (${targetIndexes.length} shapes); transform scale reset to 1.`, - "Resize Geometry failed schema validation", - { - directSelectedShapeIndexes: this.directSelectedShapeIndexes, - selectedShapeIndexes: this.selectedShapeIndexes - } - ); - } - - resizeSelectedShape() { - if (!this.canUseShapeTransform()) { - this.statusLog.write("WARN Resize Geometry skipped: select exactly one shape."); - return; - } - const input = this.readScaleInput(); - if (!input.ok) { - this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); - return; - } - - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Resize Geometry skipped: no shape is selected."); - return; - } - if (selected.locked) { - this.statusLog.write(`WARN Resize Geometry skipped: shape row ${this.selectedShapeIndex} is locked.`); - return; - } - if (this.guardSelectedObjectMutation("Resize Geometry")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const baseShape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: false }); - const transformTarget = override?.transform ? { transform: override.transform, geometry: baseShape.geometry } : baseShape; - const transform = this.ensureShapeTransform(transformTarget); - transform.scaleX = input.value; - transform.scaleY = input.value; - - try { - this.resizeShapeGeometryByTransformScale(baseShape, transform, this.objectTransformOrigin(this.selectedObject())); - transform.scaleX = 1; - transform.scaleY = 1; - if (override?.transform) { - override.transform = transform; - } else { - baseShape.transform = transform; - } - const transformErrors = this.validateShapeForTransform(baseShape); - if (transformErrors.length) { - this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); - return; - } - } catch (error) { - this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); - return; - } - - this.transformInputValues.set("objectVectorStudioV2ScaleInput", "1"); - this.commitPayloadUpdate( - nextPayload, - this.selectedObjectId, - this.selectedShapeIndex, - `OK Resize Geometry applied scale ${this.formatScaleInputValue(input.value)} to shape row ${this.selectedShapeIndex}; transform scale reset to 1.`, - "Resize Geometry failed schema validation" - ); - } - - groupSelectedShapes() { - const object = this.selectedObject(); - if (!object || this.selectedShapeIndexes.size < 2) { - this.statusLog.write("WARN Group shapes skipped: select at least two shapes."); - return; - } - if (this.guardSelectedObjectMutation("Group shapes")) { - return; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const groupId = this.uniqueGroupId(nextObject); - const directIndexes = Array.from(this.directSelectedShapeIndexes) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => this.selectedShapeIndexes.has(shapeIndex)); - const selectedIndexes = new Set((directIndexes.length >= 2 ? directIndexes : Array.from(this.selectedShapeIndexes)) - .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) - .filter((shapeIndex) => shapeIndex >= 0)); - if (selectedIndexes.size < 2) { - this.statusLog.write("WARN Group shapes skipped: select at least two shapes."); - return; - } - sortedShapes(nextObject).forEach((shape, shapeIndex) => { - if (selectedIndexes.has(shapeIndex)) { - shape.groupId = groupId; - } - }); - this.pruneSingleMemberShapeGroups(nextObject); - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Grouped ${selectedIndexes.size} shapes into ${groupId}.`, "Group shapes failed schema validation", { - directSelectedShapeIndexes: selectedIndexes, - selectedShapeIndexes: selectedIndexes - }); - } - - ungroupSelectedShapes() { - const object = this.selectedObject(); - const selected = this.selectedShape(); - if (!object || !selected) { - this.statusLog.write("WARN Ungroup skipped: no shape is selected."); - return; - } - if (this.guardSelectedObjectMutation("Ungroup shapes")) { - return; - } - const selectedIndexes = new Set(this.selectedShapeIndexes); - const selectedGroupIds = new Set(sortedShapes(object) - .filter((shape, shapeIndex) => selectedIndexes.has(shapeIndex) && shape.groupId) - .map((shape) => shape.groupId)); - if (!selectedGroupIds.size) { - this.statusLog.write("WARN Ungroup skipped: selected shapes are not grouped."); - return; - } - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - sortedShapes(nextObject).forEach((shape, shapeIndex) => { - if (selectedIndexes.has(shapeIndex) && selectedGroupIds.has(shape.groupId)) { - delete shape.groupId; - } - }); - this.pruneSingleMemberShapeGroups(nextObject); - this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Ungrouped ${selectedIndexes.size} selected shapes from ${Array.from(selectedGroupIds).join(", ")}.`, "Ungroup shapes failed schema validation", { - directSelectedShapeIndexes: selectedIndexes, - selectedShapeIndexes: selectedIndexes - }); - } - - pruneSingleMemberShapeGroups(object) { - const groupCounts = new Map(); - sortedShapes(object).forEach((shape) => { - const groupId = String(shape.groupId || "").trim(); - if (!groupId) { - return; - } - groupCounts.set(groupId, (groupCounts.get(groupId) || 0) + 1); - }); - sortedShapes(object).forEach((shape) => { - const groupId = String(shape.groupId || "").trim(); - if (groupId && groupCounts.get(groupId) < 2) { - delete shape.groupId; - } - }); - } - - applyShapeGeometryEdits(options = {}) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Geometry edit skipped: no shape is selected."); - return false; - } - if (!this.canUseShapeGeometry()) { - this.statusLog.write("WARN Geometry edit skipped: select exactly one shape."); - return false; - } - if (this.guardSelectedObjectMutation("Geometry edit")) { - return false; - } - const fields = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-shape-geometry-field]")); - const geometry = this.readShapeGeometryFields(selected, fields); - if (!geometry.ok) { - this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); - return false; - } - return this.updateSelectedShapeGeometry("geometry edit", (shape) => { - shape.geometry = geometry.value; - if (shapeGeometryTool(shape) === "polygon" || shapeGeometryTool(shape) === "polyline") { - const pointRounding = this.currentPointRoundingRows(geometry.value.points.length); - this.applyPointRoundingToShape(shape, pointRounding); - } - shape.transform = this.ensureShapeTransform(shape); - }, options.okMessage || `OK Auto-applied geometry edits to shape row ${this.selectedShapeIndex}.`); - } - - addPolygonPointAfterRow(pointIndex, sourceButton = null) { - const selected = this.selectedShape(); - if (!selected || !["polygon", "polyline"].includes(shapeGeometryTool(selected))) { - this.statusLog.write("WARN Add point skipped: no editable point-list shape is selected."); - return; - } - if (!this.canUseShapeGeometry()) { - this.statusLog.write("WARN Add point skipped: select exactly one shape."); - return; - } - if (!this.isEditablePolygonShape(selected)) { - this.statusLog.write(`WARN Add point skipped for shape row ${this.selectedShapeIndex}: triangle point count is fixed.`); - return; - } - const geometry = this.readCurrentPolygonGeometry(selected); - if (!geometry.ok) { - this.markPolygonPointAddInvalid(sourceButton, geometry.error); - this.statusLog.write(`FAIL Add point rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); - return; - } - const points = geometry.value.points; - const pointRounding = this.currentPointRoundingRows(points.length); - const insertAfterIndex = Number(pointIndex); - if (!Number.isInteger(insertAfterIndex) || insertAfterIndex < 0 || insertAfterIndex >= points.length) { - const message = `point ${insertAfterIndex + 1} is not available.`; - this.markPolygonPointAddInvalid(sourceButton, message); - this.statusLog.write(`FAIL Add point rejected for shape row ${this.selectedShapeIndex}: ${message}`); - return; - } - const currentPoint = points[insertAfterIndex]; - const nextPoint = { - x: Number(currentPoint.x), - y: Number(currentPoint.y) - }; - points.splice(insertAfterIndex + 1, 0, nextPoint); - pointRounding.splice(insertAfterIndex + 1, 0, pointRounding[insertAfterIndex] === true); - this.rebuildPolygonPointList(points, pointRounding); - this.clearPolygonPointSelections(); - this.clearPolygonPointAddValidity(); - this.clearPolygonPointDeleteValidity(); - this.applyShapeGeometryEdits({ - okMessage: `OK Added copied point after point ${insertAfterIndex + 1} for shape row ${this.selectedShapeIndex}.` - }); - } - - deletePolygonPointRow(pointIndex, sourceButton = null) { - const selected = this.selectedShape(); - if (!selected || !["polygon", "polyline"].includes(shapeGeometryTool(selected))) { - this.statusLog.write("WARN Delete point skipped: no editable point-list shape is selected."); - return; - } - if (!this.canUseShapeGeometry()) { - this.statusLog.write("WARN Delete point skipped: select exactly one shape."); - return; - } - if (!this.isEditablePolygonShape(selected)) { - this.statusLog.write(`WARN Delete point skipped for shape row ${this.selectedShapeIndex}: triangle point count is fixed.`); - return; - } - const geometry = this.readCurrentPolygonGeometry(selected); - if (!geometry.ok) { - this.markPolygonPointDeleteInvalid(sourceButton, geometry.error); - this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); - return; - } - const normalizedIndex = Number(pointIndex); - if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= geometry.value.points.length) { - const message = `point ${normalizedIndex + 1} is not available.`; - this.markPolygonPointDeleteInvalid(sourceButton, message); - this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); - return; - } - const minPointCount = shapeGeometryTool(selected) === "polyline" ? 2 : 4; - if (geometry.value.points.length - 1 < minPointCount) { - const message = `${shapeGeometryTool(selected)} must keep at least ${minPointCount} points.`; - this.markPolygonPointDeleteInvalid(sourceButton, message); - this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); - return; - } - const nextPoints = geometry.value.points.filter((_, index) => index !== normalizedIndex); - const nextPointRounding = this.currentPointRoundingRows(geometry.value.points.length).filter((_, index) => index !== normalizedIndex); - if (nextPoints.length < minPointCount) { - const message = `${shapeGeometryTool(selected)} must keep at least ${minPointCount} points.`; - this.markPolygonPointDeleteInvalid(sourceButton, message); - this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); - return; - } - this.rebuildPolygonPointList(nextPoints, nextPointRounding); - this.clearPolygonPointSelections(); - this.clearPolygonPointAddValidity(); - this.clearPolygonPointDeleteValidity(); - this.applyShapeGeometryEdits({ - okMessage: `OK Deleted point ${normalizedIndex + 1} from shape row ${this.selectedShapeIndex}.` - }); - } - - readCurrentPolygonGeometry(shape) { - const fields = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-shape-geometry-field='points']")); - return this.readShapeGeometryFields(shape, fields); - } - - clearPolygonPointSelections() { - this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-action-selected='true']").forEach((row) => { - delete row.dataset.polygonPointActionSelected; - row.classList.remove("is-action-selected"); - row.setAttribute("aria-pressed", "false"); - }); - } - - markPolygonPointAddInvalid(sourceButton, message) { - if (!sourceButton) { - return; - } - sourceButton.dataset.validationState = "invalid"; - sourceButton.setAttribute("aria-invalid", "true"); - sourceButton.title = message; - } - - clearPolygonPointAddValidity() { - this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-add='true']").forEach((button) => { - delete button.dataset.validationState; - button.removeAttribute("aria-invalid"); - button.title = `Add point after point ${Number(button.dataset.polygonPointIndex) + 1}`; - }); - } - - markPolygonPointDeleteInvalid(sourceButton, message) { - if (!sourceButton) { - return; - } - sourceButton.dataset.validationState = "invalid"; - sourceButton.setAttribute("aria-invalid", "true"); - sourceButton.title = message; - } - - clearPolygonPointDeleteValidity() { - this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-delete='true']").forEach((button) => { - delete button.dataset.validationState; - button.removeAttribute("aria-invalid"); - button.title = `Delete point ${Number(button.dataset.polygonPointIndex) + 1}`; - }); - } - - currentPointRoundingRows(expectedCount = null) { - const checkboxes = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-round='true']")); - const pointRounding = checkboxes - .sort((left, right) => Number(left.dataset.polygonPointIndex) - Number(right.dataset.polygonPointIndex)) - .map((checkbox) => checkbox.checked === true); - const count = Number.isInteger(expectedCount) ? expectedCount : pointRounding.length; - return Array.from({ length: count }, (_, index) => pointRounding[index] === true); - } - - normalizedPointRounding(shape, pointCount = this.shapeGeometryPointCount(shape)) { - const values = this.shapePointRoundingValues(shape); - return Array.from({ length: pointCount }, (_, index) => values[index] === true); - } - - applyPointRoundingToShape(shape, pointRounding) { - const normalized = Array.from({ length: this.shapeGeometryPointCount(shape) || pointRounding.length }, (_, index) => pointRounding[index] === true); - shape.style = { ...shape.style, pointRounding: normalized }; - delete shape.style.strokeLinecap; - shape.style.startPointStyle = normalized[0] ? "round" : "square"; - shape.style.endPointStyle = normalized.at(-1) ? "round" : "square"; - shape.style.pointStyle = "square"; - } - - rebuildPolygonPointList(points, pointRounding = null) { - const list = this.elements.shapeGeometryDetails.querySelector(".object-vector-studio-v2__polygon-point-list"); - if (!list) { - return; - } - const selected = this.selectedShape(); - const selectable = this.isEditablePolygonShape(selected); - const rounding = pointRounding || this.normalizedPointRounding(selected, points.length); - list.replaceChildren(this.createPointRowHeader({ addable: selectable, deletable: selectable }), ...points.map((point, index) => this.createPolygonPointRow(point, index, { - addable: selectable, - deletable: selectable, - rounded: rounding[index] === true, - selectable: false - }))); - } - - reindexPolygonPointRows() { - this.elements.shapeGeometryDetails.querySelectorAll(".object-vector-studio-v2__polygon-point-field").forEach((row, index) => { - const caption = row.querySelector(".object-vector-studio-v2__polygon-point-label"); - if (caption) { - caption.textContent = `Point ${index + 1}`; - } - row.querySelectorAll("[data-polygon-point-index]").forEach((input) => { - input.dataset.polygonPointIndex = String(index); - }); - row.dataset.polygonPointIndex = String(index); - const roundCheckbox = row.querySelector("[data-polygon-point-round='true']"); - if (roundCheckbox) { - roundCheckbox.setAttribute("aria-label", `Round point ${index + 1}`); - } - const addButton = row.querySelector("[data-polygon-point-add='true']"); - if (addButton) { - addButton.setAttribute("aria-label", `Add point after point ${index + 1}`); - addButton.title = `Add point after point ${index + 1}`; - } - const deleteButton = row.querySelector("[data-polygon-point-delete='true']"); - if (deleteButton) { - deleteButton.setAttribute("aria-label", `Delete point ${index + 1}`); - deleteButton.title = `Delete point ${index + 1}`; - } - }); - } - - readShapeGeometryFields(shape, fields) { - try { - if (shapeGeometryTool(shape) === "polygon" || shapeGeometryTool(shape) === "polyline") { - const pointInputs = fields.filter((input) => input.dataset.shapeGeometryField === "points"); - const geometryTool = shapeGeometryTool(shape); - const requiredPointCount = this.isTriangleShape(shape) ? 3 : geometryTool === "polyline" ? 2 : 4; - const shapeLabel = this.isTriangleShape(shape) ? "triangle" : geometryTool; - if (pointInputs.length < requiredPointCount) { - throw new Error(`${shapeLabel} points must contain at least ${requiredPointCount} point rows.`); - } - const pointRows = new Map(); - pointInputs.forEach((input) => { - const index = Number(input.dataset.polygonPointIndex); - const axis = input.dataset.polygonPointAxis; - if (!pointRows.has(index)) { - pointRows.set(index, {}); - } - pointRows.get(index)[axis] = input; - }); - if (pointRows.size < requiredPointCount) { - throw new Error(`${shapeLabel} points must contain at least ${requiredPointCount} point rows.`); - } - if (this.isTriangleShape(shape) && pointRows.size !== 3) { - throw new Error("triangle points must contain exactly three point rows."); - } - const points = []; - for (const index of [...pointRows.keys()].sort((left, right) => left - right)) { - const row = pointRows.get(index); - const pointNumber = index + 1; - const x = this.numberInputElementValue(row.x, `Point ${pointNumber} X`); - const y = this.numberInputElementValue(row.y, `Point ${pointNumber} Y`); - if (!x.ok || !y.ok) { - return { error: x.error || y.error, ok: false, value: null }; - } - points.push({ x: x.value, y: y.value }); - } - return { ok: true, value: { points } }; - } - const geometry = {}; - for (const input of fields) { - const key = input.dataset.shapeGeometryField; - if (input.type === "number") { - const parsed = this.numberInputElementValue(input, key); - if (!parsed.ok) { - return { error: parsed.error, ok: false, value: null }; - } - this.writeGeometryFieldValue(geometry, key, parsed.value); - } else { - this.clearInputValidity(input); - this.writeGeometryFieldValue(geometry, key, input.value); - } - } - if (shapeTool(shape) === "square") { - const sizeInput = fields.find((input) => input.dataset.shapeGeometryField === "size") || null; - if (!(geometry.size > 0)) { - const message = "Size must be greater than 0."; - if (sizeInput) { - this.markInputInvalid(sizeInput, message); - } - return { error: message, ok: false, value: null }; - } - return { - ok: true, - value: { - height: geometry.size, - width: geometry.size, - x: geometry.x, - y: geometry.y - } - }; - } - return { ok: true, value: geometry }; - } catch (error) { - if (error.input) { - this.markInputInvalid(error.input, error.message); - } else { - const pointsInput = fields.find((input) => input.dataset.shapeGeometryField === "points"); - if (pointsInput) { - this.markInputInvalid(pointsInput, error.message); - } - } - return { error: error.message, ok: false, value: null }; - } - } - - writeGeometryFieldValue(geometry, key, value) { - if (!key.includes(".")) { - geometry[key] = value; - return; - } - const [parentKey, childKey] = key.split("."); - geometry[parentKey] = { - ...(geometry[parentKey] || {}), - [childKey]: value - }; - } - - updateSelectedShapeGeometry(operation, updater, okMessage, options = {}) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write(`WARN Geometry ${operation} skipped: no shape is selected.`); - return false; - } - if (selected.locked) { - this.statusLog.write(`WARN Geometry ${operation} skipped: shape row ${this.selectedShapeIndex} is locked.`); - return false; - } - if (this.guardSelectedObjectMutation(`Geometry ${operation}`)) { - return false; - } - - const nextPayload = this.cloneCurrentPayload(); - const shape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - try { - updater(shape); - shape.transform = this.ensureShapeTransform(shape); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); - return false; - } - } catch (error) { - this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); - return false; - } - return this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, okMessage, `Geometry ${operation} failed schema validation`, options); - } - - updateSelectedShapeTransform(operation, updater, okMessage, options = {}) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write(`WARN Transform ${operation} skipped: no shape is selected.`); - return; - } - if (selected.locked) { - this.statusLog.write(`WARN Transform ${operation} skipped: shape row ${this.selectedShapeIndex} is locked.`); - return; - } - if (this.guardSelectedObjectMutation(`Transform ${operation}`)) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: true }); - const shape = override - ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, this.selectedShapeIndex), this.activeFrame(), this.selectedShapeIndex) - : this.findShapeInPayload(nextPayload, this.selectedShapeIndex); - try { - updater(shape); - const transformErrors = this.validateShapeForTransform(shape); - if (transformErrors.length) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); - return; - } - } catch (error) { - this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); - return; - } - if (override) { - override.transform = this.ensureShapeTransform(shape); - } - this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, okMessage, `Transform ${operation} failed schema validation`, options); - } - - duplicateSelectedShape() { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write("WARN Duplicate shape skipped: no shape is selected."); - return; - } - if (this.guardSelectedObjectMutation("Duplicate shape")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); - const shape = JSON.parse(JSON.stringify(selected)); - shape.order = object.shapes.length ? Math.max(...object.shapes.map((candidate) => candidate.order)) + 1 : 1; - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((shape.transform.x + 10).toFixed(3)); - shape.transform.y = Number((shape.transform.y + 10).toFixed(3)); - object.shapes.push(shape); - this.commitPayloadUpdate(nextPayload, object.id, sortedShapes(object).length - 1, `OK Duplicated shape row ${this.selectedShapeIndex}.`, "Duplicate shape failed schema validation"); - } - - deleteSelectedShape(sourceLabel) { - const selected = this.selectedShape(); - if (!selected) { - this.statusLog.write(`WARN Delete shape skipped from ${sourceLabel}: no shape is selected.`); - return; - } - if (selected.locked) { - this.statusLog.write(`WARN Delete shape skipped: shape row ${this.selectedShapeIndex} is locked.`); - return; - } - if (this.guardSelectedObjectMutation("Delete shape")) { - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); - const deleteIndex = this.selectedShapeIndex; - object.shapes = sortedShapes(object).filter((shape, shapeIndex) => shapeIndex !== deleteIndex) - .map((shape, index) => ({ ...shape, order: index + 1 })); - this.pruneSingleMemberShapeGroups(object); - this.removeDeletedShapeReferences(object, deleteIndex); - this.removeDanglingShapeOverrideReferences(nextPayload); - const selectedShapeIndex = sortedShapes(object).length ? 0 : -1; - this.commitPayloadUpdate(nextPayload, object.id, selectedShapeIndex, `OK Deleted shape row ${deleteIndex} from ${sourceLabel}.`, "Delete shape failed schema validation"); - } - - deleteShapeByIndex(shapeIndex, sourceLabel, objectId = this.selectedObjectId) { - const deleteIndex = normalizeShapeIndex(shapeIndex); - const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId) || null; - const shape = sortedShapes(object)[deleteIndex] || null; - if (!object || !shape) { - this.statusLog.write(`WARN Delete shape skipped from ${sourceLabel}: shape row ${shapeIndex ?? "unknown"} is not available.`); - return; - } - if (shape.locked) { - this.statusLog.write(`WARN Delete shape skipped: shape row ${deleteIndex} is locked.`); - return; - } - if (this.isObjectLocked(object.id)) { - this.statusLog.write(`WARN Delete shape blocked: object ${object.name} is locked for this runtime session.`); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - nextObject.shapes = sortedShapes(nextObject).filter((candidate, index) => index !== deleteIndex) - .map((candidate, index) => ({ ...candidate, order: index + 1 })); - this.pruneSingleMemberShapeGroups(nextObject); - this.removeDeletedShapeReferences(nextObject, deleteIndex); - this.removeDanglingShapeOverrideReferences(nextPayload); - const selectedShapeStillExists = this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(nextObject).length; - const selectedShapeIndex = selectedShapeStillExists ? this.selectedShapeIndex : sortedShapes(nextObject).length ? 0 : -1; - this.commitPayloadUpdate(nextPayload, object.id, selectedShapeIndex, `OK Deleted shape row ${deleteIndex} from ${sourceLabel}.`, "Delete shape failed schema validation"); - } - - removeDeletedShapeReferences(object, deletedShapeIndex) { - this.objectStates(object).forEach((state) => { - state.frames.forEach((frame) => { - frame.shapeOverrides = frame.shapeOverrides - .filter((override) => override.shapeIndex !== deletedShapeIndex) - .map((override) => ({ - ...override, - shapeIndex: override.shapeIndex > deletedShapeIndex ? override.shapeIndex - 1 : override.shapeIndex - })); - }); - }); - } - - remapShapeOverrideIndexes(object, oldShapes, nextShapes) { - const oldIndexToNextIndex = new Map(); - oldShapes.forEach((shape, oldIndex) => { - oldIndexToNextIndex.set(oldIndex, nextShapes.indexOf(shape)); - }); - this.objectStates(object).forEach((state) => { - state.frames.forEach((frame) => { - frame.shapeOverrides = frame.shapeOverrides - .map((override) => { - const nextIndex = oldIndexToNextIndex.get(override.shapeIndex); - return Number.isInteger(nextIndex) && nextIndex >= 0 - ? { ...override, shapeIndex: nextIndex } - : null; - }) - .filter(Boolean); - }); - }); - } - - numberInputValue(id, label) { - const element = this.window.document.getElementById(id); - return this.numberInputElementValue(element, label); - } - - numberInputElementValue(element, label) { - const rawValue = element?.value?.trim() || ""; - const value = Number(rawValue); - if (!element || rawValue === "" || !Number.isFinite(value)) { - const error = `${label} must be a finite number.`; - this.markInputInvalid(element, error); - return { error, ok: false, value: 0 }; - } - this.clearInputValidity(element); - const normalizedValue = this.formatViewportNumber(value); - element.value = String(normalizedValue); - return { error: "", ok: true, value: normalizedValue }; - } - - markInputInvalid(element, message) { - if (!element) { - return; - } - element.classList.add("is-invalid"); - element.setAttribute("aria-invalid", "true"); - element.setCustomValidity(message); - element.reportValidity(); - } - - clearInputValidity(element) { - if (!element) { - return; - } - element.classList.remove("is-invalid"); - element.removeAttribute("aria-invalid"); - element.setCustomValidity(""); - } - - snapDistance(value) { - return value; - } - - snapAngle(value) { - if (!this.angleSnapEnabled) { - return value; - } - const step = ANGLE_SNAP_STEPS.includes(this.angleSnapStep) ? this.angleSnapStep : 15; - return Math.round(value / step) * step; - } - - ensureShapeTransform(shape) { - if (!shape.transform) { - return defaultShapeTransform(); - } - const transform = shape.transform; - return { - rotation: this.formatViewportNumber(Number(transform.rotation ?? 0)), - scaleX: this.formatViewportNumber(Number(transform.scaleX ?? 1)), - scaleY: this.formatViewportNumber(Number(transform.scaleY ?? 1)), - x: this.formatViewportNumber(Number(transform.x ?? 0)), - y: this.formatViewportNumber(Number(transform.y ?? 0)) - }; - } - - resizeShapeGeometryByTransformScale(shape, transform, origin = { x: 0, y: 0 }) { - if (!Number.isFinite(transform.scaleX) || !Number.isFinite(transform.scaleY) || transform.scaleX <= 0 || transform.scaleY <= 0) { - throw new Error("scale must be greater than 0."); - } - const scaleX = transform.scaleX; - const scaleY = transform.scaleY; - const uniformScale = Number(((scaleX + scaleY) / 2).toFixed(3)); - const requiresUniformScale = (label) => { - if (Math.abs(scaleX - scaleY) > 0.001) { - throw new Error(`${label} geometry cannot bake non-uniform scale; set matching X/Y scale first.`); - } - return uniformScale; - }; - const applyX = (value) => Number((origin.x + (value - origin.x) * scaleX).toFixed(3)); - const applyY = (value) => Number((origin.y + (value - origin.y) * scaleY).toFixed(3)); - const geometryTool = shapeGeometryTool(shape); - if (geometryTool === "rectangle") { - shape.geometry.x = applyX(shape.geometry.x); - shape.geometry.y = applyY(shape.geometry.y); - shape.geometry.width = Number((shape.geometry.width * scaleX).toFixed(3)); - shape.geometry.height = Number((shape.geometry.height * scaleY).toFixed(3)); - return; - } - if (geometryTool === "circle") { - const scale = requiresUniformScale("circle"); - shape.geometry.cx = applyX(shape.geometry.cx); - shape.geometry.cy = applyY(shape.geometry.cy); - shape.geometry.r = Number((shape.geometry.r * scale).toFixed(3)); - return; - } - if (geometryTool === "ellipse") { - shape.geometry.cx = applyX(shape.geometry.cx); - shape.geometry.cy = applyY(shape.geometry.cy); - shape.geometry.rx = Number((shape.geometry.rx * scaleX).toFixed(3)); - shape.geometry.ry = Number((shape.geometry.ry * scaleY).toFixed(3)); - return; - } - if (geometryTool === "line") { - shape.geometry.point1.x = applyX(shape.geometry.point1.x); - shape.geometry.point1.y = applyY(shape.geometry.point1.y); - shape.geometry.point2.x = applyX(shape.geometry.point2.x); - shape.geometry.point2.y = applyY(shape.geometry.point2.y); - return; - } - if (geometryTool === "arc") { - const scale = requiresUniformScale("arc"); - shape.geometry.cx = applyX(shape.geometry.cx); - shape.geometry.cy = applyY(shape.geometry.cy); - shape.geometry.r = Number((shape.geometry.r * scale).toFixed(3)); - return; - } - if (geometryTool === "text") { - const scale = requiresUniformScale("text"); - shape.geometry.x = applyX(shape.geometry.x); - shape.geometry.y = applyY(shape.geometry.y); - shape.geometry.fontSize = Number((shape.geometry.fontSize * scale).toFixed(3)); - return; - } - shape.geometry.points = shape.geometry.points.map((point) => ({ - x: applyX(point.x), - y: applyY(point.y) - })); - } - - validateShapeForTransform(shape) { - const errors = []; - try { - const bounds = shapeBounds(shape); - if (bounds.width <= 0 || bounds.height <= 0) { - errors.push("shape bounds must remain positive."); - } - } catch (error) { - errors.push(error.message); - } - const transform = shape.transform; - if (!transform) { - return errors; - } - ["x", "y", "rotation", "scaleX", "scaleY"].forEach((key) => { - if (!Number.isFinite(transform[key])) { - errors.push(`transform.${key} must be a finite number.`); - } - }); - if (Number.isFinite(transform.scaleX) && transform.scaleX <= 0) { - errors.push("scaleX must be greater than 0."); - } - if (Number.isFinite(transform.scaleY) && transform.scaleY <= 0) { - errors.push("scaleY must be greater than 0."); - } - return errors; - } - - commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, okMessage, failPrefix, options = {}) { - const validation = this.schemaService.validatePayload(nextPayload); - if (!validation.ok) { - this.statusLog.write(`FAIL ${failPrefix}: ${validation.errors.join(" ")}`); - return false; - } - const hasPayloadChange = this.hasPayloadChanged(validation.payload); - - const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { - changedKeys: options.changedKeys, - reason: options.dirtyReason - }); - if (!workspaceSync.ok) { - this.statusLog.write(`FAIL ${failPrefix}: ${workspaceSync.message}`); - return false; - } - - this.recordPreviewHistorySnapshot(hasPayloadChange, options); - this.currentPayload = validation.payload; - this.selectedObjectId = selectedObjectId; - this.selectedShapeIndex = normalizeShapeIndex(selectedShapeIndex); - this.selectedShapeIndexes = options.selectedShapeIndexes - ? new Set(Array.from(options.selectedShapeIndexes).map((index) => normalizeShapeIndex(index)).filter((index) => index >= 0)) - : new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - this.directSelectedShapeIndexes = options.directSelectedShapeIndexes - ? new Set(Array.from(options.directSelectedShapeIndexes).map((index) => normalizeShapeIndex(index)).filter((index) => index >= 0)) - : (options.selectedShapeIndexes - ? new Set(this.selectedShapeIndexes) - : new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : [])); - this.selectedStateId = options.selectedStateId ?? this.selectedStateId; - this.stateControlStateId = this.selectedStateId || this.stateControlStateId; - this.selectedFrameId = options.selectedFrameId ?? this.selectedFrameId; - this.ensureSelectedFrame(); - this.actionNav.setJsonPayloadActionsEnabled(true); - this.renderPayload({ - preserveEmptyShapeSelection: options.preserveEmptyShapeSelection === true, - syncPaletteSelection: options.syncPaletteSelection !== false - }); - if (workspaceSync.changed) { - this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); - } - this.statusLog.write(okMessage); - return true; - } - - syncWorkspaceToolSessionPayload(payload, { changedKeys = ["data.objects"], reason = "object-vector-updated" } = {}) { - if (!this.actionNav.isWorkspaceLaunch()) { - return { changed: false, ok: true }; - } - const rawSession = this.window.sessionStorage?.getItem(WORKSPACE_TOOL_SESSION_KEY) || ""; - if (!rawSession) { - return { ok: false, message: `workspace dirty tracking failed: missing ${WORKSPACE_TOOL_SESSION_KEY}.` }; - } - - let session; - try { - session = JSON.parse(rawSession); - } catch (error) { - return { ok: false, message: `workspace dirty tracking failed: ${WORKSPACE_TOOL_SESSION_KEY} is invalid JSON: ${error.message}.` }; - } - if (!isPlainObject(session) - || !isPlainObject(session.schema) - || !isPlainObject(session.workspace) - || !Object.prototype.hasOwnProperty.call(session, "data") - || !isPlainObject(session.dirty)) { - return { ok: false, message: `workspace dirty tracking failed: ${WORKSPACE_TOOL_SESSION_KEY} must use the normalized schema/workspace/data/dirty object shape.` }; - } - - const dataChanged = JSON.stringify(session.data) !== JSON.stringify(payload); - if (!dataChanged) { - return { changed: false, ok: true }; - } - - const normalizedKeys = this.normalizedWorkspaceDirtyKeys(changedKeys); - const previousKeys = session.dirty.isDirty === true - ? this.normalizedWorkspaceDirtyKeys(session.dirty.changedKeys) - : []; - const mergedChangedKeys = Array.from(new Set([...previousKeys, ...normalizedKeys])); - const dirtyReason = String(reason || "object-vector-updated").trim() || "object-vector-updated"; - const nextSession = { - ...session, - data: payload, - dirty: { - isDirty: true, - reason: dirtyReason, - changedAt: new Date().toISOString(), - changedKeys: mergedChangedKeys - } - }; - try { - this.window.sessionStorage?.setItem(WORKSPACE_TOOL_SESSION_KEY, JSON.stringify(nextSession)); - } catch (error) { - return { ok: false, message: `workspace dirty tracking failed: unable to write ${WORKSPACE_TOOL_SESSION_KEY}: ${error.message}.` }; - } - return { - changed: true, - changedKeys: mergedChangedKeys, - ok: true, - reason: dirtyReason, - session: nextSession - }; - } - - normalizedWorkspaceDirtyKeys(changedKeys) { - const keys = Array.isArray(changedKeys) ? changedKeys : ["data.objects"]; - const normalized = keys - .map((key) => String(key || "").trim()) - .filter(Boolean); - return normalized.length ? normalized : ["data.objects"]; - } - - hasPayloadChanged(nextPayload) { - return Boolean(this.currentPayload) && JSON.stringify(this.currentPayload) !== JSON.stringify(nextPayload); - } - - recordPreviewHistorySnapshot(hasPayloadChange, options = {}) { - if (!hasPayloadChange || options.skipPreviewHistory || !this.currentPayload) { - return; - } - this.previewUndoStack.push(this.cloneCurrentPayload()); - if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { - this.previewUndoStack.shift(); - } - this.previewRedoStack = []; - } - - cloneCurrentPayload() { - return this.clonePayloadValue(this.currentPayload); - } - - clonePayloadValue(payload) { - if (typeof this.window.structuredClone === "function") { - return this.window.structuredClone(payload); - } - return JSON.parse(JSON.stringify(payload)); - } - - selectedObject() { - return this.currentPayload?.objects.find((object) => object.id === this.selectedObjectId) || null; - } - - guardSelectedObjectMutation(actionLabel) { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write(`WARN ${actionLabel} skipped: no object is selected.`); - return true; - } - if (!this.isObjectLocked(object.id)) { - return false; - } - this.statusLog.write(`WARN ${actionLabel} blocked: object ${object.name} is locked for this runtime session.`); - return true; - } - - resolveObjectForPayload(payload, objectId, chain = []) { - const object = payload?.objects.find((candidate) => candidate.id === objectId) || null; - if (!object) { - return null; - } - if (chain.includes(object.id)) { - return null; - } - if (!object.baseObjectId) { - return JSON.parse(JSON.stringify(object)); - } - const baseObject = this.resolveObjectForPayload(payload, object.baseObjectId, [...chain, object.id]); - if (!baseObject) { - return null; - } - const shapeByOrder = new Map(baseObject.shapes.map((shape) => [shape.order, JSON.parse(JSON.stringify(shape))])); - object.shapes.forEach((shape) => { - shapeByOrder.set(shape.order, JSON.parse(JSON.stringify(shape))); - }); - const stateById = new Map((baseObject.states || []).map((state) => [state.id, JSON.parse(JSON.stringify(state))])); - (object.states || []).forEach((state) => { - stateById.set(state.id, JSON.parse(JSON.stringify(state))); - }); - const resolved = { - ...baseObject, - ...JSON.parse(JSON.stringify(object)), - inheritedFrom: object.baseObjectId, - shapes: Array.from(shapeByOrder.values()).sort((left, right) => left.order - right.order) - }; - if (stateById.size) { - resolved.states = Array.from(stateById.values()); - } else { - delete resolved.states; - } - return resolved; - } - - selectedShape() { - return sortedShapes(this.selectedObject())[this.selectedShapeIndex] || null; - } - - ensureSelectedShape() { - const object = this.selectedObject(); - if (!object) { - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - return; - } - if (this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(object).length) { - if (!this.selectedShapeIndexes.size) { - this.selectedShapeIndexes = new Set([this.selectedShapeIndex]); - } - this.directSelectedShapeIndexes = new Set(Array.from(this.directSelectedShapeIndexes) - .map((index) => normalizeShapeIndex(index)) - .filter((index) => index >= 0 && index < sortedShapes(object).length)); - if (!this.directSelectedShapeIndexes.size) { - this.directSelectedShapeIndexes = new Set([this.selectedShapeIndex]); - } - return; - } - this.selectedShapeIndex = sortedShapes(object).length ? 0 : -1; - this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); - } - - ensureSelectedFrame() { - const object = this.selectedObject(); - if (!object) { - this.selectedStateId = ""; - this.selectedFrameId = ""; - return; - } - const states = this.objectStates(object); - const state = states.find((candidate) => candidate.id === this.selectedStateId) || states[0] || null; - this.selectedStateId = state?.id || ""; - if (!OBJECT_STATE_IDS.includes(this.stateControlStateId)) { - this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; - } - if (!state) { - this.selectedFrameId = ""; - return; - } - const frames = sortedFrames(state); - const frame = frames.find((candidate) => candidate.id === this.selectedFrameId) || frames[0] || null; - this.selectedFrameId = frame?.id || ""; - } - - objectStates(object) { - return Array.isArray(object?.states) ? object.states : []; - } - - groupColor(groupId) { - const text = String(groupId || ""); - const hash = Array.from(text).reduce((total, character) => total + character.codePointAt(0), 0); - return GROUP_COLOR_SWATCHES[hash % GROUP_COLOR_SWATCHES.length]; - } - - selectedState() { - return this.objectStates(this.selectedObject()).find((state) => state.id === this.selectedStateId) || null; - } - - activeFrame() { - return sortedFrames(this.selectedState()).find((frame) => frame.id === this.selectedFrameId) || null; - } - - previousFrame() { - const state = this.selectedState(); - const frame = this.activeFrame(); - if (!state || !frame) { - return null; - } - const frames = sortedFrames(state); - const index = frames.findIndex((candidate) => candidate.id === frame.id); - return index > 0 ? frames[index - 1] : null; - } - - createFrameSnapshot(object, stateId, frameId, order) { - return { - durationFrames: 1, - id: frameId, - order, - shapeOverrides: sortedShapes(object).map((shape, shapeIndex) => ({ - shapeIndex, - transform: this.shapeTransform(shape), - visible: shape.visible - })) - }; - } - - frameOverrideInPayload(payload, shapeIndex, { create = false } = {}) { - const normalizedIndex = normalizeShapeIndex(shapeIndex); - const object = payload.objects.find((candidate) => candidate.id === this.selectedObjectId); - const state = this.objectStates(object).find((candidate) => candidate.id === this.selectedStateId); - const frame = sortedFrames(state).find((candidate) => candidate.id === this.selectedFrameId); - if (!object || !state || !frame) { - return null; - } - let override = frame.shapeOverrides.find((entry) => entry.shapeIndex === normalizedIndex); - if (!override && create) { - const baseShape = sortedShapes(object)[normalizedIndex]; - if (!baseShape) { - return null; - } - override = { - shapeIndex: normalizedIndex, - transform: this.shapeTransform(baseShape), - visible: baseShape.visible - }; - frame.shapeOverrides.push(override); - } - return override || null; - } - - findShapeInPayload(payload, shapeIndex) { - const object = payload.objects.find((candidate) => candidate.id === this.selectedObjectId); - return sortedShapes(object)[normalizeShapeIndex(shapeIndex)] || null; - } - - firstPaletteColor() { - const swatch = this.runtimePalette?.swatches.find((candidate) => swatchColor(candidate)); - return swatchColor(swatch); - } - - secondPaletteColor(firstColor) { - const swatch = this.runtimePalette?.swatches.find((candidate) => swatchColor(candidate) && swatchColor(candidate) !== firstColor); - return swatchColor(swatch) || firstColor; - } - - uniqueObjectId(name, objects, gameKey = this.payloadGameKey()) { - const baseId = `object.${gameKey}.${canonicalObjectSlug(name, gameKey)}`; - const usedIds = new Set(objects.map((object) => object.id)); - if (!usedIds.has(baseId)) { - return baseId; - } - - let suffix = 2; - let candidate = `${baseId}-${suffix}`; - while (usedIds.has(candidate)) { - suffix += 1; - candidate = `${baseId}-${suffix}`; - } - return candidate; - } - - payloadGameKey() { - const counts = new Map(); - (this.currentPayload?.objects || []).forEach((object) => { - const segment = objectGameSegment(object.id); - if (segment) { - counts.set(segment, (counts.get(segment) || 0) + 1); - } - }); - const existingGame = [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0]; - return existingGame || payloadGameSlugFromName(this.currentPayload?.name); - } - - uniqueGroupId(object) { - const usedIds = new Set((object?.shapes || []).map((shape) => shape.groupId).filter(Boolean)); - let suffix = usedIds.size + 1; - let candidate = `group-${suffix}`; - while (usedIds.has(candidate)) { - suffix += 1; - candidate = `group-${suffix}`; - } - return candidate; - } - - uniqueTag(tag, tags) { - const baseTag = slugifyObjectName(tag); - const usedTags = new Set(tags); - if (!usedTags.has(baseTag)) { - return baseTag; - } - - let suffix = 2; - let candidate = `${baseTag}-${suffix}`; - while (usedTags.has(candidate)) { - suffix += 1; - candidate = `${baseTag}-${suffix}`; - } - return candidate; - } - - uniqueFrameId(state) { - const usedIds = new Set(sortedFrames(state).map((frame) => frame.id)); - let suffix = 1; - let candidate = `frame-${suffix}`; - while (usedIds.has(candidate)) { - suffix += 1; - candidate = `frame-${suffix}`; - } - return candidate; - } - - clearPreviewEditState({ clipboard = false } = {}) { - this.previewUndoStack = []; - this.previewRedoStack = []; - if (clipboard) { - this.previewClipboardShape = null; - } - } - - copyPreviewSelection() { - const shape = this.selectedShape(); - if (!shape) { - this.statusLog.write("WARN Copy skipped: no Object Preview shape is selected."); - this.updatePreviewEditActionState(); - return; - } - this.previewClipboardShape = this.clonePayloadValue(shape); - this.updatePreviewEditActionState(); - this.statusLog.write(`OK Copied shape row ${this.selectedShapeIndex} to Object Preview clipboard.`); - } - - pastePreviewClipboard() { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("WARN Paste skipped: no object is selected."); - this.updatePreviewEditActionState(); - return; - } - if (!this.previewClipboardShape) { - this.statusLog.write("WARN Paste skipped: Object Preview clipboard is empty."); - this.updatePreviewEditActionState(); - return; - } - if (this.guardSelectedObjectMutation("Paste shape")) { - this.updatePreviewEditActionState(); - return; - } - - const nextPayload = this.cloneCurrentPayload(); - const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); - const shape = this.clonePayloadValue(this.previewClipboardShape); - shape.order = nextObject.shapes.length ? Math.max(...nextObject.shapes.map((candidate) => candidate.order)) + 1 : 1; - shape.transform = this.ensureShapeTransform(shape); - shape.transform.x = Number((shape.transform.x + 10).toFixed(3)); - shape.transform.y = Number((shape.transform.y + 10).toFixed(3)); - nextObject.shapes.push(shape); - this.commitPayloadUpdate(nextPayload, nextObject.id, sortedShapes(nextObject).length - 1, `OK Pasted copied shape into ${nextObject.name}.`, "Paste shape failed schema validation"); - } - - undoPreviewEdit() { - if (!this.previewUndoStack.length) { - this.statusLog.write("WARN Undo skipped: no Object Preview edit history is available."); - this.updatePreviewEditActionState(); - return; - } - const targetPayload = this.previewUndoStack.at(-1); - const currentPayload = this.cloneCurrentPayload(); - if (!this.applyPreviewHistoryPayload(targetPayload, "Undo")) { - this.updatePreviewEditActionState(); - return; - } - this.previewUndoStack.pop(); - this.previewRedoStack.push(currentPayload); - if (this.previewRedoStack.length > PREVIEW_HISTORY_LIMIT) { - this.previewRedoStack.shift(); - } - this.updatePreviewEditActionState(); - } - - redoPreviewEdit() { - if (!this.previewRedoStack.length) { - this.statusLog.write("WARN Redo skipped: no Object Preview redo history is available."); - this.updatePreviewEditActionState(); - return; - } - const targetPayload = this.previewRedoStack.at(-1); - const currentPayload = this.cloneCurrentPayload(); - if (!this.applyPreviewHistoryPayload(targetPayload, "Redo")) { - this.updatePreviewEditActionState(); - return; - } - this.previewRedoStack.pop(); - this.previewUndoStack.push(currentPayload); - if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { - this.previewUndoStack.shift(); - } - this.updatePreviewEditActionState(); - } - - applyPreviewHistoryPayload(payload, actionLabel) { - const validation = this.schemaService.validatePayload(payload); - if (!validation.ok) { - this.statusLog.write(`FAIL ${actionLabel} failed schema validation: ${validation.errors.join(" ")}`); - return false; - } - const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { - changedKeys: ["data.objects"], - reason: `object-vector-preview-${actionLabel.toLowerCase()}` - }); - if (!workspaceSync.ok) { - this.statusLog.write(`FAIL ${actionLabel} failed schema validation: ${workspaceSync.message}`); - return false; - } - this.currentPayload = validation.payload; - if (!this.currentPayload.objects.some((object) => object.id === this.selectedObjectId)) { - this.selectedObjectId = this.currentPayload.objects[0]?.id || ""; - this.selectedShapeIndex = -1; - this.selectedShapeIndexes.clear(); - this.directSelectedShapeIndexes.clear(); - } - this.actionNav.setJsonPayloadActionsEnabled(true); - this.renderPayload({ syncPaletteSelection: false }); - if (workspaceSync.changed) { - this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); - } - this.statusLog.write(`OK ${actionLabel} applied to Object Preview edits.`); - return true; - } - - updatePreviewEditActionState() { - const object = this.selectedObject(); - const shape = this.selectedShape(); - const noShapeReason = "Disabled until a schema-valid shape is selected."; - const noObjectReason = "Disabled until a schema-valid object is selected."; - const lockedReason = object ? `Disabled because ${object.name} is locked for this runtime session.` : noObjectReason; - const isLocked = Boolean(object && this.isObjectLocked(object.id)); - this.setControlDisabled(this.elements.previewUndoButton, !this.previewUndoStack.length, "Disabled until an Object Preview edit can be undone.", "Undo the last Object Preview edit."); - this.setControlDisabled(this.elements.previewRedoButton, !this.previewRedoStack.length, "Disabled until an Object Preview edit can be redone.", "Redo the last undone Object Preview edit."); - this.setControlDisabled(this.elements.previewCopyButton, !shape, noShapeReason, "Copy the selected shape."); - this.setControlDisabled(this.elements.previewPasteButton, !object || !this.previewClipboardShape || isLocked, !object ? noObjectReason : (isLocked ? lockedReason : "Disabled until a shape has been copied."), "Paste the copied shape into the selected object."); - } - - updateObjectActionState() { - const hasSelectedObject = Boolean(this.selectedObject()); - const selectedObject = this.selectedObject(); - const noObjectReason = "Disabled until a schema-valid object is selected."; - const lockedReason = selectedObject ? `Disabled because ${selectedObject.name} is locked for this runtime session.` : noObjectReason; - const isLocked = Boolean(selectedObject && this.isObjectLocked(selectedObject.id)); - this.setControlDisabled(this.elements.renameObjectButton, !hasSelectedObject || isLocked, lockedReason, "Rename the selected object."); - this.setControlDisabled(this.elements.duplicateObjectButton, !hasSelectedObject || isLocked, lockedReason, "Duplicate the selected object."); - if (this.elements.deleteObjectButton) { - this.setControlDisabled(this.elements.deleteObjectButton, !hasSelectedObject || isLocked, lockedReason, "Delete the selected object."); - } - this.setControlDisabled(this.elements.exportSvgButton, !hasSelectedObject, noObjectReason, "Export the selected object as SVG."); - this.setControlDisabled(this.elements.runtimePreviewButton, !hasSelectedObject, noObjectReason, "Preview the selected object through the runtime asset renderer."); - this.updatePreviewEditActionState(); - this.updateAnimationActionState(); - } - - updateAnimationActionState() { - const state = this.selectedState(); - const frames = sortedFrames(state); - const hasFrame = Boolean(state && this.activeFrame()); - const frameIndex = frames.findIndex((frame) => frame.id === this.selectedFrameId); - const noFrameReason = "Disabled until the selected object has an active state frame."; - const object = this.selectedObject(); - const isLocked = Boolean(object && this.isObjectLocked(object.id)); - const lockedReason = object ? `Disabled because ${object.name} is locked for this runtime session.` : noFrameReason; - this.setControlDisabled(this.elements.duplicateFrameButton, !hasFrame || isLocked, isLocked ? lockedReason : noFrameReason, "Duplicate the active animation frame."); - this.setControlDisabled(this.elements.deleteFrameButton, !hasFrame || isLocked || frames.length <= 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected state needs at least one frame." : noFrameReason), "Delete the selected animation frame."); - this.setControlDisabled(this.elements.frameLeftButton, !hasFrame || isLocked || frameIndex <= 0, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already first." : noFrameReason), "Move the selected frame left."); - this.setControlDisabled(this.elements.frameEarlierButton, !hasFrame || isLocked || frameIndex <= 0, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already first." : noFrameReason), "Move the selected frame earlier."); - this.setControlDisabled(this.elements.frameLaterButton, !hasFrame || isLocked || frameIndex >= frames.length - 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already last." : noFrameReason), "Move the selected frame later."); - this.setControlDisabled(this.elements.frameRightButton, !hasFrame || isLocked || frameIndex >= frames.length - 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already last." : noFrameReason), "Move the selected frame right."); - this.setControlDisabled(this.elements.playButton, !hasFrame || this.isAnimationPlaying, this.isAnimationPlaying ? "Disabled while playback is already running." : noFrameReason, "Play the selected state timeline."); - this.setControlDisabled(this.elements.pauseButton, !this.isAnimationPlaying, "Disabled until playback starts.", "Pause animation playback."); - this.setControlDisabled(this.elements.stopButton, !hasFrame, noFrameReason, "Stop animation playback."); - } - - setControlDisabled(element, isDisabled, disabledReason, enabledTitle) { - element.disabled = isDisabled; - element.title = isDisabled ? disabledReason : enabledTitle; - element.setAttribute("aria-disabled", String(isDisabled)); - if (isDisabled) { - element.dataset.disabledReason = disabledReason; - return; - } - delete element.dataset.disabledReason; - } - - async copyJson() { - const payload = this.validatedJsonActionPayload("Copy JSON"); - if (!payload) { - return; - } - - const json = JSON.stringify(payload, null, 2); - if (typeof this.window.navigator?.clipboard?.writeText !== "function") { - this.statusLog.write("WARN Clipboard API unavailable. JSON Details remains read-only on the active object."); - return; - } - - try { - await this.window.navigator.clipboard.writeText(json); - this.statusLog.write(`OK Object Vector Studio V2 JSON copied with ${payload.objects.length} objects.`); - } catch (error) { - this.statusLog.write(`FAIL Copy JSON failed: ${error.message}`); - } - } - - async previewRuntimeAsset() { - const object = this.selectedObject(); - const payload = this.validatedJsonActionPayload("Runtime Preview"); - if (!object || !payload) { - this.statusLog.write("FAIL Runtime Preview blocked: select a schema-valid Object Vector Studio V2 object first."); - return; - } - if (!this.runtimePalette) { - this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY) || this.loadPaletteFromWorkspaceSession(); - } - if (!this.runtimePalette) { - this.statusLog.write("FAIL Runtime Preview blocked: runtime palette is required from workspace/session and must not be embedded in object JSON."); - return; - } - - const assetSet = await this.runtimeAssetService.loadPayload(payload, { - sourceLabel: "Object Vector Studio V2 runtime preview" - }); - if (!assetSet) { - return; - } - const result = this.runtimeAssetService.createSvgString(assetSet, { - elapsedMs: 0, - frameId: this.selectedFrameId, - objectId: object.id, - stateId: this.selectedStateId - }); - if (!result.ok) { - return; - } - - this.elements.renderSurface.dataset.runtimePreview = "true"; - this.elements.renderSurface.dataset.runtimePreviewFrame = this.selectedFrameId || ""; - this.elements.renderSurface.dataset.runtimePreviewState = this.selectedStateId || ""; - const message = `Runtime preview launched for ${object.name}${this.selectedStateId ? ` state ${this.selectedStateId}` : ""}${this.selectedFrameId ? ` frame ${this.selectedFrameId}` : ""}.`; - this.statusLog.write(`OK ${message} Runtime palette source ${this.runtimePaletteSource}; object JSON remains palette-free.`); - } - - exportJson() { - const payload = this.validatedJsonActionPayload("Export"); - if (!payload) { - return; - } - - downloadTextFile(JSON.stringify(payload, null, 2), "object-vector-studio-v2.json", { - documentRef: this.window.document, - windowRef: this.window - }); - this.statusLog.write(`OK Export JSON prepared for ${payload.objects.length} Object Vector Studio V2 objects.`); - } - - exportSelectedObjectSvg() { - const object = this.selectedObject(); - if (!object) { - this.statusLog.write("FAIL Export SVG blocked: no Object Vector Studio V2 object is selected."); - return; - } - - try { - const svg = this.createObjectSvgExport(object); - downloadTextFile(svg, `${slugifyObjectName(object.name)}.svg`, { - documentRef: this.window.document, - mimeType: "image/svg+xml", - windowRef: this.window - }); - const visibleShapeCount = sortedShapes(object).filter((shape) => shape.visible).length; - this.statusLog.write(`OK Export SVG generated for ${object.name}: ${visibleShapeCount} visible shapes.`); - } catch (error) { - this.statusLog.write(`FAIL Export SVG failed for ${object.name}: ${error.message}`); - } - } - - createObjectSvgExport(object) { - const validation = this.schemaService.validatePayload(this.currentPayload); - if (!validation.ok) { - throw new Error(`schema validation failed: ${validation.errors.join(" ")}`); - } - const visibleShapes = sortedShapes(object).filter((shape) => this.effectiveShape(shape).visible); - if (!visibleShapes.length) { - throw new Error("selected object has no visible shapes."); - } - const bounds = this.objectBounds(object, { includeInvisible: false }); - const padding = 12; - const svg = this.window.document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("xmlns", SVG_NS); - svg.setAttribute("role", "img"); - svg.setAttribute("aria-label", `${object.name} Object Vector Studio V2 export`); - if (this.selectedStateId) { - svg.dataset.objectState = this.selectedStateId; - } - if (this.selectedFrameId) { - svg.dataset.objectFrame = this.selectedFrameId; - } - svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); - const transformOrigin = this.objectTransformOrigin(object); - if (this.selectedStateId || this.selectedFrameId) { - const metadata = this.window.document.createElementNS(SVG_NS, "metadata"); - metadata.textContent = JSON.stringify({ - frameId: this.selectedFrameId || null, - stateId: this.selectedStateId || null, - toolId: "object-vector-studio-v2" - }); - svg.append(metadata); - } - visibleShapes.forEach((shape) => { - const element = this.createSvgShape(this.effectiveShape(shape), { transformOrigin }); - element.removeAttribute("tabindex"); - element.removeAttribute("class"); - svg.append(element); - this.appendShapePointStyleCaps(svg, this.effectiveShape(shape), { transformOrigin }); - }); - return new this.window.XMLSerializer().serializeToString(svg); - } - - validatedJsonActionPayload(actionLabel) { - if (!this.currentPayload) { - this.statusLog.write(`FAIL ${actionLabel} blocked: no schema-valid Object Vector Studio V2 payload is loaded.`); - return null; - } - const payload = this.cloneCurrentPayload(); - const validation = this.schemaService.validatePayload(payload); - if (!validation.ok) { - this.statusLog.write(`FAIL ${actionLabel} blocked by Object Vector Studio V2 schema: ${validation.errors.join(" ")}`); - return null; - } - return validation.payload; - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js deleted file mode 100644 index 9f52e34a9..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js +++ /dev/null @@ -1,114 +0,0 @@ -import { ToolStarterApp } from "./ToolStarterApp.js"; -import { ActionNavControl } from "./controls/ActionNavControl.js"; -import { AccordionSection } from "./controls/AccordionSection.js"; -import { StatusLogControl } from "./controls/StatusLogControl.js"; -import { ToolStarterShellControl } from "./controls/ToolStarterShellControl.js"; -import { ObjectVectorStudioV2SchemaService } from "./services/ObjectVectorStudioV2SchemaService.js"; - -function requireElement(selector) { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`Missing required starter template element: ${selector}`); - } - return element; -} - -function optionalElement(selector) { - return document.querySelector(selector); -} - -window.addEventListener("DOMContentLoaded", () => { - const accordions = Array.from(document.querySelectorAll(".accordion-v2"), (section) => new AccordionSection(section)); - const statusLog = new StatusLogControl({ - log: requireElement("#statusLog"), - clearButton: requireElement("#clearStatusButton") - }); - const app = new ToolStarterApp({ - accordions, - actionNav: new ActionNavControl({ - returnToWorkspaceButton: requireElement("#returnToWorkspaceButton"), - toolCopyJsonButton: requireElement("#objectVectorStudioV2CopyJsonButton"), - toolExportJsonButton: requireElement("#objectVectorStudioV2ExportJsonButton"), - toolImportJsonButton: requireElement("#objectVectorStudioV2ImportJsonButton"), - toolImportJsonInput: requireElement("#objectVectorStudioV2ImportJsonInput"), - toolNav: requireElement(".tool-starter__tool__menu"), - workspaceNav: requireElement(".tool-starter__workspace__menu") - }), - elements: { - addObjectButton: requireElement("#objectVectorStudioV2AddObjectButton"), - addTagButton: requireElement("#objectVectorStudioV2AddTagButton"), - angleSnapButton: requireElement("#objectVectorStudioV2AngleSnapButton"), - centerDotButton: requireElement("#objectVectorStudioV2CenterDotButton"), - coordinateDisplay: requireElement("#objectVectorStudioV2CoordinateDisplay"), - deleteFrameButton: requireElement("#objectVectorStudioV2DeleteFrameButton"), - deleteObjectButton: optionalElement("#objectVectorStudioV2DeleteObjectButton"), - dependencyGraph: requireElement("#objectVectorStudioV2DependencyGraph"), - duplicateFrameButton: requireElement("#objectVectorStudioV2DuplicateFrameButton"), - duplicateObjectButton: requireElement("#objectVectorStudioV2DuplicateObjectButton"), - exportSvgButton: requireElement("#objectVectorStudioV2ExportSvgButton"), - fillOpacity: requireElement("#objectVectorStudioV2FillOpacity"), - fpsInput: requireElement("#objectVectorStudioV2FpsInput"), - frameEarlierButton: requireElement("#objectVectorStudioV2FrameEarlierButton"), - frameLaterButton: requireElement("#objectVectorStudioV2FrameLaterButton"), - frameLeftButton: requireElement("#objectVectorStudioV2FrameLeftButton"), - frameRightButton: requireElement("#objectVectorStudioV2FrameRightButton"), - frameTimeline: requireElement("#objectVectorStudioV2FrameTimeline"), - gridRenderButton: requireElement("#objectVectorStudioV2GridRenderButton"), - jsonDetails: requireElement("#objectVectorStudioV2JsonDetails"), - leftPanel: requireElement(".tool-starter__panel--left"), - loopToggle: requireElement("#objectVectorStudioV2LoopToggle"), - shapeGeometryDetails: requireElement("#objectVectorStudioV2ShapeGeometryDetails"), - shapeGeometryName: requireElement("#objectVectorStudioV2ShapeGeometryName"), - shapeGeometrySummary: requireElement("#objectVectorStudioV2ShapeGeometrySummary"), - shapeTransform: requireElement("#objectVectorStudioV2ShapeTransform"), - objectNameInput: requireElement("#objectVectorStudioV2ObjectNameInput"), - objectPreviewFooter: requireElement("#objectVectorStudioV2ObjectPreviewFooter"), - objectTagInput: requireElement("#objectVectorStudioV2ObjectTagInput"), - objectTagList: requireElement("#objectVectorStudioV2ObjectTagList"), - objectTransform: requireElement("#objectVectorStudioV2ObjectTransform"), - objectsContent: requireElement("#objectVectorStudioV2ObjectsContent"), - objectsCount: requireElement("#objectVectorStudioV2ObjectsCount"), - objectTiles: requireElement("#objectVectorStudioV2ObjectTiles"), - paintModeButton: requireElement("#objectVectorStudioV2PaintModeButton"), - paletteSortButtons: Array.from(document.querySelectorAll("[data-palette-sort]")), - paletteSummary: requireElement("#objectVectorStudioV2PaletteSummary"), - paletteSwatchCount: requireElement("#objectVectorStudioV2PaletteSwatchCount"), - panDownButton: requireElement("#objectVectorStudioV2PanDownButton"), - panLeftButton: requireElement("#objectVectorStudioV2PanLeftButton"), - panRightButton: requireElement("#objectVectorStudioV2PanRightButton"), - panUpButton: requireElement("#objectVectorStudioV2PanUpButton"), - onionSkinToggle: requireElement("#objectVectorStudioV2OnionSkinToggle"), - pauseButton: requireElement("#objectVectorStudioV2PauseButton"), - playButton: requireElement("#objectVectorStudioV2PlayButton"), - previewCopyButton: requireElement("#objectVectorStudioV2PreviewCopyButton"), - previewPasteButton: requireElement("#objectVectorStudioV2PreviewPasteButton"), - previewRedoButton: requireElement("#objectVectorStudioV2PreviewRedoButton"), - previewUndoButton: requireElement("#objectVectorStudioV2PreviewUndoButton"), - copyJsonDetailsButton: requireElement("#objectVectorStudioV2CopyJsonDetailsButton"), - renameObjectButton: requireElement("#objectVectorStudioV2RenameObjectButton"), - renderSurface: requireElement("#objectVectorStudioV2RenderSurface"), - resetViewButton: requireElement("#objectVectorStudioV2ResetViewButton"), - runtimePreviewButton: requireElement("#objectVectorStudioV2RuntimePreviewButton"), - searchFilter: requireElement("#objectVectorStudioV2SearchFilter"), - snapModeButton: requireElement("#objectVectorStudioV2SnapModeButton"), - stopButton: requireElement("#objectVectorStudioV2StopButton"), - strokeModeButton: requireElement("#objectVectorStudioV2StrokeModeButton"), - strokeOpacity: requireElement("#objectVectorStudioV2StrokeOpacity"), - strokeWidth: requireElement("#objectVectorStudioV2StrokeWidth"), - tagFilter: requireElement("#objectVectorStudioV2TagFilter"), - toolLabelModeButton: requireElement("#objectVectorStudioV2ToolLabelModeButton"), - toolToggleGrid: requireElement("#objectVectorStudioV2ToolToggleGrid"), - toolToggles: Array.from(document.querySelectorAll("[data-shape-tool]")), - zoomInButton: requireElement("#objectVectorStudioV2ZoomInButton"), - zoomOutButton: requireElement("#objectVectorStudioV2ZoomOutButton") - }, - schemaService: new ObjectVectorStudioV2SchemaService(), - shell: new ToolStarterShellControl(), - statusLog, - windowRef: window - }); - - app.start().catch((error) => { - statusLog.write(`FAIL Object Vector Studio V2 schema contract failed to load: ${error.message}`); - }); -}); diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js deleted file mode 100644 index 1e03257e1..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js +++ /dev/null @@ -1,30 +0,0 @@ -export class AccordionSection { - constructor(section) { - this.section = section; - this.header = section?.querySelector(".accordion-v2__header") || null; - this.content = section?.querySelector(".accordion-v2__content") || null; - this.icon = this.header?.querySelector(".accordion-v2__icon") || null; - } - - mount() { - if (!this.section || !this.header || !this.content || this.header.dataset.accordionV2Bound === "true") { - return; - } - - this.header.dataset.accordionV2Bound = "true"; - this.setOpen(this.section.dataset.accordionV2Open !== "false"); - this.header.addEventListener("click", () => { - this.setOpen(!this.section.classList.contains("is-open")); - }); - } - - setOpen(isOpen) { - this.section.classList.toggle("is-open", isOpen); - this.section.dataset.accordionV2Open = String(isOpen); - this.header.setAttribute("aria-expanded", String(isOpen)); - this.content.hidden = !isOpen; - if (this.icon) { - this.icon.dataset.accordionV2IconState = isOpen ? "open" : "closed"; - } - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js deleted file mode 100644 index 8d704b10c..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js +++ /dev/null @@ -1,84 +0,0 @@ -export class ActionNavControl { - constructor({ - locationRef = window.location, - returnToWorkspaceButton, - windowRef = window, - toolCopyJsonButton, - toolExportJsonButton, - toolImportJsonButton, - toolImportJsonInput, - toolNav, - workspaceNav - }) { - this.location = locationRef; - this.returnToWorkspaceButton = returnToWorkspaceButton; - this.toolCopyJsonButton = toolCopyJsonButton; - this.toolExportJsonButton = toolExportJsonButton; - this.toolImportJsonButton = toolImportJsonButton; - this.toolImportJsonInput = toolImportJsonInput; - this.toolNav = toolNav; - this.workspaceNav = workspaceNav; - this.window = windowRef; - } - - mount({ - onCopyJson, - onExportJson, - onImportJson, - onReturnToWorkspace - }) { - this.applyLaunchMode(); - this.toolImportJsonButton.addEventListener("click", () => { - this.toolImportJsonInput.click(); - }); - this.toolImportJsonInput.addEventListener("change", () => { - const file = this.toolImportJsonInput.files?.[0] || null; - onImportJson(file); - this.toolImportJsonInput.value = ""; - }); - this.toolCopyJsonButton.addEventListener("click", onCopyJson); - this.toolExportJsonButton.addEventListener("click", onExportJson); - this.returnToWorkspaceButton.addEventListener("click", () => { - onReturnToWorkspace(this.workspaceManagerUrl()); - }); - } - - applyLaunchMode() { - const params = new URLSearchParams(this.location.search); - const isWorkspaceManagerLaunch = params.get("launch") === "workspace" - && params.get("fromTool") === "workspace-manager-v2" - && Boolean(params.get("hostContextId")); - this.toolNav.hidden = isWorkspaceManagerLaunch; - this.workspaceNav.hidden = !isWorkspaceManagerLaunch; - } - - setJsonPayloadActionsEnabled(isEnabled) { - this.toolCopyJsonButton.disabled = !isEnabled; - this.toolExportJsonButton.disabled = !isEnabled; - this.toolCopyJsonButton.title = isEnabled ? "Copy schema-valid Object Vector Studio V2 JSON." : "Disabled until a schema-valid Object Vector Studio V2 payload is loaded."; - this.toolExportJsonButton.title = isEnabled ? "Export schema-valid Object Vector Studio V2 JSON." : "Disabled until a schema-valid Object Vector Studio V2 payload is loaded."; - } - - setImportEnabled(isEnabled) { - this.toolImportJsonButton.disabled = !isEnabled; - this.toolImportJsonInput.disabled = !isEnabled; - this.toolImportJsonButton.title = isEnabled ? "Import Object Vector Studio V2 JSON." : "Disabled while schema is loading or workspace launch owns the payload source."; - } - - isWorkspaceLaunch() { - return this.workspaceNav.hidden === false; - } - - workspaceManagerUrl() { - const url = new URL("../workspace-manager-v2/index.html", this.location.href); - const params = new URLSearchParams(this.location.search); - const hostContextId = params.get("hostContextId") || ""; - if (hostContextId) { - url.searchParams.set("hostContextId", hostContextId); - } - if (params.get("workspaceMode")?.toLowerCase() === "uat") { - url.searchParams.set("workspace", "uat"); - } - return url.href; - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js deleted file mode 100644 index d89b7abb4..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js +++ /dev/null @@ -1,10 +0,0 @@ -export class InspectorControl { - constructor(output) { - this.output = output; - } - - showObject(value) { - this.output.textContent = JSON.stringify(value, null, 2); - } -} - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js deleted file mode 100644 index 56f3eca72..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js +++ /dev/null @@ -1,30 +0,0 @@ -export class PreviewPanelControl { - constructor(output) { - this.output = output; - } - - clear() { - this.output.replaceChildren(this.createEmptyState()); - } - - render(payload) { - const card = document.createElement("article"); - card.className = "tool-starter__preview-card"; - - const title = document.createElement("h2"); - title.textContent = "Rendered Preview"; - - const body = document.createElement("p"); - body.textContent = payload.sourceValue; - - card.append(title, body); - this.output.replaceChildren(card); - } - - createEmptyState() { - const emptyState = document.createElement("p"); - emptyState.textContent = "No preview rendered yet."; - return emptyState; - } -} - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js deleted file mode 100644 index 42990f590..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js +++ /dev/null @@ -1,40 +0,0 @@ -export class SourceInputControl { - constructor({ input, validationMessage }) { - this.input = input; - this.validationMessage = validationMessage; - } - - mount({ onChange }) { - this.input.addEventListener("input", onChange); - } - - clear() { - this.input.value = ""; - this.showMessage("Input is required before Export can process.", false); - } - - getValue() { - return this.input.value.trim(); - } - - hasValue() { - return this.getValue().length > 0; - } - - validate() { - const value = this.getValue(); - if (!value) { - const message = "Source value is required."; - this.showMessage(message, true); - return { message, valid: false, value: "" }; - } - - this.showMessage("Source value is ready.", false); - return { message: "Source value is ready.", valid: true, value }; - } - - showMessage(message, isError) { - this.validationMessage.textContent = message; - this.validationMessage.classList.toggle("is-error", isError); - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js deleted file mode 100644 index d5be2f9e9..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js +++ /dev/null @@ -1,28 +0,0 @@ -export class StatusLogControl { - constructor({ log, clearButton }) { - this.log = log; - this.clearButton = clearButton; - } - - mount() { - this.clearButton.addEventListener("click", (event) => { - event.stopPropagation(); - this.clear(); - }); - } - - clear() { - this.log.value = ""; - } - - error(message) { - this.write(`FAIL ${message}`); - } - - write(message) { - const timestamp = new Date().toLocaleTimeString(); - const nextLine = `[${timestamp}] ${message}`; - this.log.value = this.log.value ? `${this.log.value}\n${nextLine}` : nextLine; - this.log.scrollTop = this.log.scrollHeight; - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js deleted file mode 100644 index 7c9b039c8..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js +++ /dev/null @@ -1,130 +0,0 @@ -export class ToolStarterShellControl { - constructor({ documentRef = document } = {}) { - this.document = documentRef; - this.toolId = "object-vector-studio-v2"; - this.toolName = "Object Vector Studio V2"; - this.toolDescription = "First-Class Tools Surface V2"; - this.headerText = `${this.toolName} - ${this.toolDescription}`; - this.introText = this.toolDescription; - this.suppressFullscreenSync = false; - } - - mount() { - this.document.body.classList.add("tools-platform-surface", "tool-starter-local-shell"); - this.applyFullscreenState(Boolean(this.document.fullscreenElement)); - this.bindHeaderDetails(); - this.updateSummary(); - } - - getHeaderDetails() { - return this.document.querySelector(".is-collapsible"); - } - - getSummaryElement() { - return this.document.querySelector("[data-tool-starter-summary]"); - } - - applyFullscreenState(isActive) { - this.document.body.classList.toggle("tools-platform-fullscreen-active", isActive); - this.document.documentElement.classList.toggle("tools-platform-fullscreen-active", isActive); - if (isActive) { - this.document.body.setAttribute("data-tools-platform-fullscreen", "1"); - this.document.documentElement.setAttribute("data-tools-platform-fullscreen", "1"); - return; - } - - this.document.body.removeAttribute("data-tools-platform-fullscreen"); - this.document.documentElement.removeAttribute("data-tools-platform-fullscreen"); - } - - updateSummary() { - const details = this.getHeaderDetails(); - const summary = this.getSummaryElement(); - if (!(details instanceof HTMLDetailsElement) || !(summary instanceof HTMLElement)) { - return; - } - - const isFullscreen = Boolean(this.document.fullscreenElement) - || this.document.body.classList.contains("tools-platform-fullscreen-active"); - const isExpanded = details.open === true; - summary.textContent = isExpanded - ? "Hide Header and Details" - : (isFullscreen ? this.headerText : "Show Header and Details"); - - if (isFullscreen) { - summary.style.maxWidth = "calc(100vw - 24px)"; - summary.style.whiteSpace = "nowrap"; - summary.style.overflow = "hidden"; - summary.style.textOverflow = "ellipsis"; - } else { - summary.style.removeProperty("max-width"); - summary.style.removeProperty("white-space"); - summary.style.removeProperty("overflow"); - summary.style.removeProperty("text-overflow"); - } - - summary.setAttribute("data-tools-platform-summary-active", "1"); - summary.setAttribute("data-tools-platform-summary-error", "0"); - summary.setAttribute("data-tools-platform-summary-state", isExpanded ? "expanded" : "collapsed"); - summary.setAttribute("data-tools-platform-summary-mode", isFullscreen ? "fullscreen" : "normal"); - summary.setAttribute("data-tool-id", this.toolId); - summary.setAttribute("title", `${this.headerText}\n${this.introText}`); - } - - async enterFullscreenIfAvailable() { - if (this.document.fullscreenElement || !this.document.fullscreenEnabled) { - return; - } - if (typeof this.document.documentElement?.requestFullscreen !== "function") { - return; - } - try { - await this.document.documentElement.requestFullscreen(); - } catch { - // Collapsed header remains usable when fullscreen is unavailable. - } - } - - async exitFullscreenIfActive() { - if (!this.document.fullscreenElement || typeof this.document.exitFullscreen !== "function") { - return; - } - try { - await this.document.exitFullscreen(); - } catch { - // The fullscreenchange listener will resync if the browser exits later. - } - } - - bindHeaderDetails() { - const details = this.getHeaderDetails(); - if (!(details instanceof HTMLDetailsElement)) { - return; - } - - details.addEventListener("toggle", () => { - this.updateSummary(); - if (this.suppressFullscreenSync) { - this.suppressFullscreenSync = false; - return; - } - - if (details.open) { - void this.exitFullscreenIfActive(); - return; - } - - void this.enterFullscreenIfAvailable(); - }); - - this.document.addEventListener("fullscreenchange", () => { - const isFullscreen = Boolean(this.document.fullscreenElement); - this.applyFullscreenState(isFullscreen); - if (!isFullscreen && !details.open) { - this.suppressFullscreenSync = true; - details.open = true; - } - this.updateSummary(); - }); - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js deleted file mode 100644 index 644653b13..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js +++ /dev/null @@ -1,545 +0,0 @@ -import { isPlainObject } from "../../../../src/shared/object/objects.js"; -import { deepClone } from "../../../../src/shared/json/clone.js"; - -const SCHEMA_URL = new URL("../../../schemas/tools/object-vector-studio-v2.schema.json", import.meta.url); - -function isObjectIdentityId(value) { - return /^object\.[a-z0-9-]+\.[a-z0-9][a-z0-9-]*$/.test(String(value || "")); -} - -function withoutShapeOrigin(transform) { - if (!isPlainObject(transform)) { - return transform; - } - const { shapeOrigin, ...nextTransform } = transform; - return nextTransform; -} - -function typeMatches(expectedType, value) { - if (expectedType === "array") { - return Array.isArray(value); - } - if (expectedType === "object") { - return isPlainObject(value); - } - if (expectedType === "integer") { - return Number.isInteger(value); - } - if (expectedType === "number") { - return typeof value === "number" && Number.isFinite(value); - } - return typeof value === expectedType; -} - -function typeDescription(expectedType) { - if (expectedType === "boolean") { - return "true or false"; - } - return expectedType === "object" ? "an object" : `a ${expectedType}`; -} - -function resolveSchemaRef(schemaRoot, ref) { - if (typeof ref !== "string" || !ref.startsWith("#/")) { - throw new Error(`unsupported schema reference ${ref || "unknown"}`); - } - return ref.slice(2).split("/").reduce((current, segment) => current?.[segment], schemaRoot); -} - -export class ObjectVectorStudioV2SchemaService { - constructor({ fetchRef = (...args) => fetch(...args), schemaUrl = SCHEMA_URL } = {}) { - this.fetchRef = fetchRef; - this.schema = null; - this.schemaUrl = schemaUrl; - } - - get schemaPath() { - return this.schemaUrl.pathname || String(this.schemaUrl); - } - - async loadSchema() { - const response = await this.fetchRef(this.schemaUrl, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`${this.schemaPath} returned ${response.status}`); - } - const schema = await response.json(); - const schemaErrors = []; - this.validateSchemaShape(schema, schemaErrors); - if (schemaErrors.length) { - throw new Error(schemaErrors.join(" ")); - } - this.schema = schema; - return schema; - } - - getDefinitionDefault(definitionName) { - if (!this.schema) { - throw new Error("Object Vector Studio V2 schema is not loaded."); - } - const definition = this.schema.$defs?.[definitionName]; - if (!isPlainObject(definition) || !Object.prototype.hasOwnProperty.call(definition, "default")) { - throw new Error(`Object Vector Studio V2 schema default is missing for $defs.${definitionName}.default.`); - } - return deepClone(definition.default); - } - - validateSchemaShape(schema, errors) { - if (!isPlainObject(schema)) { - errors.push("Object Vector Studio V2 schema root must be an object."); - return; - } - if (schema.$id !== "object-vector-studio-v2.schema.json") { - errors.push("Object Vector Studio V2 schema id must be object-vector-studio-v2.schema.json."); - } - if (schema.additionalProperties !== false) { - errors.push("Object Vector Studio V2 schema root must reject unknown properties."); - } - const requiredRootFields = ["version", "toolId", "name", "objects"]; - if (!Array.isArray(schema.required) || requiredRootFields.some((key) => !schema.required.includes(key))) { - errors.push("Object Vector Studio V2 schema root must require version, toolId, name, and objects."); - } - ["palette", "selection", "viewport", "export", "vectorMaps"].forEach((key) => { - if (Object.prototype.hasOwnProperty.call(schema.properties || {}, key)) { - errors.push(`Object Vector Studio V2 schema root must not define ${key}.`); - } - if (Object.prototype.hasOwnProperty.call(schema.$defs || {}, key === "export" ? "exportPayload" : key)) { - errors.push(`Object Vector Studio V2 schema definitions must not define ${key}.`); - } - }); - if (schema.properties?.toolId?.const !== "object-vector-studio-v2") { - errors.push("Object Vector Studio V2 schema toolId must be object-vector-studio-v2."); - } - if (!isPlainObject(schema.$defs?.shape) || !Array.isArray(schema.$defs.shape.oneOf)) { - errors.push("Object Vector Studio V2 schema must define shape variants."); - } - if (Object.prototype.hasOwnProperty.call(schema.$defs?.object?.properties || {}, "type")) { - errors.push("Object Vector Studio V2 object schema must not define object type."); - } - if (Object.prototype.hasOwnProperty.call(schema.properties || {}, "assetLibrary")) { - errors.push("Object Vector Studio V2 schema must not define assetLibrary."); - } - ["id", "shapeKey", "label", "type"].forEach((key) => { - if (Object.prototype.hasOwnProperty.call(schema.$defs?.shapeCommon?.properties || {}, key)) { - errors.push(`Object Vector Studio V2 shape schema must not define deprecated shape field ${key}.`); - } - }); - const requiredShapeFields = schema.$defs?.shapeCommon?.required || []; - if (!requiredShapeFields.includes("tool")) { - errors.push("Object Vector Studio V2 shape schema must require tool."); - } - const overrideFields = schema.$defs?.shapeFrameOverride?.required || []; - if (!overrideFields.includes("shapeIndex")) { - errors.push("Object Vector Studio V2 shape frame override schema must require shapeIndex."); - } - [ - "object", - "shapeCommon", - "style", - "transform" - ].forEach((definitionName) => { - if (!Object.prototype.hasOwnProperty.call(schema.$defs?.[definitionName] || {}, "default")) { - errors.push(`Object Vector Studio V2 schema must define $defs.${definitionName}.default.`); - } - }); - [ - "rectangleGeometry", - "circleGeometry", - "ellipseGeometry", - "lineGeometry", - "triangleGeometry", - "polygonGeometry", - "polylineGeometry", - "arcGeometry", - "textGeometry" - ].forEach((definitionName) => { - if (Object.prototype.hasOwnProperty.call(schema.$defs?.[definitionName] || {}, "default")) { - errors.push(`Object Vector Studio V2 schema must not define pre-positioned $defs.${definitionName}.default geometry.`); - } - }); - } - - validatePayload(payload) { - if (!this.schema) { - return { - errors: ["Object Vector Studio V2 schema is not loaded."], - ok: false, - payload: null - }; - } - - const payloadForValidation = this.stripLegacyShapeOrigins(payload); - const errors = []; - this.validateLegacyRootFields(payloadForValidation, errors); - this.validateValue(this.schema, payloadForValidation, "root", errors); - if (!errors.length) { - this.validatePayloadReferences(payloadForValidation, errors); - } - if (errors.length) { - return { errors, ok: false, payload: null }; - } - - return { - errors: [], - ok: true, - payload: this.normalizePayload(payloadForValidation) - }; - } - - validateLegacyRootFields(payload, errors) { - if (!isPlainObject(payload)) { - return; - } - if (Object.prototype.hasOwnProperty.call(payload, "vectorMaps")) { - errors.push("root.vectorMaps is deprecated legacy vector-map data. Remove root.vectorMaps and import Object Vector Studio V2 payloads with root.objects[].tags and root.objects[].shapes only."); - } - } - - validatePayloadReferences(payload, errors) { - const objects = Array.isArray(payload?.objects) ? payload.objects : []; - const objectsById = new Map(); - objects.forEach((object, index) => { - if (!isObjectIdentityId(object.id)) { - errors.push(`root.objects[${index}].id ${object.id} must follow object.game.name.`); - } - if (objectsById.has(object.id)) { - errors.push(`root.objects[${index}].id ${object.id} duplicates an existing object id.`); - return; - } - objectsById.set(object.id, object); - }); - - objects.forEach((object, index) => { - this.validateInheritanceChain(object, objectsById, `root.objects[${index}]`, errors); - const localShapeOrders = new Set(); - object.shapes.forEach((shape, shapeIndex) => { - if (localShapeOrders.has(shape.order)) { - errors.push(`root.objects[${index}].shapes[${shapeIndex}].order ${shape.order} duplicates a local shape order.`); - } - localShapeOrders.add(shape.order); - }); - }); - - objects.forEach((object, index) => { - const shapeCount = this.collectInheritedShapeCount(object, objectsById, new Set(), errors, `root.objects[${index}]`); - (object.states || []).forEach((state, stateIndex) => { - state.frames.forEach((frame, frameIndex) => { - frame.shapeOverrides.forEach((override, overrideIndex) => { - if (override.shapeIndex >= shapeCount) { - errors.push(`root.objects[${index}].states[${stateIndex}].frames[${frameIndex}].shapeOverrides[${overrideIndex}].shapeIndex ${override.shapeIndex} must reference a local or inherited sorted shape row.`); - } - }); - }); - }); - }); - - } - - validateInheritanceChain(object, objectsById, path, errors) { - const seen = new Set([object.id]); - let current = object; - while (current?.baseObjectId) { - const baseId = current.baseObjectId; - if (seen.has(baseId)) { - errors.push(`${path}.baseObjectId creates a circular inheritance chain at ${baseId}.`); - return; - } - const baseObject = objectsById.get(baseId); - if (!baseObject) { - errors.push(`${path}.baseObjectId ${baseId} must reference an existing base object.`); - return; - } - seen.add(baseId); - current = baseObject; - } - } - - collectInheritedShapeCount(object, objectsById, seen, errors, path) { - return this.collectInheritedShapesByOrder(object, objectsById, seen, errors, path).size; - } - - collectInheritedShapesByOrder(object, objectsById, seen, errors, path) { - const shapeByOrder = new Map(); - if (!object || seen.has(object.id)) { - if (object) { - errors.push(`${path}.baseObjectId creates a circular shape inheritance chain at ${object.id}.`); - } - return shapeByOrder; - } - seen.add(object.id); - if (object.baseObjectId) { - const baseObject = objectsById.get(object.baseObjectId); - if (baseObject) { - this.collectInheritedShapesByOrder(baseObject, objectsById, seen, errors, path) - .forEach((shape, order) => shapeByOrder.set(order, shape)); - } - } - object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); - return shapeByOrder; - } - - validateRuntimePalette(palette, sourceLabel = "runtime palette") { - const errors = []; - if (!isPlainObject(palette)) { - errors.push(`${sourceLabel} must be a palette object.`); - return { errors, ok: false, palette: null }; - } - if (!Array.isArray(palette.swatches) || !palette.swatches.length) { - errors.push(`${sourceLabel}.swatches must contain at least one swatch.`); - return { errors, ok: false, palette: null }; - } - - palette.swatches.forEach((swatch, index) => { - if (!isPlainObject(swatch)) { - errors.push(`${sourceLabel}.swatches[${index}] must be an object.`); - return; - } - const label = swatch.id || swatch.name || swatch.symbol; - if (typeof label !== "string" || !label.trim()) { - errors.push(`${sourceLabel}.swatches[${index}] must provide id, name, or symbol.`); - } - const color = swatch.value || swatch.hex || swatch.color; - if (typeof color !== "string" || !color.trim()) { - errors.push(`${sourceLabel}.swatches[${index}] must provide value, hex, or color.`); - } - }); - - if (errors.length) { - return { errors, ok: false, palette: null }; - } - return { - errors: [], - ok: true, - palette: deepClone(palette) - }; - } - - stripLegacyShapeOrigins(payload) { - if (!isPlainObject(payload)) { - return payload; - } - const normalized = deepClone(payload); - if (!Array.isArray(normalized?.objects)) { - return normalized; - } - normalized.objects.forEach((object) => { - if (Array.isArray(object.shapes)) { - object.shapes.forEach((shape) => { - shape.transform = withoutShapeOrigin(shape.transform); - }); - } - if (Array.isArray(object.states)) { - object.states.forEach((state) => { - (state.frames || []).forEach((frame) => { - (frame.shapeOverrides || []).forEach((override) => { - if (isPlainObject(override.transform)) { - override.transform = withoutShapeOrigin(override.transform); - } - }); - }); - }); - } - }); - return normalized; - } - - validateValue(schemaNode, value, path, errors) { - const schema = schemaNode?.$ref ? resolveSchemaRef(this.schema, schemaNode.$ref) : schemaNode; - if (!schema) { - errors.push(`${path} references an unavailable Object Vector Studio V2 schema definition.`); - return; - } - - if (Array.isArray(schema.allOf)) { - schema.allOf.forEach((entry) => this.validateValue(entry, value, path, errors)); - return; - } - - if (Array.isArray(schema.anyOf)) { - const matched = schema.anyOf.some((entry) => { - const optionErrors = []; - this.validateValue(entry, value, path, optionErrors); - return optionErrors.length === 0; - }); - if (!matched) { - errors.push(`${path} must match at least one allowed schema option.`); - } - return; - } - - if (Array.isArray(schema.oneOf)) { - const matchedCount = schema.oneOf.reduce((count, entry) => { - const optionErrors = []; - this.validateValue(entry, value, path, optionErrors); - return optionErrors.length === 0 ? count + 1 : count; - }, 0); - if (matchedCount !== 1) { - errors.push(`${path} must match exactly one Object Vector Studio V2 shape schema.`); - } - return; - } - - this.validateConst(schema, value, path, errors); - this.validateEnum(schema, value, path, errors); - if (schema.type && !this.validateType(schema, value, path, errors)) { - return; - } - this.validateString(schema, value, path, errors); - this.validateNumber(schema, value, path, errors); - this.validateArray(schema, value, path, errors); - this.validateObject(schema, value, path, errors); - } - - validateConst(schema, value, path, errors) { - if (!Object.prototype.hasOwnProperty.call(schema, "const")) { - return; - } - if (value !== schema.const) { - errors.push(`${path} must be ${schema.const}.`); - } - } - - validateEnum(schema, value, path, errors) { - if (!Array.isArray(schema.enum)) { - return; - } - if (!schema.enum.includes(value)) { - errors.push(`${path} must be one of ${schema.enum.join(", ")}.`); - } - } - - validateType(schema, value, path, errors) { - if (Array.isArray(schema.type)) { - const matched = schema.type.some((expectedType) => typeMatches(expectedType, value)); - if (!matched) { - errors.push(`${path} must be one of ${schema.type.join(", ")}.`); - } - return matched; - } - const matched = typeMatches(schema.type, value); - if (!matched) { - errors.push(`${path} must be ${typeDescription(schema.type)}.`); - } - return matched; - } - - validateString(schema, value, path, errors) { - if (typeof value !== "string") { - return; - } - if (Number.isInteger(schema.minLength) && value.trim().length < schema.minLength) { - errors.push(`${path} must contain at least ${schema.minLength} characters.`); - } - } - - validateNumber(schema, value, path, errors) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return; - } - if (typeof schema.minimum === "number" && value < schema.minimum) { - errors.push(`${path} must be at least ${schema.minimum}.`); - } - if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) { - errors.push(`${path} must be greater than ${schema.exclusiveMinimum}.`); - } - if (typeof schema.maximum === "number" && value > schema.maximum) { - errors.push(`${path} must be at most ${schema.maximum}.`); - } - } - - validateArray(schema, value, path, errors) { - if (!Array.isArray(value)) { - return; - } - if (Number.isInteger(schema.minItems) && value.length < schema.minItems) { - errors.push(`${path} must contain at least ${schema.minItems} items.`); - } - if (Number.isInteger(schema.maxItems) && value.length > schema.maxItems) { - errors.push(`${path} must contain no more than ${schema.maxItems} items.`); - } - if (schema.items) { - value.forEach((item, index) => this.validateValue(schema.items, item, `${path}[${index}]`, errors)); - } - } - - validateObject(schema, value, path, errors) { - if (!isPlainObject(value)) { - return; - } - if (Array.isArray(schema.required)) { - schema.required.forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(value, key)) { - errors.push(`${path}.${key} is required.`); - } - }); - } - const properties = isPlainObject(schema.properties) ? schema.properties : {}; - Object.entries(properties).forEach(([key, childSchema]) => { - if (Object.prototype.hasOwnProperty.call(value, key)) { - this.validateValue(childSchema, value[key], `${path}.${key}`, errors); - } - }); - if (isPlainObject(schema.additionalProperties)) { - Object.entries(value).forEach(([key, child]) => { - if (!Object.prototype.hasOwnProperty.call(properties, key)) { - this.validateValue(schema.additionalProperties, child, `${path}.${key}`, errors); - } - }); - } - if (schema.additionalProperties === false) { - Object.keys(value).forEach((key) => { - if (!Object.prototype.hasOwnProperty.call(properties, key)) { - errors.push(`${path}.${key} is not allowed.`); - } - }); - } - } - - normalizePayload(payload) { - const normalized = deepClone(payload); - normalized.name = normalized.name.trim(); - normalized.objects = normalized.objects.map((object) => ({ - ...object, - baseObjectId: typeof object.baseObjectId === "string" ? object.baseObjectId.trim() : undefined, - id: object.id.trim(), - name: object.name.trim(), - objectOrigin: isPlainObject(object.objectOrigin) - ? { x: Number(object.objectOrigin.x), y: Number(object.objectOrigin.y) } - : undefined, - tags: Array.isArray(object.tags) ? object.tags.map((tag) => tag.trim()).filter(Boolean) : undefined, - states: Array.isArray(object.states) - ? object.states.map((state) => ({ - ...state, - id: state.id.trim().toLowerCase(), - name: state.name.trim(), - frames: state.frames.map((frame) => ({ - ...frame, - id: frame.id.trim(), - shapeOverrides: frame.shapeOverrides.map((override) => ({ - ...override, - shapeIndex: override.shapeIndex - })) - })) - })) - : undefined, - shapes: object.shapes.map((shape) => ({ - ...shape, - tool: shape.tool.trim().toLowerCase() - })) - })); - normalized.objects = normalized.objects.map((object) => { - if (object.states === undefined) { - delete object.states; - } - if (object.baseObjectId === undefined) { - delete object.baseObjectId; - } - if (object.objectOrigin === undefined) { - delete object.objectOrigin; - } - if (object.tags === undefined) { - delete object.tags; - } - return object; - }); - return normalized; - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js deleted file mode 100644 index 61ff8202f..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js +++ /dev/null @@ -1,15 +0,0 @@ -export class ToolStateSerializer { - constructor(toolId) { - this.toolId = toolId; - } - - createToolState({ sourceValue }) { - return { - toolId: this.toolId, - payload: { - sourceValue - } - }; - } -} - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs deleted file mode 100644 index 3a5ab3bf7..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs +++ /dev/null @@ -1,10 +0,0 @@ -export default { - testDir: "../../tests/playwright/tools", - testMatch: "ObjectVectorStudioV2FirstClassToolStarter.spec.mjs", - timeout: 30000, - outputDir: "./tests/results", - use: { - headless: true, - trace: "retain-on-failure" - } -}; diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt deleted file mode 100644 index ff08fbe09..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt +++ /dev/null @@ -1,31 +0,0 @@ -Object Vector Studio V2 possible future geometry additions - -Architecture notes from recent discussion: - -- Object Vector Studio V2 should stay focused on reusable atomic vector objects. -- Future World Vector or Scene layers should instance Object Vector objects instead of absorbing Object Vector editing concerns. -- Keep Object Vector and World Vector separate so object authoring, scene composition, and runtime placement do not collapse into one contract. -- Future 3D vector workflows can build from Object Vector concepts without changing the 2D object contract. -- Future 3D concepts to explore separately: - - point3d - - camera - - projection - - mesh - -- polyline - - Most likely next geometry addition. - - Would support connected open point sequences without requiring polygon closure. - -- spline - - Possible future addition for smooth point-driven curves. - - Would need explicit control-point, interpolation, and runtime tessellation contracts before implementation. - -- path - - Possible future addition for richer vector authoring. - - Would require a stricter schema/editor contract before implementation. - -- point/marker - - Possible future addition for anchors, control points, or lightweight markers. - - Would need clear runtime/editor semantics before implementation. - -Tracking note: this file is informational only. No new geometry shape is implemented by this PR. diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css deleted file mode 100644 index d13d86d72..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css +++ /dev/null @@ -1,2408 +0,0 @@ -@font-face { - font-family: "0xProto Nerd Font"; - src: url("../../../src/assets/fonts/0xProtoNerdFont/0xProtoNerdFontMono-Regular.ttf") format("truetype"); - font-display: swap; - font-style: normal; - font-weight: 400; -} - -:root { - --tool-starter-bg: var(--bg-gradient, linear-gradient(180deg, #c4b5fd 0%, #a78bfa 18%, #8b5cf6 36%, #7c3aed 54%, #6d28d9 74%, #5b21b6 88%, #4c1d95 100%)); - --tool-starter-panel: var(--panel, rgba(30, 24, 48, 0.92)); - --tool-starter-surface: var(--surface-inline, rgba(18, 16, 28, 0.78)); - --tool-starter-panel-strong: rgba(0, 0, 0, 0.24); - --tool-starter-input-surface: var(--surface-inline, rgba(43, 16, 91, 0.9)); - --tool-starter-line: var(--line, rgba(221, 214, 254, 0.26)); - --tool-starter-surface-border: var(--surface-border, rgba(221, 214, 254, 0.22)); - --tool-starter-text: var(--text, #f7f4ff); - --tool-starter-muted: var(--muted, #c4b5fd); - --tool-starter-header-muted: var(--muted, #e9ddff); - --tool-starter-accent: var(--accent, #ddd6fe); - --tool-starter-danger: #ffb4b4; - --tool-starter-shadow: 0 18px 36px rgba(0, 0, 0, 0.22); - --tool-starter-shadow-strong: 0 20px 44px rgba(0, 0, 0, 0.26); - color-scheme: dark; - font-family: Inter, Segoe UI, system-ui, sans-serif; -} - -* { - box-sizing: border-box; -} - -body { - min-height: 100vh; - min-height: 100dvh; - margin: 0; - background: var(--tool-starter-bg); - color: var(--tool-starter-text); - overflow-x: hidden; -} - -button, -input, -select, -textarea { - font: inherit; -} - -button { - border: 1px solid var(--tool-starter-line) !important; - border-radius: 10px !important; - background: ButtonFace; - color: ButtonText; - cursor: pointer; - padding: 2px 6px; -} - -.object-vector-studio-v2 { - --object-vector-studio-v2-icon-scale: 1.75; - --object-vector-studio-v2-snap-disabled-icon-color: #ef4444; - --object-vector-studio-v2-snap-point-color: #fbbf24; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.62; - filter: saturate(0.72); - box-shadow: none; -} - -button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible { - outline: 2px solid var(--tool-starter-accent); - outline-offset: 2px; -} - -input, -select, -textarea { - border: 1px solid var(--tool-starter-surface-border); - border-radius: 10px !important; - background: var(--tool-starter-input-surface); - color: var(--tool-starter-text); -} - -input:hover, -select:hover, -textarea:hover { - border-color: var(--card-hover-border, #c4b5fd); -} - -.is-collapsible { - border-bottom: 1px solid var(--tool-starter-line); - background: transparent; -} - -.is-collapsible__summary { - min-height: 36px; - display: flex; - align-items: center; - cursor: pointer; - color: var(--tool-starter-header-muted); - font-weight: 700; - padding: 8px 18px; -} - -.is-collapsible__content { - display: grid; - gap: 0; -} - -#shared-theme-header { - min-height: 0; -} - -.tool-starter__header { - background: transparent; - padding: 10px 16px; -} - -.tool-starter__local-shell-frame { - --tools-platform-frame-surface: var(--tool-starter-panel); - --tools-platform-frame-line: var(--tool-starter-line); - border: 1px solid var(--tool-starter-line); - border-radius: 18px; - background: var(--tool-starter-bg); - box-shadow: var(--tool-starter-shadow); - color: var(--tool-starter-text); -} - -.tool-starter__local-shell-frame .tools-platform-frame__accordion-content { - display: grid; - gap: 10px; - padding: 12px 14px; -} - -.tool-starter__local-shell-frame .tools-platform-frame__accordion-summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - border-radius: 12px; - background: #4c1d95; - padding: 12px 14px; -} - -.tool-starter__local-shell-frame .tools-platform-frame__title { - margin: 0; - color: #ffffff; -} - -.tool-starter__local-shell-frame .tools-platform-frame__eyebrow, -.tool-starter__local-shell-frame .tools-platform-frame__meta, -.tool-starter__local-shell-frame .tools-platform-frame__description { - margin: 0; - color: var(--tool-starter-header-muted); -} - -.tool-starter__menu { - box-sizing: border-box; - width: calc(100% - 32px); - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 12px; - margin: 16px 16px 0; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel); - padding: 10px 14px; -} - -.tool-starter__menu[hidden] { - display: none; -} - -.tool-starter.app-shell { - height: calc(100vh - 137px); - min-height: 0; - width: min(1600px, calc(100% - 32px)); - margin: 0 auto 16px; - display: grid; - grid-template-columns: 340px minmax(0, 1fr) 360px; - gap: 10px; - border: 1px solid var(--tool-starter-line); - border-radius: 20px; - box-shadow: var(--tool-starter-shadow-strong); - overflow: hidden; - padding: 12px; -} - -.tool-starter__panel { - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - gap: 5px; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel); - overflow: hidden; - padding: 8px; -} - -.tool-starter__panel--center { - min-height: 0; -} - -.tool-starter__panel--left { - overflow-y: auto; -} - -.tool-starter__panel--right { - overflow-y: auto; -} - -.tool-starter__panel--left > .accordion-v2.is-open { - flex: 1 0 220px; -} - -.tool-starter__panel--left > .accordion-v2.tool-starter__accordion--compact.is-open { - flex: 0 0 auto; -} - -.tool-starter__panel--left > .accordion-v2.tool-starter__accordion--compact.is-open > .accordion-v2__content { - flex: 0 0 auto; -} - -.accordion-v2 { - --accordion-v2-accent: var(--tool-starter-accent); - --accordion-v2-line: var(--tool-starter-line); - --accordion-v2-surface: var(--tool-starter-surface); - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-surface); - box-shadow: 0 14px 30px rgba(0, 0, 0, 0.12); - overflow: hidden; -} - -.accordion-v2.is-open { - flex: 1 1 0; -} - -.accordion-v2:not(.is-open) { - flex: 0 0 auto; -} - -.accordion-v2__header { - width: 100%; - min-height: 36px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - border: 0; - border-radius: 0; - background: transparent; - color: inherit; - font-weight: 700; - padding: 6px 10px; - text-align: left; - cursor: pointer; -} - -.accordion-v2__icon { - width: 22px; - height: 22px; - display: inline-grid; - place-items: center; - border: 1px solid var(--tool-starter-line); - border-radius: 999px; - line-height: 1; -} - -.accordion-v2.is-open .accordion-v2__icon { - transform: rotate(45deg); -} - -.accordion-v2__content { - min-height: 0; - display: flex; - flex: 1 1 auto; - flex-direction: column; - gap: 6px; - border-top: 1px solid var(--tool-starter-line); - overflow: auto; - padding: 8px; -} - -.accordion-v2__content[hidden] { - display: none; -} - -.tool-starter__status-accordion-header { - flex: 0 0 36px; - min-width: 0; -} - -.tool-starter__status-header-actions { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 8px; -} - -.tool-starter__status-clear-button { - flex: 0 0 auto; - margin-right: 0; - border-color: var(--tool-starter-line) !important; - border-radius: 10px !important; - background: ButtonFace; - color: ButtonText; - padding: 2px 6px; -} - -.tool-starter__status-clear-button:hover { - border-color: var(--tool-starter-line) !important; - background: ButtonFace; - color: ButtonText; -} - -.tool-starter__accordion--fill { - flex: 1 1 auto; -} - -.tool-starter__accordion--compact.is-open { - flex: 0 0 auto; -} - -.tool-starter__accordion--compact .accordion-v2__content { - flex: 0 0 auto; -} - -.tool-starter__field { - min-width: 0; - display: grid; - grid-template-columns: 74px minmax(0, 1fr); - align-items: center; - gap: 8px; - font-weight: 700; -} - -.tool-starter__field--compact { - grid-template-columns: 96px minmax(0, 1fr); -} - -.tool-starter__field--stacked { - grid-template-columns: 1fr; - gap: 6px; -} - -.tool-starter__field-patterns { - display: grid; - gap: 8px; -} - -.tool-starter__field input { - width: 100%; - min-width: 0; - padding: 7px 9px; -} - -#objectVectorStudioV2ObjectContent .tool-starter__field--compact > span { - white-space: nowrap; -} - -.tool-starter__field input[readonly] { - color: var(--tool-starter-muted); - cursor: default; -} - -.tool-starter__field--not-editable input { - padding-block: 0; -} - -.tool-starter__field--editable input { - background: var(--tool-starter-input-surface); - color: var(--tool-starter-text); -} - -.tool-starter__editable-tags-box { - min-height: 36px; - border-radius: 8px !important; - background: var(--tool-starter-panel-strong); - padding: 6px; -} - -.tool-starter__hint { - margin: 0; - color: var(--tool-starter-muted); - font-size: 0.9rem; -} - -.tool-starter__hint.is-error { - color: var(--tool-starter-danger); -} - -.object-vector-studio-v2__callout, -.object-vector-studio-v2__details { - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel-strong); - color: var(--tool-starter-text); - padding: 10px; -} - -.object-vector-studio-v2__scroll-content { - min-height: 0; - overflow: auto; -} - -#objectVectorStudioV2ObjectsContent { - overflow-y: auto; -} - -#objectVectorStudioV2ShapeToolsContent, -#objectVectorStudioV2PaletteContent { - flex: 0 1 auto; - max-height: 260px; -} - -#objectVectorStudioV2PaletteContent { - flex: 0 0 auto; - max-height: none; -} - -#objectVectorStudioV2ShapeToolsContent { - flex: 0 0 auto; - max-height: none; - min-height: 0; -} - -.tool-starter__panel--right > .accordion-v2.is-open { - flex: 0 0 auto; -} - -.tool-starter__panel--right > .accordion-v2.is-open > .accordion-v2__content { - flex: 0 0 auto; -} - -.tool-starter__panel--right > .accordion-v2.is-open > .accordion-v2__content:not(#objectVectorStudioV2ShapeToolsContent) { - max-height: 260px; -} - -.tool-starter__panel--right > .object-vector-studio-v2__shape-geometry-accordion.is-open { - flex: 1 1 320px; - min-height: 220px; -} - -.tool-starter__panel--right > .accordion-v2.object-vector-studio-v2__shape-geometry-accordion.is-open > .accordion-v2__content#objectVectorStudioV2ShapeGeometryContent { - flex: 1 1 auto; - max-height: none; -} - -#objectVectorStudioV2ShapeGeometryDetails { - flex: 1 1 auto; - min-height: 0; -} - -#objectVectorStudioV2ShapeGeometryDetails > .object-vector-studio-v2__object-detail-stack { - min-height: 100%; -} - -.object-vector-studio-v2__palette-swatch-list { - display: grid; - flex: 0 0 auto; - grid-template-columns: repeat(auto-fill, 32px); - gap: 8px; - align-content: start; - max-height: 176px; - min-height: 0; - overflow-y: auto; -} - -.object-vector-studio-v2__palette-swatch-list:not(:has(.object-vector-studio-v2__palette-swatch)) { - display: block; - max-height: none; - overflow: visible; - color: var(--tool-starter-muted); - font-size: 0.85rem; - line-height: 1.25; - text-align: center; -} - -.object-vector-studio-v2__palette-swatch { - position: relative; - width: 32px; - height: 32px; - min-height: 32px; - aspect-ratio: 1; - display: block; - padding: 0 !important; - overflow: visible; - background: var(--object-vector-studio-v2-swatch, transparent) !important; - border-radius: 4px !important; - color: transparent !important; - font-size: 0; -} - -.object-vector-studio-v2__palette-swatch.is-selected { - border-color: #ffffff !important; - outline: 3px solid var(--tool-starter-accent-strong); - outline-offset: 2px; - box-shadow: 0 0 0 2px #020617, 0 0 0 6px rgba(20, 184, 166, 0.34); -} - -.object-vector-studio-v2__palette-swatch::before { - display: none; -} - -.object-vector-studio-v2__palette-swatch.is-selected::before { - content: ""; - position: absolute; - left: 11px; - top: 7px; - display: block; - width: 8px; - height: 14px; - border: solid #ffffff; - border-width: 0 3px 3px 0; - filter: drop-shadow(0 1px 2px #020617); - transform: rotate(45deg); -} - -.object-vector-studio-v2__palette-swatch:hover::after, -.object-vector-studio-v2__palette-swatch:focus-visible::after { - content: attr(data-palette-details); - position: absolute; - left: 50%; - bottom: calc(100% + 6px); - z-index: 5; - min-width: 96px; - transform: translateX(-50%); - border: 1px solid var(--tool-starter-line); - border-radius: 6px; - background: rgba(15, 23, 42, 0.96); - color: var(--tool-starter-text); - font-size: 0.74rem; - line-height: 1.25; - padding: 5px 7px; - text-align: center; - white-space: pre-line; - text-indent: 0; -} - -.object-vector-studio-v2__object-actions, -.object-vector-studio-v2__tool-display-actions, -.object-vector-studio-v2__snap-actions, -.object-vector-studio-v2__z-order-actions, -.object-vector-studio-v2__viewport-controls, -.object-vector-studio-v2__preview-edit-toolbar, -.object-vector-studio-v2__palette-controls, -.object-vector-studio-v2__json-actions { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 8px; -} - -.object-vector-studio-v2__control-group { - display: grid; - gap: 8px; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel-strong); - padding: 10px; -} - -#objectVectorStudioV2ShapeToolsContent .object-vector-studio-v2__snap-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: stretch; -} - -#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions { - display: grid; - grid-template-columns: repeat(auto-fit, 34px); - align-items: stretch; - justify-content: center; - gap: 4px; - border-top: 1px solid var(--tool-starter-line); - margin-top: 3px; - padding-top: 4px; -} - -#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__shape-list-actions { - width: 100%; - grid-column: 1 / -1; - margin-top: 2px; -} - -.object-vector-studio-v2__z-action { - width: auto; - min-height: 0; - min-width: 86px; - position: relative; - display: inline-flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 3px; - overflow: hidden; - padding: 4px !important; - text-align: center; -} - -.object-vector-studio-v2__control-group h3, -.object-vector-studio-v2__object-detail-stack h3 { - margin: 0; - font-size: 0.95rem; - line-height: 1.25; -} - -.object-vector-studio-v2__object-actions button { - min-width: 68px; - min-height: 32px; -} - -.object-vector-studio-v2__tool-toggle-grid { - display: grid; - grid-template-columns: repeat(auto-fill, 76px); - align-items: center; - justify-content: center; - gap: 8px; -} - -.object-vector-studio-v2__tool-toggle { - width: auto; - min-height: 0; - min-width: 76px; - display: inline-flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - overflow: hidden; - padding: 5px 4px !important; - text-align: center; -} - -.object-vector-studio-v2__tool-toggle--future { - cursor: not-allowed; -} - -.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__shape-icon, -.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__tool-label { - color: var(--muted); -} - -.object-vector-studio-v2__tool-label { - max-width: 100%; - overflow: hidden; - font-size: 0.68rem; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.object-vector-studio-v2__z-label { - max-width: 100%; - overflow: hidden; - font-size: 0.68rem; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.object-vector-studio-v2__shape-icon { - width: 24px; - height: 24px; - display: inline-grid; - place-items: center; - font-size: 1.08rem; - color: var(--tool-starter-text); -} - -.object-vector-studio-v2__tool-toggle-grid.is-icon-only { - grid-template-columns: repeat(auto-fill, 42px); -} - -.object-vector-studio-v2__tool-toggle-grid.is-icon-only .object-vector-studio-v2__tool-toggle { - width: 100%; - min-width: 0; - min-height: 0; - aspect-ratio: 1; - gap: 0; - padding: 4px; -} - -.object-vector-studio-v2__tool-toggle-grid.is-icon-only .object-vector-studio-v2__tool-label { - width: 1px; - height: 1px; - position: absolute; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} - -.object-vector-studio-v2__z-order-actions.is-icon-only { - grid-template-columns: repeat(auto-fill, 42px); -} - -#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions.is-icon-only { - grid-template-columns: repeat(auto-fit, 34px); -} - -.object-vector-studio-v2__z-order-actions.is-icon-only .object-vector-studio-v2__z-action { - width: 100%; - min-width: 0; - min-height: 0; - aspect-ratio: 1; - padding: 2px !important; -} - -.object-vector-studio-v2__z-order-actions.is-icon-only .object-vector-studio-v2__z-label { - width: 1px; - height: 1px; - position: absolute; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} - -.object-vector-studio-v2__z-icon { - width: 24px; - height: 24px; - position: relative; - display: inline-grid; - place-items: center; - color: var(--tool-starter-text); -} - -.object-vector-studio-v2__tool-toggle.is-active, -.object-vector-studio-v2__tool-toggle[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); -} - -.object-vector-studio-v2__object-tiles { - min-height: 0; - flex: 1 0 auto; - display: grid; - align-content: start; - gap: 8px; - overflow: visible; - padding-right: 2px; -} - -.object-vector-studio-v2__object-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; - margin-top: 0; - padding-top: 0; -} - -.object-vector-studio-v2__object-actions button { - height: 28px; - min-width: 0; - min-height: 28px; - padding: 2px 5px !important; - font-size: 0.78rem; - line-height: 1; - white-space: nowrap; -} - -.object-vector-studio-v2__library-panel, -.object-vector-studio-v2__asset-library { - min-height: 0; - gap: 8px; -} - -.object-vector-studio-v2__library-panel { - display: grid; -} - -.object-vector-studio-v2__asset-library { - display: flex; - flex-direction: column; - overflow: visible; -} - -.object-vector-studio-v2__object-tile { - width: 100%; - min-height: 52px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 6px; - border: 1px solid var(--tool-starter-line); - background: var(--tool-starter-panel-strong); - border-radius: 8px !important; - padding: 8px 10px; - text-align: left; -} - -.object-vector-studio-v2__asset-tile { - position: relative; - z-index: 1; - width: 100%; - flex: 0 0 auto; - min-height: 54px; - display: grid; - gap: 4px; - border-radius: 8px !important; - padding: 8px 10px; - text-align: left; -} - -.object-vector-studio-v2__asset-tile--empty { - color: var(--tool-starter-muted); -} - -.object-vector-studio-v2__object-tile.is-selected, -.object-vector-studio-v2__object-tile[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - background: var(--tool-starter-input-surface); -} - -.object-vector-studio-v2__object-tile.is-hidden { - opacity: 0.62; -} - -.object-vector-studio-v2__object-tile.is-locked { - box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.58); -} - -.object-vector-studio-v2__object-tile--empty { - grid-template-columns: 1fr; - color: var(--tool-starter-muted); -} - -.object-vector-studio-v2__object-tile-copy { - min-width: 0; - display: grid; - gap: 2px; -} - -.object-vector-studio-v2__object-select { - min-width: 0; - width: 100%; - display: grid; - grid-template-columns: 50px minmax(0, 1fr); - align-items: center; - gap: 8px; - background: transparent; - text-align: left; - justify-items: start; -} - -.object-vector-studio-v2__object-tile-controls { - align-self: center; - justify-self: end; - position: relative; - z-index: 2; - display: flex; - flex-direction: column; - flex-wrap: nowrap; - justify-content: center; - align-items: center; - gap: 2px; -} - -.object-vector-studio-v2__object-tile-control { - position: relative; - z-index: 3; - width: 26px; - height: 26px; - min-height: 26px; - padding: 0 !important; -} - -.object-vector-studio-v2__object-tile-control--delete { - color: var(--tool-starter-danger); -} - -.object-vector-studio-v2__object-state-panel { - grid-column: 1 / -1; - display: grid; - gap: 4px; - border-top: 1px solid var(--tool-starter-line); - margin-top: 2px; - padding-top: 4px; -} - -.object-vector-studio-v2__object-state-controls { - display: grid; - grid-template-columns: auto minmax(74px, 1fr) auto 26px; - align-items: center; - gap: 4px; -} - -.object-vector-studio-v2__object-state-controls button, -.object-vector-studio-v2__object-state-controls select { - min-height: 26px; - padding: 2px 5px !important; - font-size: 0.72rem; - line-height: 1; -} - -.object-vector-studio-v2__object-state-controls select { - min-width: 0; -} - -.object-vector-studio-v2__object-state-help { - width: 26px; - min-width: 26px; - padding-inline: 0 !important; - font-weight: 900; -} - -.object-vector-studio-v2__object-state-tiles { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.object-vector-studio-v2__object-state-tiles button { - min-height: 24px; - min-width: 46px; - padding: 2px 6px !important; - font-size: 0.68rem; - line-height: 1; -} - -.object-vector-studio-v2__object-state-tiles button.is-selected, -.object-vector-studio-v2__object-state-tiles button[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); -} - -.object-vector-studio-v2__object-tile-shapes { - grid-column: 1 / -1; - display: grid; - gap: 1px; - border-top: 1px solid var(--tool-starter-line); - padding-top: 4px; -} - -.object-vector-studio-v2__object-tile-shape-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 4px; - font-size: 0.7rem; - line-height: 1.08; -} - -.object-vector-studio-v2__shape-select { - width: 100% !important; - min-height: 22px; - position: relative; - z-index: 3; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - padding: 2px 5px !important; - text-align: left; -} - -.object-vector-studio-v2__shape-select-label { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__shape-inline-actions { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 2px; -} - -.object-vector-studio-v2__shape-inline-button { - width: 24px; - height: 24px; - min-height: 24px; - display: inline-grid; - place-items: center; - padding: 0 !important; -} - -.object-vector-studio-v2__shape-group-indicator[data-ovs-icon] { - font-size: 0.78rem; -} - -.object-vector-studio-v2__object-tile-shape-row.is-selected .object-vector-studio-v2__shape-select, -.object-vector-studio-v2__shape-select[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.2); -} - -.object-vector-studio-v2__tile-icon { - width: 14px; - height: 14px; - position: relative; - display: inline-grid; - place-items: center; - color: var(--tool-starter-text); - pointer-events: none; -} - -.object-vector-studio-v2__tile-icon.is-off { - opacity: 0.42; -} - -.object-vector-studio-v2 [data-ovs-icon]::before { - content: attr(data-ovs-icon); - display: inline-block; - font-family: "0xProto Nerd Font", "Cascadia Mono", monospace; - font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75)); - font-style: normal; - font-weight: 400; - line-height: 1; - text-rendering: geometricPrecision; -} - -.object-vector-studio-v2 button[data-ovs-icon] { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; -} - -.object-vector-studio-v2__shape-icon[data-ovs-icon], -.object-vector-studio-v2__z-icon[data-ovs-icon], -.object-vector-studio-v2__tile-icon[data-ovs-icon] { - display: inline-grid; - place-items: center; - border: 0; - outline: 0; - background: transparent; - clip-path: none; - color: var(--tool-starter-text); - transform: none; -} - -.object-vector-studio-v2__snap-actions button[data-ovs-icon]::before { - color: inherit; -} - -#objectVectorStudioV2SnapModeButton[data-snap-mode="point"][data-ovs-icon]::before { - color: var(--object-vector-studio-v2-snap-point-color); -} - -#objectVectorStudioV2SnapModeButton[data-snap-mode="none"][data-ovs-icon]::before, -#objectVectorStudioV2AngleSnapButton[aria-pressed="false"][data-ovs-icon]::before, -#objectVectorStudioV2GridRenderButton[aria-pressed="false"][data-ovs-icon]::before { - color: var(--object-vector-studio-v2-snap-disabled-icon-color); -} - -.object-vector-studio-v2__shape-icon[data-ovs-icon] { - width: 24px; - height: 24px; - font-size: 1.08rem; -} - -.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__shape-icon[data-ovs-icon], -.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__tool-label { - color: var(--muted); -} - -.object-vector-studio-v2__shape-icon--select[data-ovs-icon]::before { - font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.125); -} - -.object-vector-studio-v2__shape-icon--triangle[data-ovs-icon]::before { - font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.25); -} - -.object-vector-studio-v2__shape-icon--rectangle[data-ovs-icon]::before { - font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.25); -} - -.object-vector-studio-v2__z-icon[data-ovs-icon] { - width: 24px; - height: 24px; - position: relative; - font-size: 1.05rem; -} - -.object-vector-studio-v2__tile-icon[data-ovs-icon] { - width: 14px; - height: 14px; - position: relative; - font-size: 0.84rem; -} - -.object-vector-studio-v2__shape-icon[data-ovs-icon]::before, -.object-vector-studio-v2__z-icon[data-ovs-icon]::before, -.object-vector-studio-v2__tile-icon[data-ovs-icon]::before { - position: static; - width: auto; - height: auto; - border: 0; - background: transparent; - border-radius: 0; - transform-origin: center; -} - -.object-vector-studio-v2__shape-icon[data-ovs-icon]::after, -.object-vector-studio-v2__z-icon[data-ovs-icon]::after, -.object-vector-studio-v2__tile-icon[data-ovs-icon]::after { - content: none; - display: none; -} - -.object-vector-studio-v2__shape-group-indicator.object-vector-studio-v2__tile-icon[data-ovs-icon], -#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__shape-group-indicator[data-ovs-icon] { - color: var(--object-vector-studio-v2-shape-group-color, var(--tool-starter-accent)); -} - -.object-vector-studio-v2__object-tile-control--delete .object-vector-studio-v2__tile-icon[data-ovs-icon], -.object-vector-studio-v2__shape-delete-inline .object-vector-studio-v2__tile-icon[data-ovs-icon] { - color: var(--tool-starter-danger); -} - -.object-vector-studio-v2__shape-delete-inline { - color: var(--tool-starter-danger); -} - -.object-vector-studio-v2__object-thumbnail { - width: 44px; - height: 36px; - display: block; - border: 1px solid var(--tool-starter-line); - border-radius: 4px; - background: rgba(0, 0, 0, 0.18); -} - -.object-vector-studio-v2__object-thumbnail-shape { - pointer-events: none; -} - -.object-vector-studio-v2__object-thumbnail-empty { - fill: none; - stroke: var(--tool-starter-muted); - stroke-dasharray: 4 3; - stroke-width: 2; -} - -.object-vector-studio-v2__object-tile-name { - font-weight: 800; - line-height: 1.14; -} - -.object-vector-studio-v2__object-tile-meta { - color: var(--tool-starter-muted); - font-size: 0.78rem; - line-height: 1.16; - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__work-area { - position: relative; - min-height: 0; - flex: 0 0 auto; - display: grid; - grid-template-rows: auto auto auto auto auto auto auto; - place-items: center; - gap: 8px; - border: 1px dashed var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel-strong); - color: var(--tool-starter-muted); - padding: 12px; - text-align: center; -} - -.object-vector-studio-v2__separator { - width: min(720px, 100%); - border: 0; - border-top: 1px solid var(--tool-starter-line); - margin: 0; -} - -.object-vector-studio-v2__viewport-controls button { - min-height: 30px; -} - -.object-vector-studio-v2__snap-actions button, -.object-vector-studio-v2__z-order-actions button, -.object-vector-studio-v2__palette-sort button { - min-height: 42px; -} - -#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions button { - min-height: 34px; -} - -.object-vector-studio-v2__animation-controls { - width: min(720px, 100%); - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 8px; -} - -.object-vector-studio-v2__inline-field { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--tool-starter-muted); - font-weight: 800; -} - -.object-vector-studio-v2__inline-field input[type="number"] { - width: 72px; - padding: 5px 7px; -} - -.object-vector-studio-v2__frame-timeline { - width: min(720px, 100%); - min-height: 52px; - display: flex; - align-items: stretch; - gap: 8px; - overflow-x: auto; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-surface); - padding: 8px; -} - -.object-vector-studio-v2__frame-tile { - min-width: 108px; - display: grid; - gap: 4px; - justify-items: center; - border: 1px solid var(--tool-starter-line) !important; - border-radius: 8px !important; - background: ButtonFace; - color: ButtonText; - cursor: pointer; - padding: 6px; - text-align: center; -} - -.object-vector-studio-v2__frame-tile.is-selected, -.object-vector-studio-v2__frame-tile[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); -} - -.object-vector-studio-v2__frame-thumbnail { - width: 84px; - height: 44px; - border: 1px solid var(--tool-starter-line); - border-radius: 4px; - background: rgba(0, 0, 0, 0.18); -} - -.object-vector-studio-v2__coordinate-display, -.object-vector-studio-v2__selection-metrics { - width: min(520px, 100%); - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-surface); - color: var(--tool-starter-muted); - padding: 8px 10px; - font-size: 0.9rem; -} - -.object-vector-studio-v2__header-count { - color: var(--tool-starter-muted); - font-size: 0.82rem; - font-weight: 700; - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__geometry-header-title { - min-width: 0; - display: grid; - gap: 1px; - line-height: 1.05; -} - -.object-vector-studio-v2__geometry-header-line { - min-width: 0; -} - -.object-vector-studio-v2__geometry-header-name { - min-width: 0; - color: var(--tool-starter-muted); - font-size: 0.78rem; - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.object-vector-studio-v2__shape-list { - display: grid; - flex: 0 0 auto; - gap: 6px; - max-height: 132px; - overflow-y: auto; -} - -.object-vector-studio-v2__shape-list-button { - min-height: 30px; - text-align: left; -} - -.object-vector-studio-v2__shape-list-button.is-selected, -.object-vector-studio-v2__shape-list-button[aria-pressed="true"], -.object-vector-studio-v2__palette-controls button.is-active, -.object-vector-studio-v2__palette-controls button[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); -} - -.object-vector-studio-v2__shape-list-empty { - color: var(--tool-starter-muted); - font-size: 0.9rem; -} - -.object-vector-studio-v2__palette-controls .object-vector-studio-v2__inline-field input { - width: 46px; - padding-inline: 5px; -} - -#objectVectorStudioV2StrokeWidth { - width: 58px; -} - -.object-vector-studio-v2__palette-controls { - display: grid; - gap: 5px; - justify-items: center; -} - -.object-vector-studio-v2__palette-controls button { - min-width: 0; - min-height: 30px; - padding: 3px 5px !important; -} - -.object-vector-studio-v2__palette-picker-button { - width: 30px; - min-width: 30px; - height: 30px; - padding: 0 !important; -} - -.object-vector-studio-v2__palette-picker-button .object-vector-studio-v2__shape-icon { - width: 20px; - height: 20px; -} - -.object-vector-studio-v2__palette-primary-row, -.object-vector-studio-v2__palette-opacity-row { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: nowrap; - gap: 5px; - width: 100%; -} - -.object-vector-studio-v2__palette-controls .object-vector-studio-v2__inline-field { - flex: 0 0 auto; - gap: 3px; - font-size: 0.78rem; - white-space: nowrap; -} - -.object-vector-studio-v2__palette-opacity-row .object-vector-studio-v2__inline-field { - font-size: 0.72rem; -} - -.object-vector-studio-v2__palette-opacity-heading { - flex: 0 0 auto; - font-size: 0.74rem; - font-weight: 700; - line-height: 1; -} - -.object-vector-studio-v2__palette-opacity-row .object-vector-studio-v2__inline-field input { - width: 54px; -} - -.object-vector-studio-v2__palette-controls button[data-ovs-icon]::before { - order: 2; -} - -.object-vector-studio-v2__palette-mode-swatch { - order: 1; - width: 13px; - height: 13px; - flex: 0 0 13px; - border: 1px solid rgba(255, 255, 255, 0.72); - border-radius: 2px; - background: var(--object-vector-studio-v2-mode-swatch, transparent); - opacity: var(--object-vector-studio-v2-mode-opacity, 1); - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25); -} - -.object-vector-studio-v2__palette-mode-label { - order: 3; -} - -.object-vector-studio-v2__palette-sort { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - align-items: center; - gap: 2px; - width: 100%; - border-top: 1px solid var(--tool-starter-line); - padding-top: 4px; -} - -.object-vector-studio-v2__palette-sort button { - min-width: 0; - min-height: 24px; - gap: 1px; - padding: 1px 2px !important; - font-size: 0.61rem; - line-height: 1; -} - -.object-vector-studio-v2__palette-sort button[data-ovs-icon]::before { - font-size: 1.05em; -} - -.object-vector-studio-v2__palette-sort button[aria-pressed="true"] { - border-color: var(--tool-starter-accent) !important; - box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); -} - -.object-vector-studio-v2__palette-sort-caret { - margin-left: 1px; - font-size: 0.62rem; - line-height: 1; -} - -.object-vector-studio-v2__selection-metrics { - color: var(--tool-starter-text); - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__selected-visibility { - width: min(520px, 100%); - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-surface); - color: var(--tool-starter-text); - padding: 18px; - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__render-surface { - width: 100%; - max-width: 100%; - height: auto; - min-height: 0; - aspect-ratio: 16 / 11; - align-self: stretch; - justify-self: stretch; - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: rgba(0, 0, 0, 0.16); - background-size: 20px 20px; -} - -.object-vector-studio-v2__render-surface.is-grid-visible { - background: rgba(0, 0, 0, 0.16); -} - -.object-vector-studio-v2__svg-grid { - stroke: rgba(255, 255, 255, 0.18); - stroke-width: 1; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__shape { - cursor: pointer; - transition: filter 120ms ease, opacity 120ms ease; -} - -.object-vector-studio-v2__shape-hit-layer { - pointer-events: auto; -} - -.object-vector-studio-v2__shape-hit-area { - cursor: pointer; - fill-opacity: 0; - stroke-opacity: 0; - pointer-events: all; -} - -.object-vector-studio-v2__render-surface.is-drawing-mode { - cursor: crosshair; -} - -.object-vector-studio-v2__render-surface.is-empty-canvas-object-drag-ready, -.object-vector-studio-v2__render-surface.is-canvas-pan-ready { - cursor: grab; -} - -.object-vector-studio-v2__render-surface.is-empty-canvas-object-dragging, -.object-vector-studio-v2__render-surface.is-canvas-panning { - cursor: grabbing; -} - -.object-vector-studio-v2__drawing-preview { - opacity: 0.78; - stroke-dasharray: 5 4; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__snap-line { - fill: none; - stroke: var(--object-vector-studio-v2-snap-point-color); - stroke-opacity: 0.88; - stroke-width: 1.35; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__drawing-hint { - position: absolute; - z-index: 6; - max-width: min(260px, calc(100% - 24px)); - border: 1px solid rgba(221, 214, 254, 0.45); - border-radius: 6px; - background: rgba(2, 6, 23, 0.92); - color: var(--tool-starter-text); - font-family: "0xProto Nerd Font", "Cascadia Mono", monospace; - font-size: 12px; - line-height: 1.2; - padding: 4px 7px; - white-space: nowrap; - pointer-events: none; -} - -.object-vector-studio-v2__snap-targets { - pointer-events: none; -} - -.object-vector-studio-v2__snap-target { - fill: none; - stroke: var(--object-vector-studio-v2-snap-point-color); - stroke-width: 1.25; - vector-effect: non-scaling-stroke; -} - -.object-vector-studio-v2__shape:hover, -.object-vector-studio-v2__shape.is-selected { - filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.92)); -} - -.object-vector-studio-v2__selection-box { - fill: none; - stroke: var(--tool-starter-accent); - stroke-dasharray: 5 4; - stroke-width: 0.75; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__selection-drag-area { - cursor: move; - fill: transparent; - pointer-events: all; - stroke: none; -} - -.object-vector-studio-v2__object-bounds { - fill: none; - stroke: #67e8f9; - stroke-dasharray: 8 5; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__onion-skin { - opacity: 0.28; - pointer-events: none; -} - -.object-vector-studio-v2__onion-skin * { - stroke-dasharray: 5 4; - vector-effect: non-scaling-stroke; -} - -.object-vector-studio-v2__resize-handle { - fill: var(--tool-starter-accent); - stroke: #ffffff; - stroke-width: 0.75; - vector-effect: non-scaling-stroke; - cursor: nwse-resize; - pointer-events: auto; -} - -.object-vector-studio-v2__line-endpoint-handle { - cursor: crosshair; -} - -.object-vector-studio-v2__geometry-point-handle:not([data-resize-handle]) { - cursor: crosshair; -} - -.object-vector-studio-v2__center-origin-dot { - fill: var(--tool-starter-accent-strong, #fbbf24); - stroke: #020617; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.object-vector-studio-v2__object-origin line { - stroke: #f8fafc; - stroke-width: 1.2; - pointer-events: none; - vector-effect: non-scaling-stroke; -} - -.object-vector-studio-v2__object-origin line { - stroke: var(--tool-starter-accent-strong, #fbbf24); - stroke-width: 1.5; -} - -.object-vector-studio-v2__object-detail-grid { - display: grid; - grid-template-columns: 104px minmax(0, 1fr); - gap: 8px 10px; -} - -.object-vector-studio-v2__shape-group-summary { - align-items: center; - display: grid; - grid-template-columns: 104px minmax(0, 1fr); - gap: 8px 10px; -} - -.object-vector-studio-v2__shape-group-summary-value { - align-items: center; - display: inline-flex; - gap: 6px; - min-width: 0; -} - -.object-vector-studio-v2__object-detail-stack, -.object-vector-studio-v2__shape-panel, -.object-vector-studio-v2__edit-panel { - display: grid; - gap: 12px; - margin-top: 0; -} - -.object-vector-studio-v2__object-detail-stack { - align-content: start; - gap: 6px; -} - -.object-vector-studio-v2 .object-vector-studio-v2__shape-panel, -.object-vector-studio-v2 .object-vector-studio-v2__edit-panel { - margin-top: 0; -} - -.object-vector-studio-v2__shape-panel { - border-top: 1px solid var(--tool-starter-line); - padding-top: 12px; -} - -.object-vector-studio-v2__shape-panel h3, -.object-vector-studio-v2__shape-panel h4, -.object-vector-studio-v2__shape-panel p { - margin: 0; -} - -.object-vector-studio-v2__edit-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.object-vector-studio-v2__polygon-point-list { - display: grid; - gap: 5px; - max-height: none; - overflow: visible; - padding-right: 0; -} - -.object-vector-studio-v2__edit-panel--polygon { - gap: 5px; -} - -.object-vector-studio-v2__edit-panel--polygon h4 { - margin: 0; -} - -.object-vector-studio-v2__edit-grid--ellipse { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.object-vector-studio-v2__edit-panel--geometry { - gap: 5px; -} - -.object-vector-studio-v2__edit-panel--geometry + .object-vector-studio-v2__shape-panel { - padding-top: 6px; -} - -.object-vector-studio-v2__edit-panel--geometry h4 { - margin: 0; - font-size: 0.86rem; - line-height: 1.1; -} - -.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-grid { - gap: 4px 6px; -} - -.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-field { - grid-template-columns: max-content minmax(0, 1fr); - align-items: center; - gap: 4px; - font-size: 0.78rem; -} - -.object-vector-studio-v2__edit-field--wide { - grid-column: 1 / -1; -} - -.object-vector-studio-v2__edit-field { - min-width: 0; - display: grid; - gap: 4px; - color: var(--tool-starter-muted); - font-size: 0.88rem; - font-weight: 800; - text-align: left; -} - -.object-vector-studio-v2__polygon-point-header, -.object-vector-studio-v2__polygon-point-field { - display: grid; - grid-template-columns: 50px minmax(72px, 1fr) minmax(72px, 1fr) 32px 21px 21px; - align-items: center; - gap: 4px; -} - -.object-vector-studio-v2__polygon-point-header { - color: var(--tool-starter-muted); - font-size: 0.66rem; - font-weight: 900; - line-height: 1; - text-align: center; - text-transform: uppercase; -} - -.object-vector-studio-v2__polygon-point-field[data-polygon-point-selectable="true"] { - cursor: pointer; - border-radius: 6px; -} - -.object-vector-studio-v2__polygon-point-field.is-action-selected { - background: color-mix(in srgb, var(--tool-starter-accent) 16%, transparent); - outline: 1px solid color-mix(in srgb, var(--tool-starter-accent) 48%, transparent); - outline-offset: 2px; -} - -.object-vector-studio-v2__polygon-point-label { - color: var(--tool-starter-muted); - font-size: 0.76rem; - font-weight: 800; -} - -.object-vector-studio-v2__polygon-point-axis { - min-width: 0; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: 3px; - color: var(--tool-starter-muted); - font-size: 0.76rem; - font-weight: 800; -} - -.object-vector-studio-v2__edit-field input { - min-width: 0; - width: 100%; - padding: 6px 8px; -} - -.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-field input { - min-height: 24px; - padding: 3px 5px; -} - -.object-vector-studio-v2__polygon-point-axis input { - appearance: textfield; - -moz-appearance: textfield; - min-width: 0; - width: 100%; - padding: 3px 4px; -} - -.object-vector-studio-v2__polygon-point-axis input::-webkit-inner-spin-button, -.object-vector-studio-v2__polygon-point-axis input::-webkit-outer-spin-button { - margin: 0; - -webkit-appearance: none; -} - -.object-vector-studio-v2__rounding-radius-field { - display: grid; - grid-template-columns: max-content minmax(0, 74px); - align-items: center; - justify-content: start; - gap: 6px; - width: 100%; - color: var(--tool-starter-muted); - font-size: 0.74rem; - font-weight: 800; -} - -.object-vector-studio-v2__rounding-radius-field input { - min-width: 0; - width: 100%; - min-height: 22px; - padding: 2px 5px; -} - -.object-vector-studio-v2__polygon-point-axis--readonly { - grid-template-columns: auto minmax(0, 1fr); -} - -.object-vector-studio-v2__polygon-point-value { - min-height: 24px; - display: inline-flex; - align-items: center; - padding: 3px 5px; - border: 1px solid var(--tool-starter-border); - border-radius: 6px; - background: var(--tool-starter-surface); - color: var(--tool-starter-text); - font-size: 0.78rem; -} - -.object-vector-studio-v2__polygon-point-rounding { - display: inline-grid; - grid-template-columns: 14px; - align-items: center; - justify-content: center; - color: var(--tool-starter-muted); - font-size: 0.7rem; - font-weight: 800; -} - -.object-vector-studio-v2__polygon-point-rounding span { - display: none; -} - -.object-vector-studio-v2__polygon-point-rounding input { - width: 14px; - height: 14px; - margin: 0; - accent-color: var(--tool-starter-accent); -} - -.object-vector-studio-v2__polygon-point-add, -.object-vector-studio-v2__polygon-point-delete { - width: 21px; - height: 22px; - min-height: 22px; - padding: 0 !important; - color: var(--tool-starter-danger); -} - -.object-vector-studio-v2__polygon-point-add { - color: var(--tool-starter-text); -} - -.object-vector-studio-v2__polygon-point-add[data-ovs-icon]::before, -.object-vector-studio-v2__polygon-point-delete[data-ovs-icon]::before { - font-size: 0.92rem; -} - -.object-vector-studio-v2__edit-field--inline { - grid-template-columns: 78px minmax(0, 1fr); - align-items: center; - gap: 8px; -} - -.object-vector-studio-v2__edit-panel--transform { - gap: 6px; -} - -.object-vector-studio-v2__edit-panel--transform h4 { - margin: 0; - font-size: 0.86rem; - line-height: 1.1; -} - -.object-vector-studio-v2__transform-control-row { - display: grid; - grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) max-content max-content; - align-items: center; - gap: 3px; - min-width: 0; - width: 100%; - white-space: nowrap; - --object-vector-studio-v2-icon-scale: 1.05; -} - -.object-vector-studio-v2__transform-control-row--rotate { - grid-template-columns: 50px minmax(42px, 1fr) max-content; -} - -.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled { - grid-template-columns: 50px minmax(54px, 1fr) minmax(54px, max-content) max-content; -} - -.object-vector-studio-v2__transform-control-row--auto-origin { - grid-template-columns: 50px minmax(0, 1fr); -} - -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateInput, -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateSnapSelect, -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateInput, -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateSnapSelect { - grid-column: 2; - grid-row: 1; -} - -.object-vector-studio-v2__transform-control-row--rotate .object-vector-studio-v2__snap-angle-step-field { - grid-column: 3; - grid-row: 1; -} - -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateShapeButton, -.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateButton { - grid-column: 3; - grid-row: 1; -} - -.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled #objectVectorStudioV2RotateShapeButton, -.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled #objectVectorStudioV2ObjectRotateButton { - grid-column: 4; -} - -.object-vector-studio-v2__transform-control-label { - color: var(--tool-starter-text); - font-size: 0.78rem; - font-weight: 800; -} - -.object-vector-studio-v2__transform-axis-field { - min-width: 0; - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - align-items: center; - gap: 3px; - font-size: 0.72rem; - font-weight: 800; -} - -.object-vector-studio-v2__transform-control-row input { - min-width: 0; - width: 100%; - min-height: 22px; - padding: 2px 3px; -} - -.object-vector-studio-v2__transform-control-row select { - min-width: 0; - width: 100%; - min-height: 22px; - padding: 1px 2px; - font-size: 0.72rem; -} - -.object-vector-studio-v2__snap-angle-step-field { - min-width: 0; - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - align-items: center; - gap: 2px; - font-size: 0.7rem; - font-weight: 800; -} - -.object-vector-studio-v2__transform-control-row button { - min-width: 0; - min-height: 22px; - padding: 1px 4px; - font-size: 0.72rem; - line-height: 1; -} - -.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-grid { - gap: 4px 6px; -} - -.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-field--inline { - grid-template-columns: 62px minmax(0, 1fr); - gap: 4px; - font-size: 0.78rem; -} - -.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-field input { - min-height: 24px; - padding: 3px 5px; -} - -.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__shape-actions { - gap: 4px; -} - -.object-vector-studio-v2__scale-control-row { - display: grid; - grid-template-columns: max-content 22px 21px minmax(58px, 64px) 21px 22px minmax(58px, max-content) 22px; - align-items: center; - gap: 2px; - min-width: 0; - width: 100%; - white-space: nowrap; - --object-vector-studio-v2-icon-scale: 1.05; -} - -.object-vector-studio-v2__scale-control-label { - color: var(--tool-starter-text); - font-size: 0.78rem; - font-weight: 800; -} - -.object-vector-studio-v2__scale-control-row button { - min-width: 0; - min-height: 22px; - padding: 1px 2px; - font-size: 0.72rem; - line-height: 1; -} - -.object-vector-studio-v2__scale-control-row .object-vector-studio-v2__scale-resize-button { - gap: 2px; - padding-inline: 4px; -} - -.object-vector-studio-v2__scale-control-row .object-vector-studio-v2__scale-reset-button { - font-weight: 900; -} - -.object-vector-studio-v2__scale-input { - appearance: textfield; - -moz-appearance: textfield; - min-width: 0; - width: 100%; - min-height: 22px; - padding: 2px 3px; - text-align: center; -} - -.object-vector-studio-v2__scale-input::-webkit-inner-spin-button, -.object-vector-studio-v2__scale-input::-webkit-outer-spin-button { - margin: 0; - -webkit-appearance: none; -} - -.object-vector-studio-v2__transform-summary { - margin: 0; - color: var(--tool-starter-text); - font-size: 0.86rem; - font-weight: 800; - line-height: 1.2; - text-align: center; - width: 100%; -} - -.object-vector-studio-v2__object-detail-stack.is-disabled, -.object-vector-studio-v2__shape-panel.is-disabled { - opacity: 0.58; -} - -.object-vector-studio-v2__object-detail-stack.is-disabled input, -.object-vector-studio-v2__object-detail-stack.is-disabled select, -.object-vector-studio-v2__object-detail-stack.is-disabled button, -.object-vector-studio-v2__shape-panel.is-disabled input, -.object-vector-studio-v2__shape-panel.is-disabled select, -.object-vector-studio-v2__shape-panel.is-disabled button { - cursor: not-allowed; -} - -.object-vector-studio-v2__edit-field input.is-invalid, -.object-vector-studio-v2__transform-control-row input.is-invalid, -.object-vector-studio-v2__scale-input.is-invalid, -.object-vector-studio-v2__polygon-point-axis input.is-invalid { - border-color: var(--tool-starter-danger); - box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.22); -} - -.object-vector-studio-v2 [data-validation-state="invalid"] { - border-color: var(--tool-starter-danger) !important; - box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.22); -} - -.object-vector-studio-v2__shape-actions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; -} - -.object-vector-studio-v2__details-actions { - width: min(720px, 100%); - min-height: 32px; - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 8px; - color: var(--tool-starter-muted); -} - -.object-vector-studio-v2__preview-footer { - width: min(720px, 100%); - color: var(--tool-starter-muted); - font-size: 0.88rem; - overflow-wrap: anywhere; -} - -.object-vector-studio-v2__preview-edit-toolbar { - width: min(720px, 100%); - justify-content: center; -} - -.object-vector-studio-v2__preview-edit-toolbar button { - min-width: 72px; -} - -.object-vector-studio-v2__tag-editor, -.object-vector-studio-v2__tag-list { - display: grid; - gap: 8px; -} - -.object-vector-studio-v2__tag-editor { - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 5px; -} - -.object-vector-studio-v2__tag-editor input { - width: 100%; -} - -#objectVectorStudioV2AddTagButton { - width: 77px; - min-height: 28px; - font-size: 0.78rem; - line-height: 1; - padding: 3px 7px !important; -} - -.object-vector-studio-v2__tag-list { - grid-column: 1 / -1; - grid-template-columns: repeat(auto-fill, 77px); - justify-content: start; -} - -.object-vector-studio-v2__tag-list [data-object-tag] { - width: 77px; - max-width: 77px; - min-width: 77px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.object-vector-studio-v2__detail-label { - color: var(--tool-starter-muted); - font-weight: 800; -} - -.object-vector-studio-v2__detail-value { - min-width: 0; - overflow-wrap: anywhere; -} - -.tool-starter__preview { - min-height: 240px; - flex: 1 1 auto; - display: grid; - place-items: center; - border: 1px dashed var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel-strong); - color: var(--tool-starter-muted); - padding: 16px; - text-align: center; -} - -.tool-starter__preview-card { - width: min(420px, 100%); - border: 1px solid var(--tool-starter-line); - border-radius: 8px; - background: var(--tool-starter-panel-strong); - padding: 18px; - text-align: left; -} - -.tool-starter__preview-card h2, -.tool-starter__preview-card p { - margin: 0; -} - -.tool-starter__preview-card p { - margin-top: 8px; - color: var(--tool-starter-muted); - overflow-wrap: anywhere; -} - -.tool-starter__output, -#statusLog { - width: 100%; - min-height: 0; - height: 100%; - margin: 0; - border: 1px solid var(--tool-starter-line); - border-radius: 8px !important; - background: var(--tool-starter-panel-strong); - color: var(--tool-starter-text); - overflow: auto; - padding: 12px; - white-space: pre-wrap; -} - -#statusLog { - flex: 1 1 auto; - resize: none; -} - -@media (max-width: 900px) { - .tool-starter.app-shell { - height: auto; - min-height: calc(100vh - 137px); - grid-template-columns: 1fr; - overflow: visible; - } - - .tool-starter__header { - min-width: 0; - } -} - -html.tools-platform-fullscreen-active, -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"], -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] { - height: 100vh; - max-height: 100vh; - min-height: 100vh; - overflow-x: hidden; - overflow-y: auto; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"], -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] { - display: flex; - flex-direction: column; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .is-collapsible, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .is-collapsible { - flex: 0 0 auto; - width: 100%; - margin: 0; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .is-collapsible > .is-collapsible__summary[data-tools-platform-summary-active="1"], -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .is-collapsible > .is-collapsible__summary[data-tools-platform-summary-active="1"] { - width: calc(100vw - 24px); - max-width: calc(100vw - 24px); - margin: 8px auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .tool-starter__menu, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .tool-starter__menu { - flex: 0 0 auto; - margin: 8px auto; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .tool-starter.app-shell, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .tool-starter.app-shell { - flex: 1 1 0; - grid-template-columns: 340px minmax(0, 1fr) 360px; - align-items: stretch; - gap: 16px; - height: 0; - width: 100%; - max-width: none; - min-height: 0; - margin: 0; - padding: 8px 12px 12px; - overflow: hidden; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel { - max-height: 100%; - min-height: 0; - overflow: hidden; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--left, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--left { - justify-self: start; - width: 340px; - max-width: 340px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--right, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--right { - justify-self: end; - width: 360px; - max-width: 360px; - overflow-x: hidden; - overflow-y: auto; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--center, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--center { - align-self: stretch; - height: 100%; - max-height: none; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__render-surface, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__render-surface { - width: min(100%, calc(34vh * 16 / 11)); - justify-self: center; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__work-area, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__work-area { - gap: 6px; - padding: 8px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__animation-controls, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__animation-controls { - gap: 6px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-timeline, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-timeline { - min-height: 48px; - padding: 6px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-tile, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-tile { - min-width: 96px; - gap: 2px; - padding: 4px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-thumbnail, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-thumbnail { - width: 72px; - height: 34px; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2.is-open, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2.is-open { - flex: 1 1 0; - min-height: 0; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2:not(.is-open), -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2:not(.is-open) { - flex: 0 0 auto; - max-height: 48px; - min-height: 0; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2__content, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2__content { - min-height: 0; - overflow: auto; -} - -body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__accordion--compact.is-open, -html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__accordion--compact.is-open { - flex: 0 0 auto; -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md deleted file mode 100644 index dae3f2aac..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Vector Native Arcade Template - -This template is the reusable vector-native starting point for future arcade-style games. - -## Baseline - -- first-class vector assets are the required visual contract -- strict validation, packaging, runtime, CI, export, and publishing boundaries remain active -- sprite fallback is not part of the runtime baseline -- runtime bootstrap remains compatible with `games/Asteroids/main.js#bootAsteroids` as a starter handoff seam - -## Structure - -- `assets/` contains palette, vector, tilemap, and parallax starter content -- `config/` contains starter project configuration -- `runtime/` contains starter runtime bootstrap metadata -- `docs/` contains starter guidance and rollback notes diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json deleted file mode 100644 index 61897ec6b..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "../../../../../schemas/palette.schema.json", - "schema": "html-js-gaming.palette", - "version": 1, - "name": "Vector Native Primary Palette", - "source": "custom", - "swatches": [ - { - "symbol": "!", - "hex": "#05070D", - "name": "Swatch 1" - }, - { - "symbol": "#", - "hex": "#E8F0FF", - "name": "Swatch 2" - }, - { - "symbol": "$", - "hex": "#6FD3FF", - "name": "Swatch 3" - }, - { - "symbol": "%", - "hex": "#FFB347", - "name": "Swatch 4" - } - ] -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json deleted file mode 100644 index 330bee626..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Template Backdrop Layer", - "role": "backdrop", - "imageId": "image.template.backdrop" -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg deleted file mode 100644 index 77a8feb76..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json deleted file mode 100644 index 2135cd960..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Vector Native Arcade Arena", - "states": ["title", "start", "gameplay", "restart"], - "extensionPoints": ["spawn-patterns", "score-rules", "victory-conditions"] -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json deleted file mode 100644 index 917f694b3..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Template UI Tileset", - "tileWidth": 8, - "tileHeight": 8, - "tiles": ["score-panel", "wave-panel", "status-panel"] -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json deleted file mode 100644 index 5b82aa292..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "vector.template.ui.hud", - "type": "vector", - "source": { - "kind": "svg", - "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json" - }, - "geometry": { - "viewBox": "0 0 160 32", - "paths": [ - { - "d": "M 4 4 L 156 4 L 156 28 L 4 28 Z" - }, - { - "d": "M 18 10 L 28 10 L 28 22 L 18 22 Z" - } - ] - }, - "style": { - "stroke": true, - "fill": false - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json deleted file mode 100644 index 2ef6a9f51..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "vector.template.obstacle.large", - "type": "vector", - "source": { - "kind": "svg", - "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json" - }, - "geometry": { - "viewBox": "-34 -34 68 68", - "paths": [ - { - "d": "M -20 -14 L 0 -24 L 22 -18 L 28 6 L 14 24 L -10 22 L -26 4 Z" - } - ] - }, - "style": { - "stroke": true, - "fill": false - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json deleted file mode 100644 index feaf4fe2c..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "vector.template.obstacle.small", - "type": "vector", - "source": { - "kind": "svg", - "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json" - }, - "geometry": { - "viewBox": "-18 -18 36 36", - "paths": [ - { - "d": "M -8 -8 L 2 -12 L 10 -2 L 6 10 L -6 12 L -12 0 Z" - } - ] - }, - "style": { - "stroke": true, - "fill": false - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json deleted file mode 100644 index 572784f53..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "vector.template.player", - "type": "vector", - "source": { - "kind": "svg", - "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json" - }, - "geometry": { - "viewBox": "-20 -20 40 40", - "paths": [ - { - "d": "M 0 -16 L 12 12 L 0 6 L -12 12 Z" - }, - { - "d": "M -4 10 L 0 4 L 4 10" - } - ] - }, - "style": { - "stroke": true, - "fill": false - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json deleted file mode 100644 index 16bb56320..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "vector.template.ui.title", - "type": "vector", - "source": { - "kind": "svg", - "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json" - }, - "geometry": { - "viewBox": "0 0 220 48", - "paths": [ - { - "d": "M 8 40 L 24 8 L 40 40 Z" - }, - { - "d": "M 56 40 L 56 8 L 84 8 L 84 18 L 68 18 L 68 22 L 82 22 L 82 32 L 68 32 L 68 40 Z" - } - ] - }, - "style": { - "stroke": true, - "fill": false - } -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json deleted file mode 100644 index a451abbdd..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "templateId": "vector-native-arcade", - "schema": "toolbox.template/1", - "visualContract": "vector-only", - "runtimeEntry": "games/Asteroids/main.js#bootAsteroids" -} diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md deleted file mode 100644 index c8028670d..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md +++ /dev/null @@ -1,7 +0,0 @@ -# Rollback Notes - -Rollback guidance is documentation only. - -- The vector-native template baseline does not package sprite fallback assets. -- If a project needs a temporary rollback during local experimentation, document that outside the active template runtime contract. -- Do not reintroduce sprite fallback as an active packaged dependency for the template baseline. diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md deleted file mode 100644 index 02a428d54..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md +++ /dev/null @@ -1,10 +0,0 @@ -# Starter Guide - -Use this template when a new game should begin with vector-first visuals and strict platform boundaries already in place. - -## Starter flow - -1. Replace the starter vectors under `assets/vectors/` with game-specific shapes. -2. Keep the registry IDs stable or update them consistently in the config/runtime files. -3. Validate, package, export, and publish through the accepted shared platform systems. -4. Extend gameplay logic from the runtime handoff seam without changing engine core APIs. diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json deleted file mode 100644 index 0348549ef..000000000 --- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "modulePath": "games/Asteroids/main.js", - "exportName": "bootAsteroids", - "canvasId": "game", - "requiredVectorIds": ["vector.template.player", "vector.template.obstacle.large", "vector.template.obstacle.small", "vector.template.ui.title", "vector.template.ui.hud"] -} diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md index 1e65998c6..d9f4bf6ed 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md @@ -117,7 +117,7 @@ Rules: ## Current Version/Date -- Project Instructions Version: 2026.06.28.020 +- Project Instructions Version: 2026.06.28.021 - Date: 2026-06-28 - Owner: OWNER diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md index f809be6a3..e4f81134e 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md @@ -1,6 +1,6 @@ # Project Instructions Version -Current Project Instructions Version: 2026.06.28.020 +Current Project Instructions Version: 2026.06.28.021 Last Updated: 2026-06-28 @@ -28,6 +28,7 @@ Last Updated: 2026-06-28 - Moved PR017-audited browser/www-owned legacy `src/` files into `www/src/` while preserving public `/src/...` browser route compatibility under the `www` web root. - Moved PR019a-audited active browser/runtime shared contracts and tool schemas into `www/src/shared/` while preserving public `/src/shared/...` browser route compatibility. - Removed obsolete root `src/` legacy placeholders, stale schema README files, and archived-only debug helpers while preserving active contracts and validation schemas. +- Removed inactive manifest-era `www/src/tools/common` helpers and archived old Object Vector/Parallax tool samples while preserving currently referenced archived games/samples. - Codex must read this version file and the latest repository copy of `PROJECT_INSTRUCTIONS.md` before performing work. - Codex must discard previously remembered Project Instructions and treat the repository copy as authoritative. - Codex must validate canonical report and ZIP paths, branching model, and legacy path avoidance before work proceeds. diff --git a/dev/build/ProjectInstructions/PROJECT_STATE.md b/dev/build/ProjectInstructions/PROJECT_STATE.md index 8bd72875d..87738d6f2 100644 --- a/dev/build/ProjectInstructions/PROJECT_STATE.md +++ b/dev/build/ProjectInstructions/PROJECT_STATE.md @@ -1,7 +1,7 @@ # Project State ```yaml -project_state_version: "2026.06.28.020" +project_state_version: "2026.06.28.021" last_updated: "2026-06-28" current_main_commit: "40de767476d70cadfd1292c916844c2f31b6f185" repository_status: @@ -10,9 +10,9 @@ repository_status: runtime_database: "Postgres" creator_metadata: "API to Postgres" creator_assets: "API to R2" -project_instructions_version: "2026.06.28.020" -repository_structure_version: "2026.06.28.020" -canonical_layout_version: "2026.06.28.020" +project_instructions_version: "2026.06.28.021" +repository_structure_version: "2026.06.28.021" +canonical_layout_version: "2026.06.28.021" active_teams: - "Owner" - "Alfa" @@ -20,8 +20,8 @@ active_teams: - "Charlie" - "Delta" - "Golf" -latest_owner_pr: "PR_26180_OWNER_020-src-legacy-teardown" -latest_structure_pr: "PR_26180_OWNER_020-src-legacy-teardown" +latest_owner_pr: "PR_26180_OWNER_021-archive-legacy-games-samples-teardown" +latest_structure_pr: "PR_26180_OWNER_021-archive-legacy-games-samples-teardown" valid_top_level_folders: - "api/" - "deploy/" @@ -42,5 +42,6 @@ valid_dev_folders: known_technical_debt: - "Remaining API/server-owned contracts, validation-only schemas, and project data store modules remain under src/shared/ until scoped follow-up PRs move them to api/ or dev/." - "Remaining actively validated contracts, validation schemas, and project data store modules remain under src/shared/ until scoped follow-up PRs move them to api/ or dev/." + - "Archived games and samples under dev/archive/v1-v2 remain until active runtime/test/script references are migrated away." - "repository_directory_standard.md and codex_project_instructions_startup.md are superseded pointers retained for compatibility." ``` diff --git a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md index cfbad05b6..15d68a7da 100644 --- a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md +++ b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md @@ -40,7 +40,7 @@ The backlog is the authoritative source for determining the next logical PRs. - Product Area: Repository Architecture Simplification - Status: Active - Percent Complete: 99% -- Active PR: PR_26180_OWNER_020-src-legacy-teardown +- Active PR: PR_26180_OWNER_021-archive-legacy-games-samples-teardown - Next Milestone: Move remaining API/server-owned source to api/ - Source / Reference: `dev/build/ProjectInstructions/repository/repository_layout_architecture_plan.md`; `dev/build/ProjectInstructions/repository/www_migration_map.md` diff --git a/dev/build/ProjectInstructions/repository/canonical_repository_structure.md b/dev/build/ProjectInstructions/repository/canonical_repository_structure.md index d60ba6140..43cccd10f 100644 --- a/dev/build/ProjectInstructions/repository/canonical_repository_structure.md +++ b/dev/build/ProjectInstructions/repository/canonical_repository_structure.md @@ -85,7 +85,6 @@ Legacy transition buckets: - www/src/shared/ - www/src/shared/contracts/ - www/src/shared/schemas/ -- www/src/tools/ - www/src/dev-runtime/admin/ - src/shared/contracts/ - src/shared/schemas/ diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md new file mode 100644 index 000000000..439a027f3 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md @@ -0,0 +1,13 @@ +# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Branch Validation + +| Check | Status | Notes | +|-------|--------|-------| +| Current branch | PASS | `PR_26180_OWNER_021-archive-legacy-games-samples-teardown` | +| Stack base | PASS | Created from `PR_26180_OWNER_020-src-legacy-teardown`. | +| Worktree scope | PASS | Deletes limited to inactive manifest-era helpers and archived old tool/sample folders, plus governance/report updates. | +| Runtime/API/UI/database changes | PASS | No active implementation behavior changed. | +| Protected generated workspace | PASS | `dev/workspace/generated/tool-images/**` untouched. | + +## Branch Decision + +PASS. Branch is valid for the stacked Owner Repository Architecture Simplification workstream. diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md new file mode 100644 index 000000000..0eb228746 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md @@ -0,0 +1,19 @@ +# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Manual Validation Notes + +## Manual Review + +- Confirmed current branch is `PR_26180_OWNER_021-archive-legacy-games-samples-teardown`. +- Confirmed `www/src/tools/common` had no active imports beyond shared-extraction baseline entries. +- Confirmed archived `old_object-vector-studio-v2` and `old_Parallax Scene Studio` had no active runtime/test/script references outside archive/report/history. +- Confirmed active archive game/sample paths remain referenced by current runtime/test/dev tooling and were not deleted. +- Confirmed `dev/workspace/generated/tool-images/**` was not touched. + +## Product Impact + +No product behavior, public URL behavior, API behavior, database behavior, or active UI behavior was changed. + +## ZIP + +Repo-structured ZIP path: + +`dev/workspace/zips/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_delta.zip` diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md new file mode 100644 index 000000000..36dcb6f94 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md @@ -0,0 +1,40 @@ +# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Report + +## Executive Summary + +PR021 removes legacy manifest-era and archived tool sample surfaces that have no active `www/`, `api/`, `dev/scripts`, `dev/tests`, `dev/tools`, package, or CI references. + +Deleted groups: + +- `www/src/tools/common/` (2 files): inactive manifest-era browser helpers. +- `dev/archive/v1-v2/tools/old_object-vector-studio-v2/` (35 files): archived old Object Vector tool implementation superseded by current Object Vector runtime/schema/tests under `www/` and `dev/tests/`. +- `dev/archive/v1-v2/tools/old_Parallax Scene Studio/` (22 files): archived old Parallax tool/sample folder with no active runtime/test/script references. + +Preserved groups: + +- `dev/archive/v1-v2/games/`: preserved because current `www/`, `dev/scripts`, and `dev/tests` still reference archived game paths. +- `dev/archive/v1-v2/samples/`: preserved because current dev tooling and validation baselines still reference archived sample paths. +- `dev/archive/v1-v2/tools/SpriteEditor_old_keep/`: preserved because governance/reference docs still mark it as keep-policy material. +- `dev/workspace/generated/tool-images/**`: untouched. + +## Evidence Review + +| Candidate | Status | Decision | +|-----------|--------|----------| +| `old_games/**` | No exact tracked `old_games` folder found. | No deletion required. | +| `old_samples/**` | Only exact tracked `old_samples` match is under `SpriteEditor_old_keep`. | Preserved due explicit keep-policy references. | +| `dev/archive/v1-v2/games/**` | Active references remain in `www/src/shared/toolbox/*`, dev scripts, and dev tests. | Preserved; deletion blocked until references migrate. | +| `dev/archive/v1-v2/samples/**` | Active references remain in dev tools, dev scripts, dev tests, and validation baselines. | Preserved; deletion blocked until references migrate. | +| `www/src/tools/**` | `www/src/tools/common` had no active imports beyond extraction baseline entries. | Deleted. | +| Old Object Vector archive | No active runtime/test/script references outside archive/report/history. | Deleted. | +| Old Parallax archive samples | No active runtime/test/script references outside archive/report/history. | Deleted. | + +## Runtime Boundary + +No active `www/`, `api/`, `dev/scripts`, `dev/tests`, package, or GitHub workflow reference remains to the deleted PR021 paths. + +The PR does not change product behavior, API behavior, database behavior, or active UI behavior. + +## Follow-Up + +Future PRs must migrate active archive game/sample references before deleting `dev/archive/v1-v2/games/**` or `dev/archive/v1-v2/samples/**`. diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md new file mode 100644 index 000000000..bf1ac59d1 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md @@ -0,0 +1,17 @@ +# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Requirement Checklist + +| Requirement | Status | Evidence | +|-------------|--------|----------| +| Base from PR020 | PASS | Branch created from `PR_26180_OWNER_020-src-legacy-teardown`. | +| Delete archived `old_games/**` if only archive/legacy | PASS | No exact tracked `old_games` path exists. Active archive games were preserved due current references. | +| Delete archived `old_samples/**` if only archive/legacy | PASS WITH PRESERVATION | Only exact `old_samples` file is under `SpriteEditor_old_keep`, which has active keep-policy governance references. Old Parallax sample archive was deleted. | +| Delete/archive old manifest-era code | PASS | Removed inactive `www/src/tools/common` manifest-era helpers. | +| Remove references to non-existent `games/**` | PASS | No deleted active game path references introduced; active archive-game references remain intentionally preserved. | +| Remove references to `samples/**` unless active use is proven | PASS | Active sample references remain only where current validation/dev tools prove use. | +| Audit `www/src/tools/**` | PASS | Deleted inactive `www/src/tools/common`; `www/src/tools` no longer exists in working tree. | +| Audit old Object Vector files | PASS | Deleted archived `old_object-vector-studio-v2`; current Object Vector runtime/schema/test paths remain. | +| Preserve active `www/`, `api/`, `dev/tests`, Project Instructions behavior | PASS | No active references remain to deleted PR021 paths. | +| Do not touch `dev/workspace/generated/tool-images/**` | PASS | Protected generated tool-image workspace untouched. | +| Hard stop before deleting files used by active runtime/API/validation/tests | PASS | Active-reference scan passed before deletion scope was finalized. | +| Required reports under `dev/reports/` | PASS | PR-specific report, checklist, validation report, manual notes, branch validation, diff, and changed-file report generated. | +| Required ZIP under `dev/workspace/zips/` | PASS | Repo-structured delta ZIP generated. | diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md new file mode 100644 index 000000000..3c6ca8111 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md @@ -0,0 +1,34 @@ +# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Validation Report + +## Required Validation + +| Command | Status | Notes | +|---------|--------|-------| +| `git diff --check` | PASS | No whitespace errors. | +| `npm run validate:canonical-structure` | PASS | Canonical repository structure guardrail passed with 0 blocking violations. | +| `node dev/tools/toolbox-dev/checkSharedExtractionGuard.mjs` | PASS | Baseline updated for deleted `www/src/tools/common` helper entries. | +| Deleted path active-reference scan | PASS | No active runtime/script/test/package/CI references to deleted PR021 paths. | +| `node ./dev/scripts/run-node-test-files.mjs dev/tests/tools/ToolManifestBoundary.test.mjs` | PASS | Tool manifest boundary test passed. | + +## Exploratory Broader Test Notes + +The following broader probes were run and failed for pre-existing stack issues outside PR021 deletion scope: + +- `dev/tests/shared/GetStateVariantClassification.test.mjs`: existing classifier returns `runtime` for `src/shared/toolbox/test.js`; test expects `tool`. +- `dev/tests/core/BackgroundImageAndFullscreenBezel.test.mjs`: existing fullscreen target assertion mismatch unrelated to deleted files. +- Object Vector broader test lane: existing `www/toolbox/toolRegistry.js` Node import resolves `../../src/shared/toolbox/tool-metadata-inventory.js`, while the file exists under `www/src/shared/toolbox/tool-metadata-inventory.js`. + +No deleted PR021 path appears in those failures. + +## Active Reference Scan + +Deleted path scan covered: + +- `old_Parallax Scene Studio` +- `old_object-vector-studio-v2` +- `www/src/tools/common` +- `GameManifestLoader` +- `WorkspaceDirtyNotifier` +- `notifyWorkspaceToolDirty` + +Result: PASS outside allowed Project Instructions/report references. diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt index de7027dc2..843a15fdf 100644 --- a/dev/reports/codex_changed_files.txt +++ b/dev/reports/codex_changed_files.txt @@ -1,20 +1,72 @@ +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json +D dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/OBJECT_VECTOR_STUDIO_V2_REQUIREMENTS.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/how_to_use.html +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md +D dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json M dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md M dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md M dev/build/ProjectInstructions/PROJECT_STATE.md M dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md M dev/build/ProjectInstructions/repository/canonical_repository_structure.md -A dev/reports/PR_26180_OWNER_020-src-legacy-teardown_branch-validation.md -A dev/reports/PR_26180_OWNER_020-src-legacy-teardown_manual-validation-notes.md -A dev/reports/PR_26180_OWNER_020-src-legacy-teardown_report.md -A dev/reports/PR_26180_OWNER_020-src-legacy-teardown_requirement-checklist.md -A dev/reports/PR_26180_OWNER_020-src-legacy-teardown_validation-report.md +A dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md +A dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md +A dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md +A dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md +A dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md M dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json -D src/dev-runtime/admin/.gitkeep -D src/engine/README.md -D src/shared/debug/config.js -D src/shared/debug/network.js -D src/shared/debug/noopDevConsoleIntegration.js -D src/shared/schemas/README.md -D src/shared/schemas/tools/README.md +D www/src/tools/common/GameManifestLoader.js +D www/src/tools/common/WorkspaceDirtyNotifier.js M dev/reports/codex_review.diff M dev/reports/codex_changed_files.txt diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index de1ffbfae..a7784c727 100644 --- a/dev/reports/codex_review.diff +++ b/dev/reports/codex_review.diff @@ -1,46 +1,17473 @@ +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md +deleted file mode 100644 +index 69d45af59..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/README.md ++++ /dev/null +@@ -1,22 +0,0 @@ +-Toolbox Aid +-David Quesenberry +-04/21/2026 +-README.md +- +-# Parallax Scene Studio +- +-High-level overview for 'Parallax Scene Studio' under 'tools/Parallax Scene Studio/'. +- +-## Purpose +-- Provide a focused workspace for this tool's primary authoring/debug workflow. +-- Keep output deterministic and compatible with shared Toolbox Aid contracts. +-- Integrate cleanly with shared header, workspace lifecycle, and tool navigation. +- +-## What To Expect +-- Browser-based tool interface with panel/workspace controls. +-- Shared header context and status integration. +-- Safe workflows for loading, editing, previewing, and exporting relevant data. +- +-## Entry Points +-- Tool UI: 'tools/Parallax Scene Studio/index.html' +-- How-to guide: 'tools/Parallax Scene Studio/how_to_use.html' +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html +deleted file mode 100644 +index 1e6f31a85..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/how_to_use.html ++++ /dev/null +@@ -1,91 +0,0 @@ +- +- +- +- +- +- Parallax Scene Studio - How To Use +- +- +- +- +- +-
+-
+-
+-

Parallax Scene Studio: How To Use

+-

Detailed workflow with practical steps and control guidance.

+-
+- +-
+-

Details

+-
+-

Parallax composition studio for layered scene depth, sample loading, simulation, and export flows.

+-
+-
+- +-
+-

Tool vs Workspace Saves

+-
+-

Tool save/export
Action: Use tool save/export controls for JSON, PNG, sheets, runtime payloads, and tool-local artifacts.
Why this step matters: Creates the actual outputs consumed by samples/games/pipeline steps.

+-

Workspace save
Action: Use Workspace Manager save controls to persist cross-tool session state.
Why this step matters: Restores coordinated context (selected tool state, shared refs) but does not replace explicit artifact exports.

+-
+-
+-
+-

Workflow

+-
+-
+-

Tool Mode

+-

1. Compose parallax scenes
Action: Launch this tool from tools/<tool>/index.html (not through Workspace Manager).
Why this step matters: Uses this tool as a focused single-tool surface.

+-

2. Build/edit in the tool
Action: Arrange parallax layers, tune depth/motion, and preview scene behavior.
Why this step matters: Creates layered scene composition data for runtime playback.

+-

3. Save/export deliverables
Action: Save scene data and export assets/config used in target experiences.
Why this step matters: Preserves visual composition and reproducible setup.

+-

4. Continue or close
Action: Open another tool directly as needed.
Why this step matters: Tool mode is best for isolated tasks and rapid single-tool iteration.

+-
+- +-
+-

Workspace Mode

+-

1. Create a named workspace
Action: Click New Workspace and enter a meaningful name (example: UAT 001 Workspace).
Why this step matters: Starts from a clear baseline and makes saved snapshots easy to identify.

+-

2. Open tools from Workspace Manager tiles
Action: Select needed tool tiles and perform each tool's primary task.
Why this step matters: Keeps multi-tool work coordinated under one shared context.

+-

3. Save both levels intentionally
Action: Use tool-level save/export for artifacts, then click Save Workspace for session state.
Why this step matters: Preserves both deliverable files and cross-tool orchestration state.

+-

4. Checkpoint and resume
Action: Use Save Workspace As for milestones and Open Workspace to resume later.
Why this step matters: Supports versioned progress and reliable continuation.

+-
+-
+-
+- +-
+-

Buttons

+-
+-

Add Layer #addLayerButton
Details: Triggers a tool action that updates your current workflow state.

+-

Apply Layer Settings #applyLayerSettingsButton
Details: Triggers a tool action that updates your current workflow state.

+-

Apply Map Meta #applyMapMetaButton
Details: Triggers a tool action that updates your current workflow state.

+-

Apply Source #applyImageSourceButton
Details: Triggers a tool action that updates your current workflow state.

+-

Apply Suggested Fix #applyRemediationButton
Details: Triggers a tool action that updates your current workflow state.

+-

Choose File (layerImageFileInput) #layerImageFileInput
Details: Triggers a tool action that updates your current workflow state.

+-

Choose File (loadAssetRegistryInput) #loadAssetRegistryInput
Details: Loads saved data so you can continue existing work.

+-

Choose File (loadProjectInput) #loadProjectInput
Details: Loads saved data so you can continue existing work.

+-

Duplicate #duplicateLayerButton
Details: Clones current content to speed iterative changes.

+-

Exit Simulation #exitSimulationButton
Details: Triggers a tool action that updates your current workflow state.

+-

Export Tilemap Patch #exportParallaxPatchButton
Details: Writes deliverable output for downstream use.

+-

Inspect Issues #inspectRemediationButton
Details: Checks state/output so issues are caught before final save/export.

+-

Jump to Problem #jumpToProblemButton
Details: Triggers a tool action that updates your current workflow state.

+-

Load Assets Registry #loadAssetRegistryButton
Details: Loads saved data so you can continue existing work.

+-

Load Project #loadProjectButton
Details: Loads saved data so you can continue existing work.

+-

Load Sample #loadSampleButton
Details: Loads saved data so you can continue existing work.

+-

Move Down #moveLayerDownButton
Details: Triggers a tool action that updates your current workflow state.

+-

Move Up #moveLayerUpButton
Details: Triggers a tool action that updates your current workflow state.

+-

New Project #newProjectButton
Details: Starts a fresh working baseline for the next task.

+-

Package Project #packageProjectButton
Details: Writes deliverable output for downstream use.

+-

Pause #pauseSimulationButton
Details: Triggers a tool action that updates your current workflow state.

+-

Play #playSimulationButton
Details: Triggers a tool action that updates your current workflow state.

+-

Refresh Debug View #refreshDebugVisualizationButton
Details: Checks state/output so issues are caught before final save/export.

+-

Refresh Pipeline View #refreshExperienceButton
Details: Checks state/output so issues are caught before final save/export.

+-

Remove Layer #removeLayerButton
Details: Removes current selection or closes the active context.

+-

Restart Position #restartSimulationButton
Details: Triggers a tool action that updates your current workflow state.

+-

Save Assets Registry #saveAssetRegistryButton
Details: Persists current state to prevent data loss and preserve progress.

+-

Save Project #saveProjectButton
Details: Persists current state to prevent data loss and preserve progress.

+-

Simulate #simulateButton
Details: Triggers a tool action that updates your current workflow state.

+-
+-
+-
+- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html +deleted file mode 100644 +index deca47a3f..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/index.html ++++ /dev/null +@@ -1,280 +0,0 @@ +- +- +- +- +- +- +- Tools Platform +- +- +- +- +- +- +-
+- +-
+-
+-
+-
+-
+-
+-
+-
+- +- +- +- +- +- +- +- +- +- +- +- +- +- +-
+- +-
+- +- +- +- +- +-
+- +-
+- +-
+- +- +-
+-
+- Parallax Preview +- +- +-
+-
+- +-
+- +- +-
+-
+- +- +- +- +- +-
+-
+-
Mode: EDIT
+-
Ready.
+-
+- +- +-
+-
+- +-
+- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js +deleted file mode 100644 +index c505107f3..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/main.js ++++ /dev/null +@@ -1,2082 +0,0 @@ +-/* +-Toolbox Aid +-David Quesenberry +-03/30/2026 +-main.js +-*/ +-import { +- buildAssetDependencyGraph, +- createAssetId, +- createAssetRegistry, +- createRegistryDownloadPayload, +- findRegistryEntryById, +- mergeAssetRegistries, +- normalizeProjectRelativePath, +- sanitizeAssetRegistry, +- upsertRegistryEntry +-} from "../shared/projectAssetRegistry.js"; +-import { downloadTextFile, readFileText } from '../../src/engine/persistence/FilePersistenceService.js'; +-import { registerAssetPipelineCandidate } from "../shared/assetPipelineFoundation.js"; +-import { +- getBlockingAssetValidationMessage, +- hasBlockingAssetValidationFindings, +- summarizeAssetValidation, +- validateProjectAssetState +-} from "../shared/projectAssetValidation.js"; +-import { +- buildProjectAssetRemediation, +- getPrimaryRemediationAction, +- summarizeProjectAssetRemediation +-} from "../shared/projectAssetRemediation.js"; +-import { buildProjectPackage, summarizeProjectPackaging } from "../shared/projectPackaging.js"; +-import { buildEditorExperienceLayer, summarizeEditorExperienceLayer } from "../shared/editorExperienceLayer.js"; +-import { buildDebugVisualizationLayer, summarizeDebugVisualizationLayer } from "../shared/debugVisualizationLayer.js"; +-import { registerToolBootContract } from "../shared/toolBootContract.js"; +-import { +- getToolLoadQuerySnapshot, +- getToolLoadRequestedDataPaths, +- summarizeToolLoadData, +- logToolLoadRequest, +- logToolLoadFetch, +- logToolLoadLoaded, +- logToolLoadWarning +-} from "../shared/toolLoadDiagnostics.js"; +-import { createLivePreviewSyncBridge, validateStateBindingPayload } from "../shared/livePreviewSyncChannel.js"; +-import { addToolModeMetadata, assertStandaloneToolDocument, offerImportMismatchOptions } from "../shared/documentModeGuards.js"; +- +-function normalizeSamplePresetPath(pathValue) { +- if (typeof pathValue !== "string") { +- return ""; +- } +- const trimmed = pathValue.trim().replace(/\\/g, "/"); +- if (!trimmed || trimmed.includes("..")) { +- return ""; +- } +- if (trimmed.startsWith("/samples/")) { +- return trimmed; +- } +- if (trimmed.startsWith("./samples/")) { +- return trimmed; +- } +- if (trimmed.startsWith("samples/")) { +- return `./${trimmed}`; +- } +- return ""; +-} +- +-function buildPresetLoadedStatus(sampleId, samplePresetPath) { +- const normalizedSampleId = typeof sampleId === "string" ? sampleId.trim() : ""; +- if (normalizedSampleId) { +- return `Loaded preset from sample ${normalizedSampleId}.`; +- } +- const normalizedPath = typeof samplePresetPath === "string" ? samplePresetPath.trim() : ""; +- return normalizedPath ? `Loaded preset from ${normalizedPath}.` : "Loaded preset."; +-} +- +-function clamp(value, min, max, fallback) { +- const number = Number(value); +- if (!Number.isFinite(number)) { +- return fallback; +- } +- return Math.max(min, Math.min(max, number)); +-} +- +-function cloneDeep(value) { +- if (typeof structuredClone === "function") { +- return structuredClone(value); +- } +- return JSON.parse(JSON.stringify(value)); +-} +- +-function normalizeMapMeta(rawMap) { +- const width = clamp(rawMap?.width, 4, 1024, 32); +- const height = clamp(rawMap?.height, 4, 1024, 18); +- const tileSize = clamp(rawMap?.tileSize, 8, 128, 24); +- const name = typeof rawMap?.name === "string" && rawMap.name.trim() ? rawMap.name.trim() : "untitled-map"; +- +- return { +- name, +- width, +- height, +- tileSize, +- pixelWidth: width * tileSize, +- pixelHeight: height * tileSize +- }; +-} +- +-function createDefaultLayer(index = 0, name = "Parallax Layer") { +- return { +- id: `parallax-layer-${Date.now()}-${index}`, +- name, +- parallaxSourceId: "", +- drawOrder: index, +- imageSource: "", +- scrollFactorX: 0.4, +- scrollFactorY: 0.3, +- offsetX: 0, +- offsetY: 0, +- repeatX: true, +- repeatY: false, +- wrapMode: "wrap", +- opacity: 1, +- visible: true +- }; +-} +- +-function normalizeLayer(rawLayer, index = 0) { +- const fallback = createDefaultLayer(index, `Parallax Layer ${index + 1}`); +- const repeatX = rawLayer?.repeatX !== false; +- const repeatY = rawLayer?.repeatY === true; +- const wrapMode = rawLayer?.wrapMode === "clamp" ? "clamp" : "wrap"; +- +- return { +- id: typeof rawLayer?.id === "string" && rawLayer.id.trim() ? rawLayer.id.trim() : fallback.id, +- name: typeof rawLayer?.name === "string" && rawLayer.name.trim() ? rawLayer.name.trim() : fallback.name, +- parallaxSourceId: typeof rawLayer?.parallaxSourceId === "string" ? rawLayer.parallaxSourceId.trim() : "", +- drawOrder: Math.trunc(clamp(rawLayer?.drawOrder, -999, 999, index)), +- imageSource: typeof rawLayer?.imageSource === "string" ? rawLayer.imageSource : "", +- scrollFactorX: clamp(rawLayer?.scrollFactorX, -4, 4, fallback.scrollFactorX), +- scrollFactorY: clamp(rawLayer?.scrollFactorY, -4, 4, fallback.scrollFactorY), +- offsetX: Math.trunc(clamp(rawLayer?.offsetX, -4096, 4096, 0)), +- offsetY: Math.trunc(clamp(rawLayer?.offsetY, -4096, 4096, 0)), +- repeatX, +- repeatY, +- wrapMode, +- opacity: clamp(rawLayer?.opacity, 0, 1, 1), +- visible: rawLayer?.visible !== false +- }; +-} +- +-function sortLayersByOrder(layers) { +- layers.sort((a, b) => { +- if (a.drawOrder !== b.drawOrder) { +- return a.drawOrder - b.drawOrder; +- } +- return a.name.localeCompare(b.name); +- }); +-} +- +-function normalizeDrawOrderSequence(layers) { +- sortLayersByOrder(layers); +- layers.forEach((layer, index) => { +- layer.drawOrder = index; +- }); +-} +- +-function sanitizeAssetRefs(rawAssetRefs) { +- if (!rawAssetRefs || typeof rawAssetRefs !== "object") { +- return { +- parallaxSourceIds: [] +- }; +- } +- +- const parallaxSourceIds = Array.isArray(rawAssetRefs.parallaxSourceIds) +- ? rawAssetRefs.parallaxSourceIds.filter((value) => typeof value === "string" && value.trim()) +- : []; +- +- return { +- parallaxSourceIds: Array.from(new Set(parallaxSourceIds)) +- }; +-} +- +-function createRegistryManagedParallaxSaveDocument(documentModel) { +- const output = cloneDeep(documentModel); +- output.assetRefs = sanitizeAssetRefs(output.assetRefs); +- +- output.layers = (Array.isArray(output.layers) ? output.layers : []).map((layer) => { +- const nextLayer = normalizeLayer(layer); +- if (nextLayer.parallaxSourceId && !normalizeProjectRelativePath(nextLayer.imageSource || "")) { +- nextLayer.imageSource = ""; +- } +- return nextLayer; +- }); +- +- return output; +-} +- +-function createInitialParallaxDocument(options = {}) { +- const map = normalizeMapMeta(options.map || {}); +- const createdAt = new Date().toISOString(); +- const layers = [ +- { +- ...createDefaultLayer(0, "Sky"), +- scrollFactorX: 0.2, +- scrollFactorY: 0.05, +- opacity: 1, +- repeatY: false +- }, +- { +- ...createDefaultLayer(1, "Far Mountains"), +- scrollFactorX: 0.4, +- scrollFactorY: 0.15, +- opacity: 0.95 +- }, +- { +- ...createDefaultLayer(2, "Near Trees"), +- scrollFactorX: 0.75, +- scrollFactorY: 0.35, +- opacity: 0.9 +- } +- ]; +- +- normalizeDrawOrderSequence(layers); +- +- return { +- schema: "toolbox.parallax/1", +- version: 1, +- companionEditor: "ParallaxEditor", +- map, +- layers, +- assetRefs: { +- parallaxSourceIds: [] +- }, +- metadata: { +- createdAt, +- updatedAt: createdAt, +- source: "parallax-editor-companion" +- } +- }; +-} +- +-function sanitizeParallaxDocument(rawDocument, fallbackMap = null) { +- const fallback = createInitialParallaxDocument({ map: fallbackMap || undefined }); +- if (!rawDocument || typeof rawDocument !== "object") { +- return fallback; +- } +- +- const map = normalizeMapMeta(rawDocument.map || fallback.map); +- const rawLayers = Array.isArray(rawDocument.layers) ? rawDocument.layers : []; +- const layers = rawLayers.length > 0 +- ? rawLayers.map((layer, index) => normalizeLayer(layer, index)) +- : cloneDeep(fallback.layers); +- normalizeDrawOrderSequence(layers); +- +- return { +- schema: "toolbox.parallax/1", +- version: Number.isFinite(rawDocument.version) ? rawDocument.version : 1, +- companionEditor: "ParallaxEditor", +- map, +- layers, +- assetRefs: sanitizeAssetRefs(rawDocument.assetRefs), +- metadata: { +- createdAt: typeof rawDocument?.metadata?.createdAt === "string" ? rawDocument.metadata.createdAt : fallback.metadata.createdAt, +- updatedAt: new Date().toISOString(), +- source: "parallax-editor-companion" +- } +- }; +-} +- +-function extractParallaxDocument(rawAnyDocument) { +- if (!rawAnyDocument || typeof rawAnyDocument !== "object") { +- throw new Error("Expected a JSON object."); +- } +- +- if (rawAnyDocument.schema === "toolbox.parallax/1") { +- return sanitizeParallaxDocument(rawAnyDocument); +- } +- +- if (rawAnyDocument.schema === "toolbox.tilemap/1") { +- const map = normalizeMapMeta(rawAnyDocument.map || {}); +- const rawParallax = rawAnyDocument.parallax && typeof rawAnyDocument.parallax === "object" +- ? rawAnyDocument.parallax +- : { schema: "toolbox.parallax/1", layers: [] }; +- +- const merged = { +- schema: "toolbox.parallax/1", +- version: 1, +- companionEditor: "ParallaxEditor", +- map, +- layers: Array.isArray(rawParallax.layers) ? rawParallax.layers : [], +- assetRefs: rawAnyDocument.assetRefs +- }; +- +- return sanitizeParallaxDocument(merged, map); +- } +- +- if (rawAnyDocument.parallax && rawAnyDocument.map) { +- const map = normalizeMapMeta(rawAnyDocument.map); +- const merged = { +- schema: "toolbox.parallax/1", +- version: 1, +- companionEditor: "ParallaxEditor", +- map, +- layers: Array.isArray(rawAnyDocument.parallax.layers) ? rawAnyDocument.parallax.layers : [], +- assetRefs: rawAnyDocument.assetRefs +- }; +- return sanitizeParallaxDocument(merged, map); +- } +- +- throw new Error("Unsupported schema. Expected toolbox.tilemap/1 or toolbox.parallax/1."); +-} +- +-function extractParallaxDocumentFromSamplePreset(rawPreset) { +- if (!rawPreset || typeof rawPreset !== "object") { +- return rawPreset; +- } +- +- const payload = rawPreset.payload; +- if (payload && typeof payload === "object") { +- if (payload.parallaxDocument && typeof payload.parallaxDocument === "object") { +- return payload.parallaxDocument; +- } +- if (payload.parallax && typeof payload.parallax === "object") { +- return payload.parallax; +- } +- const config = payload.config && typeof payload.config === "object" +- ? payload.config +- : null; +- if (config?.parallaxDocument && typeof config.parallaxDocument === "object") { +- return config.parallaxDocument; +- } +- if (config?.parallax && typeof config.parallax === "object") { +- return config.parallax; +- } +- } +- const rootConfig = rawPreset.config && typeof rawPreset.config === "object" +- ? rawPreset.config +- : null; +- if (rootConfig?.parallaxDocument && typeof rootConfig.parallaxDocument === "object") { +- return rootConfig.parallaxDocument; +- } +- if (rootConfig?.parallax && typeof rootConfig.parallax === "object") { +- return rootConfig.parallax; +- } +- +- return rawPreset; +-} +- +-function createTilemapParallaxPatch(parallaxDocument) { +- return { +- schema: "toolbox.tilemap-parallax-patch/1", +- map: cloneDeep(parallaxDocument.map), +- assetRefs: sanitizeAssetRefs(parallaxDocument.assetRefs), +- parallax: { +- schema: "toolbox.parallax/1", +- companionEditor: "ParallaxEditor", +- layers: cloneDeep(parallaxDocument.layers) +- }, +- metadata: { +- generatedAt: new Date().toISOString(), +- generatedBy: "tools/Parallax Scene Studio" +- } +- }; +-} +- +-function summarizeGraphFindings(findings) { +- return Array.isArray(findings) && findings.length > 0 +- ? ` Graph findings: ${findings.length}.` +- : " Graph findings: none."; +-} +- +-function mod(value, range) { +- if (range === 0) { +- return 0; +- } +- return ((value % range) + range) % range; +-} +- +-function getLayerVisualColor(layer) { +- const hashSource = `${layer.id}|${layer.name}`; +- let hash = 0; +- for (let i = 0; i < hashSource.length; i += 1) { +- hash = ((hash << 5) - hash) + hashSource.charCodeAt(i); +- hash |= 0; +- } +- const hue = Math.abs(hash) % 360; +- return `hsl(${hue} 56% 42%)`; +-} +- +-class ParallaxEditorApp { +- constructor(documentModel) { +- this.documentModel = documentModel; +- this.selectedLayerId = documentModel.layers[0]?.id || ""; +- this.cameraX = 0; +- this.cameraY = 0; +- this.refs = {}; +- this.imageCache = new Map(); +- this.imageCacheVersion = 0; +- this.isSimulationMode = false; +- this.simulation = { +- rafId: 0, +- lastTimestamp: 0, +- accumulatorMs: 0, +- playing: false, +- baseCameraX: 0, +- baseCameraY: 0, +- startCameraX: 0, +- startCameraY: 0, +- endCameraX: 0, +- endCameraY: 0, +- traversedDistance: 0, +- traversalDistance: 0, +- traversalDurationMs: 14000 +- }; +- this.assetRegistry = createAssetRegistry({ projectId: documentModel?.map?.name || "parallax-project" }); +- this.assetDependencyGraphSnapshot = null; +- this.validationResult = null; +- this.remediationResult = { remediation: { status: "unavailable", actions: [] } }; +- this.lastPackageResult = null; +- this.lastRuntimeResult = null; +- this.editorExperienceResult = null; +- this.debugVisualizationResult = null; +- this.livePreviewSync = createLivePreviewSyncBridge({ sourceId: "parallax-editor" }); +- this.livePreviewSyncFrame = 0; +- this.pendingLivePreviewReason = "init"; +- this.boundRuntimeState = null; +- this.lastRuntimeBindingStatusAt = 0; +- this.skipExternalProjectStateUntil = 0; +- this.transientLayerImageSourceByName = new Map(); +- } +- +- invalidateImageCache() { +- this.imageCache.clear(); +- this.imageCacheVersion += 1; +- } +- +- clearTransientLayerImageSources() { +- this.transientLayerImageSourceByName.forEach((objectUrl) => { +- if (typeof objectUrl === "string" && objectUrl.startsWith("blob:")) { +- URL.revokeObjectURL(objectUrl); +- } +- }); +- this.transientLayerImageSourceByName.clear(); +- } +- +- setTransientLayerImageSource(imageSource, objectUrl) { +- const key = typeof imageSource === "string" ? imageSource.trim() : ""; +- if (!key || typeof objectUrl !== "string" || !objectUrl.trim()) { +- return; +- } +- const existing = this.transientLayerImageSourceByName.get(key); +- if (typeof existing === "string" && existing.startsWith("blob:")) { +- URL.revokeObjectURL(existing); +- } +- this.transientLayerImageSourceByName.set(key, objectUrl.trim()); +- } +- +- getTransientLayerImageSource(imageSource) { +- const key = typeof imageSource === "string" ? imageSource.trim() : ""; +- if (!key) { +- return ""; +- } +- return this.transientLayerImageSourceByName.get(key) || ""; +- } +- +- init(rootDocument) { +- this.captureRefs(rootDocument); +- this.attachEvents(); +- this.syncFullscreenState(); +- this.syncInputsFromDocument(); +- this.renderAll(); +- this.bindRuntimeStateSync(); +- this.queueLivePreviewSync("init"); +- void this.tryLoadPresetFromQuery(); +- } +- +- captureRefs(rootDocument) { +- this.refs.newProjectButton = rootDocument.getElementById("newProjectButton"); +- this.refs.loadProjectButton = rootDocument.getElementById("loadProjectButton"); +- this.refs.loadProjectInput = rootDocument.getElementById("loadProjectInput"); +- this.refs.saveProjectButton = rootDocument.getElementById("saveProjectButton"); +- this.refs.loadAssetRegistryButton = rootDocument.getElementById("loadAssetRegistryButton"); +- this.refs.loadAssetRegistryInput = rootDocument.getElementById("loadAssetRegistryInput"); +- this.refs.saveAssetRegistryButton = rootDocument.getElementById("saveAssetRegistryButton"); +- this.refs.simulateButton = rootDocument.getElementById("simulateButton"); +- this.refs.playSimulationButton = rootDocument.getElementById("playSimulationButton"); +- this.refs.pauseSimulationButton = rootDocument.getElementById("pauseSimulationButton"); +- this.refs.restartSimulationButton = rootDocument.getElementById("restartSimulationButton"); +- this.refs.exitSimulationButton = rootDocument.getElementById("exitSimulationButton"); +- this.refs.exportParallaxPatchButton = rootDocument.getElementById("exportParallaxPatchButton"); +- this.refs.packageProjectButton = rootDocument.getElementById("packageProjectButton"); +- this.refs.remediationSummaryText = rootDocument.getElementById("remediationSummaryText"); +- this.refs.experienceSummaryText = rootDocument.getElementById("experienceSummaryText"); +- this.refs.experienceDetailsText = rootDocument.getElementById("experienceDetailsText"); +- this.refs.refreshExperienceButton = rootDocument.getElementById("refreshExperienceButton"); +- this.refs.debugSummaryText = rootDocument.getElementById("debugSummaryText"); +- this.refs.debugDetailsText = rootDocument.getElementById("debugDetailsText"); +- this.refs.refreshDebugVisualizationButton = rootDocument.getElementById("refreshDebugVisualizationButton"); +- this.refs.inspectRemediationButton = rootDocument.getElementById("inspectRemediationButton"); +- this.refs.jumpToProblemButton = rootDocument.getElementById("jumpToProblemButton"); +- this.refs.applyRemediationButton = rootDocument.getElementById("applyRemediationButton"); +- +- this.refs.projectNameInput = rootDocument.getElementById("projectNameInput"); +- this.refs.mapWidthInput = rootDocument.getElementById("mapWidthInput"); +- this.refs.mapHeightInput = rootDocument.getElementById("mapHeightInput"); +- this.refs.tileSizeInput = rootDocument.getElementById("tileSizeInput"); +- this.refs.applyMapMetaButton = rootDocument.getElementById("applyMapMetaButton"); +- +- this.refs.layerList = rootDocument.getElementById("layerList"); +- this.refs.newLayerNameInput = rootDocument.getElementById("newLayerNameInput"); +- this.refs.addLayerButton = rootDocument.getElementById("addLayerButton"); +- this.refs.removeLayerButton = rootDocument.getElementById("removeLayerButton"); +- this.refs.duplicateLayerButton = rootDocument.getElementById("duplicateLayerButton"); +- this.refs.moveLayerUpButton = rootDocument.getElementById("moveLayerUpButton"); +- this.refs.moveLayerDownButton = rootDocument.getElementById("moveLayerDownButton"); +- +- this.refs.cameraXInput = rootDocument.getElementById("cameraXInput"); +- this.refs.cameraYInput = rootDocument.getElementById("cameraYInput"); +- this.refs.cameraReadout = rootDocument.getElementById("cameraReadout"); +- +- this.refs.layerNameInput = rootDocument.getElementById("layerNameInput"); +- this.refs.layerDrawOrderInput = rootDocument.getElementById("layerDrawOrderInput"); +- this.refs.layerOpacityInput = rootDocument.getElementById("layerOpacityInput"); +- this.refs.layerVisibleSelect = rootDocument.getElementById("layerVisibleSelect"); +- +- this.refs.layerImageSourceInput = rootDocument.getElementById("layerImageSourceInput"); +- this.refs.applyImageSourceButton = rootDocument.getElementById("applyImageSourceButton"); +- this.refs.layerImageFileInput = rootDocument.getElementById("layerImageFileInput"); +- +- this.refs.scrollFactorXInput = rootDocument.getElementById("scrollFactorXInput"); +- this.refs.scrollFactorYInput = rootDocument.getElementById("scrollFactorYInput"); +- this.refs.offsetXInput = rootDocument.getElementById("offsetXInput"); +- this.refs.offsetYInput = rootDocument.getElementById("offsetYInput"); +- this.refs.repeatXSelect = rootDocument.getElementById("repeatXSelect"); +- this.refs.repeatYSelect = rootDocument.getElementById("repeatYSelect"); +- this.refs.wrapModeSelect = rootDocument.getElementById("wrapModeSelect"); +- this.refs.applyLayerSettingsButton = rootDocument.getElementById("applyLayerSettingsButton"); +- +- this.refs.previewMeta = rootDocument.getElementById("previewMeta"); +- this.refs.simulationContext = rootDocument.getElementById("simulationContext"); +- this.refs.previewDetailsText = rootDocument.getElementById("previewDetailsText"); +- this.refs.statusText = rootDocument.getElementById("statusText"); +- this.refs.appShell = rootDocument.querySelector(".app-shell"); +- this.refs.leftSidebar = rootDocument.getElementById("leftSidebar"); +- this.refs.rightSidebar = rootDocument.getElementById("rightSidebar"); +- this.refs.leftPanelAccordions = Array.from(rootDocument.querySelectorAll("#leftSidebar .panel-accordion")); +- this.refs.rightPanelAccordions = Array.from(rootDocument.querySelectorAll("#rightSidebar .panel-accordion")); +- this.refs.overlayToggleButtons = Array.from(rootDocument.querySelectorAll("[data-overlay-toggle]")); +- this.refs.previewWrap = rootDocument.querySelector(".preview-wrap"); +- this.refs.previewCanvas = rootDocument.getElementById("previewCanvas"); +- this.refs.previewContext = this.refs.previewCanvas.getContext("2d", { alpha: false }); +- } +- +- attachEvents() { +- this.refs.newProjectButton.addEventListener("click", () => this.handleNewProject()); +- this.refs.loadProjectButton.addEventListener("click", () => this.refs.loadProjectInput.click()); +- this.refs.loadProjectInput.addEventListener("change", (event) => this.handleLoadProject(event)); +- this.refs.saveProjectButton.addEventListener("click", () => this.handleSaveProject()); +- this.refs.loadAssetRegistryButton.addEventListener("click", () => this.refs.loadAssetRegistryInput.click()); +- this.refs.loadAssetRegistryInput.addEventListener("change", (event) => this.handleLoadAssetRegistry(event)); +- this.refs.saveAssetRegistryButton.addEventListener("click", () => this.handleSaveAssetRegistry()); +- this.refs.simulateButton.addEventListener("click", () => this.enterSimulationMode()); +- this.refs.playSimulationButton.addEventListener("click", () => this.resumeSimulation()); +- this.refs.pauseSimulationButton.addEventListener("click", () => this.pauseSimulation()); +- this.refs.restartSimulationButton.addEventListener("click", () => this.restartSimulationPosition()); +- this.refs.exitSimulationButton.addEventListener("click", () => this.exitSimulationMode()); +- this.refs.exportParallaxPatchButton.addEventListener("click", () => this.handleExportTilemapPatch()); +- this.refs.packageProjectButton.addEventListener("click", () => this.handlePackageProject()); +- this.refs.refreshExperienceButton.addEventListener("click", () => this.refreshExperienceSnapshot()); +- this.refs.refreshDebugVisualizationButton.addEventListener("click", () => this.refreshDebugVisualizationSnapshot()); +- this.refs.inspectRemediationButton.addEventListener("click", () => this.inspectRemediationActions()); +- this.refs.jumpToProblemButton.addEventListener("click", () => this.jumpToRemediationProblem()); +- this.refs.applyRemediationButton.addEventListener("click", () => this.applyRemediationAction()); +- this.refs.overlayToggleButtons.forEach((button) => { +- button.addEventListener("click", () => { +- const side = button.dataset.overlaySide === "left" ? "left" : "right"; +- const targetId = button.dataset.overlayTarget || ""; +- this.toggleOverlayPanel(side, targetId); +- }); +- }); +- this.refs.leftPanelAccordions.forEach((panel) => { +- panel.addEventListener("toggle", () => this.handleOverlayAccordionToggle("left", panel)); +- }); +- this.refs.rightPanelAccordions.forEach((panel) => { +- panel.addEventListener("toggle", () => this.handleOverlayAccordionToggle("right", panel)); +- }); +- document.addEventListener("fullscreenchange", () => { +- this.syncFullscreenState(); +- this.refs.leftSidebar?.classList.remove("visible-overlay"); +- this.refs.rightSidebar?.classList.remove("visible-overlay"); +- this.syncOverlayToggleButtons(); +- }); +- +- this.refs.applyMapMetaButton.addEventListener("click", () => this.applyMapMetaFromInputs()); +- this.refs.projectNameInput.addEventListener("change", () => this.applyMapMetaFromInputs()); +- +- this.refs.addLayerButton.addEventListener("click", () => this.addLayer()); +- this.refs.removeLayerButton.addEventListener("click", () => this.removeSelectedLayer()); +- this.refs.duplicateLayerButton.addEventListener("click", () => this.duplicateSelectedLayer()); +- this.refs.moveLayerUpButton.addEventListener("click", () => this.moveSelectedLayer(-1)); +- this.refs.moveLayerDownButton.addEventListener("click", () => this.moveSelectedLayer(1)); +- +- this.refs.layerNameInput.addEventListener("change", () => this.applyBasicLayerFields()); +- this.refs.layerDrawOrderInput.addEventListener("change", () => this.applyBasicLayerFields()); +- this.refs.layerOpacityInput.addEventListener("change", () => this.applyBasicLayerFields()); +- this.refs.layerVisibleSelect.addEventListener("change", () => this.applyBasicLayerFields()); +- +- this.refs.applyImageSourceButton.addEventListener("click", () => this.applyImageSourceFromInput()); +- this.refs.layerImageFileInput.addEventListener("change", (event) => this.assignLocalImageFile(event)); +- +- this.refs.applyLayerSettingsButton.addEventListener("click", () => this.applyExtendedLayerSettings()); +- +- this.refs.cameraXInput.addEventListener("input", () => this.handleCameraChange()); +- this.refs.cameraYInput.addEventListener("input", () => this.handleCameraChange()); +- if (this.refs.previewWrap) { +- this.refs.previewWrap.addEventListener("scroll", () => { +- if (this.isSimulationMode) { +- this.updateSimulationContextReadout(); +- } +- }); +- } +- } +- +- syncFullscreenState() { +- document.body.classList.toggle("fullscreen-mode", Boolean(document.fullscreenElement)); +- this.syncOverlayToggleButtons(); +- } +- +- syncOverlayToggleButtons() { +- const fullscreenActive = Boolean(document.fullscreenElement); +- this.refs.overlayToggleButtons.forEach((button) => { +- const side = button.dataset.overlaySide === "left" ? "left" : "right"; +- const targetId = button.dataset.overlayTarget || ""; +- const sidebar = this.getOverlaySidebar(side); +- const target = targetId ? document.getElementById(targetId) : null; +- const active = Boolean( +- fullscreenActive +- && sidebar instanceof HTMLElement +- && sidebar.classList.contains("visible-overlay") +- && target instanceof HTMLElement +- && target.open +- ); +- button.setAttribute("aria-expanded", active ? "true" : "false"); +- const symbol = button.querySelector(".overlay-toggle-symbol"); +- if (symbol) { +- symbol.textContent = active ? "-" : "+"; +- } +- const hideWhileOverlayOpen = Boolean( +- fullscreenActive +- && sidebar instanceof HTMLElement +- && sidebar.classList.contains("visible-overlay") +- ); +- button.classList.toggle("is-hidden-while-overlay-open", hideWhileOverlayOpen); +- }); +- } +- +- getOverlaySidebar(side) { +- return side === "left" ? this.refs.leftSidebar : this.refs.rightSidebar; +- } +- +- getOverlayPanels(side) { +- return side === "left" ? this.refs.leftPanelAccordions : this.refs.rightPanelAccordions; +- } +- +- toggleOverlayPanel(side, targetId) { +- const sidebar = this.getOverlaySidebar(side); +- const panels = this.getOverlayPanels(side); +- if (!(sidebar instanceof HTMLElement) || !Array.isArray(panels) || panels.length === 0) { +- return; +- } +- const target = panels.find((panel) => panel.id === targetId); +- if (!(target instanceof HTMLElement)) { +- return; +- } +- +- const fullscreenActive = Boolean(document.fullscreenElement); +- if (!fullscreenActive) { +- target.open = !target.open; +- this.syncOverlayToggleButtons(); +- return; +- } +- +- const overlayVisible = sidebar.classList.contains("visible-overlay"); +- if (!overlayVisible) { +- sidebar.classList.add("visible-overlay"); +- target.open = true; +- } else { +- target.open = !target.open; +- } +- if (!panels.some((panel) => panel.open)) { +- sidebar.classList.remove("visible-overlay"); +- } +- +- this.syncOverlayToggleButtons(); +- } +- +- handleOverlayAccordionToggle(side, panel) { +- if (!document.fullscreenElement) { +- return; +- } +- const sidebar = this.getOverlaySidebar(side); +- const panels = this.getOverlayPanels(side); +- if (!(sidebar instanceof HTMLElement) || !Array.isArray(panels)) { +- return; +- } +- if (panel.open) { +- sidebar.classList.add("visible-overlay"); +- } else if (!panels.some((entry) => entry.open)) { +- sidebar.classList.remove("visible-overlay"); +- } +- this.syncOverlayToggleButtons(); +- } +- +- getSelectedLayer() { +- return this.documentModel.layers.find((layer) => layer.id === this.selectedLayerId) || this.documentModel.layers[0] || null; +- } +- +- touchDocument() { +- this.documentModel.metadata.updatedAt = new Date().toISOString(); +- this.queueLivePreviewSync("document-update"); +- } +- +- bindRuntimeStateSync() { +- this.livePreviewSync.subscribe((payload, envelope) => { +- if (envelope?.eventType !== "runtime-state-binding") { +- return; +- } +- const validation = validateStateBindingPayload(payload); +- if (!validation.valid || !payload?.runtimeState) { +- return; +- } +- this.boundRuntimeState = payload.runtimeState; +- const now = Date.now(); +- if ((now - this.lastRuntimeBindingStatusAt) < 500) { +- return; +- } +- this.lastRuntimeBindingStatusAt = now; +- const heroX = Number(this.boundRuntimeState.heroX).toFixed(1); +- const heroY = Number(this.boundRuntimeState.heroY).toFixed(1); +- const cameraX = Number(this.boundRuntimeState.cameraX).toFixed(1); +- const cameraY = Number(this.boundRuntimeState.cameraY).toFixed(1); +- this.updateStatus(`Runtime bound: hero(${heroX}, ${heroY}) camera(${cameraX}, ${cameraY}).`); +- }); +- } +- +- publishLivePreviewSync(reason = "update") { +- this.livePreviewSync.publish( +- { +- toolId: "parallax-editor", +- reason, +- parallaxDocument: cloneDeep(this.documentModel) +- }, +- "tool-live-preview-sync" +- ); +- } +- +- queueLivePreviewSync(reason = "update") { +- this.pendingLivePreviewReason = reason; +- if (this.livePreviewSyncFrame) { +- return; +- } +- const schedule = typeof requestAnimationFrame === "function" +- ? requestAnimationFrame +- : (callback) => setTimeout(callback, 16); +- this.livePreviewSyncFrame = schedule(() => { +- this.livePreviewSyncFrame = 0; +- this.publishLivePreviewSync(this.pendingLivePreviewReason); +- }); +- } +- +- updateStatus(message) { +- this.refs.statusText.textContent = message; +- } +- +- syncInputsFromDocument() { +- this.refs.projectNameInput.value = this.documentModel.map.name; +- this.refs.mapWidthInput.value = String(this.documentModel.map.width); +- this.refs.mapHeightInput.value = String(this.documentModel.map.height); +- this.refs.tileSizeInput.value = String(this.documentModel.map.tileSize); +- this.updateCameraInputBounds(); +- this.refs.cameraXInput.value = String(this.cameraX); +- this.refs.cameraYInput.value = String(this.cameraY); +- } +- +- updateCameraInputBounds() { +- const maxHorizontal = Math.max(1024, this.documentModel.map.pixelWidth); +- const maxVertical = Math.max(1024, this.documentModel.map.pixelHeight); +- this.refs.cameraXInput.min = String(-maxHorizontal); +- this.refs.cameraXInput.max = String(maxHorizontal); +- this.refs.cameraYInput.min = String(-maxVertical); +- this.refs.cameraYInput.max = String(maxVertical); +- } +- +- refreshSimulationActionState() { +- const inSimulation = this.isSimulationMode; +- this.refs.simulateButton.disabled = inSimulation; +- this.refs.playSimulationButton.disabled = !inSimulation || this.simulation.playing; +- this.refs.pauseSimulationButton.disabled = !inSimulation || !this.simulation.playing; +- this.refs.restartSimulationButton.disabled = !inSimulation; +- this.refs.exitSimulationButton.disabled = !inSimulation; +- this.refs.cameraXInput.disabled = inSimulation; +- this.refs.cameraYInput.disabled = inSimulation; +- } +- +- configureSimulationTraverse(playing) { +- const viewportWidth = this.refs.previewCanvas.width; +- const worldWidth = Math.max(1, this.documentModel.map.pixelWidth); +- const startCameraX = -Math.floor(viewportWidth * 0.2); +- const endCameraX = Math.max(startCameraX + 1, worldWidth - Math.floor(viewportWidth * 0.8)); +- +- this.simulation.startCameraX = startCameraX; +- this.simulation.endCameraX = endCameraX; +- this.simulation.startCameraY = 0; +- this.simulation.endCameraY = 0; +- this.simulation.traversalDistance = Math.max(1, Math.abs(endCameraX - startCameraX)); +- this.simulation.traversalDurationMs = Math.max(9000, Math.min(24000, 9000 + (this.simulation.traversalDistance * 9))); +- this.simulation.traversedDistance = 0; +- this.simulation.playing = playing; +- this.simulation.lastTimestamp = 0; +- this.simulation.accumulatorMs = 0; +- +- this.cameraX = startCameraX; +- this.cameraY = 0; +- if (this.refs.previewWrap) { +- this.refs.previewWrap.scrollTop = 0; +- } +- this.refs.cameraXInput.value = String(this.cameraX); +- this.refs.cameraYInput.value = String(this.cameraY); +- this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; +- this.updateSimulationContextReadout(); +- } +- +- getSimulationProgress() { +- if (!this.isSimulationMode || this.simulation.traversalDistance <= 0) { +- return 0; +- } +- return Math.max(0, Math.min(1, this.simulation.traversedDistance / this.simulation.traversalDistance)); +- } +- +- updateSimulationContextReadout() { +- if (!this.isSimulationMode) { +- this.refs.simulationContext.textContent = ""; +- return; +- } +- +- const sortedLayers = cloneDeep(this.documentModel.layers); +- sortLayersByOrder(sortedLayers); +- const progress = this.getSimulationProgress(); +- const progressPercent = Math.round(progress * 100); +- const repeatCount = sortedLayers.filter((layer) => layer.repeatX || layer.repeatY).length; +- const wrapCount = sortedLayers.filter((layer) => layer.wrapMode === "wrap").length; +- const backLayer = sortedLayers[0]?.name || "none"; +- const frontLayer = sortedLayers[sortedLayers.length - 1]?.name || "none"; +- const mode = this.simulation.playing ? "PLAY" : "PAUSE"; +- this.refs.simulationContext.textContent = `${mode} ${progressPercent}% cam:${this.cameraX},${this.cameraY} depth:${backLayer}->${frontLayer} repeat:${repeatCount}/${sortedLayers.length} wrap:${wrapCount}`; +- } +- +- applySimulationCameraAtProgress(progress) { +- const clampedProgress = Math.max(0, Math.min(1, progress)); +- const nextX = this.simulation.startCameraX +- + ((this.simulation.endCameraX - this.simulation.startCameraX) * clampedProgress); +- +- this.cameraX = Math.round(nextX); +- this.cameraY = 0; +- this.refs.cameraXInput.value = String(this.cameraX); +- this.refs.cameraYInput.value = String(this.cameraY); +- this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; +- } +- +- enterSimulationMode() { +- if (this.isSimulationMode) { +- return; +- } +- +- this.isSimulationMode = true; +- this.simulation.baseCameraX = this.cameraX; +- this.simulation.baseCameraY = this.cameraY; +- this.configureSimulationTraverse(true); +- this.refreshSimulationActionState(); +- this.updateStatus("Simulation mode active. Running full-map parallax traversal."); +- this.startSimulationLoop(); +- this.renderPreview(); +- } +- +- pauseSimulation() { +- if (!this.isSimulationMode || !this.simulation.playing) { +- return; +- } +- this.simulation.playing = false; +- this.simulation.lastTimestamp = 0; +- this.simulation.accumulatorMs = 0; +- this.refreshSimulationActionState(); +- this.updateSimulationContextReadout(); +- this.renderPreview(); +- this.updateStatus("Simulation paused."); +- } +- +- resumeSimulation() { +- if (!this.isSimulationMode || this.simulation.playing) { +- return; +- } +- this.simulation.playing = true; +- this.simulation.lastTimestamp = 0; +- this.simulation.accumulatorMs = 0; +- this.refreshSimulationActionState(); +- this.updateSimulationContextReadout(); +- this.renderPreview(); +- this.updateStatus("Simulation resumed."); +- } +- +- restartSimulationPosition() { +- if (!this.isSimulationMode) { +- return; +- } +- this.configureSimulationTraverse(this.simulation.playing); +- this.refreshSimulationActionState(); +- this.renderPreview(); +- this.updateStatus("Simulation restarted from the initial traversal position."); +- } +- +- exitSimulationMode() { +- const wasSimulationMode = this.isSimulationMode; +- if (!wasSimulationMode && !this.simulation.rafId) { +- return; +- } +- +- this.isSimulationMode = false; +- if (this.simulation.rafId) { +- cancelAnimationFrame(this.simulation.rafId); +- this.simulation.rafId = 0; +- } +- +- this.simulation.playing = false; +- this.simulation.lastTimestamp = 0; +- this.simulation.accumulatorMs = 0; +- this.simulation.traversedDistance = 0; +- this.simulation.traversalDistance = 0; +- this.cameraX = this.simulation.baseCameraX; +- this.cameraY = this.simulation.baseCameraY; +- this.refs.cameraXInput.value = String(this.cameraX); +- this.refs.cameraYInput.value = String(this.cameraY); +- this.refs.simulationContext.textContent = ""; +- this.refreshSimulationActionState(); +- this.renderPreview(); +- if (wasSimulationMode) { +- this.updateStatus("Exited simulation mode."); +- } +- } +- +- startSimulationLoop() { +- const tick = (timestamp) => { +- if (!this.isSimulationMode) { +- return; +- } +- +- if (this.simulation.playing) { +- if (this.simulation.lastTimestamp === 0) { +- this.simulation.lastTimestamp = timestamp; +- } +- const deltaMs = timestamp - this.simulation.lastTimestamp; +- this.simulation.lastTimestamp = timestamp; +- this.advanceSimulationCamera(deltaMs); +- } +- +- this.renderPreview(); +- this.simulation.rafId = requestAnimationFrame(tick); +- }; +- +- this.simulation.rafId = requestAnimationFrame(tick); +- } +- +- advanceSimulationCamera(deltaMs) { +- const durationMs = Math.max(1, this.simulation.traversalDurationMs); +- const advanceBy = Math.max(0, deltaMs) * (this.simulation.traversalDistance / durationMs); +- this.simulation.traversedDistance = Math.min( +- this.simulation.traversalDistance, +- this.simulation.traversedDistance + advanceBy +- ); +- const progress = this.getSimulationProgress(); +- this.applySimulationCameraAtProgress(progress); +- this.updateSimulationContextReadout(); +- +- if (progress >= 1) { +- this.simulation.playing = false; +- this.refreshSimulationActionState(); +- this.updateStatus("Simulation reached the end of the full-map traverse."); +- } +- } +- +- async tryLoadPresetFromQuery() { +- const searchParams = new URLSearchParams(window.location.search); +- const samplePresetPath = normalizeSamplePresetPath(searchParams.get("samplePresetPath") || ""); +- const launchQuery = getToolLoadQuerySnapshot(searchParams); +- logToolLoadRequest({ +- toolId: "parallax-editor", +- sampleId: String(searchParams.get("sampleId") || "").trim(), +- samplePresetPath, +- requestedDataPaths: getToolLoadRequestedDataPaths(launchQuery), +- launchQuery +- }); +- if (!samplePresetPath) { +- logToolLoadWarning({ +- toolId: "parallax-editor", +- reason: "samplePresetPath missing", +- launchQuery +- }); +- return; +- } +- +- const sampleId = String(searchParams.get("sampleId") || "").trim(); +- this.exitSimulationMode(); +- try { +- const presetUrl = new URL(samplePresetPath, window.location.href); +- const presetHref = presetUrl.toString(); +- logToolLoadFetch({ +- toolId: "parallax-editor", +- phase: "attempt", +- fetchUrl: presetHref, +- requestedPath: samplePresetPath, +- pathSource: "tool-input:query.samplePresetPath" +- }); +- const response = await fetch(presetHref, { cache: "no-store" }); +- logToolLoadFetch({ +- toolId: "parallax-editor", +- phase: "response", +- fetchUrl: presetHref, +- requestedPath: samplePresetPath, +- pathSource: "tool-input:query.samplePresetPath", +- status: response.status, +- ok: response.ok +- }); +- if (!response.ok) { +- throw new Error(`Preset request failed (${response.status}).`); +- } +- const rawPreset = await response.json(); +- await logToolLoadLoaded({ +- toolId: "parallax-editor", +- toolName: "Parallax Scene Studio", +- sampleId, +- samplePresetPath, +- requestedPath: samplePresetPath, +- fetchUrl: presetHref, +- loadedDocument: rawPreset, +- loaded: summarizeToolLoadData(rawPreset) +- }); +- const toolDocument = extractParallaxDocumentFromSamplePreset(rawPreset); +- this.skipExternalProjectStateUntil = Date.now() + 3000; +- this.applyParallaxDocument(extractParallaxDocument(toolDocument)); +- this.queueLivePreviewSync("sample-preset"); +- this.updateStatus(buildPresetLoadedStatus(sampleId, samplePresetPath)); +- } catch (error) { +- logToolLoadWarning({ +- toolId: "parallax-editor", +- sampleId, +- samplePresetPath, +- error: error instanceof Error ? error.message : "unknown error" +- }); +- this.updateStatus(`Preset load failed: ${error instanceof Error ? error.message : "unknown error"}`); +- } +- } +- +- applyParallaxDocument(nextDocument) { +- this.documentModel = nextDocument; +- this.resolveAssetRefsFromRegistry(); +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.selectedLayerId = this.documentModel.layers[0]?.id || ""; +- this.clearTransientLayerImageSources(); +- this.invalidateImageCache(); +- this.cameraX = 0; +- this.cameraY = 0; +- this.syncInputsFromDocument(); +- this.renderAll(); +- } +- +- handleNewProject() { +- this.exitSimulationMode(); +- this.documentModel = createInitialParallaxDocument({ map: this.documentModel.map }); +- this.selectedLayerId = this.documentModel.layers[0]?.id || ""; +- this.clearTransientLayerImageSources(); +- this.invalidateImageCache(); +- this.cameraX = 0; +- this.cameraY = 0; +- this.syncInputsFromDocument(); +- this.renderAll(); +- this.queueLivePreviewSync("new-project"); +- this.updateStatus("Created new parallax document."); +- } +- +- syncAssetRegistryFromDocument() { +- const projectName = this.documentModel?.map?.name || "parallax-map"; +- this.assetRegistry = sanitizeAssetRegistry(this.assetRegistry); +- this.assetRegistry.projectId = this.assetRegistry.projectId || projectName; +- +- const sourceIds = []; +- this.documentModel.layers.forEach((layer, index) => { +- const layerPath = normalizeProjectRelativePath(layer?.imageSource || ""); +- if (!layerPath) { +- return; +- } +- +- const imageId = createAssetId("image", layerPath, `parallax-image-${index + 1}`); +- const sourceId = layer.parallaxSourceId || createAssetId("parallax", layer.id || layer.name || `source-${index + 1}`, `source-${index + 1}`); +- layer.parallaxSourceId = sourceId; +- +- const imageRegistration = registerAssetPipelineCandidate({ +- registry: this.assetRegistry, +- section: "images", +- ingest: { +- id: imageId, +- name: layer.name || `Parallax Layer ${index + 1}`, +- path: layerPath, +- sourceTool: "parallax-editor" +- } +- }); +- this.assetRegistry = imageRegistration.registry; +- +- const sourceRegistration = registerAssetPipelineCandidate({ +- registry: this.assetRegistry, +- section: "parallaxSources", +- ingest: { +- id: sourceId, +- name: layer.name || `Parallax Layer ${index + 1}`, +- path: layerPath, +- sourceTool: "parallax-editor" +- }, +- entryFields: { +- imageId +- } +- }); +- this.assetRegistry = sourceRegistration.registry; +- +- sourceIds.push(sourceId); +- }); +- +- this.documentModel.assetRefs = { +- parallaxSourceIds: Array.from(new Set(sourceIds)) +- }; +- } +- +- resolveAssetRefsFromRegistry() { +- this.documentModel.assetRefs = sanitizeAssetRefs(this.documentModel.assetRefs); +- const sourceIds = this.documentModel.assetRefs.parallaxSourceIds; +- if (!Array.isArray(sourceIds) || sourceIds.length === 0) { +- return { +- resolvedCount: 0, +- unresolvedCount: 0 +- }; +- } +- +- let fallbackIndex = 0; +- let resolvedCount = 0; +- let unresolvedCount = 0; +- this.documentModel.layers.forEach((layer) => { +- const sourceId = layer.parallaxSourceId || sourceIds[fallbackIndex] || ""; +- if (!sourceId) { +- return; +- } +- +- if (!layer.parallaxSourceId) { +- layer.parallaxSourceId = sourceId; +- } +- +- if (normalizeProjectRelativePath(layer.imageSource || "")) { +- fallbackIndex += 1; +- return; +- } +- +- const sourceEntry = findRegistryEntryById(this.assetRegistry, "parallaxSources", sourceId); +- const sourcePath = normalizeProjectRelativePath(sourceEntry?.path || ""); +- if (sourcePath) { +- layer.imageSource = sourcePath; +- resolvedCount += 1; +- } else { +- unresolvedCount += 1; +- } +- fallbackIndex += 1; +- }); +- +- return { +- resolvedCount, +- unresolvedCount +- }; +- } +- +- handleSaveProject() { +- this.touchDocument(); +- this.syncAssetRegistryFromDocument(); +- const validation = this.validateProjectAssets(); +- if (hasBlockingAssetValidationFindings(validation)) { +- this.updateStatus(`${getBlockingAssetValidationMessage("Save Project", validation)} (${summarizeAssetValidation(validation)}).`); +- return; +- } +- const output = addToolModeMetadata(createRegistryManagedParallaxSaveDocument(this.documentModel), { toolId: "parallax-editor" }); +- const { graph, findings } = buildAssetDependencyGraph(this.assetRegistry); +- this.assetDependencyGraphSnapshot = graph; +- output.project = { +- ...(output.project && typeof output.project === "object" ? output.project : {}), +- assetDependencyGraph: graph +- }; +- const payload = JSON.stringify(output, null, 2); +- const fileName = `${this.documentModel.map.name || "map"}.parallax.json`; +- downloadTextFile(payload, fileName); +- this.updateStatus(`Saved ${fileName} (${output.assetRefs.parallaxSourceIds.length} parallax asset refs, ID-based layer references).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`); +- } +- +- handleSaveAssetRegistry() { +- this.syncAssetRegistryFromDocument(); +- const validation = this.validateProjectAssets(); +- if (hasBlockingAssetValidationFindings(validation)) { +- this.updateStatus(`${getBlockingAssetValidationMessage("Save Asset Registry", validation)} (${summarizeAssetValidation(validation)}).`); +- return; +- } +- const { findings } = buildAssetDependencyGraph(this.assetRegistry); +- const payload = createRegistryDownloadPayload(this.assetRegistry); +- downloadTextFile(payload, "project.assets.json"); +- this.updateStatus(`Saved project.assets.json (${this.assetRegistry.parallaxSources.length} parallax sources).${summarizeGraphFindings(findings)} Validation: ${summarizeAssetValidation(validation)}.`); +- } +- +- handleExportTilemapPatch() { +- const validation = this.validateProjectAssets(); +- if (hasBlockingAssetValidationFindings(validation)) { +- this.updateStatus(`${getBlockingAssetValidationMessage("Export Parallax Patch", validation)} (${summarizeAssetValidation(validation)}).`); +- return; +- } +- const patch = createTilemapParallaxPatch(createRegistryManagedParallaxSaveDocument(this.documentModel)); +- const payload = JSON.stringify(patch, null, 2); +- const fileName = `${this.documentModel.map.name || "map"}.tilemap-parallax.patch.json`; +- downloadTextFile(payload, fileName); +- this.updateStatus(`Exported ${fileName}. Validation: ${summarizeAssetValidation(validation)}.`); +- } +- +- handlePackageProject() { +- this.touchDocument(); +- this.syncAssetRegistryFromDocument(); +- const validation = this.validateProjectAssets(); +- const persistableDocument = createRegistryManagedParallaxSaveDocument(this.documentModel); +- const packageResult = buildProjectPackage({ +- registry: this.assetRegistry, +- validationResult: validation, +- parallaxDocument: persistableDocument +- }); +- this.lastPackageResult = packageResult; +- this.editorExperienceResult = buildEditorExperienceLayer({ +- assetDependencyGraph: validation.assetDependencyGraph, +- validationResult: validation, +- remediationResult: this.remediationResult, +- packageResult: this.lastPackageResult, +- runtimeResult: this.lastRuntimeResult +- }); +- this.debugVisualizationResult = buildDebugVisualizationLayer({ +- assetDependencyGraph: validation.assetDependencyGraph, +- validationResult: validation, +- remediationResult: this.remediationResult, +- packageResult: this.lastPackageResult, +- runtimeResult: this.lastRuntimeResult +- }); +- this.updateEditorExperienceUI(); +- this.updateDebugVisualizationUI(); +- if (packageResult.packageStatus !== "ready") { +- this.updateStatus(`${summarizeProjectPackaging(packageResult)} ${packageResult.manifest.package.reports[0]?.message || ""}`.trim()); +- return; +- } +- const fileBase = `${this.assetRegistry.projectId || this.documentModel.map.name || "parallax-project"}.package`; +- downloadTextFile(`${JSON.stringify(packageResult.manifest, null, 2)}\n`, `${fileBase}.json`); +- downloadTextFile(`${packageResult.reportText}\n`, `${fileBase}.report.txt`); +- this.updateStatus(`${summarizeProjectPackaging(packageResult)} Manifest and report exported.`); +- } +- +- async handleLoadProject(event) { +- this.exitSimulationMode(); +- const file = event.target.files?.[0]; +- if (!file) { +- return; +- } +- +- try { +- const text = await readFileText(file); +- const raw = JSON.parse(text); +- const guard = assertStandaloneToolDocument(raw, { +- expectedLabel: "Parallax project", +- acceptedSchemas: ["toolbox.parallax/1", "toolbox.tilemap/1"], +- requiredToolId: "parallax-editor" +- }); +- if (!guard.ok) { +- const handled = offerImportMismatchOptions(guard, { +- viewerToolId: "state-inspector", +- viewerPayload: raw, +- sourceToolId: "parallax-editor" +- }); +- if (handled) { +- return; +- } +- throw new Error(guard.reason); +- } +- this.documentModel = extractParallaxDocument(raw); +- this.assetDependencyGraphSnapshot = raw?.project?.assetDependencyGraph || null; +- const resolution = this.resolveAssetRefsFromRegistry(); +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.selectedLayerId = this.documentModel.layers[0]?.id || ""; +- this.clearTransientLayerImageSources(); +- this.invalidateImageCache(); +- this.cameraX = 0; +- this.cameraY = 0; +- this.syncInputsFromDocument(); +- this.renderAll(); +- this.queueLivePreviewSync("load-project"); +- const validation = this.validateProjectAssets(); +- if (resolution.resolvedCount > 0) { +- this.updateStatus(`Loaded ${file.name} (${resolution.resolvedCount} layer image refs restored from asset registry, validation: ${summarizeAssetValidation(validation)}).`); +- } else if (resolution.unresolvedCount > 0) { +- this.updateStatus(`Loaded ${file.name} (${resolution.unresolvedCount} registry image refs unresolved; layer image paths are missing, validation: ${summarizeAssetValidation(validation)}).`); +- } else { +- this.updateStatus(`Loaded ${file.name} (validation: ${summarizeAssetValidation(validation)}).`); +- } +- } catch (error) { +- this.updateStatus(`Load failed: ${error instanceof Error ? error.message : "invalid JSON"}`); +- } +- this.refs.loadProjectInput.value = ""; +- } +- +- async handleLoadAssetRegistry(event) { +- const file = event.target.files?.[0]; +- if (!file) { +- return; +- } +- +- try { +- const text = await readFileText(file); +- const parsed = JSON.parse(text); +- this.assetRegistry = mergeAssetRegistries(this.assetRegistry, parsed); +- const resolution = this.resolveAssetRefsFromRegistry(); +- this.invalidateImageCache(); +- this.renderAll(); +- const validation = this.validateProjectAssets(); +- if (resolution.resolvedCount > 0) { +- this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources; ${resolution.resolvedCount} layer refs restored; validation: ${summarizeAssetValidation(validation)}).`); +- } else if (resolution.unresolvedCount > 0) { +- this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources; ${resolution.unresolvedCount} refs still unresolved; validation: ${summarizeAssetValidation(validation)}).`); +- } else { +- this.updateStatus(`Loaded ${file.name} (${this.assetRegistry.parallaxSources.length} parallax sources, validation: ${summarizeAssetValidation(validation)}).`); +- } +- } catch (error) { +- this.updateStatus(`Asset registry load failed: ${error instanceof Error ? error.message : "invalid JSON"}`); +- } +- this.refs.loadAssetRegistryInput.value = ""; +- } +- +- applyMapMetaFromInputs() { +- const map = normalizeMapMeta({ +- name: this.refs.projectNameInput.value, +- width: this.refs.mapWidthInput.value, +- height: this.refs.mapHeightInput.value, +- tileSize: this.refs.tileSizeInput.value +- }); +- +- this.documentModel.map = map; +- this.updateCameraInputBounds(); +- this.touchDocument(); +- this.renderPreview(); +- this.refs.previewMeta.textContent = `${map.width}x${map.height} @ ${map.tileSize}px (${map.pixelWidth}x${map.pixelHeight})`; +- this.updateStatus("Updated map metadata for parallax preview."); +- } +- +- addLayer() { +- const name = this.refs.newLayerNameInput.value.trim() || `Parallax Layer ${this.documentModel.layers.length + 1}`; +- const newLayer = createDefaultLayer(this.documentModel.layers.length, name); +- newLayer.drawOrder = this.documentModel.layers.length; +- +- this.documentModel.layers.push(newLayer); +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.selectedLayerId = newLayer.id; +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Added layer ${name}.`); +- } +- +- removeSelectedLayer() { +- if (this.documentModel.layers.length <= 1) { +- this.updateStatus("Cannot remove the last parallax layer."); +- return; +- } +- +- const index = this.documentModel.layers.findIndex((layer) => layer.id === this.selectedLayerId); +- if (index < 0) { +- return; +- } +- +- const removed = this.documentModel.layers[index]; +- this.documentModel.layers.splice(index, 1); +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.selectedLayerId = this.documentModel.layers[Math.max(0, index - 1)]?.id || this.documentModel.layers[0].id; +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Removed layer ${removed.name}.`); +- } +- +- duplicateSelectedLayer() { +- const selected = this.getSelectedLayer(); +- if (!selected) { +- return; +- } +- +- const duplicate = normalizeLayer(cloneDeep(selected), this.documentModel.layers.length); +- duplicate.id = `parallax-layer-${Date.now()}-${Math.floor(Math.random() * 10000)}`; +- duplicate.name = `${selected.name} Copy`; +- duplicate.drawOrder = this.documentModel.layers.length; +- +- this.documentModel.layers.push(duplicate); +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.selectedLayerId = duplicate.id; +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Duplicated layer ${selected.name}.`); +- } +- +- moveSelectedLayer(direction) { +- const currentIndex = this.documentModel.layers.findIndex((layer) => layer.id === this.selectedLayerId); +- if (currentIndex < 0) { +- return; +- } +- +- const nextIndex = currentIndex + direction; +- if (nextIndex < 0 || nextIndex >= this.documentModel.layers.length) { +- return; +- } +- +- const [layer] = this.documentModel.layers.splice(currentIndex, 1); +- this.documentModel.layers.splice(nextIndex, 0, layer); +- // Preserve the user-driven array move, then reindex drawOrder without re-sorting. +- this.documentModel.layers.forEach((entry, index) => { +- entry.drawOrder = index; +- }); +- this.selectedLayerId = layer.id; +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Moved layer ${layer.name} ${direction < 0 ? "up" : "down"}.`); +- } +- +- applyBasicLayerFields() { +- const layer = this.getSelectedLayer(); +- if (!layer) { +- return; +- } +- +- layer.name = this.refs.layerNameInput.value.trim() || layer.name; +- layer.drawOrder = Math.trunc(clamp(this.refs.layerDrawOrderInput.value, -999, 999, layer.drawOrder)); +- layer.opacity = clamp(this.refs.layerOpacityInput.value, 0, 1, layer.opacity); +- layer.visible = this.refs.layerVisibleSelect.value === "true"; +- +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Updated basic layer settings for ${layer.name}.`); +- } +- +- applyExtendedLayerSettings() { +- const layer = this.getSelectedLayer(); +- if (!layer) { +- return; +- } +- +- layer.scrollFactorX = clamp(this.refs.scrollFactorXInput.value, -4, 4, layer.scrollFactorX); +- layer.scrollFactorY = clamp(this.refs.scrollFactorYInput.value, -4, 4, layer.scrollFactorY); +- layer.offsetX = Math.trunc(clamp(this.refs.offsetXInput.value, -4096, 4096, layer.offsetX)); +- layer.offsetY = Math.trunc(clamp(this.refs.offsetYInput.value, -4096, 4096, layer.offsetY)); +- layer.repeatX = this.refs.repeatXSelect.value === "true"; +- layer.repeatY = this.refs.repeatYSelect.value === "true"; +- layer.wrapMode = this.refs.wrapModeSelect.value === "clamp" ? "clamp" : "wrap"; +- +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Applied scroll/repeat settings for ${layer.name}.`); +- } +- +- applyImageSourceFromInput() { +- const layer = this.getSelectedLayer(); +- if (!layer) { +- return; +- } +- +- const source = this.refs.layerImageSourceInput.value.trim(); +- if (layer.imageSource && layer.imageSource !== source) { +- const previousTransient = this.getTransientLayerImageSource(layer.imageSource); +- if (previousTransient) { +- URL.revokeObjectURL(previousTransient); +- this.transientLayerImageSourceByName.delete(layer.imageSource.trim()); +- } +- } +- layer.imageSource = source; +- +- this.invalidateImageCache(); +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(source ? `Assigned image source to ${layer.name}.` : `Cleared image source for ${layer.name}.`); +- } +- +- assignLocalImageFile(event) { +- const layer = this.getSelectedLayer(); +- if (!layer) { +- return; +- } +- +- const file = event.target.files?.[0]; +- if (!file) { +- return; +- } +- +- const previousSource = layer.imageSource; +- const objectUrl = URL.createObjectURL(file); +- layer.imageSource = file.name; +- this.setTransientLayerImageSource(layer.imageSource, objectUrl); +- if (previousSource && previousSource !== layer.imageSource) { +- const previousTransient = this.getTransientLayerImageSource(previousSource); +- if (previousTransient) { +- URL.revokeObjectURL(previousTransient); +- this.transientLayerImageSourceByName.delete(previousSource.trim()); +- } +- } +- this.invalidateImageCache(); +- this.touchDocument(); +- this.renderAll(); +- this.updateStatus(`Assigned local image file ${file.name} to ${layer.name}.`); +- this.refs.layerImageFileInput.value = ""; +- } +- +- handleCameraChange() { +- if (this.isSimulationMode) { +- return; +- } +- const maxHorizontal = Math.max(1024, this.documentModel.map.pixelWidth); +- const maxVertical = Math.max(1024, this.documentModel.map.pixelHeight); +- this.cameraX = Math.trunc(clamp(this.refs.cameraXInput.value, -maxHorizontal, maxHorizontal, 0)); +- this.cameraY = Math.trunc(clamp(this.refs.cameraYInput.value, -maxVertical, maxVertical, 0)); +- this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; +- this.renderPreview(); +- } +- +- renderAll() { +- normalizeDrawOrderSequence(this.documentModel.layers); +- this.renderLayerList(); +- this.renderSelectedLayerFields(); +- this.renderPreview(); +- this.refs.previewMeta.textContent = `${this.documentModel.map.width}x${this.documentModel.map.height} @ ${this.documentModel.map.tileSize}px (${this.documentModel.map.pixelWidth}x${this.documentModel.map.pixelHeight})`; +- this.refs.cameraReadout.textContent = `camera: ${this.cameraX}, ${this.cameraY}`; +- this.refreshSimulationActionState(); +- this.updateSimulationContextReadout(); +- this.updateRemediationUI(); +- this.updateEditorExperienceUI(); +- this.updateDebugVisualizationUI(); +- } +- +- renderLayerList() { +- const list = this.refs.layerList; +- list.innerHTML = ""; +- +- const fragment = document.createDocumentFragment(); +- this.documentModel.layers.forEach((layer) => { +- const item = document.createElement("li"); +- item.className = `layer-item${layer.id === this.selectedLayerId ? " selected" : ""}`; +- +- const row = document.createElement("div"); +- row.className = "layer-row"; +- +- const selectButton = document.createElement("button"); +- selectButton.type = "button"; +- selectButton.className = "layer-select-btn"; +- selectButton.textContent = layer.name; +- selectButton.addEventListener("click", () => { +- this.selectedLayerId = layer.id; +- this.renderAll(); +- this.updateStatus(`Selected layer ${layer.name}.`); +- }); +- +- const orderChip = document.createElement("span"); +- orderChip.className = "layer-order-chip"; +- orderChip.textContent = `order ${layer.drawOrder}`; +- +- row.appendChild(selectButton); +- row.appendChild(orderChip); +- item.appendChild(row); +- fragment.appendChild(item); +- }); +- +- list.appendChild(fragment); +- } +- +- renderSelectedLayerFields() { +- const layer = this.getSelectedLayer(); +- if (!layer) { +- return; +- } +- +- this.refs.layerNameInput.value = layer.name; +- this.refs.layerDrawOrderInput.value = String(layer.drawOrder); +- this.refs.layerOpacityInput.value = String(layer.opacity); +- this.refs.layerVisibleSelect.value = layer.visible ? "true" : "false"; +- +- this.refs.layerImageSourceInput.value = layer.imageSource || ""; +- this.refs.scrollFactorXInput.value = String(layer.scrollFactorX); +- this.refs.scrollFactorYInput.value = String(layer.scrollFactorY); +- this.refs.offsetXInput.value = String(layer.offsetX); +- this.refs.offsetYInput.value = String(layer.offsetY); +- this.refs.repeatXSelect.value = layer.repeatX ? "true" : "false"; +- this.refs.repeatYSelect.value = layer.repeatY ? "true" : "false"; +- this.refs.wrapModeSelect.value = layer.wrapMode; +- } +- +- resolveLayerImageUrl(source) { +- if (!source || source.startsWith("blob:")) { +- return source; +- } +- +- try { +- const url = new URL(source, window.location.href); +- url.searchParams.set("_cb", String(this.imageCacheVersion)); +- return url.toString(); +- } catch (error) { +- const separator = source.includes("?") ? "&" : "?"; +- return `${source}${separator}_cb=${this.imageCacheVersion}`; +- } +- } +- +- getLayerImageRecord(layer) { +- const source = this.getTransientLayerImageSource(layer.imageSource) || layer.imageSource; +- if (!source) { +- return null; +- } +- +- const cacheKey = `${source}::v${this.imageCacheVersion}`; +- if (this.imageCache.has(cacheKey)) { +- return this.imageCache.get(cacheKey); +- } +- +- const record = { +- status: "loading", +- image: new Image(), +- error: null +- }; +- +- record.image.onload = () => { +- record.status = "ready"; +- this.renderPreview(); +- }; +- record.image.onerror = () => { +- record.status = "error"; +- record.error = "Image failed to load."; +- this.renderPreview(); +- }; +- +- if (!source.startsWith("blob:")) { +- record.image.crossOrigin = "anonymous"; +- } +- record.image.src = this.resolveLayerImageUrl(source); +- +- this.imageCache.set(cacheKey, record); +- return record; +- } +- +- drawFallbackLayer(context, layer, viewportWidth, viewportHeight, screenX, screenY) { +- const color = getLayerVisualColor(layer); +- context.fillStyle = color; +- +- if (layer.repeatX || layer.repeatY) { +- context.globalAlpha = layer.opacity * 0.25; +- context.fillRect(0, 0, viewportWidth, viewportHeight); +- context.globalAlpha = layer.opacity * 0.6; +- const stripeHeight = Math.max(24, Math.floor(viewportHeight / 10)); +- for (let y = 0; y < viewportHeight; y += stripeHeight * 2) { +- context.fillRect(0, y, viewportWidth, stripeHeight); +- } +- return; +- } +- +- const width = Math.max(240, Math.floor(viewportWidth * 0.4)); +- const height = Math.max(140, Math.floor(viewportHeight * 0.25)); +- context.globalAlpha = layer.opacity * 0.65; +- context.fillRect(screenX, screenY, width, height); +- } +- +- drawSingleLayer(context, layer, viewportWidth, viewportHeight, mapOriginX, mapOriginY) { +- if (!layer.visible || layer.opacity <= 0) { +- return; +- } +- +- const screenX = Math.round(mapOriginX + (-this.cameraX * layer.scrollFactorX) + layer.offsetX); +- const screenY = Math.round(mapOriginY + (-this.cameraY * layer.scrollFactorY) + layer.offsetY); +- +- context.save(); +- context.globalAlpha = layer.opacity; +- +- const imageRecord = this.getLayerImageRecord(layer); +- if (!imageRecord || imageRecord.status === "error" || imageRecord.status === "loading") { +- this.drawFallbackLayer(context, layer, viewportWidth, viewportHeight, screenX, screenY); +- context.restore(); +- return; +- } +- +- const image = imageRecord.image; +- const tileWidth = Math.max(1, image.naturalWidth || image.width || viewportWidth); +- const tileHeight = Math.max(1, image.naturalHeight || image.height || viewportHeight); +- +- const xPositions = []; +- const yPositions = []; +- +- if (layer.repeatX) { +- if (layer.wrapMode === "wrap") { +- let startX = mod(screenX, tileWidth) - tileWidth; +- for (let x = startX; x <= viewportWidth + tileWidth; x += tileWidth) { +- xPositions.push(x); +- } +- } else { +- for (let x = screenX; x <= viewportWidth + tileWidth; x += tileWidth) { +- xPositions.push(x); +- } +- for (let x = screenX - tileWidth; x >= -tileWidth; x -= tileWidth) { +- xPositions.push(x); +- } +- } +- } else { +- xPositions.push(screenX); +- } +- +- if (layer.repeatY) { +- if (layer.wrapMode === "wrap") { +- let startY = mod(screenY, tileHeight) - tileHeight; +- for (let y = startY; y <= viewportHeight + tileHeight; y += tileHeight) { +- yPositions.push(y); +- } +- } else { +- for (let y = screenY; y <= viewportHeight + tileHeight; y += tileHeight) { +- yPositions.push(y); +- } +- for (let y = screenY - tileHeight; y >= -tileHeight; y -= tileHeight) { +- yPositions.push(y); +- } +- } +- } else { +- yPositions.push(screenY); +- } +- +- for (let yi = 0; yi < yPositions.length; yi += 1) { +- for (let xi = 0; xi < xPositions.length; xi += 1) { +- context.drawImage(image, Math.round(xPositions[xi]), Math.round(yPositions[yi])); +- } +- } +- +- context.restore(); +- } +- +- ensureSimulationViewportFocus(focusX) { +- if (!this.isSimulationMode || !this.refs.previewWrap) { +- return; +- } +- const wrap = this.refs.previewWrap; +- const maxScrollX = Math.max(0, wrap.scrollWidth - wrap.clientWidth); +- const targetLeft = Math.max(0, Math.min(maxScrollX, Math.round(focusX - (wrap.clientWidth * 0.45)))); +- if (wrap.scrollLeft !== targetLeft) { +- wrap.scrollLeft = targetLeft; +- } +- if (wrap.scrollTop !== 0) { +- wrap.scrollTop = 0; +- } +- } +- +- renderPreview() { +- const context = this.refs.previewContext; +- const canvas = this.refs.previewCanvas; +- const desiredWidth = Math.max(960, Math.min(4096, this.documentModel.map.pixelWidth + 360)); +- const desiredHeight = Math.max(540, Math.min(2160, this.documentModel.map.pixelHeight + 260)); +- if (canvas.width !== desiredWidth) { +- canvas.width = desiredWidth; +- } +- if (canvas.height !== desiredHeight) { +- canvas.height = desiredHeight; +- } +- const viewportWidth = canvas.width; +- const viewportHeight = canvas.height; +- const worldWidth = this.documentModel.map.pixelWidth; +- const worldHeight = this.documentModel.map.pixelHeight; +- const mapOriginX = Math.max(20, Math.round((viewportWidth - worldWidth) / 2)); +- const mapOriginY = Math.max(20, Math.round((viewportHeight - worldHeight) / 2)); +- +- context.fillStyle = "#0b1830"; +- context.fillRect(0, 0, viewportWidth, viewportHeight); +- +- context.fillStyle = "#0f2340"; +- for (let y = 0; y < viewportHeight; y += 48) { +- context.fillRect(0, y, viewportWidth, 24); +- } +- +- const sortedLayers = cloneDeep(this.documentModel.layers); +- sortLayersByOrder(sortedLayers); +- +- const progress = this.getSimulationProgress(); +- const proxyX = mapOriginX + Math.round(progress * Math.max(1, worldWidth - 1)); +- const proxyY = mapOriginY + Math.round(worldHeight * 0.62); +- const visibleHeroLayer = sortedLayers.find((layer) => { +- if (!layer.visible) { +- return false; +- } +- const id = typeof layer.id === "string" ? layer.id.toLowerCase() : ""; +- const name = typeof layer.name === "string" ? layer.name.toLowerCase() : ""; +- return id.includes("hero") || name.includes("hero"); +- }); +- const heroDrawOrder = visibleHeroLayer ? visibleHeroLayer.drawOrder : null; +- let markerDrawn = false; +- const drawTraversalMarker = (includeTrackLine) => { +- if (includeTrackLine) { +- context.strokeStyle = "rgba(244, 244, 245, 0.35)"; +- context.lineWidth = 1; +- context.beginPath(); +- context.moveTo(proxyX, mapOriginY); +- context.lineTo(proxyX, mapOriginY + worldHeight); +- context.stroke(); +- } +- context.fillStyle = this.isSimulationMode ? "#f59e0b" : "#e2e8f0"; +- context.beginPath(); +- context.arc(proxyX, proxyY, 8, 0, Math.PI * 2); +- context.fill(); +- }; +- +- sortedLayers.forEach((layer) => { +- this.drawSingleLayer(context, layer, viewportWidth, viewportHeight, mapOriginX, mapOriginY); +- if (!markerDrawn && heroDrawOrder !== null && layer.drawOrder >= heroDrawOrder) { +- drawTraversalMarker(false); +- markerDrawn = true; +- } +- }); +- if (!markerDrawn) { +- drawTraversalMarker(true); +- } +- +- context.strokeStyle = "rgba(255, 255, 255, 0.45)"; +- context.lineWidth = 2; +- context.strokeRect(mapOriginX, mapOriginY, worldWidth, worldHeight); +- this.ensureSimulationViewportFocus(proxyX); +- +- const modeText = this.isSimulationMode +- ? `SIMULATION ${this.simulation.playing ? "PLAY" : "PAUSE"}` +- : "EDIT"; +- const repeatCount = sortedLayers.filter((layer) => layer.repeatX || layer.repeatY).length; +- const wrapCount = sortedLayers.filter((layer) => layer.wrapMode === "wrap").length; +- +- if (this.refs.previewDetailsText instanceof HTMLElement) { +- const lines = [ +- `Mode: ${modeText}`, +- `Map: ${this.documentModel.map.name}`, +- `Camera: ${this.cameraX}, ${this.cameraY}`, +- `Traverse: ${Math.round(progress * 100)}% (${Math.round(this.simulation.traversedDistance)}/${Math.round(this.simulation.traversalDistance || 0)} px)`, +- `Layers: ${sortedLayers.length} repeat=${repeatCount} wrap=${wrapCount}` +- ]; +- sortedLayers.slice(0, 3).forEach((layer) => { +- lines.push(`${layer.drawOrder}:${layer.name} sf(${layer.scrollFactorX.toFixed(2)},${layer.scrollFactorY.toFixed(2)}) ${layer.repeatX ? "RX" : "--"}/${layer.repeatY ? "RY" : "--"} ${layer.wrapMode}`); +- }); +- this.refs.previewDetailsText.textContent = lines.join("\n"); +- } +- this.updateSimulationContextReadout(); +- } +- +- validateProjectAssets() { +- this.validationResult = validateProjectAssetState({ +- registry: this.assetRegistry, +- assetDependencyGraph: this.assetDependencyGraphSnapshot, +- parallaxDocument: this.documentModel +- }); +- this.remediationResult = buildProjectAssetRemediation({ +- validationResult: this.validationResult, +- registry: this.assetRegistry +- }); +- this.editorExperienceResult = buildEditorExperienceLayer({ +- assetDependencyGraph: this.validationResult.assetDependencyGraph, +- validationResult: this.validationResult, +- remediationResult: this.remediationResult, +- packageResult: this.lastPackageResult, +- runtimeResult: this.lastRuntimeResult +- }); +- this.debugVisualizationResult = buildDebugVisualizationLayer({ +- assetDependencyGraph: this.validationResult.assetDependencyGraph, +- validationResult: this.validationResult, +- remediationResult: this.remediationResult, +- packageResult: this.lastPackageResult, +- runtimeResult: this.lastRuntimeResult +- }); +- return this.validationResult; +- } +- +- updateRemediationUI() { +- if (!this.refs.remediationSummaryText) { +- return; +- } +- this.refs.remediationSummaryText.textContent = summarizeProjectAssetRemediation(this.remediationResult); +- const navigateAction = getPrimaryRemediationAction(this.remediationResult, "navigate"); +- const fixAction = getPrimaryRemediationAction(this.remediationResult, "confirmable-fix"); +- this.refs.inspectRemediationButton.disabled = this.remediationResult?.remediation?.status !== "available"; +- this.refs.jumpToProblemButton.disabled = !navigateAction; +- this.refs.applyRemediationButton.disabled = !fixAction; +- } +- +- updateEditorExperienceUI() { +- if (!this.refs.experienceSummaryText || !this.refs.experienceDetailsText) { +- return; +- } +- this.refs.experienceSummaryText.textContent = summarizeEditorExperienceLayer(this.editorExperienceResult); +- this.refs.experienceDetailsText.textContent = this.editorExperienceResult?.experience?.reportText || "No experience snapshot available."; +- } +- +- updateDebugVisualizationUI() { +- if (!this.refs.debugSummaryText || !this.refs.debugDetailsText) { +- return; +- } +- this.refs.debugSummaryText.textContent = summarizeDebugVisualizationLayer(this.debugVisualizationResult); +- this.refs.debugDetailsText.textContent = this.debugVisualizationResult?.debugVisualization?.reportText || "No debug visualization available."; +- } +- +- refreshExperienceSnapshot() { +- this.validateProjectAssets(); +- this.updateEditorExperienceUI(); +- this.updateStatus(summarizeEditorExperienceLayer(this.editorExperienceResult)); +- } +- +- refreshDebugVisualizationSnapshot() { +- this.validateProjectAssets(); +- this.updateDebugVisualizationUI(); +- this.updateStatus(summarizeDebugVisualizationLayer(this.debugVisualizationResult)); +- } +- +- inspectRemediationActions() { +- this.validateProjectAssets(); +- const actions = this.remediationResult?.remediation?.actions || []; +- if (actions.length === 0) { +- this.updateStatus("No remediation actions are currently available."); +- this.updateRemediationUI(); +- return false; +- } +- this.updateStatus(`Remediation actions: ${actions.slice(0, 3).map((action) => `${action.label}: ${action.message}`).join(" | ")}`); +- this.updateRemediationUI(); +- return true; +- } +- +- jumpToRemediationProblem() { +- this.validateProjectAssets(); +- const action = getPrimaryRemediationAction(this.remediationResult, "navigate"); +- if (!action) { +- this.updateStatus("No navigation remediation action is available."); +- this.updateRemediationUI(); +- return false; +- } +- +- if (action.findingCode === "UNRESOLVED_IMAGE_LINK") { +- const targetLayer = this.documentModel.layers.find((layer) => layer.id === action.sourceId) || this.documentModel.layers[0] || null; +- if (targetLayer) { +- this.selectedLayerId = targetLayer.id; +- } +- this.renderAll(); +- this.refs.layerImageSourceInput.focus(); +- this.updateStatus(`Jumped to image assignment for ${action.sourceId || "the current layer"}: ${action.message}`); +- } else if (action.findingCode === "MISSING_ASSET_ID" || action.findingCode === "DUPLICATE_REGISTRY_ID") { +- this.refs.saveAssetRegistryButton.focus(); +- this.updateStatus(`Jumped to project I/O: ${action.message}`); +- } else { +- this.updateStatus(`Inspected ${action.sourceId || "the current issue"}: ${action.message}`); +- } +- this.updateRemediationUI(); +- return true; +- } +- +- applyRemediationAction() { +- this.validateProjectAssets(); +- const action = getPrimaryRemediationAction(this.remediationResult, "confirmable-fix"); +- if (!action) { +- this.updateStatus("No confirmable remediation fix is available."); +- this.updateRemediationUI(); +- return false; +- } +- +- const shouldApply = typeof globalThis.confirm === "function" +- ? globalThis.confirm(`${action.label}? ${action.message}`) +- : true; +- if (!shouldApply) { +- this.updateStatus("Remediation fix canceled."); +- this.updateRemediationUI(); +- return false; +- } +- +- if (action.payload?.fixKind === "refresh-owned-registry-entry") { +- this.syncAssetRegistryFromDocument(); +- } else if (action.payload?.fixKind === "relink-reference" && action.payload.referenceField === "parallaxSourceId") { +- const targetLayer = this.documentModel.layers.find((layer) => layer.id === action.sourceId) || this.getSelectedLayer(); +- if (targetLayer) { +- targetLayer.parallaxSourceId = action.payload.candidateId || ""; +- } +- this.resolveAssetRefsFromRegistry(); +- } else if (action.payload?.fixKind === "refresh-graph-snapshot") { +- const validation = validateProjectAssetState({ +- registry: this.assetRegistry, +- parallaxDocument: this.documentModel +- }); +- this.assetDependencyGraphSnapshot = validation.assetDependencyGraph; +- } +- +- const validation = this.validateProjectAssets(); +- this.renderAll(); +- this.updateStatus(`Applied remediation: ${action.label}. Validation: ${summarizeAssetValidation(validation)}.`); +- return true; +- } +-} +- +-let parallaxSceneStudioApp = null; +- +-function isStrictWorkspaceParallaxSnapshot(snapshot) { +- if (!snapshot || typeof snapshot !== "object") { +- return false; +- } +- const documentModel = snapshot.documentModel; +- const assetRegistry = snapshot.assetRegistry; +- if (!documentModel || typeof documentModel !== "object") { +- return false; +- } +- if (documentModel.schema !== "toolbox.parallax/1") { +- return false; +- } +- if (!documentModel.map || typeof documentModel.map !== "object") { +- return false; +- } +- if (!Array.isArray(documentModel.layers)) { +- return false; +- } +- if (!assetRegistry || typeof assetRegistry !== "object") { +- return false; +- } +- return true; +-} +- +-function bootParallaxSceneStudio() { +- if (parallaxSceneStudioApp) { +- window.parallaxSceneStudioApp = parallaxSceneStudioApp; +- return parallaxSceneStudioApp; +- } +- +- const initialDocument = createInitialParallaxDocument(); +- const app = new ParallaxEditorApp(initialDocument); +- app.init(document); +- app.applyProjectSystemState = function applyProjectSystemState(snapshot) { +- if (Date.now() <= Number(this.skipExternalProjectStateUntil || 0)) { +- this.skipExternalProjectStateUntil = 0; +- return true; +- } +- if (!isStrictWorkspaceParallaxSnapshot(snapshot)) { +- this.updateStatus("Project state rejected: workspace snapshot is missing required parallax source data."); +- return false; +- } +- const nextDocument = sanitizeParallaxDocument(snapshot?.documentModel); +- this.documentModel = nextDocument; +- this.assetRegistry = sanitizeAssetRegistry(snapshot.assetRegistry); +- this.selectedLayerId = typeof snapshot?.selectedLayerId === "string" && nextDocument.layers.some((layer) => layer.id === snapshot.selectedLayerId) +- ? snapshot.selectedLayerId +- : nextDocument.layers[0]?.id || ""; +- this.cameraX = Number.isFinite(Number(snapshot?.cameraX)) ? Number(snapshot.cameraX) : 0; +- this.cameraY = Number.isFinite(Number(snapshot?.cameraY)) ? Number(snapshot.cameraY) : 0; +- this.resolveAssetRefsFromRegistry(); +- this.clearTransientLayerImageSources(); +- this.invalidateImageCache(); +- this.syncInputsFromDocument(); +- this.renderAll(); +- this.updateStatus(`Project state loaded for ${this.documentModel.map.name}.`); +- return true; +- }; +- parallaxSceneStudioApp = app; +- window.parallaxSceneStudioApp = parallaxSceneStudioApp; +- return parallaxSceneStudioApp; +-} +- +-registerToolBootContract("parallax-editor", { +- init: bootParallaxSceneStudio, +- destroy() { +- return true; +- }, +- getApi() { +- return window.parallaxSceneStudioApp || null; +- } +-}); +- +-bootParallaxSceneStudio(); +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css +deleted file mode 100644 +index 7aabd9a85..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/parallaxEditor.css ++++ /dev/null +@@ -1,419 +0,0 @@ +-/* +-Toolbox Aid +-David Quesenberry +-03/30/2026 +-parallaxEditor.css +-*/ +-.parallax-editor-page { +- --parallax-border: var(--line, rgba(221, 214, 254, 0.26)); +- --parallax-text: var(--text, #f7f4ff); +- --parallax-muted: var(--muted, #e9ddff); +- --parallax-accent: var(--accent, #ddd6fe); +- margin: 0; +- min-height: 100vh; +- color: var(--parallax-text); +-} +- +-.app-shell { +- min-height: 100vh; +- display: flex; +- flex-direction: column; +- gap: 12px; +- padding: 12px; +- box-sizing: border-box; +-} +- +-.toolbar { +- display: flex; +- flex-wrap: wrap; +- gap: 12px; +- border: 1px solid var(--parallax-border); +- border-radius: 10px; +- background: var(--surface-inline, rgba(43, 16, 91, 0.9)); +- padding: 10px; +-} +- +-.toolbar-group { +- display: flex; +- flex-wrap: wrap; +- align-items: flex-end; +- gap: 8px; +-} +- +-label { +- display: flex; +- flex-direction: column; +- gap: 4px; +- font-size: 12px; +- color: var(--parallax-muted); +-} +- +-input, +-select, +-button { +- font: inherit; +-} +- +-input, +-select { +- color: var(--parallax-text); +- background: var(--surface-inline, rgba(43, 16, 91, 0.9)); +- border: 1px solid var(--parallax-border); +- border-radius: 6px; +- padding: 5px 8px; +-} +- +-button, +-.file-input-label { +- color: var(--parallax-text); +- background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); +- border: 1px solid var(--parallax-border); +- border-radius: 6px; +- padding: 6px 10px; +- cursor: pointer; +-} +- +-button:hover, +-.file-input-label:hover { +- border-color: var(--card-hover-border, #c4b5fd); +- background: color-mix(in srgb, var(--panel, rgba(76, 29, 149, 0.88)) 72%, var(--accent, #ddd6fe)); +-} +- +-.inline-label { +- display: inline-flex; +- align-items: center; +- justify-content: center; +-} +- +-.hidden-file-input { +- position: absolute; +- width: 1px; +- height: 1px; +- opacity: 0; +- pointer-events: none; +-} +- +-.workspace { +- display: grid; +- grid-template-columns: minmax(250px, 320px) minmax(420px, 1fr) minmax(260px, 360px); +- gap: 12px; +- flex: 1; +- min-height: 0; +-} +- +-.sidebar, +-.preview-panel { +- border: 1px solid var(--parallax-border); +- border-radius: 10px; +- background: var(--surface-inline, rgba(43, 16, 91, 0.9)); +- min-height: 0; +-} +- +-.sidebar { +- padding: 10px; +- overflow: auto; +-} +- +-.panel { +- border: 1px solid var(--parallax-border); +- border-radius: 8px; +- padding: 8px; +- margin-bottom: 10px; +- background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); +-} +- +-.panel h3 { +- margin: 0 0 8px; +- font-size: 14px; +-} +- +-.panel-accordion { +- margin: 0 0 10px; +- border: 1px solid var(--parallax-border); +- border-radius: 8px; +- background: linear-gradient(180deg, var(--panel, rgba(76, 29, 149, 0.88)) 0%, var(--panel2, rgba(49, 17, 102, 0.94)) 100%); +- overflow: hidden; +-} +- +-.panel-accordion__summary { +- list-style: none; +- cursor: pointer; +- padding: 8px; +- border-bottom: 1px solid transparent; +-} +- +-.panel-accordion__summary::-webkit-details-marker { +- display: none; +-} +- +-.panel-accordion__summary h3 { +- margin: 0; +- font-size: 14px; +-} +- +-.panel-accordion[open] .panel-accordion__summary { +- border-bottom-color: var(--parallax-border); +-} +- +-.panel-accordion__body { +- padding: 8px; +-} +- +-.preview-panel { +- display: flex; +- flex-direction: column; +- padding: 10px; +-} +- +-.preview-toolbar { +- display: flex; +- justify-content: space-between; +- align-items: center; +- flex-wrap: wrap; +- gap: 8px; +- margin-bottom: 8px; +-} +- +-.preview-wrap { +- position: relative; +- flex: 1; +- min-height: 260px; +- border: 1px solid var(--parallax-border); +- border-radius: 8px; +- overflow: auto; +- background: var(--surface-code, rgba(34, 12, 74, 0.92)); +-} +- +-#previewCanvas { +- image-rendering: auto; +- display: block; +-} +- +-.status-text, +-.muted { +- color: var(--parallax-muted); +- font-size: 12px; +-} +- +-.status-text { +- margin-top: 8px; +-} +- +-.preview-details-text { +- margin: 8px 0 0; +- min-height: 84px; +- max-height: 180px; +- overflow: auto; +- border: 1px solid var(--parallax-border); +- border-radius: 8px; +- padding: 10px; +- background: rgba(15, 23, 42, 0.72); +- color: #dbeafe; +- font: 12px/1.35 Consolas, Monaco, monospace; +- white-space: pre-wrap; +-} +- +-.layer-list { +- list-style: none; +- margin: 0; +- padding: 0; +- display: flex; +- flex-direction: column; +- gap: 6px; +-} +- +-.layer-item { +- border: 1px solid var(--parallax-border); +- border-radius: 6px; +- background: var(--surface-inline, rgba(43, 16, 91, 0.9)); +- padding: 6px; +-} +- +-.layer-item.selected { +- border-color: var(--parallax-accent); +- box-shadow: 0 0 0 1px color-mix(in srgb, var(--parallax-accent) 30%, transparent) inset; +-} +- +-.layer-row { +- display: flex; +- gap: 6px; +- align-items: center; +-} +- +-.layer-select-btn { +- flex: 1; +-} +- +-.layer-order-chip { +- font-size: 10px; +- color: var(--parallax-muted); +- border: 1px solid var(--parallax-border); +- border-radius: 999px; +- padding: 2px 6px; +- line-height: 1.2; +-} +- +-.row { +- display: flex; +- gap: 6px; +- align-items: center; +- margin-top: 6px; +-} +- +-.row.buttons { +- flex-wrap: wrap; +-} +- +-.usage-list { +- margin: 0; +- padding-left: 18px; +- color: var(--parallax-muted); +- font-size: 12px; +-} +- +-.parallax-editor-page .fullscreen-overlay-button { +- position: relative; +- z-index: 80; +- display: none; +- min-height: 32px; +- padding: 0 10px; +-} +- +-.parallax-editor-page .fullscreen-overlay-toggle-group { +- display: none; +- position: fixed; +- top: 96px; +- z-index: 80; +- gap: 8px; +- flex-direction: column; +-} +- +-.parallax-editor-page .fullscreen-overlay-toggle-group--left { +- left: 10px; +- align-items: flex-start; +-} +- +-.parallax-editor-page .fullscreen-overlay-toggle-group--right { +- right: 10px; +- align-items: flex-end; +-} +- +-.parallax-editor-page .overlay-toggle-symbol { +- display: inline-flex; +- align-items: center; +- justify-content: center; +- width: 14px; +- font-weight: 900; +- line-height: 1; +-} +- +-body.parallax-editor-page.fullscreen-mode .app-shell { +- min-height: 100vh; +- min-height: 100dvh; +- overflow: hidden; +- padding: 8px; +- gap: 8px; +-} +- +-body.parallax-editor-page.fullscreen-mode .toolbar { +- padding: 8px; +- gap: 8px; +-} +- +-body.parallax-editor-page.fullscreen-mode .toolbar-group.tools-platform-control-cluster--workflow, +-body.parallax-editor-page.fullscreen-mode .toolbar-group.tools-platform-control-cluster--preview { +- display: none; +-} +- +-body.parallax-editor-page.fullscreen-mode .workspace { +- grid-template-columns: minmax(0, 1fr); +- height: calc(100dvh - 90px); +- gap: 8px; +-} +- +-body.parallax-editor-page.fullscreen-mode .sidebar { +- display: none; +-} +- +-body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay { +- display: block; +- position: fixed; +- top: 70px; +- bottom: 8px; +- width: min(360px, calc(100vw - 24px)); +- z-index: 50; +- overflow: auto; +- padding-top: 10px; +-} +- +-body.parallax-editor-page.fullscreen-mode .left-sidebar.visible-overlay { +- left: 12px; +-} +- +-body.parallax-editor-page.fullscreen-mode .right-sidebar.visible-overlay { +- right: 12px; +-} +- +-body.parallax-editor-page.fullscreen-mode .preview-panel { +- width: 100%; +- min-width: 0; +- height: 100%; +- padding: 8px; +-} +- +-body.parallax-editor-page.fullscreen-mode .preview-wrap { +- overflow: hidden; +- min-height: 0; +- height: 100%; +-} +- +-body.parallax-editor-page.fullscreen-mode #previewCanvas { +- width: 100%; +- height: 100%; +-} +- +-body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-button { +- display: inline-flex; +- align-items: center; +- justify-content: center; +- gap: 6px; +-} +- +-body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-toggle-group { +- display: flex; +-} +- +-body.parallax-editor-page.fullscreen-mode .fullscreen-overlay-button.is-hidden-while-overlay-open { +- visibility: hidden; +- pointer-events: none; +-} +- +-body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion__summary { +- display: flex; +- align-items: center; +- gap: 8px; +-} +- +-body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion__summary::before { +- content: "+"; +- display: inline-flex; +- align-items: center; +- justify-content: center; +- width: 14px; +- font-weight: 900; +- line-height: 1; +-} +- +-body.parallax-editor-page.fullscreen-mode .sidebar.visible-overlay .panel-accordion[open] > .panel-accordion__summary::before { +- content: "-"; +-} +- +-body.parallax-editor-page.fullscreen-mode .preview-details-text { +- max-height: 150px; +-} +- +-@media (max-width: 1260px) { +- .workspace { +- grid-template-columns: 1fr; +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg +deleted file mode 100644 +index 5b50167a8..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/cloud-bands.svg ++++ /dev/null +@@ -1,23 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg +deleted file mode 100644 +index d46c9f740..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/foreground-occluder.svg ++++ /dev/null +@@ -1,19 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg +deleted file mode 100644 +index 5ab3498d9..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/hero-proxy.svg ++++ /dev/null +@@ -1,9 +0,0 @@ +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg +deleted file mode 100644 +index 092831880..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-short.svg ++++ /dev/null +@@ -1,4 +0,0 @@ +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg +deleted file mode 100644 +index 41f40628e..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge-tall.svg ++++ /dev/null +@@ -1,4 +0,0 @@ +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg +deleted file mode 100644 +index 92c68880c..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/mountain-ridge.svg ++++ /dev/null +@@ -1,5 +0,0 @@ +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg +deleted file mode 100644 +index 9ed22bed6..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/images/sky-gradient.svg ++++ /dev/null +@@ -1,10 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg +deleted file mode 100644 +index 8197d1925..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_bushes_foreground.svg ++++ /dev/null +@@ -1,32 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg +deleted file mode 100644 +index b53882fa4..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_clouds.svg ++++ /dev/null +@@ -1,29 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg +deleted file mode 100644 +index f6f01325e..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_hills.svg ++++ /dev/null +@@ -1,13 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg +deleted file mode 100644 +index df5696462..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_leaf_overlay.svg ++++ /dev/null +@@ -1,9 +0,0 @@ +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json +deleted file mode 100644 +index 20255362f..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_learning_level_parallax.json ++++ /dev/null +@@ -1,109 +0,0 @@ +-{ +- "schema": "toolbox.parallax/1", +- "version": 1, +- "companionEditor": "ParallaxEditor", +- "map": { +- "name": "mario-style-learning-level", +- "width": 64, +- "height": 16, +- "tileSize": 24 +- }, +- "layers": [ +- { +- "id": "mario-sky", +- "name": "Mario Sky", +- "drawOrder": 0, +- "imageSource": "./samples/mario_style_sky.svg", +- "scrollFactorX": 0.08, +- "scrollFactorY": 0.03, +- "offsetX": 0, +- "offsetY": 0, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 1, +- "visible": true +- }, +- { +- "id": "mario-clouds", +- "name": "Mario Clouds", +- "drawOrder": 1, +- "imageSource": "./samples/mario_style_clouds.svg", +- "scrollFactorX": 0.18, +- "scrollFactorY": 0.06, +- "offsetX": 0, +- "offsetY": 36, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.95, +- "visible": true +- }, +- { +- "id": "mario-mountains-tall", +- "name": "Mario Mountains Tall", +- "drawOrder": 2, +- "imageSource": "./samples/mario_style_mountains.svg", +- "scrollFactorX": 0.34, +- "scrollFactorY": 0.18, +- "offsetX": 0, +- "offsetY": 178, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- }, +- { +- "id": "mario-mountains-short", +- "name": "Mario Mountains Short", +- "drawOrder": 3, +- "imageSource": "./samples/mario_style_hills.svg", +- "scrollFactorX": 0.58, +- "scrollFactorY": 0.22, +- "offsetX": 0, +- "offsetY": 214, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- }, +- { +- "id": "mario-hero-proxy", +- "name": "Mario Hero Proxy", +- "drawOrder": 4, +- "imageSource": "./samples/images/hero-proxy.svg", +- "scrollFactorX": 1, +- "scrollFactorY": 1, +- "offsetX": 720, +- "offsetY": 258, +- "repeatX": false, +- "repeatY": false, +- "wrapMode": "clamp", +- "opacity": 1, +- "visible": true +- }, +- { +- "id": "mario-foreground-trees", +- "name": "Mario Foreground Trees", +- "drawOrder": 5, +- "imageSource": "./samples/mario_style_bushes_foreground.svg", +- "scrollFactorX": 1.08, +- "scrollFactorY": 0.36, +- "offsetX": 84, +- "offsetY": 182, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- } +- ], +- "metadata": { +- "createdAt": "2026-03-30T00:00:00.000Z", +- "updatedAt": "2026-03-30T00:00:00.000Z", +- "source": "parallax-editor-samples", +- "notes": "Mario-style learning level parallax sample aligned with tilemap concept." +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg +deleted file mode 100644 +index 5f3db0956..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_mountains.svg ++++ /dev/null +@@ -1,13 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg +deleted file mode 100644 +index 80ef1847f..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/mario_style_sky.svg ++++ /dev/null +@@ -1,11 +0,0 @@ +- +- +- +- +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json +deleted file mode 100644 +index 50c277ca5..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/parallax_sample.json ++++ /dev/null +@@ -1,108 +0,0 @@ +-{ +- "schema": "toolbox.parallax/1", +- "version": 1, +- "companionEditor": "ParallaxEditor", +- "map": { +- "name": "sample-parallax-only", +- "width": 64, +- "height": 24, +- "tileSize": 24 +- }, +- "layers": [ +- { +- "id": "sample-sky", +- "name": "Sample Sky", +- "drawOrder": 0, +- "imageSource": "./samples/images/sky-gradient.svg", +- "scrollFactorX": 0.08, +- "scrollFactorY": 0.03, +- "offsetX": 0, +- "offsetY": 0, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 1, +- "visible": true +- }, +- { +- "id": "sample-clouds", +- "name": "Sample Clouds", +- "drawOrder": 1, +- "imageSource": "./samples/images/cloud-bands.svg", +- "scrollFactorX": 0.2, +- "scrollFactorY": 0.08, +- "offsetX": 0, +- "offsetY": 48, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.92, +- "visible": true +- }, +- { +- "id": "sample-mountains-tall", +- "name": "Sample Mountains Tall", +- "drawOrder": 2, +- "imageSource": "./samples/images/mountain-ridge-tall.svg", +- "scrollFactorX": 0.38, +- "scrollFactorY": 0.2, +- "offsetX": 0, +- "offsetY": 208, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.95, +- "visible": true +- }, +- { +- "id": "sample-mountains-short", +- "name": "Sample Mountains Short", +- "drawOrder": 3, +- "imageSource": "./samples/images/mountain-ridge-short.svg", +- "scrollFactorX": 0.64, +- "scrollFactorY": 0.24, +- "offsetX": 0, +- "offsetY": 242, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.95, +- "visible": true +- }, +- { +- "id": "sample-hero", +- "name": "Sample Hero Proxy", +- "drawOrder": 4, +- "imageSource": "./samples/images/hero-proxy.svg", +- "scrollFactorX": 1, +- "scrollFactorY": 1, +- "offsetX": 780, +- "offsetY": 350, +- "repeatX": false, +- "repeatY": false, +- "wrapMode": "clamp", +- "opacity": 1, +- "visible": true +- }, +- { +- "id": "sample-foreground-trees", +- "name": "Sample Foreground Trees", +- "drawOrder": 5, +- "imageSource": "./samples/images/foreground-occluder.svg", +- "scrollFactorX": 1.08, +- "scrollFactorY": 0.4, +- "offsetX": 84, +- "offsetY": 188, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- } +- ], +- "metadata": { +- "createdAt": "2026-03-30T00:00:00.000Z", +- "updatedAt": "2026-03-30T00:00:00.000Z", +- "source": "parallax-editor-samples" +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json +deleted file mode 100644 +index 00775cea6..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/sample-manifest.json ++++ /dev/null +@@ -1,21 +0,0 @@ +-{ +- "schema": "toolbox.parallax-sample-manifest/1", +- "version": 1, +- "samples": [ +- { +- "id": "parallax-basic", +- "label": "Parallax Sample (toolbox.parallax/1)", +- "path": "./samples/parallax_sample.json" +- }, +- { +- "id": "tilemap-embedded", +- "label": "Tilemap + Parallax Sample (toolbox.tilemap/1)", +- "path": "./samples/tilemap_parallax_sample.json" +- }, +- { +- "id": "mario-style-learning", +- "label": "Mario Style Learning Level (toolbox.parallax/1)", +- "path": "./samples/mario_style_learning_level_parallax.json" +- } +- ] +-} +diff --git a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json b/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json +deleted file mode 100644 +index 6ce556238..000000000 +--- a/dev/archive/v1-v2/tools/old_Parallax Scene Studio/samples/tilemap_parallax_sample.json ++++ /dev/null +@@ -1,75 +0,0 @@ +-{ +- "schema": "toolbox.tilemap/1", +- "version": 1, +- "map": { +- "name": "sample-tilemap-with-parallax", +- "width": 80, +- "height": 30, +- "tileSize": 24 +- }, +- "tileset": [ +- { +- "id": 0, +- "name": "Empty", +- "color": "transparent" +- } +- ], +- "layers": [], +- "markers": [], +- "parallax": { +- "schema": "toolbox.parallax/1", +- "companionEditor": "ParallaxEditor", +- "layers": [ +- { +- "id": "tm-sky", +- "name": "Tilemap Sky", +- "drawOrder": 0, +- "imageSource": "./samples/images/sky-gradient.svg", +- "scrollFactorX": 0.1, +- "scrollFactorY": 0.05, +- "offsetX": 0, +- "offsetY": 0, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 1, +- "visible": true +- }, +- { +- "id": "tm-ridge-tall", +- "name": "Tilemap Ridge Tall", +- "drawOrder": 1, +- "imageSource": "./samples/images/mountain-ridge-tall.svg", +- "scrollFactorX": 0.36, +- "scrollFactorY": 0.18, +- "offsetX": 0, +- "offsetY": 250, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- }, +- { +- "id": "tm-ridge-short", +- "name": "Tilemap Ridge Short", +- "drawOrder": 2, +- "imageSource": "./samples/images/mountain-ridge-short.svg", +- "scrollFactorX": 0.88, +- "scrollFactorY": 0.24, +- "offsetX": 0, +- "offsetY": 268, +- "repeatX": true, +- "repeatY": false, +- "wrapMode": "wrap", +- "opacity": 0.96, +- "visible": true +- } +- ] +- }, +- "metadata": { +- "createdAt": "2026-03-30T00:00:00.000Z", +- "updatedAt": "2026-03-30T00:00:00.000Z", +- "notes": "Sample for Parallax Scene Studio direct loading." +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md +deleted file mode 100644 +index 873ee3c42..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/README.md ++++ /dev/null +@@ -1,50 +0,0 @@ +-# Object Vector Studio V2 +- +-Object Vector Studio V2 is the vector authoring surface for reusable gameplay entities: ships, enemies, pickups, actors, weapons, UI vector assets, and other object-level game pieces. +- +-Tagline: "Build the thing." +- +-The tool folder was copied from `tools/templates-v2/` for `PR_26132_001-add-world-object-vector-studios`. It keeps the copied template CSS and modular control structure intact while giving the new studio its own first-class tool id: `object-vector-studio-v2`. +- +-## Architectural Split +- +-Object Vector Studio V2 owns the thing being placed, animated, reused, equipped, collected, or rendered as an object. World Vector Studio V2 owns the place where those things live. +- +-Use Object Vector Studio V2 for: +- +-- Reusable gameplay entities. +-- Ships. +-- Enemies. +-- Pickups. +-- Actors. +-- Weapons. +-- UI vector assets. +-- SVG/vector authoring. +-- Primitives, shapes, and path editing. +-- Animation-ready structures. +- +-Use World Vector Studio V2 instead for terrain, maps, tile/world geometry, layered scenes, parallax, environment composition, collision/world regions, level layout, spawn placement, and camera zones. +- +-## Migration Direction +- +-- Object Vector Studio V2 is the current vector asset authoring surface. +-- World Vector Studio V2 is the current map, layer, parallax, and world layout surface. +-- Current object geometry lives in Object Vector Studio V2 payloads as `objects[]`, `objects[].tags`, and `objects[].shapes`. +- +-## Examples +- +-- Asteroids ship/UFO -> Object Vector Studio V2. +-- Weapon icons, pickups, HUD markers, and actor silhouettes: Object Vector Studio V2. +-- Platformer level/parallax scene -> World Vector Studio V2. +- +-## Runtime Notes +- +-- The current implementation is the copied Tool Template V2 shell with Object Vector Studio V2 naming and documentation. +-- JavaScript and CSS remain external. +-- Paint and Stroke modes use selectable palette swatches, click/drag application, Alt-click sampling, and fill/stroke swap/default shortcuts. This model is intended to scale into shaders, gradients, patterns, neon effects, SVG export, and runtime rendering without storing palette data inside object JSON. +-- The copied `docs/CONTROL_SERVICE_CONTRACTS.md` and `docs/BATCH_GUARDRAIL_CONTRACT.md` remain the local implementation contracts. +-- `docs/OBJECT_VECTOR_STUDIO_V2_REQUIREMENTS.md` captures the future Object Vector Studio V2 requirements and design constraints. +- +-## Validation +- +-This documentation PR has no Playwright impact. Runtime implementation remains future work. +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md +deleted file mode 100644 +index ae3718606..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/BATCH_GUARDRAIL_CONTRACT.md ++++ /dev/null +@@ -1,51 +0,0 @@ +-# Tool Template V2 Batch Guardrail Contract +- +-Batch operations process repeated work across real discovered inputs. A First-Class Tool V2 batch flow must be explicit, observable, and safe to resume or diagnose. +- +-## Discovery Rules +- +-- Discover real files and directories. +-- Never assume numeric folder sequences. +-- Missing discovered candidates are logged as `SKIP`, not `FAIL`, unless the missing item is the selected single input. +-- Logs must identify the resolved input path or identifier for every item. +- +-## Per-Item Logging +- +-Every item must log exactly one terminal outcome through the tool logger: +- +-- `OK`: item completed and wrote or updated the expected output. +-- `WARN`: item completed with a recoverable warning. +-- `FAIL`: item failed with an actionable error. +-- `SKIP`: item was intentionally skipped. +- +-Per-item logs must include the item identifier and the reason for `WARN`, `FAIL`, or `SKIP`. +- +-## Failure Isolation +- +-- One item failure must not stop the batch unless the failure is global. +-- Global failures include missing required configuration, unavailable repo access, invalid destination root, or a cancelled run. +-- Batch processors must keep processing remaining discovered items after item-level `FAIL` or `SKIP` outcomes. +- +-## Summary Contract +- +-Every batch run must finish with a summary that includes: +- +-- `written` +-- `failed` +-- `skipped` +-- `warnings` +- +-The summary must match the per-item log outcomes. +- +-## Stop/Cancel Contract +- +-Long-running batches must support a stop or cancel pattern when applicable. +- +-- Stop/cancel requests must prevent new items from starting. +-- Already-running item work should finish or fail safely. +-- Cancelled remaining items should be logged as `SKIP` or another clearly documented non-success outcome. +-- The final summary must make cancellation visible. +- +-## No Silent Fallback +- +-Batch processors must not invent default inputs, substitute fallback targets, or claim success when a fallback or partial result occurred. +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md +deleted file mode 100644 +index 917b12484..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/docs/CONTROL_SERVICE_CONTRACTS.md ++++ /dev/null +@@ -1,174 +0,0 @@ +-# Tool Template V2 Control And Service Contracts +- +-These contracts define the required structure for First-Class Tool V2 code created from Tool Template V2. +- +-## Deprecated Shared Tool Surface +- +-`tools/shared/` is deprecated for First-Class Tool V2 surfaces. +- +-First-Class Tool V2 surfaces must have: +- +-- no imports from `tools/shared/` +-- no script references to `tools/shared/` +-- no CSS references to `tools/shared/` +-- no runtime dependency on `tools/shared/` +- +-## Control Contract +- +-Controls own one UI control or one UI section. +- +-Required rules: +- +-- One control or section per class. +-- One class per file. +-- A control owns its DOM references. +-- A control owns its event binding. +-- A control must not reach into sibling controls directly. +-- A control must not perform unrelated business logic. +-- A control exposes clear methods that match its responsibility. +- +-Common methods: +- +-- `init()` or `mount()` prepares the control. +-- `bindEvents()` wires DOM events when event wiring is separate from initialization. +-- `getValue()` returns the current control value when applicable. +-- `setValue(value)` updates the control value when applicable. +-- `validate()` returns validation status and messages when applicable. +-- `setEnabled(isEnabled)` updates enabled state when applicable. +-- `clear()` clears visible state when applicable. +- +-Controls communicate through injected callbacks, injected services, or the app/root coordinator. A control must not directly edit another control's DOM or internal state. +- +-## Panel Role Contract +- +-First-Class Tool V2 surfaces use a consistent three-column layout contract: +- +-- Left panel owns user input, setup, and intent controls only. +-- Center panel owns the primary work surface, editor, canvas, or preview. +-- Right panel owns generated output, summaries, diagnostics, logs, and status. +-- Status and log controls belong at the bottom of the right panel unless a PR explicitly justifies a different placement. +- +-Controls should be placed according to these panel roles before adding new sections. A control that mixes input and output responsibilities should be split into separate controls. +- +-## Service Contract +- +-Services contain non-DOM logic. +- +-Required rules: +- +-- Services must not query or manipulate UI directly. +-- Services must not write to status text, textareas, headers, buttons, or panels. +-- Services return data, status objects, or errors for controls, the app/root coordinator, or logger to display. +-- Services must avoid hidden defaults and silent fallback data. +-- Services should be deterministic for the same input unless they explicitly own I/O. +- +-Recommended result shape: +- +-```js +-{ +- ok: true, +- value: {}, +- warnings: [] +-} +-``` +- +-Recommended error shape: +- +-```js +-{ +- ok: false, +- code: "VALIDATION_FAILED", +- message: "Explain the actionable failure.", +- details: {} +-} +-``` +- +-## App/Root Contract +- +-The app/root class is a coordinator only. +- +-Required rules: +- +-- The app/root class wires controls and services. +-- The app/root class coordinates flows between controls and services. +-- The app/root class does not own DOM references except for constructing controls when unavoidable. +-- The app/root class does not own business logic. +-- The app/root class does not write directly to log/status UI. +-- The app/root class does not reach into control internals. +- +-Allowed responsibilities: +- +-- instantiate controls and services +-- inject dependencies +-- connect callbacks +-- coordinate high-level flows such as load, validate, render, export +-- route service results to the owning control or logger +- +-## Logger Contract +- +-Each tool has one status/log writer. +- +-Required rules: +- +-- Logger is the single writer for status/log output. +-- Controls, services, and app/root code must route log output through the logger. +-- Logger owns status/log DOM writes. +-- Logger supports these levels: +- - `OK` +- - `WARN` +- - `FAIL` +- - `SKIP` +- - `INFO` +-- Logger entries must be clear and actionable. +-- Batch operations must include the item identifier in per-item log lines. +- +-Recommended logger methods: +- +-- `ok(message)` +-- `warn(message)` +-- `fail(message)` +-- `skip(message)` +-- `info(message)` +-- `clear()` +- +-## Batch Processor Contract +- +-Batch processors handle repeated work across discovered inputs. +- +-See `BATCH_GUARDRAIL_CONTRACT.md` for the full batch guardrail contract. +- +-Required rules: +- +-- Process real discovered inputs only. +-- Never assume numeric sequences. +-- Log every item through the logger with `OK`, `WARN`, `FAIL`, or `SKIP`. +-- One item failure must not stop the batch unless the failure is global. +-- Missing inputs are `SKIP` during batch processing. +-- Batch failures identify the exact item and underlying error. +-- Batch summaries include written, failed, skipped, and warnings. +-- Long-running batches support stop or cancel when applicable. +- +-Recommended summary shape: +- +-```js +-{ +- written: 0, +- failed: 0, +- skipped: 0, +- warnings: 0, +- items: [] +-} +-``` +- +-## Review Checklist +- +-Before a First-Class Tool V2 PR is ready: +- +-- Every control or section has its own class. +-- Every service is DOM-free. +-- The app/root class coordinates only. +-- All logging goes through the logger. +-- Batch work logs per item and summarizes counts. +-- No inline event handlers exist in HTML. +-- No inline ` +- +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html +deleted file mode 100644 +index ce7748b53..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/index.html ++++ /dev/null +@@ -1,392 +0,0 @@ +- +- +- +- +- +- Object Vector Studio V2 +- +- +- +- +- +- +- +-
+- Hide Header and Details +-
+-
+-
+-
+-
+-
+-
+-

Object Vector Studio V2

+-

First-Class Tools Surface V2

+-
+-
+-
Build vector assets for ships, enemies, pickups, actors, UI elements, and reusable game objects.
+-
+-
+-
+-

Author reusable vector gameplay objects for actors, collectibles, hazards, and entity-driven scenes.

+-
+-
+-
+-
+-
+-
+- +- +- +- +- +-
+- +- +-
+-
+- +-
+- +-
+-
+-
+- +- +-
+- +- +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js +deleted file mode 100644 +index cc0d32253..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/ToolStarterApp.js ++++ /dev/null +@@ -1,9178 +0,0 @@ +-import { CANONICAL_WORLD_TO_SCREEN_SCALE } from '../../../src/engine/rendering/WorldScreenTransform.js'; +-import { combineObjectVectorBounds, inverseTransformObjectVectorShapePoint, normalizeObjectVectorOrigin, normalizeRotationDegrees as normalizeSharedRotationDegrees, objectVectorBoundsCornerPoints, objectVectorSvgTransformAttribute, rotatePointAround as rotateSharedPointAround, transformedObjectVectorShapeBounds, transformObjectVectorShapePoint } from '../../../src/engine/rendering/OrientationTransform.js'; +-import ObjectVectorRuntimeAssetService from '../../../src/engine/rendering/ObjectVectorRuntimeAssetService.js'; +-import { downloadTextFile, readFileText } from '../../../src/engine/persistence/FilePersistenceService.js'; +-import { isPlainObject } from "../../../src/shared/object/objects.js"; +- +-const WORKSPACE_TOOL_SESSION_KEY = "workspace.tools.object-vector-studio-v2"; +-const WORKSPACE_PALETTE_SESSION_KEY = "workspace.tools.palette-manager-v2"; +-const RUNTIME_PALETTE_SESSION_KEY = "object-vector-studio-v2.runtimePalette"; +-const TOOL_DISPLAY_MODE_KEY = "object-vector-studio-v2.toolDisplayMode"; +-const SNAP_MODE_SESSION_KEY = "object-vector-studio-v2.snapMode"; +-const ANGLE_SNAP_SESSION_KEY = "object-vector-studio-v2.angleSnap"; +-const ANGLE_SNAP_STEP_SESSION_KEY = "object-vector-studio-v2.angleSnapStep"; +-const GRID_RENDER_SESSION_KEY = "object-vector-studio-v2.gridRender"; +-const CENTER_ORIGIN_SESSION_KEY = "object-vector-studio-v2.centerOrigin"; +-const SVG_NS = "http://www.w3.org/2000/svg"; +-const DEFAULT_VIEWPORT = Object.freeze({ height: 2200, width: 3200, x: 0, y: 0, zoom: 1.0 }); +-const GRID_STEP = 10; +-const OBJECT_PREVIEW_DRAWING_SCALE = GRID_STEP; +-const MAX_ZOOM = 10.0; +-const MIN_ZOOM = 0.1; +-const ZOOM_STEP = 0.1; +-const TRANSPARENT_STYLE_COLOR = "#00000000"; +-const PREVIEW_HISTORY_LIMIT = 50; +-const PREVIEW_DRAG_START_THRESHOLD_PX = 5; +-const SNAP_MODES = Object.freeze(["grid", "point", "none"]); +-const ANGLE_SNAP_STEPS = Object.freeze([15, 30, 45, 90]); +-const POINT_SNAP_RADIUS = 1.5; +-const SNAP_MODE_DETAILS = Object.freeze({ +- grid: { +- iconKey: "snapGrid", +- label: "Snap Grid", +- title: "Snap drawing and point dragging to grid intersections" +- }, +- none: { +- iconKey: "snapNone", +- label: "Snap None", +- title: "Disable drawing and point-drag snapping" +- }, +- point: { +- iconKey: "snapPoint", +- label: "Snap Point", +- title: "Snap drawing and point dragging to visible shape points" +- } +-}); +- +-const objectVectorStudioIcon = (name, glyph) => Object.freeze({ glyph, name }); +- +-const OBJECT_VECTOR_STUDIO_ICON_GLYPHS = Object.freeze({ +- add: objectVectorStudioIcon("nf-fa-plus", "\uf067"), +- angle: objectVectorStudioIcon("nf-md-angle_acute", "\u{f0937}"), +- annulusRing: objectVectorStudioIcon("nf-md-circle_double", "\u{f0e95}"), +- arc: objectVectorStudioIcon("nf-md-vector_radius", "\u{f074a}"), +- bri: objectVectorStudioIcon("nf-fa-sun_o", "\uf185"), +- bringForward: objectVectorStudioIcon("nf-fa-arrow_up", "\uf062"), +- bringFront: objectVectorStudioIcon("nf-fa-angle_double_up", "\uf102"), +- capsuleStadium: objectVectorStudioIcon("nf-md-rounded_corner", "\u{f0607}"), +- center: objectVectorStudioIcon("nf-fa-dot_circle_o", "\uf192"), +- circle: objectVectorStudioIcon("nf-md-vector_circle_variant", "\u{f0557}"), +- copy: objectVectorStudioIcon("nf-fa-copy", "\uf0c5"), +- delete: objectVectorStudioIcon("nf-md-trash_can_outline", "\u{f0a7a}"), +- duplicate: objectVectorStudioIcon("nf-fa-copy", "\uf0c5"), +- edit: objectVectorStudioIcon("nf-fa-pencil_square_o", "\uf044"), +- ellipse: objectVectorStudioIcon("nf-md-vector_ellipse", "\u{f0893}"), +- eye: objectVectorStudioIcon("nf-fa-eye", "\uf06e"), +- eyeOff: objectVectorStudioIcon("nf-fa-eye_slash", "\uf070"), +- grid: objectVectorStudioIcon("nf-md-grid", "\u{f02c1}"), +- gridOff: objectVectorStudioIcon("nf-md-grid_off", "\u{f02c2}"), +- polyline: objectVectorStudioIcon("nf-md-vector_polyline", "\u{f0561}"), +- kite: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), +- lune: objectVectorStudioIcon("nf-md-moon_waning_crescent", "\u{f0f64}"), +- parallelogram: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), +- snapGrid: objectVectorStudioIcon("nf-md-grid_large", "\u{f0758}"), +- snapNone: objectVectorStudioIcon("nf-fa-not_equal", "\u{efcb}"), +- snapPoint: objectVectorStudioIcon("nf-md-vector_point", "\u{f055f}"), +- group: objectVectorStudioIcon("nf-fa-object_group", "\uf247"), +- hue: objectVectorStudioIcon("nf-fa-eyedropper", "\uf1fb"), +- line: objectVectorStudioIcon("nf-md-vector_line", "\u{f055e}"), +- lock: objectVectorStudioIcon("nf-fa-lock", "\uf023"), +- move: objectVectorStudioIcon("nf-fa-arrows", "\uf047"), +- name: objectVectorStudioIcon("nf-fa-font", "\uf031"), +- paint: objectVectorStudioIcon("nf-fa-paint_brush", "\uf1fc"), +- panDown: objectVectorStudioIcon("nf-fa-arrow_down", "\uf063"), +- panLeft: objectVectorStudioIcon("nf-fa-arrow_left", "\uf060"), +- panRight: objectVectorStudioIcon("nf-fa-arrow_right", "\uf061"), +- panUp: objectVectorStudioIcon("nf-fa-arrow_up", "\uf062"), +- paste: objectVectorStudioIcon("nf-oct-paste", "\uf0ea"), +- picker: objectVectorStudioIcon("nf-fa-eye_dropper", "\uf1fb"), +- polygon: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), +- rectangle: objectVectorStudioIcon("nf-md-vector_rectangle", "\u{f05c6}"), +- redo: objectVectorStudioIcon("nf-md-redo", "\u{f044e}"), +- reset: objectVectorStudioIcon("nf-fa-undo", "\uf0e2"), +- resize: objectVectorStudioIcon("nf-md-resize", "\u{f0a68}"), +- rhombus: objectVectorStudioIcon("nf-md-rhombus_outline", "\u{f070b}"), +- rotate: objectVectorStudioIcon("nf-fa-repeat", "\uf01e"), +- sat: objectVectorStudioIcon("nf-fa-tint", "\uf043"), +- scale: objectVectorStudioIcon("nf-fa-scale_unbalanced", "\u{eddf}"), +- sector: objectVectorStudioIcon("nf-md-chart_pie", "\u{f012b}"), +- select: objectVectorStudioIcon("nf-md-select", "\u{f0485}"), +- segment: objectVectorStudioIcon("nf-md-circle_slice_4", "\u{f0a9e}"), +- sendBack: objectVectorStudioIcon("nf-fa-angle_double_down", "\uf103"), +- sendBackward: objectVectorStudioIcon("nf-fa-arrow_down", "\uf063"), +- stroke: objectVectorStudioIcon("nf-fa-pencil", "\uf040"), +- square: objectVectorStudioIcon("nf-fa-vector_square", "\u{ee92}"), +- squircle: objectVectorStudioIcon("nf-md-rounded_corner", "\u{f0607}"), +- starPolygon: objectVectorStudioIcon("nf-fa-star_o", "\uf006"), +- tag: objectVectorStudioIcon("nf-fa-tag", "\uf02b"), +- text: objectVectorStudioIcon("nf-fa-font", "\uf031"), +- trapezoidTrapezium: objectVectorStudioIcon("nf-md-vector_polygon", "\u{f0560}"), +- triangle: objectVectorStudioIcon("nf-md-vector_triangle", "\u{f0563}"), +- ungroup: objectVectorStudioIcon("nf-fa-object_ungroup", "\uf248"), +- undo: objectVectorStudioIcon("nf-md-undo", "\u{f054c}"), +- unlock: objectVectorStudioIcon("nf-fa-unlock", "\uf09c"), +- zoomIn: objectVectorStudioIcon("nf-oct-zoom_in", "\u{f531}"), +- zoomOut: objectVectorStudioIcon("nf-oct-zoom_out", "\u{f532}") +-}); +- +-const OBJECT_VECTOR_STUDIO_STATIC_ICON_TARGETS = Object.freeze([ +- ["#objectVectorStudioV2AddTagButton", "add"], +- ["#objectVectorStudioV2AddObjectButton", "add"], +- ["#objectVectorStudioV2RenameObjectButton", "edit"], +- ["#objectVectorStudioV2DuplicateObjectButton", "duplicate"], +- ["#objectVectorStudioV2PaintModeButton", "paint"], +- ["#objectVectorStudioV2StrokeModeButton", "stroke"], +- [".object-vector-studio-v2__palette-sort [data-palette-sort='hue']", "hue"], +- [".object-vector-studio-v2__palette-sort [data-palette-sort='sat']", "sat"], +- [".object-vector-studio-v2__palette-sort [data-palette-sort='bri']", "bri"], +- [".object-vector-studio-v2__palette-sort [data-palette-sort='name']", "name"], +- [".object-vector-studio-v2__palette-sort [data-palette-sort='tag']", "tag"], +- ["#objectVectorStudioV2ZoomOutButton", "zoomOut"], +- ["#objectVectorStudioV2ZoomInButton", "zoomIn"], +- ["#objectVectorStudioV2PanUpButton", "panUp"], +- ["#objectVectorStudioV2PanDownButton", "panDown"], +- ["#objectVectorStudioV2PanLeftButton", "panLeft"], +- ["#objectVectorStudioV2PanRightButton", "panRight"], +- ["#objectVectorStudioV2ResetViewButton", "reset"], +- ["#objectVectorStudioV2CenterDotButton", "center"], +- ["#objectVectorStudioV2FrameLeftButton", "panLeft"], +- ["#objectVectorStudioV2FrameEarlierButton", "panLeft"], +- ["#objectVectorStudioV2DuplicateFrameButton", "duplicate"], +- ["#objectVectorStudioV2FrameLaterButton", "panRight"], +- ["#objectVectorStudioV2FrameRightButton", "panRight"], +- ["#objectVectorStudioV2DeleteFrameButton", "delete"], +- ["#objectVectorStudioV2PreviewUndoButton", "undo"], +- ["#objectVectorStudioV2PreviewRedoButton", "redo"], +- ["#objectVectorStudioV2PreviewCopyButton", "copy"], +- ["#objectVectorStudioV2PreviewPasteButton", "paste"], +- ["#objectVectorStudioV2AngleSnapButton", "angle"], +- ["#objectVectorStudioV2GridRenderButton", "grid"], +- [".object-vector-studio-v2__shape-icon--select", "select"], +- [".object-vector-studio-v2__shape-icon--annulus-ring", "annulusRing"], +- [".object-vector-studio-v2__shape-icon--triangle", "triangle"], +- [".object-vector-studio-v2__shape-icon--rectangle", "rectangle"], +- [".object-vector-studio-v2__shape-icon--square", "square"], +- [".object-vector-studio-v2__shape-icon--circle", "circle"], +- [".object-vector-studio-v2__shape-icon--capsule-stadium", "capsuleStadium"], +- [".object-vector-studio-v2__shape-icon--ellipse", "ellipse"], +- [".object-vector-studio-v2__shape-icon--kite", "kite"], +- [".object-vector-studio-v2__shape-icon--line", "line"], +- [".object-vector-studio-v2__shape-icon--lune", "lune"], +- [".object-vector-studio-v2__shape-icon--parallelogram", "parallelogram"], +- [".object-vector-studio-v2__shape-icon--picker", "picker"], +- [".object-vector-studio-v2__shape-icon--polygon", "polygon"], +- [".object-vector-studio-v2__shape-icon--polyline", "polyline"], +- [".object-vector-studio-v2__shape-icon--rhombus", "rhombus"], +- [".object-vector-studio-v2__shape-icon--arc", "arc"], +- [".object-vector-studio-v2__shape-icon--sector", "sector"], +- [".object-vector-studio-v2__shape-icon--segment", "segment"], +- [".object-vector-studio-v2__shape-icon--squircle", "squircle"], +- [".object-vector-studio-v2__shape-icon--star-polygon", "starPolygon"], +- [".object-vector-studio-v2__shape-icon--text", "text"], +- [".object-vector-studio-v2__shape-icon--trapezoid-trapezium", "trapezoidTrapezium"], +- [".object-vector-studio-v2__z-icon--bring-forward", "bringForward"], +- [".object-vector-studio-v2__z-icon--send-backward", "sendBackward"], +- [".object-vector-studio-v2__z-icon--bring-front", "bringFront"], +- [".object-vector-studio-v2__z-icon--send-back", "sendBack"], +- [".object-vector-studio-v2__z-icon--group", "group"], +- [".object-vector-studio-v2__z-icon--ungroup", "ungroup"] +-]); +- +-const OBJECT_STATE_IDS = Object.freeze(["idle", "move", "active", "inactive", "damaged", "destroyed"]); +- +-const OBJECT_STATE_LABELS = Object.freeze({ +- active: "Active", +- damaged: "Damaged", +- destroyed: "Destroyed", +- idle: "Idle", +- inactive: "Inactive", +- move: "Move" +-}); +- +-const OBJECT_STATE_HELP = Object.freeze({ +- active: ["Object is enabled and participating in gameplay.", "Typically the default active runtime state."], +- damaged: ["Object is visually damaged but still active."], +- destroyed: ["Object destruction/death state.", "Usually final or transitional before removal."], +- idle: ["Default stationary state.", "No movement or action animation active."], +- inactive: ["Object is disabled, hidden, sleeping, or not participating in gameplay."], +- move: ["Movement/action state.", "Used for thrusting, walking, flying, or active movement visuals."] +-}); +- +-const GROUP_COLOR_SWATCHES = Object.freeze(["#22d3ee", "#a78bfa", "#fb7185", "#34d399", "#fbbf24", "#60a5fa", "#f472b6", "#4ade80"]); +- +-const SHAPE_TYPE_DETAILS = Object.freeze({ +- arc: "Arc primitive metadata with center, radius, and angle span.", +- circle: "Circle primitive metadata with center point and radius.", +- ellipse: "Ellipse primitive metadata with center point and radius pair.", +- line: "Line primitive metadata with start and end points.", +- polygon: "Polygon primitive metadata with ordered point ownership.", +- polyline: "Polyline primitive metadata with ordered open point ownership.", +- rectangle: "Rectangle primitive metadata with position and dimensions.", +- square: "Square tool metadata backed by rectangle geometry with equal width and height.", +- text: "Text primitive metadata with position, font size, and content." +-}); +- +-const PRIMITIVE_TOOLS = Object.freeze(["triangle", "rectangle", "square", "circle", "ellipse", "line", "polygon", "polyline", "arc", "text"]); +-const POINT_STYLE_VALUES = Object.freeze(["round", "square"]); +-const OPEN_POINT_STYLE_TOOLS = Object.freeze(new Set(["line", "polyline", "arc"])); +-const CLOSED_POINT_STYLE_TOOLS = Object.freeze(new Set(["polygon", "rectangle"])); +-const OBJECT_ID_SIZE_WORDS = Object.freeze(new Set(["large", "medium", "small"])); +-const OBJECT_ID_ORDERED_NOUNS = Object.freeze(new Set(["asteroid", "ufo"])); +- +-function shapeTool(shape) { +- return String(shape?.tool || "").trim().toLowerCase(); +-} +- +-function shapeGeometryTool(shape) { +- const tool = shapeTool(shape); +- if (tool === "triangle") { +- return "polygon"; +- } +- if (tool === "square") { +- return "rectangle"; +- } +- return tool; +-} +- +-function shapeTypeLabel(shapeOrTool) { +- const tool = typeof shapeOrTool === "string" ? shapeOrTool : shapeTool(shapeOrTool); +- return tool.replace(/(^|-)([a-z])/g, (match) => match.toUpperCase()).replaceAll("-", " "); +-} +- +-function shapeDisplayLabel(shape) { +- return shapeTypeLabel(shape); +-} +- +-function shapeCountLabel(count) { +- return `${count} ${count === 1 ? "shape" : "shapes"}`; +-} +- +-function normalizeShapeIndex(value) { +- const index = Number(value); +- return Number.isInteger(index) && index >= 0 ? index : -1; +-} +- +-function linePoint(geometry, pointKey) { +- return geometry?.[pointKey]; +-} +- +-function tagList(value) { +- if (Array.isArray(value)) { +- return Array.from(new Set(value.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean))).sort(); +- } +- return Array.from(new Set(String(value || "") +- .split(",") +- .map((tag) => tag.trim().toLowerCase()) +- .filter(Boolean))).sort(); +-} +- +-function slugifyObjectName(name) { +- const slug = name +- .trim() +- .toLowerCase() +- .replace(/[^a-z0-9]+/g, "-") +- .replace(/^-+|-+$/g, ""); +- return slug || "object"; +-} +- +-function canonicalObjectSlug(name, gameKey = "") { +- const gameWords = slugifyObjectName(gameKey).split("-").filter(Boolean); +- let words = slugifyObjectName(name).split("-").filter(Boolean); +- if (gameWords.length && gameWords.every((word, index) => words[index] === word)) { +- words = words.slice(gameWords.length); +- } +- words = words.filter((word, index) => index === 0 || word !== words[index - 1]); +- if (words.length === 2 && OBJECT_ID_ORDERED_NOUNS.has(words[0]) && OBJECT_ID_SIZE_WORDS.has(words[1])) { +- words = [words[1], words[0]]; +- } +- if (words.length > 2 && words.at(-1) === "1" && OBJECT_ID_ORDERED_NOUNS.has(words.at(-2))) { +- words = words.slice(0, -1); +- } +- return words.join("-") || "object"; +-} +- +-function objectGameSegment(objectId) { +- const match = /^object\.([a-z0-9-]+)\./.exec(String(objectId || "")); +- return match?.[1] || ""; +-} +- +-function payloadGameSlugFromName(name) { +- const slug = slugifyObjectName(name || "game") +- .replace(/-(object-vector-assets|object-vector-asset|object-assets|object-set|payload|assets)$/u, ""); +- return slug || "game"; +-} +- +-function sortedShapes(object) { +- return [...(object?.shapes || [])].sort((left, right) => left.order - right.order); +-} +- +-function sortedFrames(state) { +- return [...(state?.frames || [])].sort((left, right) => left.order - right.order); +-} +- +-function swatchColor(swatch) { +- return swatch?.value || swatch?.hex || swatch?.color || ""; +-} +- +-function colorMetrics(color) { +- const hex = String(color || "").trim().replace(/^#/, ""); +- if (!/^[0-9a-f]{6}$/i.test(hex)) { +- return { bri: 0, hue: 0, sat: 0 }; +- } +- const red = parseInt(hex.slice(0, 2), 16) / 255; +- const green = parseInt(hex.slice(2, 4), 16) / 255; +- const blue = parseInt(hex.slice(4, 6), 16) / 255; +- const max = Math.max(red, green, blue); +- const min = Math.min(red, green, blue); +- const delta = max - min; +- let hue = 0; +- if (delta !== 0) { +- if (max === red) { +- hue = ((green - blue) / delta) % 6; +- } else if (max === green) { +- hue = (blue - red) / delta + 2; +- } else { +- hue = (red - green) / delta + 4; +- } +- hue = Math.round(hue * 60); +- if (hue < 0) { +- hue += 360; +- } +- } +- return { +- bri: max, +- hue, +- sat: max === 0 ? 0 : delta / max +- }; +-} +- +-function shapeBounds(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "rectangle") { +- return { +- height: shape.geometry.height, +- width: shape.geometry.width, +- x: shape.geometry.x, +- y: shape.geometry.y +- }; +- } +- if (geometryTool === "circle") { +- return { +- height: shape.geometry.r * 2, +- width: shape.geometry.r * 2, +- x: shape.geometry.cx - shape.geometry.r, +- y: shape.geometry.cy - shape.geometry.r +- }; +- } +- if (geometryTool === "ellipse") { +- return { +- height: shape.geometry.ry * 2, +- width: shape.geometry.rx * 2, +- x: shape.geometry.cx - shape.geometry.rx, +- y: shape.geometry.cy - shape.geometry.ry +- }; +- } +- if (geometryTool === "line") { +- const point1 = linePoint(shape.geometry, "point1"); +- const point2 = linePoint(shape.geometry, "point2"); +- const x = Math.min(point1.x, point2.x); +- const y = Math.min(point1.y, point2.y); +- return { +- height: Math.max(1, Math.abs(point2.y - point1.y)), +- width: Math.max(1, Math.abs(point2.x - point1.x)), +- x, +- y +- }; +- } +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- const xValues = shape.geometry.points.map((point) => point.x); +- const yValues = shape.geometry.points.map((point) => point.y); +- const minX = Math.min(...xValues); +- const minY = Math.min(...yValues); +- return { +- height: Math.max(1, Math.max(...yValues) - minY), +- width: Math.max(1, Math.max(...xValues) - minX), +- x: minX, +- y: minY +- }; +- } +- if (geometryTool === "arc") { +- return { +- height: shape.geometry.r * 2, +- width: shape.geometry.r * 2, +- x: shape.geometry.cx - shape.geometry.r, +- y: shape.geometry.cy - shape.geometry.r +- }; +- } +- if (geometryTool === "text") { +- return { +- height: shape.geometry.fontSize, +- width: Math.max(24, shape.geometry.text.length * shape.geometry.fontSize * 0.6), +- x: shape.geometry.x, +- y: shape.geometry.y - shape.geometry.fontSize +- }; +- } +- throw new Error(`unsupported shape bounds for ${shapeTool(shape)}`); +-} +- +-function ellipseSamplePoints(cx, cy, rx, ry, sampleCount = 64) { +- return Array.from({ length: sampleCount }, (_, index) => { +- const radians = (index / sampleCount) * Math.PI * 2; +- return { +- x: cx + Math.cos(radians) * rx, +- y: cy + Math.sin(radians) * ry +- }; +- }); +-} +- +-function arcSamplePoints(geometry, sampleCount = 32) { +- const startAngle = geometry.startAngle; +- const endAngle = geometry.endAngle; +- const span = endAngle - startAngle; +- const steps = Math.max(1, Math.ceil(Math.abs(span) / (360 / sampleCount))); +- +- return Array.from({ length: steps + 1 }, (_, index) => { +- const angle = startAngle + (span * index) / steps; +- const radians = ((angle - 90) * Math.PI) / 180; +- return { +- x: geometry.cx + Math.cos(radians) * geometry.r, +- y: geometry.cy + Math.sin(radians) * geometry.r +- }; +- }); +-} +- +-function shapeBoundsPoints(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "line") { +- return [linePoint(shape.geometry, "point1"), linePoint(shape.geometry, "point2")].filter(Boolean); +- } +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- return Array.isArray(shape.geometry.points) ? shape.geometry.points : []; +- } +- if (geometryTool === "circle") { +- return ellipseSamplePoints(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.r); +- } +- if (geometryTool === "ellipse") { +- return ellipseSamplePoints(shape.geometry.cx, shape.geometry.cy, shape.geometry.rx, shape.geometry.ry); +- } +- if (geometryTool === "arc") { +- return arcSamplePoints(shape.geometry); +- } +- return objectVectorBoundsCornerPoints(shapeBounds(shape)); +-} +- +-function defaultShapeTransform() { +- return { +- rotation: 0, +- scaleX: 1, +- scaleY: 1, +- x: 0, +- y: 0 +- }; +-} +- +-export class ToolStarterApp { +- constructor({ accordions, actionNav, elements, runtimeAssetService = null, schemaService, shell, statusLog, windowRef = window }) { +- this.accordions = accordions; +- this.actionNav = actionNav; +- this.elements = elements; +- this.schemaService = schemaService; +- this.shell = shell; +- this.statusLog = statusLog; +- this.runtimeAssetService = runtimeAssetService || new ObjectVectorRuntimeAssetService({ logger: statusLog }); +- this.window = windowRef; +- this.currentPayload = null; +- this.runtimePalette = null; +- this.runtimePaletteSource = ""; +- this.selectedObjectId = ""; +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes = new Set(); +- this.directSelectedShapeIndexes = new Set(); +- this.selectedStateId = ""; +- this.selectedFrameId = ""; +- this.playbackTimerId = 0; +- this.isAnimationPlaying = false; +- this.activeTool = "select"; +- this.paletteTarget = "paint"; +- this.paletteSortMode = "name"; +- this.paletteSortDirection = "asc"; +- this.selectedFillColor = "#ffffff"; +- this.selectedStrokeColor = "#000000"; +- this.selectedFillLabel = "default fill"; +- this.selectedStrokeLabel = "default stroke"; +- this.selectedFillOpacity = 1; +- this.selectedStrokeOpacity = 1; +- this.isPaintDragging = false; +- this.activeDrawing = null; +- this.drawingPreviewPoint = null; +- this.drawingHintClientPoint = null; +- this.previewClipboardShape = null; +- this.previewUndoStack = []; +- this.previewRedoStack = []; +- this.previewPointerEdit = null; +- this.transformInputValues = new Map(); +- this.stateControlStateId = ""; +- this.pendingAddObjectClick = false; +- this.hiddenObjectIds = new Set(); +- this.lockedObjectIds = new Set(); +- this.snapMode = this.readSnapMode(); +- this.angleSnapEnabled = false; +- this.angleSnapStep = 15; +- this.gridRenderEnabled = true; +- this.centerOriginVisible = this.window.sessionStorage?.getItem(CENTER_ORIGIN_SESSION_KEY) !== "0"; +- this.schemaReady = false; +- this.viewportPan = null; +- this.viewport = { ...DEFAULT_VIEWPORT }; +- } +- +- async start() { +- this.window.__objectVectorStudioV2App = this; +- this.shell.mount(); +- this.accordions.forEach((accordion) => accordion.mount()); +- this.actionNav.mount({ +- onCopyJson: () => { +- void this.copyJson(); +- }, +- onExportJson: () => this.exportJson(), +- onImportJson: (file) => { +- void this.importJson(file); +- }, +- onReturnToWorkspace: (url) => { +- this.window.location.href = url; +- } +- }); +- this.statusLog.mount(); +- this.applyNerdFontIcons(); +- this.bindObjectActions(); +- this.bindToolToggles(); +- this.bindSnapControls(); +- this.bindPaletteControls(); +- this.bindViewportControls(); +- this.bindPreviewEditActions(); +- this.bindObjectFilters(); +- this.bindAnimationControls(); +- this.bindJsonDetailsActions(); +- this.bindKeyboardShortcuts(); +- this.applyToolDisplayMode(this.window.sessionStorage?.getItem(TOOL_DISPLAY_MODE_KEY) || "words", false); +- this.applySnapState(); +- this.updateViewport(); +- this.actionNav.setImportEnabled(false); +- this.renderEmptyState("Object Vector Studio V2 schema contract is loading."); +- this.statusLog.write("OK Object Vector Studio V2 layout shell ready."); +- this.statusLog.write("INFO Schema-only loading is idle. Import JSON or launch with workspace toolState data. Runtime palette is required before rendering."); +- this.statusLog.write("INFO Tools primitive buttons enter drawing mode; use the canvas to commit schema-valid geometry."); +- this.statusLog.write("INFO Disabled controls stay inactive until a schema-valid payload, runtime palette, selected object, or active frame is available."); +- this.statusLog.write("INFO Object identity uses object.game.name ids."); +- this.statusLog.write(`INFO Object Vector Studio V2 editor zoom is viewport-only; runtime/world scale remains ${CANONICAL_WORLD_TO_SCREEN_SCALE}:1 through the shared world-to-screen transform.`); +- this.statusLog.write("INFO Paint and stroke selection is structured to scale later into shaders, gradients, patterns, neon, SVG export, and runtime rendering."); +- await this.schemaService.loadSchema(); +- this.schemaReady = true; +- this.statusLog.write(`OK Object Vector Studio V2 schema contract loaded from ${this.schemaService.schemaPath}.`); +- await this.loadRuntimePaletteFromSources(); +- this.renderEmptyState("Schema-only loading is idle. Import JSON or launch with workspace toolState data. Runtime palette is required before rendering."); +- if (this.actionNav.isWorkspaceLaunch()) { +- this.loadWorkspaceToolState(); +- } +- } +- +- async loadRuntimePaletteFromSources() { +- const params = new URLSearchParams(this.window.location.search); +- const paletteSourcePath = params.get("paletteSourcePath") || ""; +- if (paletteSourcePath) { +- await this.loadPaletteFromUrl(paletteSourcePath); +- if (this.runtimePalette) { +- return; +- } +- } +- +- if (this.actionNav.isWorkspaceLaunch() && this.loadPaletteFromWorkspaceSession()) { +- return; +- } +- +- if (this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY)) { +- return; +- } +- this.loadPaletteFromWorkspaceSession(); +- } +- +- async loadPaletteFromUrl(paletteSourcePath) { +- let paletteUrl; +- try { +- paletteUrl = new URL(paletteSourcePath, this.window.location.href); +- const response = await this.window.fetch(paletteUrl, { cache: "no-store" }); +- if (!response.ok) { +- this.statusLog.write(`FAIL Runtime palette load failed from ${paletteSourcePath}: ${response.status}.`); +- return; +- } +- const palette = await response.json(); +- this.acceptRuntimePalette(palette, `paletteSourcePath:${paletteSourcePath}`, true); +- } catch (error) { +- this.statusLog.write(`FAIL Runtime palette load failed from ${paletteSourcePath}: ${error.message}`); +- } +- } +- +- loadPaletteFromSessionKey(sessionKey) { +- const storedPalette = this.window.sessionStorage?.getItem(sessionKey) || ""; +- if (!storedPalette) { +- return false; +- } +- try { +- const palette = JSON.parse(storedPalette); +- return this.acceptRuntimePalette(palette, sessionKey, sessionKey !== RUNTIME_PALETTE_SESSION_KEY); +- } catch (error) { +- this.statusLog.write(`FAIL Runtime palette load failed from ${sessionKey}: ${error.message}`); +- return false; +- } +- } +- +- loadPaletteFromWorkspaceSession() { +- const storedSession = this.window.sessionStorage?.getItem(WORKSPACE_PALETTE_SESSION_KEY) || ""; +- if (!storedSession) { +- return false; +- } +- try { +- const session = JSON.parse(storedSession); +- const palette = session?.data && typeof session.data === "object" ? session.data : session; +- return this.acceptRuntimePalette(palette, `${WORKSPACE_PALETTE_SESSION_KEY}.data`, true); +- } catch (error) { +- this.statusLog.write(`FAIL Runtime palette load failed from ${WORKSPACE_PALETTE_SESSION_KEY}: ${error.message}`); +- return false; +- } +- } +- +- acceptRuntimePalette(palette, sourceLabel, persistToRuntimeSession) { +- const validation = this.schemaService.validateRuntimePalette(palette, sourceLabel); +- if (!validation.ok) { +- this.runtimePalette = null; +- this.runtimePaletteSource = ""; +- this.statusLog.write(`FAIL Runtime palette validation failed from ${sourceLabel}: ${validation.errors.join(" ")}`); +- return false; +- } +- this.runtimePalette = validation.palette; +- this.runtimePaletteSource = sourceLabel; +- const firstColor = validation.palette.swatches.map((swatch) => swatchColor(swatch)).find(Boolean); +- if (firstColor) { +- this.selectedFillColor = firstColor; +- this.selectedStrokeColor = firstColor; +- const firstSwatch = validation.palette.swatches.find((swatch) => swatchColor(swatch) === firstColor); +- const firstLabel = firstSwatch?.name || firstSwatch?.id || firstSwatch?.symbol || "first swatch"; +- this.selectedFillLabel = firstLabel; +- this.selectedStrokeLabel = firstLabel; +- } +- if (persistToRuntimeSession) { +- this.window.sessionStorage?.setItem(RUNTIME_PALETTE_SESSION_KEY, JSON.stringify(validation.palette)); +- } +- this.statusLog.write(`OK Runtime palette loaded from ${sourceLabel}: ${validation.palette.swatches.length} swatches.`); +- this.statusLog.write("INFO Runtime palette is read-only session/workspace data; Object Vector JSON remains palette-free."); +- return true; +- } +- +- bindObjectActions() { +- this.elements.addObjectButton.addEventListener("pointerdown", () => { +- this.pendingAddObjectClick = true; +- }); +- this.elements.addObjectButton.addEventListener("click", () => this.addObject()); +- this.elements.addTagButton.addEventListener("click", () => this.addTagToSelectedObject()); +- this.elements.objectNameInput.addEventListener("input", () => this.updateObjectPreviewFooterFromNameInput()); +- this.elements.renameObjectButton.addEventListener("click", () => this.renameSelectedObject()); +- this.elements.duplicateObjectButton.addEventListener("click", () => this.duplicateSelectedObject()); +- this.elements.deleteObjectButton?.addEventListener("click", () => this.deleteSelectedObject()); +- this.elements.exportSvgButton.addEventListener("click", () => this.exportSelectedObjectSvg()); +- } +- +- applyNerdFontIcons() { +- OBJECT_VECTOR_STUDIO_STATIC_ICON_TARGETS.forEach(([selector, iconKey]) => { +- this.window.document.querySelectorAll(selector).forEach((element) => this.applyIconGlyph(element, iconKey)); +- }); +- } +- +- applyIconGlyph(element, iconKey) { +- const icon = OBJECT_VECTOR_STUDIO_ICON_GLYPHS[iconKey]; +- if (!element || !icon?.glyph) { +- return; +- } +- element.dataset.ovsIcon = icon.glyph; +- element.dataset.ovsIconKey = iconKey; +- element.dataset.ovsIconName = icon.name; +- element.classList.add("object-vector-studio-v2__nerd-icon"); +- } +- +- bindAnimationControls() { +- this.elements.deleteFrameButton.addEventListener("click", () => this.deleteSelectedFrame()); +- this.elements.duplicateFrameButton.addEventListener("click", () => this.duplicateSelectedFrame()); +- this.elements.frameLeftButton.addEventListener("click", () => this.moveSelectedFrame("earlier", "left")); +- this.elements.frameEarlierButton.addEventListener("click", () => this.moveSelectedFrame("earlier", "earlier")); +- this.elements.frameLaterButton.addEventListener("click", () => this.moveSelectedFrame("later", "later")); +- this.elements.frameRightButton.addEventListener("click", () => this.moveSelectedFrame("later", "right")); +- this.elements.playButton.addEventListener("click", () => this.playAnimation()); +- this.elements.pauseButton.addEventListener("click", () => this.pauseAnimation()); +- this.elements.stopButton.addEventListener("click", () => this.stopAnimation()); +- this.elements.runtimePreviewButton.addEventListener("click", () => { +- void this.previewRuntimeAsset(); +- }); +- this.elements.onionSkinToggle.addEventListener("change", () => { +- this.renderWorkSurface(); +- this.statusLog.write(`OK Onion-skin preview ${this.elements.onionSkinToggle.checked ? "enabled" : "disabled"}.`); +- }); +- } +- +- bindToolToggles() { +- this.elements.toolLabelModeButton.addEventListener("click", () => { +- const nextMode = this.elements.toolToggleGrid.classList.contains("is-icon-only") ? "words" : "icons"; +- this.applyToolDisplayMode(nextMode, true); +- }); +- +- this.elements.toolToggles.forEach((button) => { +- button.addEventListener("click", () => { +- const tool = button.dataset.shapeTool || "unknown"; +- this.setActiveToolButton(button); +- this.setPaletteTarget("stroke", false); +- if (PRIMITIVE_TOOLS.includes(tool)) { +- this.startDrawingMode(tool); +- return; +- } +- this.cancelActiveDrawing("tool switch", { log: false }); +- this.activeTool = tool; +- if (tool === "select" && this.selectedShapeIndex >= 0) { +- const clearedShape = this.selectedShapeIndex; +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- this.renderObjectTiles(); +- this.renderSelectedObject(); +- this.renderWorkSurface(); +- this.updateObjectActionState(); +- this.statusLog.write(`OK Select tool cleared selected shape row ${clearedShape}.`); +- return; +- } +- this.statusLog.write(`OK Tools mode selected: ${tool}.`); +- }); +- }); +- } +- +- bindSnapControls() { +- this.elements.snapModeButton.addEventListener("click", () => this.cycleSnapMode()); +- this.elements.angleSnapButton.addEventListener("click", () => { +- this.angleSnapEnabled = !this.angleSnapEnabled; +- this.window.sessionStorage?.setItem(ANGLE_SNAP_SESSION_KEY, this.angleSnapEnabled ? "1" : "0"); +- this.applySnapState(); +- this.statusLog.write(`OK Snap angle ${this.angleSnapEnabled ? "enabled" : "disabled"}: Rotate action ${this.angleSnapEnabled ? `uses dropdown values in ${this.angleSnapStep} degree increments` : "uses raw numeric textbox values"}.`); +- }); +- this.elements.gridRenderButton.addEventListener("click", () => { +- this.gridRenderEnabled = !this.gridRenderEnabled; +- this.window.sessionStorage?.setItem(GRID_RENDER_SESSION_KEY, this.gridRenderEnabled ? "1" : "0"); +- this.applySnapState(); +- this.renderWorkSurface(); +- this.statusLog.write(`OK Grid rendering ${this.gridRenderEnabled ? "enabled" : "disabled"}.`); +- }); +- this.elements.centerDotButton.addEventListener("click", () => this.toggleCenterOriginMarker()); +- } +- +- bindPaletteControls() { +- this.elements.paintModeButton.addEventListener("click", () => this.setPaletteTarget("paint")); +- this.elements.strokeModeButton.addEventListener("click", () => this.setPaletteTarget("stroke")); +- this.elements.strokeWidth.addEventListener("change", () => { +- const width = Number(this.elements.strokeWidth.value); +- if (!Number.isFinite(width) || width <= 0) { +- this.elements.strokeWidth.value = "2"; +- this.statusLog.write("WARN Stroke width reset to 2: value must be greater than 0."); +- return; +- } +- this.statusLog.write(`OK Palette stroke width set to ${width}.`); +- }); +- [ +- this.elements.fillOpacity, +- this.elements.strokeOpacity +- ].forEach((input) => input.addEventListener("input", () => this.clearInputValidity(input))); +- this.elements.fillOpacity.addEventListener("change", () => this.changePaletteOpacity("fill")); +- this.elements.strokeOpacity.addEventListener("change", () => this.changePaletteOpacity("stroke")); +- this.elements.paletteSortButtons.forEach((button) => { +- button.addEventListener("click", () => { +- const nextSortMode = button.dataset.paletteSort || "name"; +- if (this.paletteSortMode === nextSortMode) { +- this.paletteSortDirection = this.paletteSortDirection === "asc" ? "desc" : "asc"; +- } else { +- this.paletteSortMode = nextSortMode; +- this.paletteSortDirection = "asc"; +- } +- this.updatePaletteSortButtons(); +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- this.statusLog.write(`OK Palette sort set to ${this.paletteSortMode} ${this.paletteSortDirection}.`); +- }); +- }); +- this.setPaletteTarget("paint", false); +- this.updatePaletteSortButtons(); +- this.updatePaletteModeSwatches(); +- } +- +- bindJsonDetailsActions() { +- this.elements.copyJsonDetailsButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- void this.copyJson(); +- }); +- } +- +- setPaletteTarget(target, shouldLog = true) { +- this.paletteTarget = target === "stroke" ? "stroke" : "paint"; +- this.elements.paintModeButton.setAttribute("aria-pressed", String(this.paletteTarget === "paint")); +- this.elements.strokeModeButton.setAttribute("aria-pressed", String(this.paletteTarget === "stroke")); +- this.elements.paintModeButton.classList.toggle("is-active", this.paletteTarget === "paint"); +- this.elements.strokeModeButton.classList.toggle("is-active", this.paletteTarget === "stroke"); +- this.updatePaletteModeSwatches(); +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- if (shouldLog) { +- this.cancelActiveDrawing("palette mode change", { log: false }); +- this.activeTool = this.paletteTarget === "stroke" ? "stroke" : "paint"; +- this.setActiveToolButton(null); +- this.statusLog.write(`OK Palette target set to ${this.paletteTarget === "stroke" ? "Stroke" : "Paint"}.`); +- } +- } +- +- updatePaletteSortButtons() { +- this.elements.paletteSortButtons.forEach((button) => { +- const isActive = button.dataset.paletteSort === this.paletteSortMode; +- const baseLabel = button.dataset.paletteSortLabel || button.textContent.replace(/[▲▼]\s*$/u, "").trim() || shapeTypeLabel(button.dataset.paletteSort || "name"); +- button.dataset.paletteSortLabel = baseLabel; +- button.dataset.sortDirection = isActive ? this.paletteSortDirection : ""; +- button.textContent = ""; +- const label = document.createElement("span"); +- label.textContent = baseLabel; +- button.append(label); +- const caret = document.createElement("span"); +- caret.className = "object-vector-studio-v2__palette-sort-caret"; +- caret.setAttribute("aria-hidden", "true"); +- caret.textContent = isActive ? (this.paletteSortDirection === "asc" ? "▲" : "▼") : "◇"; +- button.append(caret); +- button.setAttribute("aria-pressed", String(isActive)); +- button.setAttribute("aria-label", `${baseLabel} sort ${isActive ? this.paletteSortDirection : "inactive"}`); +- button.title = isActive ? `Sort ${baseLabel} ${this.paletteSortDirection === "asc" ? "ascending" : "descending"}` : `Sort ${baseLabel} ascending`; +- button.classList.toggle("is-active", isActive); +- }); +- } +- +- updatePaletteModeSwatches() { +- this.elements.paintModeButton.style.setProperty("--object-vector-studio-v2-mode-swatch", this.selectedFillColor); +- this.elements.paintModeButton.style.setProperty("--object-vector-studio-v2-mode-opacity", String(this.selectedFillOpacity)); +- this.elements.strokeModeButton.style.setProperty("--object-vector-studio-v2-mode-swatch", this.selectedStrokeColor); +- this.elements.strokeModeButton.style.setProperty("--object-vector-studio-v2-mode-opacity", String(this.selectedStrokeOpacity)); +- this.elements.paintModeButton.dataset.paletteModeColor = this.selectedFillColor; +- this.elements.paintModeButton.dataset.paletteModeOpacity = String(this.selectedFillOpacity); +- this.elements.strokeModeButton.dataset.paletteModeColor = this.selectedStrokeColor; +- this.elements.strokeModeButton.dataset.paletteModeOpacity = String(this.selectedStrokeOpacity); +- } +- +- pointStyleValue(value) { +- return POINT_STYLE_VALUES.includes(value) ? value : "square"; +- } +- +- strokeLinejoinValue(value = "round") { +- return this.pointStyleValue(value) === "square" ? "miter" : "round"; +- } +- +- supportsOpenPointStyle(shape) { +- return OPEN_POINT_STYLE_TOOLS.has(shapeGeometryTool(shape)); +- } +- +- supportsClosedPointStyle(shape) { +- return CLOSED_POINT_STYLE_TOOLS.has(shapeGeometryTool(shape)); +- } +- +- shapeSupportsPointRoundingControls(shape) { +- const geometryTool = shapeGeometryTool(shape); +- return !["circle", "ellipse", "text"].includes(geometryTool) && this.shapeGeometryPointCount(shape) > 0; +- } +- +- shapeSupportsRoundingRadiusControl(shape) { +- return ["polygon", "polyline", "rectangle"].includes(shapeGeometryTool(shape)) && this.shapeGeometryPointCount(shape) > 0; +- } +- +- shapeRoundingRadius(shape) { +- const value = Number(shape?.style?.roundingRadius); +- if (Number.isFinite(value) && value >= 0) { +- return value; +- } +- return 4; +- } +- +- shapeUnifiedPointStyle(shape) { +- const pointStyles = this.shapePointStyleValues(shape); +- if (pointStyles.length) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "polygon" || geometryTool === "polyline" || geometryTool === "rectangle") { +- return "square"; +- } +- return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap); +- } +- return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap); +- } +- +- shapeStartPointStyle(shape) { +- const pointStyles = this.shapePointStyleValues(shape); +- if (pointStyles.length) { +- return pointStyles[0]; +- } +- return this.pointStyleValue(shape?.style?.startPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle); +- } +- +- shapeEndPointStyle(shape) { +- const pointStyles = this.shapePointStyleValues(shape); +- if (pointStyles.length) { +- return pointStyles.at(-1); +- } +- return this.pointStyleValue(shape?.style?.endPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle); +- } +- +- openShapeUsesSplitPointStyles(shape) { +- return this.supportsOpenPointStyle(shape) && this.shapeStartPointStyle(shape) !== this.shapeEndPointStyle(shape); +- } +- +- shapeGeometryPointCount(shape) { +- return this.shapeGeometryPoints(shape).length; +- } +- +- shapeGeometryPoints(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "line") { +- return [linePoint(shape.geometry, "point1"), linePoint(shape.geometry, "point2")]; +- } +- if (geometryTool === "arc") { +- return [ +- this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.startAngle), +- this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.endAngle) +- ]; +- } +- if (geometryTool === "rectangle") { +- const x = Number(shape.geometry.x) || 0; +- const y = Number(shape.geometry.y) || 0; +- const width = Number(shape.geometry.width) || 0; +- const height = Number(shape.geometry.height) || 0; +- return [ +- { x, y }, +- { x: x + width, y }, +- { x: x + width, y: y + height }, +- { x, y: y + height } +- ]; +- } +- if ((geometryTool === "polygon" || geometryTool === "polyline") && Array.isArray(shape?.geometry?.points)) { +- return shape.geometry.points; +- } +- return []; +- } +- +- shapePointRoundingValues(shape) { +- const pointCount = this.shapeGeometryPointCount(shape); +- if (!pointCount) { +- return []; +- } +- const explicit = Array.isArray(shape?.style?.pointRounding) ? shape.style.pointRounding : null; +- if (explicit) { +- return Array.from({ length: pointCount }, (_, index) => explicit[index] === true); +- } +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "polygon" || geometryTool === "rectangle") { +- return Array.from({ length: pointCount }, () => false); +- } +- return Array.from({ length: pointCount }, (_, index) => { +- if (geometryTool === "line" || geometryTool === "polyline") { +- if (index === 0) { +- return this.pointStyleValue(shape?.style?.startPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle) === "round"; +- } +- if (index === pointCount - 1) { +- return this.pointStyleValue(shape?.style?.endPointStyle ?? shape?.style?.strokeLinecap ?? shape?.style?.pointStyle) === "round"; +- } +- if (geometryTool === "polyline") { +- return false; +- } +- } +- return this.pointStyleValue(shape?.style?.pointStyle ?? shape?.style?.strokeLinecap) === "round"; +- }); +- } +- +- shapePointStyleValues(shape) { +- return this.shapePointRoundingValues(shape).map((isRounded) => isRounded ? "round" : "square"); +- } +- +- shapeJoinPointIndexes(shape, pointCount = this.shapeGeometryPointCount(shape)) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "polyline") { +- return Array.from({ length: Math.max(0, pointCount - 2) }, (_, index) => index + 1); +- } +- if (geometryTool === "polygon") { +- return Array.from({ length: pointCount }, (_, index) => index); +- } +- return []; +- } +- +- bindViewportControls() { +- this.elements.zoomInButton.addEventListener("click", () => this.zoomViewportByStep(ZOOM_STEP)); +- this.elements.zoomOutButton.addEventListener("click", () => this.zoomViewportByStep(-ZOOM_STEP)); +- this.elements.panUpButton.addEventListener("click", () => this.panViewport(0, -20)); +- this.elements.panDownButton.addEventListener("click", () => this.panViewport(0, 20)); +- this.elements.panLeftButton.addEventListener("click", () => this.panViewport(-20, 0)); +- this.elements.panRightButton.addEventListener("click", () => this.panViewport(20, 0)); +- this.elements.resetViewButton.addEventListener("click", () => this.resetViewport()); +- this.elements.renderSurface.addEventListener("mousemove", (event) => this.updateCoordinateDisplay(event)); +- this.elements.renderSurface.addEventListener("pointerdown", (event) => this.handleRenderSurfacePointerDown(event)); +- this.elements.renderSurface.addEventListener("contextmenu", (event) => event.preventDefault()); +- this.elements.renderSurface.addEventListener("dblclick", (event) => this.finishMultiPointDrawing("double-click", event)); +- this.window.addEventListener("pointermove", (event) => this.updatePreviewPointerEdit(event)); +- this.window.addEventListener("pointermove", (event) => this.updateDrawingPreview(event)); +- this.window.addEventListener("pointermove", (event) => this.updateViewportPan(event)); +- this.elements.renderSurface.addEventListener("wheel", (event) => { +- event.preventDefault(); +- this.zoomViewportByStepAtPointer(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP, event); +- }, { passive: false }); +- this.window.addEventListener("pointerup", (event) => { +- this.isPaintDragging = false; +- this.finishViewportPan(event); +- this.finishPreviewPointerEdit(event); +- }); +- } +- +- bindPreviewEditActions() { +- this.elements.previewUndoButton.addEventListener("click", () => this.undoPreviewEdit()); +- this.elements.previewRedoButton.addEventListener("click", () => this.redoPreviewEdit()); +- this.elements.previewCopyButton.addEventListener("click", () => this.copyPreviewSelection()); +- this.elements.previewPasteButton.addEventListener("click", () => this.pastePreviewClipboard()); +- } +- +- bindObjectFilters() { +- this.elements.tagFilter.addEventListener("change", () => { +- this.renderObjectTiles(); +- this.statusLog.write(`OK Object tag filter set to ${this.elements.tagFilter.value}.`); +- }); +- this.elements.searchFilter.addEventListener("input", () => { +- this.renderObjectTiles(); +- }); +- } +- +- bindKeyboardShortcuts() { +- const handleShortcut = (event) => { +- if (event.defaultPrevented) { +- return; +- } +- if (event.key === "Enter" && this.activeDrawing) { +- event.preventDefault(); +- this.finishMultiPointDrawing("Enter", event); +- return; +- } +- const tagName = event.target?.tagName || ""; +- if (["INPUT", "SELECT", "TEXTAREA"].includes(tagName)) { +- return; +- } +- if (event.key === "Delete" || event.key === "Backspace") { +- event.preventDefault(); +- this.deleteSelectedShape("keyboard"); +- return; +- } +- const key = event.key.toLowerCase(); +- if (key === "v") { +- event.preventDefault(); +- this.activateToolMode("select", "keyboard"); +- } else if (key === "f") { +- event.preventDefault(); +- this.setPaletteTarget("paint"); +- } else if (key === "s") { +- event.preventDefault(); +- this.setPaletteTarget("stroke"); +- } else if (key === "i") { +- event.preventDefault(); +- this.activateToolMode("picker", "keyboard"); +- } else if (key === "x") { +- event.preventDefault(); +- this.swapFillStrokeColors(); +- } else if (key === "d") { +- event.preventDefault(); +- this.restoreDefaultColors(); +- } +- }; +- this.elements.renderSurface.addEventListener("keydown", handleShortcut); +- this.window.document.addEventListener("keydown", handleShortcut); +- } +- +- applySnapState() { +- this.snapMode = this.readSnapMode(); +- this.angleSnapEnabled = this.window.sessionStorage?.getItem(ANGLE_SNAP_SESSION_KEY) === "1"; +- this.angleSnapStep = this.readAngleSnapStep(); +- this.gridRenderEnabled = this.window.sessionStorage?.getItem(GRID_RENDER_SESSION_KEY) !== "0"; +- this.centerOriginVisible = this.window.sessionStorage?.getItem(CENTER_ORIGIN_SESSION_KEY) !== "0"; +- const snapDetails = SNAP_MODE_DETAILS[this.snapMode] || SNAP_MODE_DETAILS.grid; +- this.elements.snapModeButton.textContent = snapDetails.label; +- this.elements.snapModeButton.dataset.snapMode = this.snapMode; +- this.elements.snapModeButton.setAttribute("aria-label", snapDetails.label); +- this.elements.snapModeButton.setAttribute("aria-pressed", String(this.snapMode !== "none")); +- this.elements.snapModeButton.title = snapDetails.title; +- this.applyIconGlyph(this.elements.snapModeButton, snapDetails.iconKey); +- this.elements.renderSurface.classList.toggle("is-snap-point-mode", this.snapMode === "point"); +- this.elements.angleSnapButton.setAttribute("aria-pressed", String(this.angleSnapEnabled)); +- this.elements.angleSnapButton.textContent = "Snap Angle"; +- this.elements.angleSnapButton.setAttribute("aria-label", "Snap Angle for Shape/Object Rotate"); +- this.elements.angleSnapButton.title = "Snap Angle switches Rotate to a constrained dropdown using the selected 15, 30, 45, or 90 degree step."; +- this.elements.gridRenderButton.setAttribute("aria-pressed", String(this.gridRenderEnabled)); +- this.applyIconGlyph(this.elements.gridRenderButton, this.gridRenderEnabled ? "grid" : "gridOff"); +- this.elements.centerDotButton.setAttribute("aria-pressed", String(this.centerOriginVisible)); +- this.elements.renderSurface.classList.toggle("is-grid-visible", this.gridRenderEnabled); +- this.updateRotateSnapControls(); +- } +- +- updateRotateSnapControls(scope = this.window.document) { +- const root = scope || this.window.document; +- [ +- { +- inputId: "objectVectorStudioV2RotateInput", +- selectId: "objectVectorStudioV2RotateSnapSelect", +- stepId: "objectVectorStudioV2SnapAngleStepSelect" +- }, +- { +- inputId: "objectVectorStudioV2ObjectRotateInput", +- selectId: "objectVectorStudioV2ObjectRotateSnapSelect", +- stepId: "objectVectorStudioV2ObjectSnapAngleStepSelect" +- } +- ].forEach(({ inputId, selectId, stepId }) => { +- const numericInput = root.querySelector?.(`#${inputId}`) || this.window.document.getElementById(inputId); +- const snapSelect = root.querySelector?.(`#${selectId}`) || this.window.document.getElementById(selectId); +- const stepSelect = root.querySelector?.(`#${stepId}`) || this.window.document.getElementById(stepId); +- if (!numericInput && !snapSelect && !stepSelect) { +- return; +- } +- const row = (numericInput || snapSelect || stepSelect)?.closest?.(".object-vector-studio-v2__transform-control-row--rotate") || null; +- const panelDisabled = row?.closest?.(".object-vector-studio-v2__shape-panel")?.classList.contains("is-disabled") === true; +- row?.classList.toggle("is-angle-snap-enabled", this.angleSnapEnabled); +- if (stepSelect) { +- stepSelect.value = String(this.angleSnapStep); +- stepSelect.disabled = panelDisabled || !this.angleSnapEnabled; +- stepSelect.hidden = !this.angleSnapEnabled; +- const stepField = stepSelect.closest(".object-vector-studio-v2__snap-angle-step-field"); +- if (stepField) { +- stepField.hidden = !this.angleSnapEnabled; +- } +- } +- if (snapSelect) { +- const preferred = snapSelect.value || numericInput?.value || this.transformInputValue(snapSelect.id, "15"); +- this.populateRotateSnapSelect(snapSelect, this.angleSnapStep, preferred); +- snapSelect.disabled = panelDisabled || !this.angleSnapEnabled; +- snapSelect.hidden = !this.angleSnapEnabled; +- } +- if (numericInput) { +- numericInput.disabled = panelDisabled || this.angleSnapEnabled; +- numericInput.hidden = this.angleSnapEnabled; +- } +- }); +- } +- +- populateRotateSnapSelect(select, step, preferredValue = "15") { +- if (!select) { +- return; +- } +- const normalizedStep = ANGLE_SNAP_STEPS.includes(Number(step)) ? Number(step) : 15; +- const previous = Number(preferredValue); +- const normalizedPreferred = Number.isFinite(previous) +- ? this.normalizeRotationDegrees(Math.round(previous / normalizedStep) * normalizedStep) +- : normalizedStep; +- select.replaceChildren(); +- for (let value = 0; value < 360; value += normalizedStep) { +- const option = document.createElement("option"); +- option.value = String(value); +- option.textContent = String(value); +- select.append(option); +- } +- select.value = Array.from(select.options).some((option) => option.value === String(normalizedPreferred)) +- ? String(normalizedPreferred) +- : "0"; +- this.transformInputValues.set(select.id, select.value); +- } +- +- readSnapMode() { +- const storedMode = this.window.sessionStorage?.getItem(SNAP_MODE_SESSION_KEY) || "grid"; +- return SNAP_MODES.includes(storedMode) ? storedMode : "grid"; +- } +- +- readAngleSnapStep() { +- const storedStep = Number(this.window.sessionStorage?.getItem(ANGLE_SNAP_STEP_SESSION_KEY) || 15); +- return ANGLE_SNAP_STEPS.includes(storedStep) ? storedStep : 15; +- } +- +- setSnapMode(mode, label = "") { +- if (!SNAP_MODES.includes(mode)) { +- this.statusLog.write(`WARN Snap mode skipped: ${mode || "unknown"} is not supported.`); +- return; +- } +- this.snapMode = mode; +- this.window.sessionStorage?.setItem(SNAP_MODE_SESSION_KEY, mode); +- this.applySnapState(); +- this.renderWorkSurface(); +- this.statusLog.write(`OK ${label || shapeTypeLabel(mode)} mode selected for drawing and point dragging.`); +- } +- +- cycleSnapMode() { +- const currentIndex = SNAP_MODES.indexOf(this.snapMode); +- const nextMode = SNAP_MODES[(currentIndex + 1) % SNAP_MODES.length] || "grid"; +- this.setSnapMode(nextMode, SNAP_MODE_DETAILS[nextMode]?.label); +- } +- +- applyToolDisplayMode(mode, shouldLog) { +- const isCompact = mode === "icons"; +- this.elements.toolToggleGrid.classList.toggle("is-icon-only", isCompact); +- this.elements.toolLabelModeButton.setAttribute("aria-pressed", String(isCompact)); +- this.elements.toolLabelModeButton.textContent = isCompact ? "Words" : "Icons"; +- this.window.sessionStorage?.setItem(TOOL_DISPLAY_MODE_KEY, isCompact ? "icons" : "words"); +- if (shouldLog) { +- this.statusLog.write(`OK Tools display mode set to ${isCompact ? "compact icons" : "words and icons"}.`); +- } +- } +- +- setActiveToolButton(button) { +- this.elements.toolToggles.forEach((candidate) => { +- candidate.classList.toggle("is-active", candidate === button); +- candidate.setAttribute("aria-pressed", String(candidate === button)); +- }); +- } +- +- activateToolMode(tool, sourceLabel) { +- if (!PRIMITIVE_TOOLS.includes(tool)) { +- this.cancelActiveDrawing("tool mode change", { log: false }); +- } +- this.activeTool = tool; +- const button = this.elements.toolToggles.find((candidate) => candidate.dataset.shapeTool === tool) || null; +- this.setActiveToolButton(button); +- if (tool === "paint") { +- this.setPaletteTarget("paint", false); +- } else if (tool === "stroke") { +- this.setPaletteTarget("stroke", false); +- } else { +- this.setPaletteTarget("stroke", false); +- } +- this.statusLog.write(`OK Tools mode selected from ${sourceLabel}: ${tool}.`); +- } +- +- renderEmptyState(message) { +- this.currentPayload = null; +- this.selectedObjectId = ""; +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- this.activeDrawing = null; +- this.drawingPreviewPoint = null; +- this.activeDrawing = null; +- this.drawingPreviewPoint = null; +- this.clearPreviewEditState({ clipboard: true }); +- this.selectedStateId = ""; +- this.stateControlStateId = ""; +- this.selectedFrameId = ""; +- this.stopPlaybackTimer(); +- this.updateObjectsHeader(0, 0); +- this.updatePaletteHeader(this.runtimePalette?.swatches?.length || 0); +- this.elements.objectNameInput.value = ""; +- this.elements.objectTagInput.value = ""; +- this.renderObjectTagList(null); +- this.elements.paletteSummary.textContent = this.runtimePalette ? "" : "Palette required before render."; +- this.elements.shapeGeometryDetails.textContent = "No object selected."; +- this.updateShapeGeometryHeader(null); +- this.elements.objectTransform.textContent = "No object selected."; +- this.elements.shapeTransform.textContent = "No shape selected."; +- this.elements.objectPreviewFooter.textContent = "Object ID: none"; +- this.elements.jsonDetails.textContent = "{}"; +- this.renderFrameTimeline(); +- this.updateViewport(); +- this.elements.renderSurface.replaceChildren(); +- this.renderSvgGrid(); +- this.renderCenterOriginMarker(); +- this.elements.objectTiles.replaceChildren(this.createEmptyObjectTile()); +- this.renderDependencyGraph(); +- this.actionNav.setJsonPayloadActionsEnabled(false); +- this.elements.exportSvgButton.disabled = true; +- this.actionNav.setImportEnabled(this.schemaReady && !this.actionNav.isWorkspaceLaunch()); +- this.updateObjectActionState(); +- } +- +- updateObjectsHeader(objectCount, shapeCount) { +- const stateCount = this.objectStates(this.selectedObject()).length; +- this.elements.objectsCount.textContent = `(${objectCount} obj, states ${stateCount}, ${shapeCount} ${shapeCount === 1 ? "shape" : "shapes"})`; +- } +- +- updateShapeGeometryHeader(shape) { +- this.elements.shapeGeometrySummary.textContent = ""; +- if (this.elements.shapeGeometryName) { +- this.elements.shapeGeometryName.textContent = ""; +- } +- } +- +- updatePaletteHeader(swatchCount) { +- this.elements.paletteSwatchCount.textContent = `(${swatchCount} ${swatchCount === 1 ? "swatch" : "swatches"})`; +- } +- +- createEmptyObjectTile() { +- const tile = document.createElement("article"); +- tile.className = "object-vector-studio-v2__object-tile object-vector-studio-v2__object-tile--empty"; +- tile.textContent = "No objects loaded. Import schema-valid Object Vector Studio V2 JSON and provide a runtime palette to show object tiles."; +- return tile; +- } +- +- async importJson(file) { +- if (!file) { +- this.statusLog.write("FAIL Import JSON blocked: choose an Object Vector Studio V2 JSON file."); +- return; +- } +- +- let payload; +- try { +- payload = JSON.parse(await readFileText(file)); +- } catch (error) { +- this.statusLog.write(`FAIL Import JSON blocked: ${file.name || "selected file"} is invalid JSON: ${error.message}`); +- return; +- } +- +- this.loadPayload(payload, `import:${file.name || "selected file"}`); +- } +- +- loadWorkspaceToolState() { +- const storedPayload = this.window.sessionStorage?.getItem(WORKSPACE_TOOL_SESSION_KEY) || ""; +- if (!storedPayload) { +- const message = `Workspace toolState loading blocked: ${WORKSPACE_TOOL_SESSION_KEY} is missing. A valid Object Vector Studio V2 payload is required.`; +- this.renderEmptyState(message); +- this.statusLog.write(`FAIL ${message}`); +- return; +- } +- +- let payload; +- try { +- const session = JSON.parse(storedPayload); +- payload = session?.data && typeof session.data === "object" ? session.data : session; +- } catch (error) { +- const message = `Workspace toolState loading blocked: ${WORKSPACE_TOOL_SESSION_KEY} is invalid JSON: ${error.message}`; +- this.renderEmptyState(message); +- this.statusLog.write(`FAIL ${message}`); +- return; +- } +- +- this.loadPayload(payload, WORKSPACE_TOOL_SESSION_KEY); +- } +- +- loadPayload(payload, sourceLabel) { +- const validation = this.schemaService.validatePayload(payload); +- if (!validation.ok) { +- const message = `Object Vector Studio V2 schema validation failed from ${sourceLabel}: ${validation.errors.join(" ")}`; +- this.renderEmptyState(message); +- this.statusLog.write(`FAIL ${message}`); +- return; +- } +- +- this.currentPayload = validation.payload; +- this.applyLoadedRuntimeState(sourceLabel); +- this.actionNav.setJsonPayloadActionsEnabled(true); +- this.actionNav.setImportEnabled(!this.actionNav.isWorkspaceLaunch()); +- this.renderPayload(); +- this.statusLog.write(`OK Loaded Object Vector Studio V2 schema payload from ${sourceLabel}: ${this.currentPayload.objects.length} objects.`); +- } +- +- applyLoadedRuntimeState(sourceLabel) { +- const selectedObject = this.currentPayload.objects[0] || null; +- this.selectedObjectId = selectedObject?.id || ""; +- const selectedShape = sortedShapes(selectedObject)[0] || null; +- this.selectedShapeIndex = selectedShape ? 0 : -1; +- this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- const selectedState = this.objectStates(selectedObject)[0] || null; +- this.selectedStateId = selectedState?.id || ""; +- this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; +- this.selectedFrameId = selectedState ? sortedFrames(selectedState)[0]?.id || "" : ""; +- this.hiddenObjectIds.clear(); +- this.lockedObjectIds.clear(); +- this.clearPreviewEditState({ clipboard: true }); +- this.activeDrawing = null; +- this.drawingPreviewPoint = null; +- this.viewport = { ...DEFAULT_VIEWPORT }; +- this.updateViewport(); +- if (sourceLabel) { +- this.statusLog.write(`OK Runtime selection initialized from ${sourceLabel}: ${this.selectedObjectId || "no object selected"}.`); +- } +- } +- +- renderPayload(options = {}) { +- if (!this.currentPayload) { +- return; +- } +- +- const validation = this.schemaService.validatePayload(this.currentPayload); +- if (!validation.ok) { +- const message = `Render blocked by Object Vector Studio V2 schema validation: ${validation.errors.join(" ")}`; +- this.renderEmptyState(message); +- this.statusLog.write(`FAIL ${message}`); +- return; +- } +- +- this.currentPayload = validation.payload; +- if (!this.runtimePalette) { +- this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY) || this.loadPaletteFromWorkspaceSession(); +- } +- const paletteValidation = this.schemaService.validateRuntimePalette(this.runtimePalette, "runtime palette"); +- if (!paletteValidation.ok) { +- const message = `Render blocked: runtime palette is required separately from Object Vector Studio V2 JSON. ${paletteValidation.errors.join(" ")}`; +- this.renderPaletteRequiredState(message); +- this.statusLog.write(`FAIL ${message}`); +- return; +- } +- this.runtimePalette = paletteValidation.palette; +- const preserveEmptyShapeSelection = options.preserveEmptyShapeSelection === true +- && this.selectedObject() +- && this.selectedShapeIndex < 0 +- && !this.selectedShapeIndexes.size +- && !this.directSelectedShapeIndexes.size; +- if (!preserveEmptyShapeSelection) { +- this.ensureSelectedShape(); +- } +- this.ensureSelectedFrame(); +- if (options.syncPaletteSelection !== false) { +- this.syncPaletteSelectionFromCurrentShape({ render: false }); +- } +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- this.renderTagFilter(); +- this.renderObjectTiles(); +- this.renderDependencyGraph(); +- this.renderSelectedObject(); +- this.renderWorkSurface(); +- this.updateObjectActionState(); +- } +- +- renderPaletteRequiredState(message) { +- this.selectedObjectId = ""; +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- this.selectedStateId = ""; +- this.stateControlStateId = ""; +- this.selectedFrameId = ""; +- this.stopPlaybackTimer(); +- this.updateObjectsHeader(0, 0); +- this.updatePaletteHeader(0); +- this.elements.paletteSummary.textContent = message; +- this.elements.objectNameInput.value = ""; +- this.elements.objectTagInput.value = ""; +- this.renderObjectTagList(null); +- this.elements.shapeGeometryDetails.textContent = "Runtime palette required before object render."; +- this.updateShapeGeometryHeader(null); +- this.elements.objectTransform.textContent = "Runtime palette required before object transform."; +- this.elements.shapeTransform.textContent = "Runtime palette required before shape transform."; +- this.elements.objectPreviewFooter.textContent = "Object ID: none"; +- this.elements.jsonDetails.textContent = "{}"; +- this.renderFrameTimeline(); +- this.elements.renderSurface.replaceChildren(); +- this.renderSvgGrid(); +- this.renderCenterOriginMarker(); +- const tile = this.createEmptyObjectTile(); +- tile.textContent = "Runtime palette required before rendering object tiles."; +- this.elements.objectTiles.replaceChildren(tile); +- this.renderDependencyGraph(); +- this.actionNav.setJsonPayloadActionsEnabled(Boolean(this.currentPayload)); +- this.elements.exportSvgButton.disabled = true; +- this.actionNav.setImportEnabled(this.schemaReady && !this.actionNav.isWorkspaceLaunch()); +- this.updateObjectActionState(); +- } +- +- renderPalette() { +- const swatchCount = this.runtimePalette.swatches.length; +- this.updatePaletteModeSwatches(); +- this.updatePaletteHeader(swatchCount); +- this.elements.paletteSummary.replaceChildren(); +- this.sortedPaletteSwatches().forEach((swatch, index) => { +- const label = swatch?.name || swatch?.id || swatch?.symbol || `swatch-${index + 1}`; +- const color = swatchColor(swatch); +- const item = document.createElement("button"); +- item.className = "object-vector-studio-v2__palette-swatch"; +- item.type = "button"; +- item.dataset.paletteColor = color; +- item.dataset.paletteLabel = label; +- item.dataset.paletteTags = tagList(swatch?.tags).join(", "); +- item.dataset.paletteDetails = `${label}\n${color || "value unavailable"}`; +- item.title = item.dataset.paletteDetails; +- item.setAttribute("aria-label", `Palette swatch ${label} ${color || "value unavailable"}`); +- item.classList.toggle("is-selected", color && color === this.currentTargetColor()); +- if (color) { +- item.style.setProperty("--object-vector-studio-v2-swatch", color); +- } +- item.addEventListener("click", () => this.selectPaletteColor(color, label)); +- this.elements.paletteSummary.append(item); +- }); +- } +- +- sortedPaletteSwatches() { +- const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +- return [...this.runtimePalette.swatches].sort((left, right) => { +- const leftLabel = left?.name || left?.id || left?.symbol || ""; +- const rightLabel = right?.name || right?.id || right?.symbol || ""; +- let result = 0; +- if (this.paletteSortMode === "name") { +- result = collator.compare(leftLabel, rightLabel); +- } else if (this.paletteSortMode === "tag") { +- const leftTags = tagList(left?.tags).join(", "); +- const rightTags = tagList(right?.tags).join(", "); +- if (leftTags && !rightTags) { +- return -1; +- } +- if (!leftTags && rightTags) { +- return 1; +- } +- result = collator.compare(leftTags, rightTags) || collator.compare(leftLabel, rightLabel); +- } else { +- const leftMetrics = colorMetrics(swatchColor(left)); +- const rightMetrics = colorMetrics(swatchColor(right)); +- result = (leftMetrics[this.paletteSortMode] - rightMetrics[this.paletteSortMode]) || collator.compare(leftLabel, rightLabel); +- } +- return this.paletteSortDirection === "desc" ? -result : result; +- }); +- } +- +- paletteDisplayName() { +- return this.runtimePalette?.id || this.runtimePalette?.name || this.runtimePaletteSource || "runtime palette"; +- } +- +- renderObjectTiles() { +- const previousScrollTop = this.elements.objectsContent.scrollTop; +- this.updateObjectsHeader(this.currentPayload.objects.length, this.selectedObject()?.shapes.length || 0); +- this.elements.objectTiles.replaceChildren(); +- if (!this.currentPayload.objects.length) { +- this.elements.objectTiles.append(this.createEmptyObjectTile()); +- this.restoreObjectsScroll(previousScrollTop); +- return; +- } +- +- const filteredObjects = this.filteredObjects(); +- if (!filteredObjects.length) { +- const tile = this.createEmptyObjectTile(); +- tile.textContent = "No objects match the current tag or search filter."; +- this.elements.objectTiles.append(tile); +- this.restoreObjectsScroll(previousScrollTop); +- return; +- } +- +- filteredObjects.forEach((object) => { +- const tile = document.createElement("article"); +- tile.className = "object-vector-studio-v2__object-tile"; +- tile.dataset.objectId = object.id; +- tile.classList.toggle("is-selected", object.id === this.selectedObjectId); +- tile.classList.toggle("is-hidden", this.isObjectHidden(object.id)); +- tile.classList.toggle("is-locked", this.isObjectLocked(object.id)); +- tile.setAttribute("aria-pressed", String(object.id === this.selectedObjectId)); +- +- const selectButton = document.createElement("button"); +- selectButton.className = "object-vector-studio-v2__object-select"; +- selectButton.type = "button"; +- selectButton.title = `Select object ${object.name}`; +- selectButton.setAttribute("aria-pressed", String(object.id === this.selectedObjectId)); +- +- const thumbnail = this.createObjectThumbnail(object); +- const name = document.createElement("span"); +- name.className = "object-vector-studio-v2__object-tile-name"; +- name.textContent = object.name; +- +- const meta = document.createElement("span"); +- meta.className = "object-vector-studio-v2__object-tile-meta"; +- const tags = tagList(object.tags); +- const inheritedText = object.baseObjectId ? ` - inherits ${object.baseObjectId}` : ""; +- const gameSegment = objectGameSegment(object.id) || this.payloadGameKey(); +- meta.textContent = `object > ${gameSegment} > ${object.name} | ${shapeCountLabel(object.shapes.length)}${tags.length ? ` | ${tags.join(", ")}` : ""}${inheritedText}`; +- +- const copy = document.createElement("span"); +- copy.className = "object-vector-studio-v2__object-tile-copy"; +- copy.append(name, meta); +- +- selectButton.append(thumbnail, copy); +- selectButton.addEventListener("click", () => { +- this.selectObject(object.id, "tile"); +- }); +- +- const controls = document.createElement("div"); +- controls.className = "object-vector-studio-v2__object-tile-controls"; +- controls.append( +- this.createObjectTileControl(object, "visibility"), +- this.createObjectTileControl(object, "lock"), +- this.createObjectTileControl(object, "delete") +- ); +- tile.append(selectButton, controls); +- tile.addEventListener("click", () => { +- this.selectObject(object.id, "tile"); +- }); +- if (object.id === this.selectedObjectId) { +- tile.append(this.createObjectStatePanel(object), this.createObjectTileShapeList(object)); +- } +- this.elements.objectTiles.append(tile); +- }); +- this.restoreObjectsScroll(previousScrollTop); +- } +- +- renderShapeTiles(object = this.selectedObject()) { +- void object; +- } +- +- restoreObjectsScroll(scrollTop) { +- this.restoreElementScrollTop(this.elements.objectsContent, scrollTop); +- } +- +- captureLeftPanelScrollState() { +- return { +- leftPanelScrollTop: this.elements.leftPanel.scrollTop, +- objectsScrollTop: this.elements.objectsContent.scrollTop +- }; +- } +- +- restoreLeftPanelScrollState(scrollState) { +- this.restoreElementScrollTop(this.elements.leftPanel, scrollState.leftPanelScrollTop); +- this.restoreElementScrollTop(this.elements.objectsContent, scrollState.objectsScrollTop); +- } +- +- restoreElementScrollTop(element, scrollTop) { +- const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); +- element.scrollTop = Math.min(scrollTop, maxScrollTop); +- } +- +- createObjectTileControl(object, kind) { +- const button = document.createElement("button"); +- button.className = "object-vector-studio-v2__object-tile-control"; +- button.type = "button"; +- const isVisibility = kind === "visibility"; +- const isDelete = kind === "delete"; +- const isLocked = this.isObjectLocked(object.id); +- const isActive = isVisibility ? !this.isObjectHidden(object.id) : !isDelete && isLocked; +- button.dataset.objectControl = kind; +- button.dataset.objectControlId = object.id; +- if (isDelete) { +- button.classList.add("object-vector-studio-v2__object-tile-control--delete"); +- button.setAttribute("aria-label", `Delete object ${object.name}`); +- button.append(this.createIconSpan("delete", true)); +- button.title = isLocked ? "Unlock object before deleting" : "Delete this object"; +- button.disabled = isLocked; +- } else { +- button.setAttribute("aria-pressed", String(isActive)); +- button.setAttribute("aria-label", isVisibility ? `${isActive ? "Hide" : "Show"} object ${object.name}` : `${isActive ? "Unlock" : "Lock"} object ${object.name}`); +- button.append(this.createIconSpan(isVisibility ? "eye" : "lock", isActive)); +- button.title = isVisibility ? "Toggle object visibility" : "Toggle runtime object lock"; +- } +- button.addEventListener("click", (event) => { +- event.stopPropagation(); +- if (isVisibility) { +- this.toggleObjectVisibility(object.id); +- } else if (isDelete) { +- this.deleteObjectById(object.id, "object tile delete"); +- } else { +- this.toggleObjectLock(object.id); +- } +- }); +- return button; +- } +- +- createIconSpan(kind, isActive = true) { +- const icon = document.createElement("span"); +- icon.className = `object-vector-studio-v2__tile-icon object-vector-studio-v2__tile-icon--${kind}`; +- icon.classList.toggle("is-off", !isActive); +- icon.setAttribute("aria-hidden", "true"); +- const iconKey = kind === "eye" && !isActive ? "eyeOff" : kind === "lock" && !isActive ? "unlock" : kind; +- this.applyIconGlyph(icon, iconKey); +- return icon; +- } +- +- createObjectStatePanel(object) { +- const panel = document.createElement("div"); +- panel.className = "object-vector-studio-v2__object-state-panel"; +- panel.addEventListener("click", (event) => event.stopPropagation()); +- +- const controls = document.createElement("div"); +- controls.className = "object-vector-studio-v2__object-state-controls"; +- controls.setAttribute("aria-label", "Object state controls"); +- +- const selectedStateId = this.stateControlSelectionId(object); +- const stateExists = this.objectStates(object).some((state) => state.id === selectedStateId); +- const isLocked = this.isObjectLocked(object.id); +- +- const addButton = document.createElement("button"); +- addButton.type = "button"; +- addButton.dataset.objectStateAction = "add"; +- addButton.textContent = "Add"; +- addButton.title = stateExists ? "Selected state already exists." : `Add ${selectedStateId} state`; +- addButton.disabled = isLocked || stateExists || !OBJECT_STATE_IDS.includes(selectedStateId); +- addButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.createSelectedState(selectedStateId); +- }); +- +- const select = document.createElement("select"); +- select.dataset.objectStateSelect = object.id; +- select.setAttribute("aria-label", "State"); +- OBJECT_STATE_IDS.forEach((stateId) => { +- const option = document.createElement("option"); +- option.value = stateId; +- option.textContent = stateId; +- select.append(option); +- }); +- select.value = selectedStateId; +- select.addEventListener("change", (event) => { +- event.stopPropagation(); +- this.stateControlStateId = select.value; +- const nextState = this.objectStates(this.selectedObject()).find((state) => state.id === select.value); +- if (nextState) { +- this.selectState(nextState.id, "state dropdown"); +- return; +- } +- this.renderPayload(); +- this.statusLog.write(`INFO State ${select.value} is ready to add for ${object.name}.`); +- }); +- +- const deleteButton = document.createElement("button"); +- deleteButton.type = "button"; +- deleteButton.dataset.objectStateAction = "delete"; +- deleteButton.textContent = "Delete"; +- deleteButton.title = stateExists ? `Delete ${selectedStateId} state` : "Selected state has not been added."; +- deleteButton.disabled = isLocked || !stateExists || this.objectStates(object).length <= 1; +- deleteButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.deleteSelectedState(selectedStateId); +- }); +- +- const helpButton = document.createElement("button"); +- helpButton.className = "object-vector-studio-v2__object-state-help"; +- helpButton.type = "button"; +- helpButton.textContent = "?"; +- const allHelpText = this.allStateHelpText(); +- helpButton.title = allHelpText; +- helpButton.dataset.objectStateHelp = "all"; +- helpButton.setAttribute("aria-label", `State help for all states: ${allHelpText.replace(/\s+/gu, " ")}`); +- +- controls.append(select, addButton, deleteButton, helpButton); +- panel.append(controls, this.createObjectStateTileList(object)); +- return panel; +- } +- +- createObjectStateTileList(object) { +- const states = this.objectStates(object); +- const list = document.createElement("div"); +- list.className = "object-vector-studio-v2__object-state-tiles"; +- list.setAttribute("aria-label", "Object states"); +- if (!states.length) { +- const empty = document.createElement("span"); +- empty.className = "object-vector-studio-v2__shape-list-empty"; +- empty.textContent = "No states."; +- list.append(empty); +- return list; +- } +- +- states.forEach((state) => { +- const button = document.createElement("button"); +- button.type = "button"; +- button.dataset.objectStateTile = state.id; +- button.textContent = state.id; +- button.title = `Select ${state.id} state`; +- button.setAttribute("aria-pressed", String(state.id === this.selectedStateId)); +- button.classList.toggle("is-selected", state.id === this.selectedStateId); +- button.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.selectState(state.id, "state tile"); +- }); +- list.append(button); +- }); +- return list; +- } +- +- stateControlSelectionId(object) { +- if (OBJECT_STATE_IDS.includes(this.stateControlStateId)) { +- return this.stateControlStateId; +- } +- if (OBJECT_STATE_IDS.includes(this.selectedStateId)) { +- return this.selectedStateId; +- } +- const firstState = this.objectStates(object).find((state) => OBJECT_STATE_IDS.includes(state.id)); +- return firstState?.id || OBJECT_STATE_IDS[0]; +- } +- +- createObjectTileShapeList(object) { +- const list = document.createElement("div"); +- list.className = "object-vector-studio-v2__object-tile-shapes"; +- const displayedShapes = sortedShapes(object) +- .map((shape, shapeIndex) => ({ shape, shapeIndex })) +- .reverse(); +- displayedShapes.forEach(({ shape, shapeIndex }) => { +- const displayShape = object.id === this.selectedObjectId ? this.effectiveShape(shape, shapeIndex) : shape; +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__object-tile-shape-row"; +- row.classList.toggle("is-selected", this.selectedShapeIndexes.has(shapeIndex)); +- const selectButton = document.createElement("button"); +- selectButton.type = "button"; +- selectButton.className = "object-vector-studio-v2__shape-select"; +- selectButton.dataset.objectTileShapeIndex = String(shapeIndex); +- selectButton.setAttribute("aria-pressed", String(this.selectedShapeIndexes.has(shapeIndex))); +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__shape-select-label"; +- label.textContent = `${shapeIndex}. ${shapeDisplayLabel(shape)}`; +- selectButton.append(label); +- selectButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.selectShape(shapeIndex, "object tile shape list", { additive: event.shiftKey || event.ctrlKey || event.metaKey }); +- }); +- const actions = document.createElement("div"); +- actions.className = "object-vector-studio-v2__shape-inline-actions"; +- const groupButton = this.createShapeGroupButton(shape, shapeIndex); +- const visibilityButton = document.createElement("button"); +- visibilityButton.type = "button"; +- visibilityButton.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-eye-inline"; +- visibilityButton.dataset.shapeVisibilityIndex = String(shapeIndex); +- visibilityButton.setAttribute("aria-label", `${displayShape.visible ? "Hide" : "Show"} shape ${shapeDisplayLabel(shape)} in the selected state/frame`); +- visibilityButton.title = "Toggle state/frame shape visibility"; +- visibilityButton.append(this.createIconSpan("eye", displayShape.visible)); +- visibilityButton.addEventListener("pointerdown", (event) => { +- event.stopPropagation(); +- }); +- visibilityButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.toggleShapeVisibilityByIndex(shapeIndex, object.id); +- }); +- const deleteButton = document.createElement("button"); +- deleteButton.type = "button"; +- deleteButton.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-delete-inline"; +- deleteButton.dataset.shapeDeleteIndex = String(shapeIndex); +- deleteButton.dataset.shapeDeleteObjectId = object.id; +- deleteButton.setAttribute("aria-label", `Delete shape ${shapeDisplayLabel(shape)}`); +- deleteButton.title = "Delete this base shape from every state"; +- deleteButton.append(this.createIconSpan("delete", true)); +- deleteButton.addEventListener("pointerdown", (event) => { +- event.stopPropagation(); +- }); +- deleteButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.deleteShapeByIndex(shapeIndex, "object tile shape delete", object.id); +- }); +- if (groupButton) { +- actions.append(groupButton); +- } +- actions.append(visibilityButton, deleteButton); +- row.append(selectButton, actions); +- list.append(row); +- }); +- if (!list.children.length) { +- const empty = document.createElement("div"); +- empty.className = "object-vector-studio-v2__shape-list-empty"; +- empty.textContent = "No shapes."; +- list.append(empty); +- } +- list.append(this.createSelectedShapeActions(object)); +- return list; +- } +- +- createShapeGroupButton(shape, shapeIndex) { +- const groupId = String(shape.groupId || "").trim(); +- if (!groupId) { +- return null; +- } +- const button = document.createElement("button"); +- button.type = "button"; +- button.className = "object-vector-studio-v2__shape-inline-button object-vector-studio-v2__shape-group-button"; +- button.dataset.shapeGroupId = groupId; +- button.setAttribute("aria-label", `Shape group ${groupId}`); +- button.title = `Shape group ${groupId}`; +- button.addEventListener("pointerdown", (event) => { +- event.stopPropagation(); +- }); +- button.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.selectShapeGroup(groupId, shapeIndex, "shape group icon"); +- }); +- button.append(this.createGroupIndicator(groupId)); +- return button; +- } +- +- createGroupIndicator(groupId) { +- const icon = document.createElement("span"); +- icon.className = "object-vector-studio-v2__shape-group-indicator object-vector-studio-v2__tile-icon object-vector-studio-v2__tile-icon--group"; +- icon.style.setProperty("--object-vector-studio-v2-shape-group-color", this.groupColor(groupId)); +- this.applyIconGlyph(icon, "group"); +- return icon; +- } +- +- createSelectedShapeActions(object) { +- const actions = document.createElement("div"); +- actions.className = "object-vector-studio-v2__shape-list-actions object-vector-studio-v2__z-order-actions is-icon-only"; +- actions.setAttribute("aria-label", "Selected shape move and group controls"); +- const shape = this.selectedShape(); +- const isLocked = this.isObjectLocked(object.id); +- const noShapeReason = "Disabled until a schema-valid shape is selected."; +- [ +- { action: "back", iconClass: "send-back", iconKey: "sendBack", label: "Send To Back", title: "Send selected shape to the back." }, +- { action: "backward", iconClass: "send-backward", iconKey: "sendBackward", label: "Move Backward", title: "Move selected shape backward one row." }, +- { action: "forward", iconClass: "bring-forward", iconKey: "bringForward", label: "Move Forward", title: "Move selected shape forward one row." }, +- { action: "front", iconClass: "bring-front", iconKey: "bringFront", label: "Bring To Front", title: "Bring selected shape to the front." } +- ].forEach((definition) => { +- const button = this.createShapeActionButton(definition.label, definition.iconClass, definition.iconKey, definition.title, () => this.changeSelectedShapeOrder(definition.action)); +- this.setControlDisabled(button, !shape || isLocked, isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : noShapeReason, definition.title); +- actions.append(button); +- }); +- +- const groupButton = this.createShapeActionButton("Group", "group", "group", "Group selected shapes. Shift-click shapes to select more than one.", () => this.groupSelectedShapes()); +- this.setControlDisabled( +- groupButton, +- this.selectedShapeIndexes.size < 2 || isLocked, +- isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : "Disabled until two or more shapes are selected. Shift-click shapes in the preview or object shape list to build a group selection.", +- "Group selected shapes. Shift-click shapes to select more than one." +- ); +- actions.append(groupButton); +- +- const ungroupButton = this.createShapeActionButton("Ungroup", "ungroup", "ungroup", "Ungroup selected shapes.", () => this.ungroupSelectedShapes()); +- const hasValidGroup = this.shapeBelongsToValidGroup(object, this.selectedShapeIndex); +- this.setControlDisabled( +- ungroupButton, +- !shape || isLocked || !hasValidGroup, +- isLocked ? `Disabled because ${object.name} is locked for this runtime session.` : "Disabled until the selected shape belongs to a group with at least two shapes.", +- "Ungroup selected shapes." +- ); +- actions.append(ungroupButton); +- return actions; +- } +- +- createShapeActionButton(labelText, iconClass, iconKey, title, onClick) { +- const button = document.createElement("button"); +- button.className = "object-vector-studio-v2__z-action"; +- button.type = "button"; +- button.dataset.shapeListAction = labelText.toLowerCase().replace(/\s+/gu, "-"); +- button.setAttribute("aria-label", labelText); +- button.title = title; +- const icon = document.createElement("span"); +- icon.className = `object-vector-studio-v2__z-icon object-vector-studio-v2__z-icon--${iconClass}`; +- icon.setAttribute("aria-hidden", "true"); +- this.applyIconGlyph(icon, iconKey); +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__z-label"; +- label.textContent = labelText; +- button.append(icon, label); +- button.addEventListener("click", (event) => { +- event.stopPropagation(); +- onClick(); +- }); +- return button; +- } +- +- isObjectHidden(objectId) { +- return this.hiddenObjectIds.has(objectId); +- } +- +- isObjectLocked(objectId) { +- return this.lockedObjectIds.has(objectId); +- } +- +- toggleObjectVisibility(objectId) { +- const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId); +- if (!object) { +- this.statusLog.write(`WARN Object visibility skipped: object id ${objectId || "unknown"} is not available.`); +- return; +- } +- if (this.hiddenObjectIds.has(objectId)) { +- this.hiddenObjectIds.delete(objectId); +- } else { +- this.hiddenObjectIds.add(objectId); +- } +- this.renderPayload(); +- this.statusLog.write(`OK Object ${object.name} visibility set to ${this.isObjectHidden(objectId) ? "hidden" : "visible"} for this runtime session.`); +- } +- +- toggleObjectLock(objectId) { +- const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId); +- if (!object) { +- this.statusLog.write(`WARN Object lock skipped: object id ${objectId || "unknown"} is not available.`); +- return; +- } +- if (this.lockedObjectIds.has(objectId)) { +- this.lockedObjectIds.delete(objectId); +- } else { +- this.lockedObjectIds.add(objectId); +- } +- this.renderPayload(); +- this.statusLog.write(`OK Object ${object.name} lock set to ${this.isObjectLocked(objectId) ? "locked" : "unlocked"} for this runtime session.`); +- } +- +- filteredObjects() { +- const tag = this.elements.tagFilter.value || "all"; +- const query = this.elements.searchFilter.value.trim().toLowerCase(); +- return this.currentPayload.objects.filter((object) => { +- const matchesTag = tag === "all" || tagList(object.tags).includes(tag); +- const haystack = `${object.id} ${object.name} ${objectGameSegment(object.id)} ${(object.tags || []).join(" ")} ${object.baseObjectId || ""}`.toLowerCase(); +- return matchesTag && (!query || haystack.includes(query)); +- }); +- } +- +- renderTagFilter() { +- const selectedValue = this.elements.tagFilter.value || "all"; +- this.elements.tagFilter.replaceChildren(); +- const allOption = document.createElement("option"); +- allOption.value = "all"; +- allOption.textContent = "All"; +- this.elements.tagFilter.append(allOption); +- this.availableObjectTags().forEach((tag) => { +- const option = document.createElement("option"); +- option.value = tag; +- option.textContent = tag; +- this.elements.tagFilter.append(option); +- }); +- this.elements.tagFilter.value = Array.from(this.elements.tagFilter.options).some((option) => option.value === selectedValue) ? selectedValue : "all"; +- } +- +- availableObjectTags() { +- return Array.from(new Set((this.currentPayload?.objects || []).flatMap((object) => tagList(object.tags)))).sort(); +- } +- +- renderDependencyGraph() { +- if (!this.currentPayload) { +- this.elements.dependencyGraph.textContent = "No dependency graph loaded."; +- return; +- } +- const usageLines = this.currentPayload.objects.map((object) => `${object.id}: ${object.name}${tagList(object.tags).length ? ` [${tagList(object.tags).join(", ")}]` : ""}`); +- const graphLines = this.currentPayload.objects +- .map((object) => `${object.id}${object.baseObjectId ? ` inherits ${object.baseObjectId}` : " has no base object"}`); +- this.elements.dependencyGraph.textContent = [ +- "Dependency details:", +- graphLines.join("\n") || "No objects loaded.", +- "", +- "Object tags:", +- usageLines.join("\n") || "No object tags." +- ].join("\n"); +- } +- +- createObjectThumbnail(object) { +- const svg = document.createElementNS(SVG_NS, "svg"); +- svg.classList.add("object-vector-studio-v2__object-thumbnail"); +- svg.dataset.objectThumbnail = object.id; +- svg.setAttribute("aria-label", `${object.name} thumbnail`); +- svg.setAttribute("role", "img"); +- +- const bounds = this.objectBounds(object, { includeInvisible: false }); +- const transformOrigin = this.objectTransformOrigin(object); +- const padding = 12; +- svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); +- sortedShapes(object).filter((shape) => shape.visible).forEach((shape, shapeIndex) => { +- try { +- const element = this.createSvgShape(shape, { transformOrigin }); +- element.classList.add("object-vector-studio-v2__object-thumbnail-shape"); +- svg.append(element); +- this.appendShapePointStyleCaps(svg, shape, { transformOrigin }); +- } catch (error) { +- this.statusLog.write(`FAIL Thumbnail render failed for ${object.id}/shape-${shapeIndex} (${shapeTool(shape)}): ${error.message}`); +- } +- }); +- if (!svg.children.length) { +- const empty = document.createElementNS(SVG_NS, "rect"); +- empty.setAttribute("x", bounds.x); +- empty.setAttribute("y", bounds.y); +- empty.setAttribute("width", bounds.width); +- empty.setAttribute("height", bounds.height); +- empty.classList.add("object-vector-studio-v2__object-thumbnail-empty"); +- svg.append(empty); +- } +- return svg; +- } +- +- renderSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.elements.objectNameInput.value = ""; +- this.elements.objectTagInput.value = ""; +- this.renderObjectTagList(null); +- this.updateObjectsHeader(this.currentPayload?.objects.length || 0, 0); +- this.elements.shapeGeometryDetails.textContent = "No object selected."; +- this.elements.objectTransform.textContent = "No object selected."; +- this.elements.shapeTransform.textContent = "No shape selected."; +- this.updateShapeGeometryHeader(null); +- this.elements.objectPreviewFooter.textContent = "Object ID: none"; +- this.elements.jsonDetails.textContent = "{}"; +- this.renderFrameTimeline(); +- return; +- } +- +- const shape = this.selectedShape(); +- this.elements.objectNameInput.value = selected.name; +- this.renderObjectTagList(selected); +- this.updateObjectsHeader(this.currentPayload.objects.length, selected.shapes.length); +- this.elements.shapeGeometryDetails.replaceChildren(this.createShapeGeometryDetails(selected, shape)); +- this.elements.objectTransform.replaceChildren(this.createObjectTransformDetails(selected)); +- this.elements.shapeTransform.replaceChildren(this.createShapeTransformDetails(shape)); +- this.updateShapeGeometryHeader(shape); +- this.elements.objectPreviewFooter.textContent = `Object ID: ${selected.id}`; +- this.updateSelectedObjectJsonDetails(); +- this.renderFrameTimeline(); +- } +- +- updateSelectedObjectJsonDetails() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.elements.jsonDetails.textContent = "{}"; +- return; +- } +- const shape = this.selectedShape(); +- this.elements.jsonDetails.textContent = JSON.stringify({ +- object: selected, +- selectedFrame: this.activeFrame(), +- selectedShape: shape, +- selectedShapeIndexes: Array.from(this.selectedShapeIndexes), +- selectedState: this.selectedState() +- }, null, 2); +- } +- +- updateTransformSummaryText() { +- const objectSummary = this.elements.objectTransform.querySelector(".object-vector-studio-v2__transform-summary"); +- const object = this.selectedObject(); +- if (objectSummary && object) { +- objectSummary.textContent = this.formatObjectTransformSummary(object); +- } +- const summary = this.elements.shapeTransform.querySelector(".object-vector-studio-v2__transform-summary"); +- const shape = this.selectedShape(); +- if (!summary || !shape) { +- return; +- } +- summary.textContent = this.formatTransformSummary(this.shapeTransform(this.effectiveShape(shape))); +- } +- +- updateObjectPreviewFooterFromNameInput() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.elements.objectPreviewFooter.textContent = "Object ID: none"; +- return; +- } +- +- const name = this.elements.objectNameInput.value.trim(); +- if (!name) { +- this.elements.objectPreviewFooter.textContent = `Object ID: ${selected.id}`; +- return; +- } +- +- const siblingObjects = this.currentPayload.objects.filter((object) => object.id !== selected.id); +- const previewId = this.uniqueObjectId(name, siblingObjects, objectGameSegment(selected.id) || this.payloadGameKey()); +- this.elements.objectPreviewFooter.textContent = `Object ID: ${previewId}`; +- } +- +- renderObjectTagList(object) { +- this.elements.objectTagList.replaceChildren(); +- if (!object) { +- const empty = document.createElement("span"); +- empty.className = "object-vector-studio-v2__shape-list-empty"; +- empty.textContent = "No selected object tags."; +- this.elements.objectTagList.append(empty); +- return; +- } +- const tags = tagList(object.tags); +- if (!tags.length) { +- const empty = document.createElement("span"); +- empty.className = "object-vector-studio-v2__shape-list-empty"; +- empty.textContent = "No tags."; +- this.elements.objectTagList.append(empty); +- return; +- } +- tags.forEach((tag) => { +- const button = document.createElement("button"); +- button.type = "button"; +- button.dataset.objectTag = tag; +- button.textContent = tag; +- button.title = `Remove tag ${tag}`; +- button.addEventListener("click", () => this.removeTagFromSelectedObject(tag)); +- this.elements.objectTagList.append(button); +- }); +- } +- +- renderFrameTimeline() { +- this.elements.frameTimeline.replaceChildren(); +- const object = this.selectedObject(); +- const state = this.selectedState(); +- if (!object || !state) { +- const empty = document.createElement("p"); +- empty.className = "tool-starter__hint"; +- empty.textContent = "No state timeline selected."; +- this.elements.frameTimeline.append(empty); +- this.updateAnimationActionState(); +- return; +- } +- +- sortedFrames(state).forEach((frame) => { +- const button = document.createElement("div"); +- button.className = "object-vector-studio-v2__frame-tile"; +- button.dataset.stateId = state.id; +- button.dataset.frameId = frame.id; +- button.title = `Select frame ${frame.id} in state ${state.id}`; +- button.setAttribute("aria-pressed", String(frame.id === this.selectedFrameId)); +- button.setAttribute("role", "button"); +- button.tabIndex = 0; +- button.classList.toggle("is-selected", frame.id === this.selectedFrameId); +- button.append(this.createFrameThumbnail(object, frame)); +- const label = document.createElement("span"); +- label.textContent = `${frame.order}. ${frame.id}`; +- button.append(label); +- button.addEventListener("click", () => this.selectFrame(frame.id, "timeline")); +- button.addEventListener("keydown", (event) => { +- if (event.key !== "Enter" && event.key !== " ") { +- return; +- } +- event.preventDefault(); +- this.selectFrame(frame.id, "timeline"); +- }); +- this.elements.frameTimeline.append(button); +- }); +- this.updateAnimationActionState(); +- } +- +- stateHelpText(stateId) { +- const helpLines = OBJECT_STATE_HELP[stateId] || [`No contextual help is available for state ${stateId || "unknown"}.`]; +- return helpLines.join("\n"); +- } +- +- allStateHelpText() { +- return OBJECT_STATE_IDS +- .map((stateId) => `${stateId}\n${this.stateHelpText(stateId)}`) +- .join("\n\n"); +- } +- +- createFrameThumbnail(object, frame) { +- const svg = document.createElementNS(SVG_NS, "svg"); +- svg.classList.add("object-vector-studio-v2__frame-thumbnail"); +- svg.dataset.frameThumbnail = frame.id; +- const bounds = this.objectBoundsForFrame(object, frame); +- const transformOrigin = this.objectTransformOrigin(object); +- const padding = 12; +- svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); +- sortedShapes(object).forEach((shape, shapeIndex) => { +- const renderShape = this.effectiveShapeForFrame(shape, frame, shapeIndex); +- if (!renderShape.visible) { +- return; +- } +- try { +- svg.append(this.createSvgShape(renderShape, { transformOrigin })); +- this.appendShapePointStyleCaps(svg, renderShape, { transformOrigin }); +- } catch (error) { +- this.statusLog.write(`FAIL Frame thumbnail render failed for ${object.id}/${frame.id}/shape-${shapeIndex}: ${error.message}`); +- } +- }); +- return svg; +- } +- +- createShapeGeometryDetails(object, shape) { +- const wrapper = document.createElement("div"); +- wrapper.className = "object-vector-studio-v2__object-detail-stack"; +- if (!shape) { +- const shapePanel = document.createElement("section"); +- shapePanel.className = "object-vector-studio-v2__shape-panel"; +- const empty = document.createElement("p"); +- empty.textContent = "No shape selected. Create a primitive from Tools."; +- shapePanel.append(empty); +- wrapper.append(shapePanel); +- return wrapper; +- } +- +- wrapper.append(this.createShapeGeometryControls(shape), this.createSelectedShapeSummary(shape)); +- if (!this.canUseShapeGeometry()) { +- this.setGeometryPanelDisabled(wrapper, "Shape Geometry is disabled until exactly one shape is selected."); +- } +- return wrapper; +- } +- +- createSelectedShapeSummary(shape) { +- const shapePanel = document.createElement("section"); +- shapePanel.className = "object-vector-studio-v2__shape-panel"; +- const groupRow = document.createElement("div"); +- groupRow.className = "object-vector-studio-v2__shape-group-summary"; +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__detail-label"; +- label.textContent = "Group"; +- const value = document.createElement("span"); +- value.className = "object-vector-studio-v2__detail-value object-vector-studio-v2__shape-group-summary-value"; +- if (shape.groupId) { +- value.append(this.createGroupIndicator(shape.groupId), document.createTextNode(shape.groupId)); +- } else { +- value.textContent = "None"; +- } +- groupRow.append(label, value); +- shapePanel.append(groupRow); +- return shapePanel; +- } +- +- shapeSummaryTypeLabel(shape) { +- return shapeTool(shape); +- } +- +- isTriangleShape(shape) { +- return this.shapeSummaryTypeLabel(shape) === "triangle"; +- } +- +- isEditablePolygonShape(shape) { +- const geometryTool = shapeGeometryTool(shape); +- return (geometryTool === "polygon" && !this.isTriangleShape(shape)) || geometryTool === "polyline"; +- } +- +- shapeGeometryHeadingLabel(shape) { +- return shapeTypeLabel(shape); +- } +- +- createObjectTransformDetails(object) { +- const wrapper = document.createElement("div"); +- wrapper.className = "object-vector-studio-v2__object-detail-stack"; +- if (!object) { +- const empty = document.createElement("p"); +- empty.textContent = "No object selected."; +- wrapper.append(empty); +- return wrapper; +- } +- +- const transformPanel = document.createElement("section"); +- transformPanel.className = "object-vector-studio-v2__shape-panel"; +- const summary = document.createElement("p"); +- summary.className = "object-vector-studio-v2__transform-summary"; +- summary.dataset.transformSummary = "object"; +- summary.textContent = this.formatObjectTransformSummary(object); +- transformPanel.append(this.createObjectTransformControls(object), summary); +- wrapper.append(transformPanel); +- return wrapper; +- } +- +- createShapeTransformDetails(shape) { +- const wrapper = document.createElement("div"); +- wrapper.className = "object-vector-studio-v2__object-detail-stack"; +- if (!shape) { +- const empty = document.createElement("p"); +- empty.textContent = "No shape selected. Create a primitive from Tools."; +- wrapper.append(empty); +- return wrapper; +- } +- +- const transformPanel = document.createElement("section"); +- transformPanel.className = "object-vector-studio-v2__shape-panel"; +- const transform = this.shapeTransform(this.effectiveShape(shape)); +- const summary = document.createElement("p"); +- summary.className = "object-vector-studio-v2__transform-summary"; +- summary.dataset.transformSummary = "shape"; +- summary.textContent = this.formatTransformSummary(transform); +- transformPanel.append(this.createShapeTransformControls(shape), summary); +- if (!this.canUseShapeTransform()) { +- this.setTransformPanelDisabled(transformPanel, "Shape Transform is disabled until exactly one shape is selected."); +- } +- wrapper.append(transformPanel); +- return wrapper; +- } +- +- canUseShapeTransform() { +- return this.selectedShapeIndex >= 0 && this.selectedShapeIndexes.size === 1; +- } +- +- canUseShapeGeometry() { +- return this.selectedShapeIndex >= 0 && this.selectedShapeIndexes.size === 1; +- } +- +- setGeometryPanelDisabled(panel, reason) { +- panel.classList.add("is-disabled"); +- panel.querySelectorAll("input, select, button").forEach((control) => { +- control.disabled = true; +- control.dataset.disabledReason = reason; +- control.title = reason; +- }); +- } +- +- setTransformPanelDisabled(panel, reason) { +- panel.classList.add("is-disabled"); +- panel.querySelectorAll("input, select, button").forEach((control) => { +- control.disabled = true; +- control.dataset.disabledReason = reason; +- control.title = reason; +- }); +- } +- +- formatTransformSummary(transform) { +- const rotation = this.normalizeRotationDegrees(transform.rotation); +- const scaleText = transform.scaleX === transform.scaleY +- ? String(transform.scaleX) +- : `${transform.scaleX} x ${transform.scaleY}`; +- return `x ${transform.x}, y ${transform.y}, rot ${rotation}, scale ${scaleText}`; +- } +- +- formatObjectTransformSummary(object) { +- const shapes = sortedShapes(object); +- const origin = this.objectTransformOrigin(object); +- const objectScale = object?.id === this.selectedObjectId ? this.objectScalePreviewValue() : 1; +- if (!shapes.length) { +- return `origin ${origin.x}, ${origin.y}, rot 0, scale ${this.formatScaleInputValue(objectScale)}`; +- } +- const frame = object?.id === this.selectedObjectId ? this.activeFrame() : null; +- const transforms = shapes.map((shape, shapeIndex) => this.shapeTransform(this.effectiveShapeForFrame(shape, frame, shapeIndex))); +- const sameNumber = (key) => transforms.every((transform) => Math.abs(transform[key] - transforms[0][key]) < 0.001); +- const sameScale = transforms.every((transform) => Math.abs(transform.scaleX - transforms[0].scaleX) < 0.001 && Math.abs(transform.scaleY - transforms[0].scaleY) < 0.001); +- let scaleText = "mixed"; +- if (Math.abs(objectScale - 1) >= 0.001) { +- scaleText = this.formatScaleInputValue(objectScale); +- } else if (sameScale) { +- scaleText = Math.abs(transforms[0].scaleX - transforms[0].scaleY) < 0.001 +- ? String(this.formatViewportNumber(transforms[0].scaleX)) +- : `${this.formatViewportNumber(transforms[0].scaleX)} x ${this.formatViewportNumber(transforms[0].scaleY)}`; +- } +- const rotationText = sameNumber("rotation") ? String(this.normalizeRotationDegrees(transforms[0].rotation)) : "mixed"; +- return `origin ${origin.x}, ${origin.y}, rot ${rotationText}, scale ${scaleText}`; +- } +- +- createDetailGrid(entries) { +- const grid = document.createElement("div"); +- grid.className = "object-vector-studio-v2__object-detail-grid"; +- entries.forEach(([label, value]) => { +- const labelElement = document.createElement("span"); +- labelElement.className = "object-vector-studio-v2__detail-label"; +- labelElement.textContent = label; +- const valueElement = document.createElement("span"); +- valueElement.className = "object-vector-studio-v2__detail-value"; +- valueElement.textContent = value; +- grid.append(labelElement, valueElement); +- }); +- return grid; +- } +- +- createShapeGeometryControls(shape) { +- const section = document.createElement("section"); +- const geometryTool = shapeGeometryTool(shape); +- section.className = `object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--geometry object-vector-studio-v2__edit-panel--${geometryTool}`; +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- section.classList.add("object-vector-studio-v2__edit-panel--polygon"); +- } +- const heading = document.createElement("h4"); +- heading.textContent = `${this.shapeGeometryHeadingLabel(shape)} Geometry`; +- const grid = document.createElement("div"); +- grid.className = geometryTool === "polygon" || geometryTool === "polyline" +- ? "object-vector-studio-v2__polygon-point-list" +- : `object-vector-studio-v2__edit-grid object-vector-studio-v2__edit-grid--${geometryTool}${geometryTool === "ellipse" ? " object-vector-studio-v2__edit-grid--ellipse" : ""}`; +- const editablePolygon = this.isEditablePolygonShape(shape); +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- const pointRounding = this.shapePointRoundingValues(shape); +- grid.append(this.createPointRowHeader({ addable: editablePolygon, deletable: editablePolygon })); +- shape.geometry.points.forEach((point, index) => { +- grid.append(this.createPolygonPointRow(point, index, { +- addable: editablePolygon, +- deletable: editablePolygon, +- rounded: pointRounding[index] === true, +- selectable: false +- })); +- }); +- } else { +- this.shapeGeometryFields(shape).forEach((field) => { +- const label = document.createElement("label"); +- label.className = "object-vector-studio-v2__edit-field"; +- if (field.wide) { +- label.classList.add("object-vector-studio-v2__edit-field--wide"); +- } +- const caption = document.createElement("span"); +- caption.textContent = field.label; +- const input = document.createElement("input"); +- input.dataset.shapeGeometryField = field.key; +- input.type = field.kind; +- input.value = field.kind === "number" +- ? (field.coordinate ? this.formatCoordinateInputValue(field.value) : String(this.formatViewportNumber(field.value))) +- : String(field.value); +- this.bindGeometryAutoApplyInput(input); +- label.append(caption, input); +- grid.append(label); +- }); +- } +- section.append(heading); +- const roundingRadiusControl = this.createRoundingRadiusControl(shape); +- if (roundingRadiusControl) { +- section.append(roundingRadiusControl); +- } +- section.append(grid); +- if (geometryTool !== "polygon" && geometryTool !== "polyline" && this.shapeSupportsPointRoundingControls(shape)) { +- section.append(this.createShapePointRoundingControls(shape)); +- } +- return section; +- } +- +- createRoundingRadiusControl(shape) { +- if (!this.shapeSupportsRoundingRadiusControl(shape)) { +- return null; +- } +- const label = document.createElement("label"); +- label.className = "object-vector-studio-v2__rounding-radius-field"; +- const caption = document.createElement("span"); +- caption.textContent = "Rounding Radius"; +- const input = document.createElement("input"); +- input.type = "number"; +- input.min = "0"; +- input.step = "0.1"; +- input.value = String(this.formatViewportNumber(this.shapeRoundingRadius(shape))); +- input.dataset.shapeRoundingRadius = "true"; +- input.setAttribute("aria-label", "Rounding radius"); +- input.addEventListener("input", () => this.clearInputValidity(input)); +- input.addEventListener("change", () => { +- const shapeIndex = this.selectedShapeIndex; +- this.window.setTimeout(() => { +- if (this.selectedShapeIndex !== shapeIndex) { +- return; +- } +- this.updateSelectedShapeRoundingRadius(input); +- }, 0); +- }); +- label.append(caption, input); +- return label; +- } +- +- updateSelectedShapeRoundingRadius(input) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Rounding radius update skipped: no shape is selected."); +- return; +- } +- if (!this.canUseShapeGeometry()) { +- this.statusLog.write("WARN Rounding radius update skipped: select exactly one shape."); +- return; +- } +- if (!this.shapeSupportsPointRoundingControls(selected)) { +- this.statusLog.write(`WARN Rounding radius update skipped: ${shapeTypeLabel(selected)} does not use rounded points.`); +- return; +- } +- const rawValue = String(input?.value ?? "").trim(); +- const value = Number(rawValue); +- if (rawValue === "" || !Number.isFinite(value) || value < 0) { +- const error = "Rounding Radius must be a finite number greater than or equal to 0."; +- this.markInputInvalid(input, error); +- this.statusLog.write(`FAIL Invalid rounding radius rejected for shape row ${this.selectedShapeIndex}: ${error}`); +- return; +- } +- if (this.guardSelectedObjectMutation("Rounding radius update")) { +- return; +- } +- this.clearInputValidity(input); +- const roundedValue = Number(value.toFixed(3)); +- const nextPayload = this.cloneCurrentPayload(); +- const nextShape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- if (!nextShape) { +- this.statusLog.write(`WARN Rounding radius update skipped: selected shape row ${this.selectedShapeIndex} was not found.`); +- return; +- } +- nextShape.style = { +- ...nextShape.style, +- roundingRadius: roundedValue +- }; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Updated rounding radius to ${roundedValue} for shape row ${this.selectedShapeIndex}.`, "Rounding radius update failed schema validation"); +- } +- +- updateSelectedShapePointRounding(pointIndex, isRounded) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Point rounding update skipped: no shape is selected."); +- return; +- } +- if (!this.canUseShapeGeometry()) { +- this.statusLog.write("WARN Point rounding update skipped: select exactly one shape."); +- return; +- } +- if (!this.shapeSupportsPointRoundingControls(selected)) { +- this.statusLog.write(`WARN Point rounding update skipped: ${shapeTypeLabel(selected)} does not use editable point rows.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Point rounding update")) { +- return; +- } +- const objectIndex = this.currentPayload.objects.findIndex((object) => object.id === this.selectedObjectId); +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects[objectIndex]; +- const nextShape = sortedShapes(nextObject)[this.selectedShapeIndex]; +- if (!nextShape) { +- this.statusLog.write(`WARN Point rounding update skipped: selected shape row ${this.selectedShapeIndex} was not found.`); +- return; +- } +- const pointCount = this.shapeGeometryPointCount(nextShape); +- if (!Number.isInteger(pointIndex) || pointIndex < 0 || pointIndex >= pointCount) { +- this.statusLog.write(`WARN Point rounding update skipped: point ${pointIndex + 1} is not available.`); +- return; +- } +- const pointRounding = this.normalizedPointRounding(nextShape, pointCount); +- pointRounding[pointIndex] = isRounded === true; +- this.applyPointRoundingToShape(nextShape, pointRounding); +- const pointStyle = isRounded ? "round" : "square"; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Updated point ${pointIndex + 1} rounding to ${pointStyle} for shape row ${this.selectedShapeIndex}.`, "Point rounding update failed schema validation"); +- } +- +- bindGeometryAutoApplyInput(input) { +- input.addEventListener("input", () => this.clearInputValidity(input)); +- input.addEventListener("change", () => { +- const shapeIndex = this.selectedShapeIndex; +- this.window.setTimeout(() => { +- if (this.selectedShapeIndex !== shapeIndex) { +- return; +- } +- this.applyShapeGeometryEdits({ +- okMessage: `OK Auto-applied geometry edits to shape row ${this.selectedShapeIndex}.` +- }); +- }, 0); +- }); +- } +- +- createPointRowHeader({ addable = false, deletable = false } = {}) { +- const header = document.createElement("div"); +- header.className = "object-vector-studio-v2__polygon-point-header"; +- header.setAttribute("aria-hidden", "true"); +- ["", "", "", "Round", "", ""].forEach((label) => { +- const cell = document.createElement("span"); +- cell.textContent = label; +- header.append(cell); +- }); +- return header; +- } +- +- createPolygonPointRow(point, index, { addable = true, deletable = true, rounded = false, selectable = true } = {}) { +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__polygon-point-field"; +- row.dataset.polygonPointIndex = String(index); +- if (selectable) { +- row.dataset.polygonPointSelectable = "true"; +- row.setAttribute("role", "button"); +- row.setAttribute("tabindex", "0"); +- row.setAttribute("aria-pressed", "false"); +- row.title = "Select this point row for Add Point."; +- row.addEventListener("click", (event) => this.handlePolygonPointRowSelection(event, row)); +- row.addEventListener("keydown", (event) => { +- if (event.key === "Enter" || event.key === " ") { +- event.preventDefault(); +- this.togglePolygonPointRowSelection(row, event); +- } +- }); +- } +- const caption = document.createElement("span"); +- caption.className = "object-vector-studio-v2__polygon-point-label"; +- caption.textContent = `Point ${index + 1}`; +- row.append(caption); +- [ +- ["x", "X", point.x], +- ["y", "Y", point.y] +- ].forEach(([axis, labelText, value]) => { +- const label = document.createElement("label"); +- label.className = "object-vector-studio-v2__polygon-point-axis"; +- const axisLabel = document.createElement("span"); +- axisLabel.textContent = labelText; +- const input = document.createElement("input"); +- input.dataset.shapeGeometryField = "points"; +- input.dataset.polygonPointAxis = axis; +- input.dataset.polygonPointIndex = String(index); +- input.inputMode = "decimal"; +- input.type = "text"; +- input.value = this.formatCoordinateInputValue(value); +- this.bindGeometryAutoApplyInput(input); +- label.append(axisLabel, input); +- row.append(label); +- }); +- const roundLabel = document.createElement("label"); +- roundLabel.className = "object-vector-studio-v2__polygon-point-rounding"; +- const roundCaption = document.createElement("span"); +- roundCaption.textContent = "Round"; +- const roundCheckbox = document.createElement("input"); +- roundCheckbox.type = "checkbox"; +- roundCheckbox.checked = rounded === true; +- roundCheckbox.dataset.polygonPointRound = "true"; +- roundCheckbox.dataset.polygonPointIndex = String(index); +- roundCheckbox.setAttribute("aria-label", `Round point ${index + 1}`); +- roundCheckbox.addEventListener("click", (event) => event.stopPropagation()); +- roundCheckbox.addEventListener("change", () => this.updateSelectedShapePointRounding(index, roundCheckbox.checked)); +- roundLabel.append(roundCaption, roundCheckbox); +- row.append(roundLabel); +- if (addable) { +- const addButton = document.createElement("button"); +- addButton.type = "button"; +- addButton.className = "object-vector-studio-v2__polygon-point-add"; +- addButton.dataset.polygonPointAdd = "true"; +- addButton.dataset.polygonPointIndex = String(index); +- addButton.setAttribute("aria-label", `Add point after point ${index + 1}`); +- addButton.title = `Add point after point ${index + 1}`; +- this.applyIconGlyph(addButton, "add"); +- addButton.addEventListener("pointerdown", (event) => event.stopPropagation()); +- addButton.addEventListener("click", (event) => { +- event.preventDefault(); +- event.stopPropagation(); +- this.addPolygonPointAfterRow(index, addButton); +- }); +- row.append(addButton); +- } +- if (deletable) { +- const deleteButton = document.createElement("button"); +- deleteButton.type = "button"; +- deleteButton.className = "object-vector-studio-v2__polygon-point-delete"; +- deleteButton.dataset.polygonPointDelete = "true"; +- deleteButton.dataset.polygonPointIndex = String(index); +- deleteButton.setAttribute("aria-label", `Delete point ${index + 1}`); +- deleteButton.title = `Delete point ${index + 1}`; +- this.applyIconGlyph(deleteButton, "delete"); +- deleteButton.addEventListener("pointerdown", (event) => event.stopPropagation()); +- deleteButton.addEventListener("click", (event) => { +- event.preventDefault(); +- event.stopPropagation(); +- this.deletePolygonPointRow(index, deleteButton); +- }); +- row.append(deleteButton); +- } +- return row; +- } +- +- createShapePointRoundingControls(shape) { +- const panel = document.createElement("div"); +- panel.className = "object-vector-studio-v2__point-rounding-panel"; +- const heading = document.createElement("h4"); +- heading.textContent = "Point Rounding"; +- const list = document.createElement("div"); +- list.className = "object-vector-studio-v2__polygon-point-list object-vector-studio-v2__point-rounding-list"; +- const points = this.shapeGeometryPoints(shape); +- const pointRounding = this.shapePointRoundingValues(shape); +- list.append(this.createPointRowHeader()); +- points.forEach((point, index) => { +- list.append(this.createReadonlyPointRoundingRow(shape, point, index, pointRounding[index] === true)); +- }); +- panel.append(heading, list); +- return panel; +- } +- +- createReadonlyPointRoundingRow(shape, point, index, rounded = false) { +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__polygon-point-field object-vector-studio-v2__polygon-point-field--rounding-only"; +- row.dataset.polygonPointIndex = String(index); +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__polygon-point-label"; +- const geometryTool = shapeGeometryTool(shape); +- label.textContent = OPEN_POINT_STYLE_TOOLS.has(geometryTool) +- ? (index === 0 ? "Start" : "End") +- : `Point ${index + 1}`; +- row.append(label); +- [ +- ["X", point.x], +- ["Y", point.y] +- ].forEach(([axis, value]) => { +- const field = document.createElement("span"); +- field.className = "object-vector-studio-v2__polygon-point-axis object-vector-studio-v2__polygon-point-axis--readonly"; +- const axisLabel = document.createElement("span"); +- axisLabel.textContent = axis; +- const valueElement = document.createElement("span"); +- valueElement.className = "object-vector-studio-v2__polygon-point-value"; +- valueElement.textContent = this.formatCoordinateInputValue(value); +- field.append(axisLabel, valueElement); +- row.append(field); +- }); +- const roundLabel = document.createElement("label"); +- roundLabel.className = "object-vector-studio-v2__polygon-point-rounding"; +- const roundCaption = document.createElement("span"); +- roundCaption.textContent = "Round"; +- const roundCheckbox = document.createElement("input"); +- roundCheckbox.type = "checkbox"; +- roundCheckbox.checked = rounded === true; +- roundCheckbox.dataset.polygonPointRound = "true"; +- roundCheckbox.dataset.polygonPointIndex = String(index); +- roundCheckbox.setAttribute("aria-label", `Round ${label.textContent.toLowerCase()} point`); +- roundCheckbox.addEventListener("change", () => this.updateSelectedShapePointRounding(index, roundCheckbox.checked)); +- roundLabel.append(roundCaption, roundCheckbox); +- row.append(roundLabel); +- return row; +- } +- +- handlePolygonPointRowSelection(event, row) { +- if (event.target.closest("input, button, select, textarea, label")) { +- return; +- } +- this.togglePolygonPointRowSelection(row, event); +- } +- +- togglePolygonPointRowSelection(row, event = null) { +- if (!row?.dataset || row.dataset.polygonPointSelectable !== "true") { +- return; +- } +- const shouldExtend = Boolean(event?.ctrlKey || event?.metaKey || event?.shiftKey); +- if (!shouldExtend) { +- this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-action-selected='true']").forEach((candidate) => { +- if (candidate !== row) { +- delete candidate.dataset.polygonPointActionSelected; +- candidate.classList.remove("is-action-selected"); +- candidate.setAttribute("aria-pressed", "false"); +- } +- }); +- } +- const isSelected = row.dataset.polygonPointActionSelected === "true"; +- if (isSelected) { +- delete row.dataset.polygonPointActionSelected; +- row.classList.remove("is-action-selected"); +- row.setAttribute("aria-pressed", "false"); +- return; +- } +- row.dataset.polygonPointActionSelected = "true"; +- row.classList.add("is-action-selected"); +- row.setAttribute("aria-pressed", "true"); +- } +- +- createShapeTransformControls(shape) { +- const section = document.createElement("section"); +- section.className = "object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--transform"; +- const transform = this.shapeTransform(this.effectiveShape(shape)); +- section.append( +- this.createMoveControlRow(), +- this.createRotateControlRow(), +- this.createScaleControlRow(transform) +- ); +- return section; +- } +- +- createObjectTransformControls(object) { +- const section = document.createElement("section"); +- section.className = "object-vector-studio-v2__edit-panel object-vector-studio-v2__edit-panel--transform object-vector-studio-v2__edit-panel--object-transform"; +- const origin = this.objectTransformOrigin(object); +- const objectScale = this.objectScaleControlValue(object); +- section.append( +- this.createObjectOriginControlRow(origin), +- this.createObjectRotateControlRow(), +- this.createScaleControlRow({ scaleX: objectScale, scaleY: objectScale }, { +- downLargeId: "objectVectorStudioV2ObjectScaleDownLargeButton", +- downSmallId: "objectVectorStudioV2ObjectScaleDownSmallButton", +- inputId: "objectVectorStudioV2ObjectScaleInput", +- inputLabel: "Object Scale", +- onInput: (input) => this.applyObjectScaleInputLive(input), +- onReset: () => this.resetSelectedObjectScale(), +- onResize: () => this.resizeSelectedObject(), +- onStep: (delta) => this.adjustSelectedObjectScale(delta), +- resetButtonId: "objectVectorStudioV2ObjectResetScaleButton", +- resizeButtonId: "objectVectorStudioV2ObjectResizeButton", +- upLargeId: "objectVectorStudioV2ObjectScaleUpLargeButton", +- upSmallId: "objectVectorStudioV2ObjectScaleUpSmallButton" +- }) +- ); +- return section; +- } +- +- objectScaleControlValue(object) { +- return this.objectScalePreviewValueForObject(object); +- } +- +- objectScalePreviewValue() { +- const value = Number(this.transformInputValue("objectVectorStudioV2ObjectScaleInput", "1")); +- return Number.isFinite(value) && value > 0 ? this.formatViewportNumber(value) : 1; +- } +- +- objectScalePreviewValueForObject(object) { +- return object?.id === this.selectedObjectId ? this.objectScalePreviewValue() : 1; +- } +- +- shapeWithObjectScalePreview(shape, scale = this.objectScalePreviewValue()) { +- if (!shape || Math.abs(scale - 1) < 0.001) { +- return shape; +- } +- const transform = this.shapeTransform(shape); +- return { +- ...shape, +- transform: { +- ...transform, +- scaleX: this.formatViewportNumber(transform.scaleX * scale), +- scaleY: this.formatViewportNumber(transform.scaleY * scale), +- x: this.formatViewportNumber(transform.x * scale), +- y: this.formatViewportNumber(transform.y * scale) +- } +- }; +- } +- +- createMoveControlRow() { +- return this.createTransformAxisControlRow({ +- action: () => this.moveSelectedShape(), +- buttonId: "objectVectorStudioV2MoveShapeButton", +- buttonLabel: "Move", +- iconKey: "move", +- label: "Move", +- rowType: "move", +- xInput: { id: "objectVectorStudioV2MoveXInput", label: "Move X", value: "10" }, +- yInput: { id: "objectVectorStudioV2MoveYInput", label: "Move Y", value: "0" } +- }); +- } +- +- createObjectOriginControlRow(origin) { +- return this.createTransformAxisControlRow({ +- action: () => this.applySelectedObjectOrigin(), +- buttonId: "objectVectorStudioV2ObjectApplyOriginButton", +- buttonLabel: "Apply", +- buttonTitle: "Apply Object Origin", +- label: "Origin", +- rowType: "origin", +- extraButtons: [ +- this.createTransformActionButton({ +- handler: () => this.autoOriginSelectedObjectPivot(), +- id: "objectVectorStudioV2ObjectAutoOriginButton", +- label: "Auto", +- title: "Auto Origin" +- }) +- ], +- xInput: { id: "objectVectorStudioV2ObjectOriginXInput", label: "Object Origin X", value: String(this.formatViewportNumber(origin.x)) }, +- yInput: { id: "objectVectorStudioV2ObjectOriginYInput", label: "Object Origin Y", value: String(this.formatViewportNumber(origin.y)) } +- }); +- } +- +- createRotateControlRow() { +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--rotate"; +- row.dataset.transformControlRow = "rotate"; +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__transform-control-label"; +- label.textContent = "Rotate"; +- const input = this.createTransformNumberInput("objectVectorStudioV2RotateInput", "Rotate", "15", { +- max: "359", +- min: "-359" +- }); +- const snapSelect = this.createRotateSnapSelect(); +- const stepField = this.createAngleSnapStepField(); +- const button = this.createTransformActionButton({ +- handler: () => this.rotateSelectedShape(), +- iconKey: "rotate", +- id: "objectVectorStudioV2RotateShapeButton", +- label: "Rotate" +- }); +- row.append(label, input, snapSelect, stepField, button); +- this.updateRotateSnapControls(row); +- return row; +- } +- +- createObjectRotateControlRow() { +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--rotate object-vector-studio-v2__transform-control-row--object-rotate"; +- row.dataset.transformControlRow = "rotate"; +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__transform-control-label"; +- label.textContent = "Rotate"; +- const input = this.createTransformNumberInput("objectVectorStudioV2ObjectRotateInput", "Object Rotate", "15", { +- max: "359", +- min: "-359" +- }); +- const snapSelect = this.createRotateSnapSelect({ +- id: "objectVectorStudioV2ObjectRotateSnapSelect", +- label: "Object Rotate Snap Angle" +- }); +- const stepField = this.createAngleSnapStepField({ +- id: "objectVectorStudioV2ObjectSnapAngleStepSelect", +- label: "Object Snap Angle Step" +- }); +- const button = this.createTransformActionButton({ +- handler: () => this.rotateSelectedObject(), +- iconKey: "rotate", +- id: "objectVectorStudioV2ObjectRotateButton", +- label: "Rotate" +- }); +- row.append(label, input, snapSelect, stepField, button); +- this.updateRotateSnapControls(row); +- return row; +- } +- +- createRotateSnapSelect(options = {}) { +- const { +- id = "objectVectorStudioV2RotateSnapSelect", +- label = "Rotate Snap Angle" +- } = options; +- const select = document.createElement("select"); +- select.id = id; +- select.className = "object-vector-studio-v2__rotate-snap-select"; +- select.setAttribute("aria-label", label); +- select.title = "Valid Rotate values generated by the selected Snap Angle step."; +- this.populateRotateSnapSelect(select, this.angleSnapStep, this.transformInputValue(select.id, "15")); +- select.addEventListener("change", () => { +- this.transformInputValues.set(select.id, select.value); +- }); +- return select; +- } +- +- createAngleSnapStepField(options = {}) { +- const { +- id = "objectVectorStudioV2SnapAngleStepSelect", +- label: labelText = "Snap Angle Step" +- } = options; +- const labelElement = document.createElement("label"); +- labelElement.className = "object-vector-studio-v2__snap-angle-step-field"; +- const text = document.createElement("span"); +- text.textContent = "Step"; +- const select = document.createElement("select"); +- select.id = id; +- select.setAttribute("aria-label", labelText); +- select.title = "Choose the angle increment used to generate Rotate dropdown values."; +- ANGLE_SNAP_STEPS.forEach((step) => { +- const option = document.createElement("option"); +- option.value = String(step); +- option.textContent = String(step); +- select.append(option); +- }); +- select.value = String(this.angleSnapStep); +- select.addEventListener("change", () => { +- const nextStep = Number(select.value); +- this.angleSnapStep = ANGLE_SNAP_STEPS.includes(nextStep) ? nextStep : 15; +- this.window.sessionStorage?.setItem(ANGLE_SNAP_STEP_SESSION_KEY, String(this.angleSnapStep)); +- this.updateRotateSnapControls(); +- this.statusLog.write(`OK Snap angle step set to ${this.angleSnapStep} degrees.`); +- }); +- labelElement.append(text, select); +- return labelElement; +- } +- +- createTransformAxisControlRow({ action, buttonId, buttonLabel, buttonTitle, extraButtons = [], iconKey, label, rowType, xInput, yInput }) { +- const row = document.createElement("div"); +- row.className = `object-vector-studio-v2__transform-control-row object-vector-studio-v2__transform-control-row--${rowType}`; +- row.dataset.transformControlRow = rowType; +- const rowLabel = document.createElement("span"); +- rowLabel.className = "object-vector-studio-v2__transform-control-label"; +- rowLabel.textContent = label; +- const actionButton = this.createTransformActionButton({ +- handler: action, +- iconKey, +- id: buttonId, +- label: buttonLabel, +- title: buttonTitle +- }); +- row.append( +- rowLabel, +- this.createTransformAxisField("X", xInput), +- this.createTransformAxisField("Y", yInput), +- actionButton, +- ...extraButtons +- ); +- return row; +- } +- +- createTransformAxisField(axisLabel, inputOptions) { +- const label = document.createElement("label"); +- label.className = "object-vector-studio-v2__transform-axis-field"; +- const axis = document.createElement("span"); +- axis.textContent = axisLabel; +- label.append(axis, this.createTransformNumberInput(inputOptions.id, inputOptions.label, inputOptions.value)); +- return label; +- } +- +- createTransformNumberInput(id, label, value, options = {}) { +- const input = document.createElement("input"); +- input.id = id; +- input.type = "number"; +- input.step = "0.1"; +- const initialValue = this.transformInputValue(id, value); +- const numericInitialValue = Number(initialValue); +- input.value = String(initialValue).trim() !== "" && Number.isFinite(numericInitialValue) +- ? String(this.formatViewportNumber(numericInitialValue)) +- : initialValue; +- input.setAttribute("aria-label", label); +- if (options.min !== undefined) { +- input.min = options.min; +- } +- if (options.max !== undefined) { +- input.max = options.max; +- } +- input.addEventListener("input", () => { +- this.transformInputValues.set(id, input.value); +- this.clearInputValidity(input); +- }); +- return input; +- } +- +- createTransformActionButton({ handler, iconKey, id, label, title }) { +- const button = document.createElement("button"); +- button.id = id; +- button.type = "button"; +- button.textContent = label; +- if (title) { +- button.title = title; +- button.setAttribute("aria-label", title); +- } +- this.applyIconGlyph(button, iconKey); +- button.addEventListener("click", handler); +- return button; +- } +- +- createScaleControlRow(transform, options = {}) { +- const { +- downLargeId = "objectVectorStudioV2ScaleDownLargeButton", +- downSmallId = "objectVectorStudioV2ScaleDownSmallButton", +- inputId = "objectVectorStudioV2ScaleInput", +- inputLabel = "Scale", +- onInput = (input) => this.applyScaleInputLive(input), +- onReset = () => this.resetSelectedShapeScale(), +- onResize = () => this.resizeSelectedShape(), +- onStep = (delta) => this.adjustSelectedShapeScale(delta), +- resetButtonId = "objectVectorStudioV2ResetShapeScaleButton", +- resizeButtonId = "objectVectorStudioV2ResizeShapeButton", +- upLargeId = "objectVectorStudioV2ScaleUpLargeButton", +- upSmallId = "objectVectorStudioV2ScaleUpSmallButton" +- } = options; +- const row = document.createElement("div"); +- row.className = "object-vector-studio-v2__scale-control-row"; +- const label = document.createElement("span"); +- label.className = "object-vector-studio-v2__scale-control-label"; +- label.textContent = "Scale"; +- row.append(label); +- +- [ +- [downLargeId, "--", -0.1], +- [downSmallId, "-", -0.01] +- ].forEach(([id, text, delta]) => { +- row.append(this.createScaleStepButton(id, text, delta, onStep)); +- }); +- +- const input = document.createElement("input"); +- input.id = inputId; +- input.className = "object-vector-studio-v2__scale-input"; +- input.type = "number"; +- input.min = "0.01"; +- input.step = "0.01"; +- input.value = this.formatScaleInputValue(transform.scaleX); +- input.setAttribute("aria-label", inputLabel); +- input.addEventListener("input", () => { +- this.transformInputValues.set(input.id, input.value); +- onInput(input); +- }); +- row.append(input); +- +- [ +- [upSmallId, "+", 0.01], +- [upLargeId, "++", 0.1] +- ].forEach(([id, text, delta]) => { +- row.append(this.createScaleStepButton(id, text, delta, onStep)); +- }); +- +- const resizeButton = document.createElement("button"); +- resizeButton.id = resizeButtonId; +- resizeButton.className = "object-vector-studio-v2__scale-resize-button"; +- resizeButton.type = "button"; +- resizeButton.textContent = "Resize"; +- resizeButton.title = "Resize Geometry"; +- resizeButton.setAttribute("aria-label", "Resize Geometry"); +- this.applyIconGlyph(resizeButton, "resize"); +- resizeButton.addEventListener("click", onResize); +- row.append(resizeButton); +- +- const resetButton = document.createElement("button"); +- resetButton.id = resetButtonId; +- resetButton.className = "object-vector-studio-v2__scale-reset-button"; +- resetButton.type = "button"; +- resetButton.textContent = "X"; +- resetButton.title = "Reset Scale to 1.0"; +- resetButton.setAttribute("aria-label", `${inputLabel} Reset Scale to 1.0`); +- resetButton.addEventListener("click", onReset); +- row.append(resetButton); +- return row; +- } +- +- createScaleStepButton(id, text, delta, onStep = (scaleDelta) => this.adjustSelectedShapeScale(scaleDelta)) { +- const button = document.createElement("button"); +- button.id = id; +- button.type = "button"; +- button.dataset.scaleStep = String(delta); +- button.textContent = text; +- button.title = `Scale ${delta > 0 ? "up" : "down"} ${Math.abs(delta).toFixed(2)}`; +- button.addEventListener("click", () => onStep(delta)); +- return button; +- } +- +- transformInputValue(id, defaultValue) { +- return this.transformInputValues.has(id) ? this.transformInputValues.get(id) : defaultValue; +- } +- +- shapeGeometryFields(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (shapeTool(shape) === "square") { +- return [ +- { key: "x", kind: "number", label: "x", value: shape.geometry.x }, +- { key: "y", kind: "number", label: "y", value: shape.geometry.y }, +- { key: "size", kind: "number", label: "Size", value: shape.geometry.width, wide: true } +- ]; +- } +- if (geometryTool === "rectangle") { +- return ["x", "y", "width", "height"].map((key) => ({ key, kind: "number", label: key, value: shape.geometry[key] })); +- } +- if (geometryTool === "circle") { +- return ["cx", "cy", "r"].map((key) => ({ key, kind: "number", label: key, value: shape.geometry[key], wide: key === "r" })); +- } +- if (geometryTool === "ellipse") { +- return [ +- { key: "cx", kind: "number", label: "Cx", value: shape.geometry.cx }, +- { key: "cy", kind: "number", label: "Cy", value: shape.geometry.cy }, +- { key: "rx", kind: "number", label: "Rx", value: shape.geometry.rx }, +- { key: "ry", kind: "number", label: "Ry", value: shape.geometry.ry } +- ]; +- } +- if (geometryTool === "line") { +- const point1 = linePoint(shape.geometry, "point1"); +- const point2 = linePoint(shape.geometry, "point2"); +- return [ +- { coordinate: true, key: "point1.x", kind: "number", label: "Point 1 X", value: point1.x }, +- { coordinate: true, key: "point1.y", kind: "number", label: "Point 1 Y", value: point1.y }, +- { coordinate: true, key: "point2.x", kind: "number", label: "Point 2 X", value: point2.x }, +- { coordinate: true, key: "point2.y", kind: "number", label: "Point 2 Y", value: point2.y } +- ]; +- } +- if (geometryTool === "arc") { +- return ["cx", "cy", "r", "startAngle", "endAngle"].map((key) => ({ +- key, +- kind: "number", +- label: key, +- value: shape.geometry[key], +- wide: key === "r" +- })); +- } +- if (geometryTool === "text") { +- return [ +- { key: "x", kind: "number", label: "x", value: shape.geometry.x }, +- { key: "y", kind: "number", label: "y", value: shape.geometry.y }, +- { key: "fontSize", kind: "number", label: "fontSize", value: shape.geometry.fontSize, wide: true }, +- { key: "text", kind: "text", label: "text", value: shape.geometry.text, wide: true } +- ]; +- } +- return [ +- { +- key: "points", +- kind: "text", +- label: "points", +- value: shape.geometry.points.map((point) => `${point.x},${point.y}`).join(" ") +- } +- ]; +- } +- +- renderWorkSurface() { +- const object = this.selectedObject(); +- this.elements.renderSurface.classList.toggle("is-drawing-mode", Boolean(this.activeDrawing)); +- this.updateRenderSurfaceCursorState(); +- this.removeDrawingHint(); +- this.elements.renderSurface.replaceChildren(); +- this.renderSvgGrid(); +- if (!object) { +- this.renderCenterOriginMarker(); +- return; +- } +- if (this.isObjectHidden(object.id)) { +- this.renderCenterOriginMarker(); +- this.statusLog.write(`OK Render mode svg-work-surface: object ${object.name} is hidden for this runtime session; capture mode none.`); +- return; +- } +- +- if (this.elements.onionSkinToggle.checked) { +- this.renderOnionSkin(object); +- } +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- let renderedCount = 0; +- const hitLayer = document.createElementNS(SVG_NS, "g"); +- hitLayer.classList.add("object-vector-studio-v2__shape-hit-layer"); +- this.elements.renderSurface.append(hitLayer); +- sortedShapes(object).forEach((shape, shapeIndex) => { +- const renderShape = this.shapeWithObjectScalePreview(this.effectiveShape(shape, shapeIndex), objectScale); +- if (!renderShape.visible) { +- return; +- } +- try { +- const element = this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); +- element.dataset.shapeIndex = String(shapeIndex); +- element.dataset.renderShapeTool = shapeTool(shape); +- element.classList.add("object-vector-studio-v2__shape"); +- element.classList.toggle("is-selected", this.selectedShapeIndexes.has(shapeIndex)); +- element.setAttribute("tabindex", "0"); +- this.bindRenderedShapePointerEvents(element, shape, shapeIndex); +- this.elements.renderSurface.append(element); +- this.appendShapePointStyleCaps(this.elements.renderSurface, renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); +- hitLayer.append(this.createShapeHitArea(renderShape, shape, shapeIndex, transformOrigin)); +- renderedCount += 1; +- } catch (error) { +- this.statusLog.write(`FAIL Render mode svg-work-surface failed for shape-${shapeIndex} (${shapeTool(shape)}): ${error.message}`); +- } +- }); +- if (!hitLayer.childNodes.length) { +- hitLayer.remove(); +- } +- this.renderObjectBounds(object); +- this.renderObjectOriginMarker(object); +- this.renderSnapPointTargets(object); +- this.renderDrawingPreview(); +- this.renderDrawingHint(); +- this.renderSelectionOverlay(object); +- this.renderCenterOriginMarker(); +- +- const frame = this.activeFrame(); +- const message = `Render mode svg-work-surface: rendered ${object.name} with ${renderedCount} visible shapes${frame ? ` for ${this.selectedStateId}/${frame.id}` : ""}; capture mode none.`; +- this.statusLog.write(`OK ${message}`); +- } +- +- bindRenderedShapePointerEvents(element, shape, shapeIndex) { +- element.addEventListener("pointerdown", (event) => { +- event.stopPropagation(); +- if (event.button === 2) { +- event.preventDefault(); +- return; +- } +- if (this.activeDrawing) { +- this.handleDrawingPointerDown(event); +- return; +- } +- if (this.activeTool === "paint" || this.activeTool === "stroke") { +- this.isPaintDragging = true; +- return; +- } +- this.startPreviewShapeMove(event, shapeIndex); +- }); +- element.addEventListener("click", (event) => { +- event.stopPropagation(); +- if (this.activeDrawing) { +- return; +- } +- this.handleShapePointer(event, shape, shapeIndex, { source: "render surface click" }); +- }); +- element.addEventListener("contextmenu", (event) => this.handleShapeContextMenu(event, shape, shapeIndex)); +- element.addEventListener("pointerenter", (event) => { +- if (this.isPaintDragging) { +- event.stopPropagation(); +- this.handleShapePointer(event, shape, shapeIndex, { source: "render surface drag" }); +- } +- }); +- } +- +- createShapeHitArea(renderShape, sourceShape, shapeIndex, transformOrigin = { x: 0, y: 0 }) { +- const hitArea = this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); +- const geometryTool = shapeGeometryTool(sourceShape); +- hitArea.classList.add("object-vector-studio-v2__shape-hit-area"); +- hitArea.dataset.shapeHitIndex = String(shapeIndex); +- hitArea.dataset.shapeHitTool = shapeTool(sourceShape); +- hitArea.removeAttribute("tabindex"); +- hitArea.setAttribute("fill", ["arc", "line", "polyline"].includes(geometryTool) ? "none" : "transparent"); +- hitArea.setAttribute("stroke", "transparent"); +- hitArea.setAttribute("stroke-width", String(Math.max(12, Number(renderShape.style?.strokeWidth) || 1))); +- hitArea.setAttribute("pointer-events", "all"); +- hitArea.setAttribute("aria-hidden", "true"); +- this.bindRenderedShapePointerEvents(hitArea, sourceShape, shapeIndex); +- return hitArea; +- } +- +- renderSvgGrid() { +- if (!this.gridRenderEnabled) { +- return; +- } +- const width = DEFAULT_VIEWPORT.width / this.viewport.zoom; +- const height = DEFAULT_VIEWPORT.height / this.viewport.zoom; +- const minX = this.viewport.x - width / 2; +- const maxX = this.viewport.x + width / 2; +- const minY = this.viewport.y - height / 2; +- const maxY = this.viewport.y + height / 2; +- const group = document.createElementNS(SVG_NS, "g"); +- group.classList.add("object-vector-studio-v2__svg-grid"); +- group.dataset.gridRendered = "true"; +- for (let x = Math.floor(minX / GRID_STEP) * GRID_STEP; x <= maxX; x += GRID_STEP) { +- const line = document.createElementNS(SVG_NS, "line"); +- line.setAttribute("x1", this.formatViewportNumber(x)); +- line.setAttribute("x2", this.formatViewportNumber(x)); +- line.setAttribute("y1", this.formatViewportNumber(minY)); +- line.setAttribute("y2", this.formatViewportNumber(maxY)); +- group.append(line); +- } +- for (let y = Math.floor(minY / GRID_STEP) * GRID_STEP; y <= maxY; y += GRID_STEP) { +- const line = document.createElementNS(SVG_NS, "line"); +- line.setAttribute("x1", this.formatViewportNumber(minX)); +- line.setAttribute("x2", this.formatViewportNumber(maxX)); +- line.setAttribute("y1", this.formatViewportNumber(y)); +- line.setAttribute("y2", this.formatViewportNumber(y)); +- group.append(line); +- } +- this.elements.renderSurface.append(group); +- } +- +- handleShapePointer(event, shape, shapeIndex, options = {}) { +- if (event.altKey || this.activeTool === "picker") { +- this.sampleShapeStyle(shapeIndex); +- return; +- } +- if (event.shiftKey) { +- this.selectShape(shapeIndex, "render surface additive", { additive: true }); +- return; +- } +- const isStrokeMode = this.activeTool === "stroke"; +- const isPaintMode = this.activeTool === "paint"; +- if (isStrokeMode || isPaintMode) { +- if (options.dragStart) { +- this.isPaintDragging = true; +- } +- this.applySelectedPaletteColorToShape(shapeIndex, isStrokeMode ? "stroke" : "fill", options.source || (options.dragStart ? "render surface click" : "render surface drag")); +- return; +- } +- if (this.preserveSelectedShapeClick(shapeIndex, options.source || "render surface")) { +- return; +- } +- this.selectShape(shapeIndex, "render surface"); +- } +- +- preserveSelectedShapeClick(shapeIndex, sourceLabel) { +- const object = this.selectedObject(); +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- const shapes = sortedShapes(object); +- if (normalizedIndex < 0 || normalizedIndex >= shapes.length || !this.selectedShapeIndexes.has(normalizedIndex)) { +- return false; +- } +- +- const scrollState = this.captureLeftPanelScrollState(); +- this.selectedShapeIndex = normalizedIndex; +- this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); +- this.setPaletteTarget("stroke", false); +- this.renderPayload(); +- this.restoreLeftPanelScrollState(scrollState); +- const shape = this.selectedShape(); +- this.statusLog.write(`OK Preserved selected shape from ${sourceLabel}: row ${this.selectedShapeIndex} (${shapeTool(shape)}). Multi-select count: ${this.selectedShapeIndexes.size}.`); +- return true; +- } +- +- handleShapeContextMenu(event, shape, shapeIndex) { +- event.preventDefault(); +- if (this.activeDrawing || this.previewPointerEdit) { +- return; +- } +- this.applyTransparentStyleToShape(shapeIndex, "fill", "right-click"); +- } +- +- renderOnionSkin(object) { +- const previousFrame = this.previousFrame(); +- if (!previousFrame) { +- return; +- } +- const group = document.createElementNS(SVG_NS, "g"); +- group.classList.add("object-vector-studio-v2__onion-skin"); +- group.dataset.onionSkinFrame = previousFrame.id; +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- sortedShapes(object).forEach((shape, shapeIndex) => { +- const renderShape = this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(shape, previousFrame, shapeIndex), objectScale); +- if (!renderShape.visible) { +- return; +- } +- try { +- group.append(this.createSvgShape(renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin })); +- this.appendShapePointStyleCaps(group, renderShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); +- } catch (error) { +- this.statusLog.write(`FAIL Onion-skin render failed for ${object.name}/shape-${shapeIndex}: ${error.message}`); +- } +- }); +- this.elements.renderSurface.append(group); +- } +- +- renderObjectBounds(object) { +- const bounds = this.objectBounds(object, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, includeInvisible: false }); +- const box = document.createElementNS(SVG_NS, "rect"); +- box.classList.add("object-vector-studio-v2__object-bounds"); +- box.dataset.objectBounds = object.id; +- box.setAttribute("x", bounds.x - 6); +- box.setAttribute("y", bounds.y - 6); +- box.setAttribute("width", bounds.width + 12); +- box.setAttribute("height", bounds.height + 12); +- this.elements.renderSurface.append(box); +- } +- +- renderObjectOriginMarker(object) { +- if (!object) { +- return; +- } +- const origin = this.objectTransformOrigin(object); +- const x = this.scaleDrawingValue(origin.x, OBJECT_PREVIEW_DRAWING_SCALE); +- const y = this.scaleDrawingValue(origin.y, OBJECT_PREVIEW_DRAWING_SCALE); +- const marker = document.createElementNS(SVG_NS, "g"); +- marker.classList.add("object-vector-studio-v2__object-origin"); +- marker.dataset.objectOrigin = object.id; +- marker.setAttribute("role", "img"); +- marker.setAttribute("aria-label", "Object origin/pivot marker for object rotation and scale"); +- const markerTitle = document.createElementNS(SVG_NS, "title"); +- markerTitle.textContent = "Object Origin/Pivot: rotate and scale pivot for the selected object."; +- const horizontal = document.createElementNS(SVG_NS, "line"); +- horizontal.setAttribute("x1", x - 8); +- horizontal.setAttribute("x2", x + 8); +- horizontal.setAttribute("y1", y); +- horizontal.setAttribute("y2", y); +- const vertical = document.createElementNS(SVG_NS, "line"); +- vertical.setAttribute("x1", x); +- vertical.setAttribute("x2", x); +- vertical.setAttribute("y1", y - 8); +- vertical.setAttribute("y2", y + 8); +- marker.append(markerTitle, horizontal, vertical); +- this.elements.renderSurface.append(marker); +- } +- +- renderCenterOriginMarker() { +- if (!this.centerOriginVisible) { +- return; +- } +- const marker = document.createElementNS(SVG_NS, "circle"); +- marker.classList.add("object-vector-studio-v2__center-origin-dot"); +- marker.dataset.centerOrigin = "0,0"; +- marker.setAttribute("cx", "0"); +- marker.setAttribute("cy", "0"); +- marker.setAttribute("r", "9"); +- marker.setAttribute("aria-label", "Canvas origin 0,0"); +- this.elements.renderSurface.append(marker); +- } +- +- renderSnapPointTargets(object) { +- if (this.snapMode !== "point") { +- return; +- } +- const points = this.visibleSnapPoints(object); +- if (!points.length) { +- return; +- } +- const group = document.createElementNS(SVG_NS, "g"); +- group.classList.add("object-vector-studio-v2__snap-targets"); +- points.forEach((point, index) => { +- const target = document.createElementNS(SVG_NS, "circle"); +- target.classList.add("object-vector-studio-v2__snap-target"); +- target.dataset.snapTargetIndex = String(index); +- target.setAttribute("cx", this.scaleDrawingValue(point.x, OBJECT_PREVIEW_DRAWING_SCALE)); +- target.setAttribute("cy", this.scaleDrawingValue(point.y, OBJECT_PREVIEW_DRAWING_SCALE)); +- target.setAttribute("r", 4); +- group.append(target); +- }); +- this.elements.renderSurface.append(group); +- } +- +- renderDrawingPreview() { +- if (!this.activeDrawing) { +- return; +- } +- const previewShape = this.previewShapeFromDrawing(); +- if (!previewShape) { +- return; +- } +- try { +- this.renderDrawingSnapLine(previewShape); +- const element = this.createSvgShape(previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); +- element.classList.add("object-vector-studio-v2__drawing-preview"); +- element.dataset.drawingPreviewTool = this.activeDrawing.tool; +- this.applyDrawingPreviewPresentation(element, previewShape); +- this.elements.renderSurface.append(element); +- this.appendShapePointStyleCaps(this.elements.renderSurface, previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); +- } catch (error) { +- this.statusLog.write(`FAIL Drawing preview failed for ${this.activeDrawing.tool}: ${error.message}`); +- } +- } +- +- renderDrawingSnapLine(previewShape) { +- if (this.snapMode === "none") { +- return; +- } +- const element = this.createSvgShape(previewShape, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }); +- element.classList.add("object-vector-studio-v2__snap-line"); +- element.dataset.snapLineMode = this.snapMode; +- element.dataset.snapLineTool = this.activeDrawing?.tool || shapeTool(previewShape); +- element.setAttribute("aria-hidden", "true"); +- this.applyDrawingPreviewPresentation(element, previewShape); +- element.style.strokeDasharray = "none"; +- this.elements.renderSurface.append(element); +- } +- +- renderDrawingHint() { +- const drawing = this.activeDrawing; +- if (!drawing || !["polygon", "polyline"].includes(drawing.tool) || !this.drawingPreviewPoint) { +- this.removeDrawingHint(); +- return; +- } +- const workArea = this.elements.renderSurface.closest(".object-vector-studio-v2__work-area"); +- if (!workArea) { +- return; +- } +- const clientPoint = this.drawingHintClientPoint || this.clientPointFromDrawingPoint(this.drawingPreviewPoint); +- if (!clientPoint) { +- this.removeDrawingHint(); +- return; +- } +- const workAreaBounds = workArea.getBoundingClientRect(); +- const hint = document.createElement("div"); +- hint.className = "object-vector-studio-v2__drawing-hint"; +- hint.dataset.drawingHintTool = drawing.tool; +- hint.textContent = "Double-click / Enter to complete"; +- hint.style.left = `${Math.round(clientPoint.x - workAreaBounds.left + 12)}px`; +- hint.style.top = `${Math.round(clientPoint.y - workAreaBounds.top + 16)}px`; +- workArea.append(hint); +- } +- +- removeDrawingHint() { +- this.elements.renderSurface.closest(".object-vector-studio-v2__work-area") +- ?.querySelector(".object-vector-studio-v2__drawing-hint") +- ?.remove(); +- } +- +- clientPointFromDrawingPoint(point) { +- const bounds = this.elements.renderSurface.getBoundingClientRect(); +- if (!bounds.width || !bounds.height) { +- return null; +- } +- const viewWidth = DEFAULT_VIEWPORT.width / this.viewport.zoom; +- const viewHeight = DEFAULT_VIEWPORT.height / this.viewport.zoom; +- const drawingX = point.x * OBJECT_PREVIEW_DRAWING_SCALE; +- const drawingY = point.y * OBJECT_PREVIEW_DRAWING_SCALE; +- return { +- x: bounds.left + ((drawingX - this.viewport.x + viewWidth / 2) / viewWidth) * bounds.width, +- y: bounds.top + ((drawingY - this.viewport.y + viewHeight / 2) / viewHeight) * bounds.height +- }; +- } +- +- applyDrawingPreviewPresentation(element, previewShape) { +- if (shapeGeometryTool(previewShape) === "text") { +- element.style.strokeDasharray = "none"; +- return; +- } +- element.style.strokeDasharray = this.drawingPreviewDashArray(previewShape.style.strokeWidth); +- const previewPointStyle = this.supportsOpenPointStyle(previewShape) +- ? (this.openShapeUsesSplitPointStyles(previewShape) ? "butt" : this.shapeStartPointStyle(previewShape)) +- : this.shapeUnifiedPointStyle(previewShape); +- element.style.strokeLinecap = previewPointStyle; +- element.style.strokeLinejoin = this.strokeLinejoinValue(this.shapeUnifiedPointStyle(previewShape)); +- } +- +- drawingPreviewDashArray(strokeWidth) { +- const normalizedWidth = Number(strokeWidth); +- const width = Number.isFinite(normalizedWidth) && normalizedWidth > 0 ? normalizedWidth : 2; +- const unitsPerPixel = this.svgUnitsPerCssPixel(); +- const dashPixels = Math.max(12, Math.min(24, width * 1.1)); +- const gapPixels = Math.max(8, Math.min(18, width * 0.75)); +- return `${this.formatViewportNumber(dashPixels * unitsPerPixel)} ${this.formatViewportNumber(gapPixels * unitsPerPixel)}`; +- } +- +- svgUnitsPerCssPixel() { +- const bounds = this.elements.renderSurface.getBoundingClientRect(); +- const viewBox = this.elements.renderSurface.viewBox?.baseVal; +- if (!bounds.width || !viewBox?.width) { +- return 1; +- } +- return viewBox.width / bounds.width; +- } +- +- visibleSnapPoints(object = this.selectedObject(), options = {}) { +- if (!object) { +- return []; +- } +- const points = []; +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- sortedShapes(object).forEach((shape, shapeIndex) => { +- if (normalizeShapeIndex(options.excludeShapeIndex) === shapeIndex) { +- return; +- } +- const effective = this.shapeWithObjectScalePreview(this.effectiveShape(shape, shapeIndex), objectScale); +- if (!effective.visible) { +- return; +- } +- const transform = this.shapeTransform(effective); +- const definitions = this.geometryPointHandleDefinitions(effective); +- const shapePoints = definitions.length +- ? definitions.map((definition) => definition.point) +- : boundsCornerPoints(shapeBounds(effective)); +- shapePoints.forEach((point) => points.push(this.transformedPoint(point, transform, transformOrigin))); +- }); +- return points; +- } +- +- nearestVisibleSnapPoint(point, options = {}) { +- const candidates = this.visibleSnapPoints(this.selectedObject(), options); +- let nearest = null; +- candidates.forEach((candidate) => { +- const distance = Math.hypot(candidate.x - point.x, candidate.y - point.y); +- if (distance <= POINT_SNAP_RADIUS && (!nearest || distance < nearest.distance)) { +- nearest = { distance, point: candidate }; +- } +- }); +- return nearest?.point || null; +- } +- +- normalizeDrawingPoint(point) { +- return { +- x: this.formatViewportNumber(point?.x || 0), +- y: this.formatViewportNumber(point?.y || 0) +- }; +- } +- +- snapCanvasPoint(point, options = {}) { +- if (this.snapMode === "grid") { +- return { +- x: Math.round(point.x), +- y: Math.round(point.y) +- }; +- } +- if (this.snapMode === "point") { +- const target = this.nearestVisibleSnapPoint(point, options); +- if (target) { +- return { +- x: this.formatViewportNumber(target.x), +- y: this.formatViewportNumber(target.y) +- }; +- } +- } +- return this.normalizeDrawingPoint(point); +- } +- +- supportsDrawingAngleSnap(tool) { +- return ["line", "polygon", "polyline"].includes(tool); +- } +- +- drawingAngleSnapAnchor(drawing) { +- if (!drawing || !this.supportsDrawingAngleSnap(drawing.tool)) { +- return null; +- } +- if (drawing.flow === "two-click") { +- return drawing.start || null; +- } +- if (drawing.flow === "points") { +- return drawing.points.at(-1) || null; +- } +- return null; +- } +- +- snapPointToDrawingAngle(anchor, point) { +- const normalizedAnchor = this.normalizeDrawingPoint(anchor); +- const normalizedPoint = this.normalizeDrawingPoint(point); +- const dx = normalizedPoint.x - normalizedAnchor.x; +- const dy = normalizedPoint.y - normalizedAnchor.y; +- const distance = Math.hypot(dx, dy); +- if (!distance) { +- return normalizedPoint; +- } +- const degrees = Math.atan2(dy, dx) * 180 / Math.PI; +- const snappedDegrees = this.snapAngle(degrees); +- const radians = snappedDegrees * Math.PI / 180; +- return { +- x: this.formatViewportNumber(normalizedAnchor.x + Math.cos(radians) * distance), +- y: this.formatViewportNumber(normalizedAnchor.y + Math.sin(radians) * distance) +- }; +- } +- +- snapDrawingPoint(point, drawing, options = {}) { +- const snappedPoint = this.snapCanvasPoint(point, options); +- if (!this.angleSnapEnabled) { +- return this.normalizeDrawingPoint(snappedPoint); +- } +- const anchor = this.drawingAngleSnapAnchor(drawing); +- if (!anchor) { +- return this.normalizeDrawingPoint(snappedPoint); +- } +- return this.snapPointToDrawingAngle(anchor, snappedPoint); +- } +- +- createSvgShape(shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "rectangle") { +- const roundedPath = this.roundedPointPath(shape, { closed: true, drawingScale }); +- const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "rect"); +- if (roundedPath) { +- element.setAttribute("d", roundedPath); +- element.dataset.roundedPointRender = "path"; +- element.setAttribute("points", this.svgPointList(this.shapeGeometryPoints(shape), drawingScale)); +- } +- element.setAttribute("x", this.scaleDrawingValue(shape.geometry.x, drawingScale)); +- element.setAttribute("y", this.scaleDrawingValue(shape.geometry.y, drawingScale)); +- element.setAttribute("width", this.scaleDrawingValue(shape.geometry.width, drawingScale)); +- element.setAttribute("height", this.scaleDrawingValue(shape.geometry.height, drawingScale)); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "circle") { +- const element = document.createElementNS(SVG_NS, "circle"); +- element.setAttribute("cx", this.scaleDrawingValue(shape.geometry.cx, drawingScale)); +- element.setAttribute("cy", this.scaleDrawingValue(shape.geometry.cy, drawingScale)); +- element.setAttribute("r", this.scaleDrawingValue(shape.geometry.r, drawingScale)); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "ellipse") { +- const element = document.createElementNS(SVG_NS, "ellipse"); +- element.setAttribute("cx", this.scaleDrawingValue(shape.geometry.cx, drawingScale)); +- element.setAttribute("cy", this.scaleDrawingValue(shape.geometry.cy, drawingScale)); +- element.setAttribute("rx", this.scaleDrawingValue(shape.geometry.rx, drawingScale)); +- element.setAttribute("ry", this.scaleDrawingValue(shape.geometry.ry, drawingScale)); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "line") { +- const element = document.createElementNS(SVG_NS, "line"); +- element.setAttribute("x1", this.scaleDrawingValue(shape.geometry.point1.x, drawingScale)); +- element.setAttribute("y1", this.scaleDrawingValue(shape.geometry.point1.y, drawingScale)); +- element.setAttribute("x2", this.scaleDrawingValue(shape.geometry.point2.x, drawingScale)); +- element.setAttribute("y2", this.scaleDrawingValue(shape.geometry.point2.y, drawingScale)); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "polygon") { +- const roundedPath = this.roundedPointPath(shape, { closed: true, drawingScale }); +- const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "polygon"); +- const points = this.svgPointList(shape.geometry.points, drawingScale); +- if (roundedPath) { +- element.setAttribute("d", roundedPath); +- element.dataset.roundedPointRender = "path"; +- } +- element.setAttribute("points", points); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "polyline") { +- const roundedPath = this.roundedPointPath(shape, { closed: false, drawingScale }); +- const element = document.createElementNS(SVG_NS, roundedPath ? "path" : "polyline"); +- const points = this.svgPointList(shape.geometry.points, drawingScale); +- if (roundedPath) { +- element.setAttribute("d", roundedPath); +- element.dataset.roundedPointRender = "path"; +- } +- element.setAttribute("points", points); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "arc") { +- const element = document.createElementNS(SVG_NS, "path"); +- element.setAttribute("d", this.arcPath({ +- ...shape.geometry, +- cx: this.scaleDrawingValue(shape.geometry.cx, drawingScale), +- cy: this.scaleDrawingValue(shape.geometry.cy, drawingScale), +- r: this.scaleDrawingValue(shape.geometry.r, drawingScale) +- })); +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- if (geometryTool === "text") { +- const element = document.createElementNS(SVG_NS, "text"); +- element.setAttribute("x", this.scaleDrawingValue(shape.geometry.x, drawingScale)); +- element.setAttribute("y", this.scaleDrawingValue(shape.geometry.y, drawingScale)); +- element.setAttribute("font-size", this.scaleDrawingValue(shape.geometry.fontSize, drawingScale)); +- element.textContent = shape.geometry.text; +- this.applySvgStyle(element, shape, { drawingScale, transformOrigin }); +- return element; +- } +- throw new Error(`unsupported shape tool ${shapeTool(shape)}`); +- } +- +- svgPointList(points, drawingScale = 1) { +- return points.map((point) => `${this.scaleDrawingValue(point.x, drawingScale)},${this.scaleDrawingValue(point.y, drawingScale)}`).join(" "); +- } +- +- roundedPointPath(shape, { closed, drawingScale = 1 } = {}) { +- const geometryTool = shapeGeometryTool(shape); +- if (!["polygon", "polyline", "rectangle"].includes(geometryTool)) { +- return ""; +- } +- const sourcePoints = geometryTool === "rectangle" ? this.shapeGeometryPoints(shape) : shape.geometry.points; +- if (!Array.isArray(sourcePoints)) { +- return ""; +- } +- const pointCount = sourcePoints.length; +- if (pointCount < (closed ? 3 : 3)) { +- return ""; +- } +- const pointRounding = this.shapePointRoundingValues(shape); +- const roundedIndexes = pointRounding +- .map((isRounded, index) => isRounded === true ? index : -1) +- .filter((index) => index >= 0 && (closed || (index > 0 && index < pointCount - 1))); +- if (!roundedIndexes.length) { +- return ""; +- } +- const points = sourcePoints.map((point) => ({ +- x: Number(this.scaleDrawingValue(point.x, drawingScale)), +- y: Number(this.scaleDrawingValue(point.y, drawingScale)) +- })); +- const preferredRadius = this.shapeRoundingRadius(shape) * drawingScale; +- if (preferredRadius <= 0) { +- return ""; +- } +- const roundedVertex = (index) => { +- if (pointRounding[index] !== true || (!closed && (index === 0 || index === pointCount - 1))) { +- return null; +- } +- const previous = points[index === 0 ? pointCount - 1 : index - 1]; +- const current = points[index]; +- const next = points[index === pointCount - 1 ? 0 : index + 1]; +- if (!previous || !current || !next) { +- return null; +- } +- const previousDistance = Math.hypot(previous.x - current.x, previous.y - current.y); +- const nextDistance = Math.hypot(next.x - current.x, next.y - current.y); +- if (previousDistance <= 0 || nextDistance <= 0) { +- return null; +- } +- const radius = Math.min(preferredRadius, previousDistance / 2, nextDistance / 2); +- if (radius <= 0) { +- return null; +- } +- return { +- after: { +- x: current.x + (next.x - current.x) / nextDistance * radius, +- y: current.y + (next.y - current.y) / nextDistance * radius +- }, +- before: { +- x: current.x + (previous.x - current.x) / previousDistance * radius, +- y: current.y + (previous.y - current.y) / previousDistance * radius +- }, +- current +- }; +- }; +- const commandPoint = (point) => `${this.formatViewportNumber(point.x)} ${this.formatViewportNumber(point.y)}`; +- const appendVertex = (commands, index) => { +- const rounded = roundedVertex(index); +- if (!rounded) { +- commands.push(`L ${commandPoint(points[index])}`); +- return; +- } +- commands.push(`L ${commandPoint(rounded.before)}`); +- commands.push(`Q ${commandPoint(rounded.current)} ${commandPoint(rounded.after)}`); +- }; +- if (closed) { +- const start = roundedVertex(0)?.after || points[0]; +- const commands = [`M ${commandPoint(start)}`]; +- for (let index = 1; index < pointCount; index += 1) { +- appendVertex(commands, index); +- } +- appendVertex(commands, 0); +- commands.push("Z"); +- return commands.join(" "); +- } +- const commands = [`M ${commandPoint(points[0])}`]; +- for (let index = 1; index < pointCount - 1; index += 1) { +- appendVertex(commands, index); +- } +- commands.push(`L ${commandPoint(points[pointCount - 1])}`); +- return commands.join(" "); +- } +- +- applySvgStyle(element, shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { +- const geometryTool = shapeGeometryTool(shape); +- element.setAttribute("fill", shape.style.fill); +- element.setAttribute("stroke", shape.style.stroke); +- element.setAttribute("stroke-width", shape.style.strokeWidth); +- element.setAttribute("fill-opacity", shape.style.fillOpacity); +- element.setAttribute("stroke-opacity", shape.style.strokeOpacity); +- if (OPEN_POINT_STYLE_TOOLS.has(geometryTool)) { +- const lineCap = this.openShapeUsesSplitPointStyles(shape) ? "butt" : this.shapeStartPointStyle(shape); +- element.setAttribute("stroke-linecap", lineCap); +- element.setAttribute("stroke-linejoin", this.strokeLinejoinValue(this.shapeUnifiedPointStyle(shape))); +- element.dataset.pointStyle = this.shapeUnifiedPointStyle(shape); +- element.dataset.startPointStyle = this.shapeStartPointStyle(shape); +- element.dataset.endPointStyle = this.shapeEndPointStyle(shape); +- } else if (CLOSED_POINT_STYLE_TOOLS.has(geometryTool)) { +- const pointStyle = this.shapeUnifiedPointStyle(shape); +- element.setAttribute("stroke-linecap", pointStyle); +- element.setAttribute("stroke-linejoin", this.strokeLinejoinValue(pointStyle)); +- element.dataset.pointStyle = pointStyle; +- } +- const transform = this.scaledDrawingTransform(this.shapeTransform(shape), drawingScale); +- element.setAttribute("transform", this.svgTransformAttribute(transform, this.scaledDrawingPoint(transformOrigin, drawingScale))); +- } +- +- appendShapePointStyleCaps(parent, shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { +- const endpoints = this.openShapeEndpointPoints(shape); +- const pointMarkers = this.shapePointStyleMarkers(shape); +- if (!this.openShapeUsesSplitPointStyles(shape) && !pointMarkers.length) { +- return; +- } +- const group = document.createElementNS(SVG_NS, "g"); +- group.classList.add("object-vector-studio-v2__point-style-caps"); +- group.dataset.pointStyleCaps = shapeTool(shape); +- group.setAttribute("aria-hidden", "true"); +- group.setAttribute("pointer-events", "none"); +- group.setAttribute("transform", this.svgTransformAttribute( +- this.scaledDrawingTransform(this.shapeTransform(shape), drawingScale), +- this.scaledDrawingPoint(transformOrigin, drawingScale) +- )); +- if (this.openShapeUsesSplitPointStyles(shape) && endpoints) { +- [ +- ["start", endpoints.start.point, this.shapeStartPointStyle(shape), endpoints.start.angle], +- ["end", endpoints.end.point, this.shapeEndPointStyle(shape), endpoints.end.angle] +- ].forEach(([endpoint, point, pointStyle, angle]) => { +- group.append(this.createPointStyleCapMarker(point, pointStyle, shape, endpoint, drawingScale, angle)); +- }); +- } +- pointMarkers.forEach(({ angle = 0, markerId, point, pointStyle }) => { +- group.append(this.createPointStyleCapMarker(point, pointStyle, shape, markerId, drawingScale, angle)); +- }); +- parent.append(group); +- } +- +- shapePointStyleMarkers(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (!["polygon", "polyline", "rectangle"].includes(geometryTool)) { +- return []; +- } +- const points = this.shapeGeometryPoints(shape); +- const styles = this.shapePointStyleValues(shape); +- return points +- .map((point, index) => ({ index, point, pointStyle: styles[index] })) +- .filter(({ index, pointStyle }) => pointStyle === "round" +- && !(geometryTool === "polyline" && (index === 0 || index === points.length - 1))) +- .map(({ index, point, pointStyle }) => ({ +- markerId: `point-${index}`, +- point, +- pointStyle +- })); +- } +- +- createPointStyleCapMarker(point, pointStyle, shape, endpoint, drawingScale, angle = 0) { +- const strokeWidth = Math.max(1, Number(shape.style?.strokeWidth) || 1); +- const size = strokeWidth; +- const x = this.scaleDrawingValue(point.x, drawingScale); +- const y = this.scaleDrawingValue(point.y, drawingScale); +- const marker = pointStyle === "square" +- ? document.createElementNS(SVG_NS, "rect") +- : document.createElementNS(SVG_NS, "circle"); +- marker.classList.add("object-vector-studio-v2__point-style-cap"); +- marker.dataset.pointStyleCap = endpoint; +- marker.dataset.pointStyle = pointStyle; +- marker.setAttribute("fill", shape.style.stroke); +- marker.setAttribute("fill-opacity", shape.style.strokeOpacity); +- marker.setAttribute("stroke", "none"); +- if (pointStyle === "square") { +- marker.setAttribute("x", x - size / 2); +- marker.setAttribute("y", y - size / 2); +- marker.setAttribute("width", size); +- marker.setAttribute("height", size); +- marker.setAttribute("transform", `rotate(${this.formatViewportNumber(angle)} ${this.formatViewportNumber(x)} ${this.formatViewportNumber(y)})`); +- return marker; +- } +- marker.setAttribute("cx", x); +- marker.setAttribute("cy", y); +- marker.setAttribute("r", size / 2); +- return marker; +- } +- +- openShapeEndpointPoints(shape) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "line") { +- const start = linePoint(shape.geometry, "point1"); +- const end = linePoint(shape.geometry, "point2"); +- const angle = this.angleBetweenPoints(start, end); +- return { +- end: { angle, point: end }, +- start: { angle, point: start } +- }; +- } +- if (geometryTool === "polyline" && Array.isArray(shape.geometry?.points) && shape.geometry.points.length >= 2) { +- const points = shape.geometry.points; +- return { +- end: { angle: this.angleBetweenPoints(points.at(-2), points.at(-1)), point: points.at(-1) }, +- start: { angle: this.angleBetweenPoints(points[0], points[1]), point: points[0] } +- }; +- } +- if (geometryTool === "arc") { +- const start = this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.startAngle); +- const end = this.polarPoint(shape.geometry.cx, shape.geometry.cy, shape.geometry.r, shape.geometry.endAngle); +- return { +- end: { angle: Number(shape.geometry.endAngle) + 90, point: end }, +- start: { angle: Number(shape.geometry.startAngle) + 90, point: start } +- }; +- } +- return null; +- } +- +- angleBetweenPoints(start, end) { +- if (!start || !end) { +- return 0; +- } +- return Math.atan2(Number(end.y) - Number(start.y), Number(end.x) - Number(start.x)) * 180 / Math.PI; +- } +- +- shapeTransform(shape) { +- return this.ensureShapeTransform(shape || {}); +- } +- +- effectiveShape(shape, shapeIndex = this.selectedShapeIndex) { +- return this.effectiveShapeForFrame(shape, this.activeFrame(), shapeIndex); +- } +- +- effectiveShapeForFrame(shape, frame, shapeIndex) { +- const effective = JSON.parse(JSON.stringify(shape)); +- const override = frame?.shapeOverrides?.find((entry) => entry.shapeIndex === shapeIndex) || null; +- if (override && Object.prototype.hasOwnProperty.call(override, "visible")) { +- effective.visible = override.visible; +- } +- if (override?.transform) { +- effective.transform = { ...override.transform }; +- } +- return effective; +- } +- +- svgTransformAttribute(transform, origin = { x: 0, y: 0 }) { +- return objectVectorSvgTransformAttribute(transform, origin); +- } +- +- scaleDrawingValue(value, drawingScale = 1) { +- return drawingScale === 1 ? value : this.formatViewportNumber(value * drawingScale); +- } +- +- scaledDrawingTransform(transform, drawingScale = 1) { +- if (drawingScale === 1) { +- return transform; +- } +- return { +- ...transform, +- x: this.scaleDrawingValue(transform.x, drawingScale), +- y: this.scaleDrawingValue(transform.y, drawingScale) +- }; +- } +- +- scaledDrawingPoint(point, drawingScale = 1) { +- if (drawingScale === 1) { +- return point; +- } +- return { +- x: this.scaleDrawingValue(point.x, drawingScale), +- y: this.scaleDrawingValue(point.y, drawingScale) +- }; +- } +- +- scaledDrawingBounds(bounds, drawingScale = 1) { +- if (drawingScale === 1) { +- return bounds; +- } +- return { +- height: this.scaleDrawingValue(bounds.height, drawingScale), +- originX: this.scaleDrawingValue(bounds.originX, drawingScale), +- originY: this.scaleDrawingValue(bounds.originY, drawingScale), +- width: this.scaleDrawingValue(bounds.width, drawingScale), +- x: this.scaleDrawingValue(bounds.x, drawingScale), +- y: this.scaleDrawingValue(bounds.y, drawingScale) +- }; +- } +- +- transformedPoint(point, transform, origin = { x: 0, y: 0 }) { +- return transformObjectVectorShapePoint(point, transform, origin); +- } +- +- localPointFromTransformedPoint(point, transform, origin = { x: 0, y: 0 }) { +- const localPoint = inverseTransformObjectVectorShapePoint(point, transform, origin); +- return { +- x: this.formatViewportNumber(localPoint.x), +- y: this.formatViewportNumber(localPoint.y) +- }; +- } +- +- transformedBounds(shape, { drawingScale = 1, transformOrigin = { x: 0, y: 0 } } = {}) { +- return this.scaledDrawingBounds( +- transformedObjectVectorShapeBounds(shapeBoundsPoints(shape), this.shapeTransform(shape), transformOrigin), +- drawingScale +- ); +- } +- +- objectBounds(object, { drawingScale = 1, includeInvisible = true } = {}) { +- const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- const shapes = sortedShapes(object) +- .map((shape, shapeIndex) => ({ shape, shapeIndex })) +- .map(({ shape, shapeIndex }) => ({ +- shape: this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(shape, activeFrame, shapeIndex), objectScale), +- shapeIndex +- })) +- .filter((entry) => includeInvisible || entry.shape.visible !== false); +- if (!shapes.length) { +- return { +- height: 80, +- width: 120, +- x: -60, +- y: -40 +- }; +- } +- +- return combineObjectVectorBounds(shapes.map(({ shape }) => this.transformedBounds(shape, { drawingScale, transformOrigin }))); +- } +- +- rawVisibleObjectGeometryBounds(object) { +- const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; +- const bounds = sortedShapes(object) +- .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, activeFrame, shapeIndex)) +- .filter((shape) => shape.visible !== false) +- .map((shape) => shapeBounds(shape)); +- if (!bounds.length) { +- return null; +- } +- return combineObjectVectorBounds(bounds); +- } +- +- objectBoundsForFrame(object, frame) { +- const transformOrigin = this.objectTransformOrigin(object); +- const shapes = sortedShapes(object) +- .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, frame, shapeIndex)) +- .filter((shape) => shape.visible); +- if (!shapes.length) { +- return { +- height: 80, +- width: 120, +- x: -60, +- y: -40 +- }; +- } +- return combineObjectVectorBounds(shapes.map((shape) => this.transformedBounds(shape, { transformOrigin }))); +- } +- +- shapeSetBounds(object, shapeIndexes, { drawingScale = 1, includeInvisible = false } = {}) { +- const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; +- const objectShapes = sortedShapes(object); +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- const targetIndexes = Array.from(new Set(Array.from(shapeIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) +- .sort((left, right) => left - right); +- const shapes = targetIndexes +- .map((shapeIndex) => this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex), objectScale)) +- .filter((shape) => includeInvisible || shape.visible !== false); +- if (!shapes.length) { +- return null; +- } +- return combineObjectVectorBounds(shapes.map((shape) => this.transformedBounds(shape, { drawingScale, transformOrigin }))); +- } +- +- renderSelectionOverlay(object) { +- const selectedShape = this.selectedShape(); +- if (!selectedShape || selectedShape.visible === false) { +- return; +- } +- try { +- const selectedIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- const bounds = selectedIndexes.length > 1 +- ? this.shapeSetBounds(object, selectedIndexes, { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE }) +- : this.transformedBounds(this.shapeWithObjectScalePreview(this.effectiveShape(selectedShape), objectScale), { drawingScale: OBJECT_PREVIEW_DRAWING_SCALE, transformOrigin }); +- if (!bounds) { +- return; +- } +- const dragArea = document.createElementNS(SVG_NS, "rect"); +- dragArea.classList.add("object-vector-studio-v2__selection-drag-area"); +- dragArea.dataset.selectionDragBounds = selectedIndexes.join(","); +- dragArea.setAttribute("x", bounds.x - 4); +- dragArea.setAttribute("y", bounds.y - 4); +- dragArea.setAttribute("width", bounds.width + 8); +- dragArea.setAttribute("height", bounds.height + 8); +- dragArea.addEventListener("pointerdown", (event) => this.startPreviewSelectionBoundsMove(event)); +- dragArea.addEventListener("click", (event) => this.handleSelectionDragAreaClick(event)); +- dragArea.addEventListener("contextmenu", (event) => this.handleSelectionDragAreaContextMenu(event)); +- this.elements.renderSurface.insertBefore(dragArea, this.elements.renderSurface.querySelector(".object-vector-studio-v2__shape")); +- +- const box = document.createElementNS(SVG_NS, "rect"); +- box.classList.add("object-vector-studio-v2__selection-box"); +- box.dataset.selectionBounds = String(this.selectedShapeIndex); +- box.setAttribute("x", bounds.x - 4); +- box.setAttribute("y", bounds.y - 4); +- box.setAttribute("width", bounds.width + 8); +- box.setAttribute("height", bounds.height + 8); +- this.elements.renderSurface.append(box); +- +- [ +- [bounds.x - 4, bounds.y - 4, "nw"], +- [bounds.x + bounds.width + 4, bounds.y - 4, "ne"], +- [bounds.x - 4, bounds.y + bounds.height + 4, "sw"], +- [bounds.x + bounds.width + 4, bounds.y + bounds.height + 4, "se"] +- ].forEach(([cx, cy, handle]) => { +- const point = document.createElementNS(SVG_NS, "rect"); +- point.classList.add("object-vector-studio-v2__resize-handle"); +- point.dataset.resizeHandle = handle; +- point.dataset.resizeShapeIndex = String(this.selectedShapeIndex); +- if (shapeGeometryTool(selectedShape) === "rectangle") { +- point.classList.add("object-vector-studio-v2__geometry-point-handle"); +- point.dataset.geometryPointHandle = `rectangle-${handle}`; +- point.dataset.geometryPointKind = "rectangle-corner"; +- } +- const handleSize = shapeGeometryTool(selectedShape) === "circle" ? 5 : 3; +- point.setAttribute("x", cx - handleSize / 2); +- point.setAttribute("y", cy - handleSize / 2); +- point.setAttribute("width", handleSize); +- point.setAttribute("height", handleSize); +- point.addEventListener("pointerdown", (event) => this.startPreviewHandleEdit(event, this.selectedShapeIndex, { handle, mode: "resize" })); +- this.elements.renderSurface.append(point); +- }); +- +- this.renderGeometryPointHandles(selectedShape); +- } catch (error) { +- this.statusLog.write(`FAIL Selection overlay render failed for ${object.name}/shape-${this.selectedShapeIndex} (${shapeTool(selectedShape)}): ${error.message}`); +- } +- } +- +- selectedPreviewDragIndexes(object, preferredShapeIndex = this.selectedShapeIndex) { +- const objectShapes = sortedShapes(object); +- const preferredIndex = normalizeShapeIndex(preferredShapeIndex); +- const selectedIndexes = Array.from(this.selectedShapeIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length && objectShapes[shapeIndex]?.visible !== false); +- if (selectedIndexes.length) { +- return Array.from(new Set(selectedIndexes)).sort((left, right) => left - right); +- } +- if (preferredIndex >= 0 && preferredIndex < objectShapes.length && objectShapes[preferredIndex]?.visible !== false) { +- return [preferredIndex]; +- } +- return []; +- } +- +- selectedPreviewDragGroupId(objectShapes, targetIndexes) { +- if (!Array.isArray(targetIndexes) || targetIndexes.length < 2) { +- return ""; +- } +- const groupIds = new Set(targetIndexes +- .map((shapeIndex) => String(objectShapes[shapeIndex]?.groupId || "").trim()) +- .filter(Boolean)); +- return groupIds.size === 1 ? Array.from(groupIds)[0] : ""; +- } +- +- selectedPreviewHitShapeIndex(object, shapeIndexes, point) { +- const objectShapes = sortedShapes(object); +- const activeFrame = object?.id === this.selectedObjectId ? this.activeFrame() : null; +- const transformOrigin = this.objectTransformOrigin(object); +- const objectScale = this.objectScalePreviewValueForObject(object); +- return Array.from(shapeIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length) +- .reverse() +- .find((shapeIndex) => { +- const effectiveShape = this.shapeWithObjectScalePreview(this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex), objectScale); +- if (!effectiveShape || effectiveShape.visible === false) { +- return false; +- } +- const bounds = this.transformedBounds(effectiveShape, { transformOrigin }); +- return point.x >= bounds.x - 4 +- && point.x <= bounds.x + bounds.width + 4 +- && point.y >= bounds.y - 4 +- && point.y <= bounds.y + bounds.height + 4; +- }); +- } +- +- handleSelectionDragAreaContextMenu(event) { +- const object = this.selectedObject(); +- const targetIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); +- const shapeIndex = this.selectedPreviewHitShapeIndex(object, targetIndexes, this.pointerPreviewPoint(event)); +- if (!Number.isInteger(shapeIndex)) { +- event.preventDefault(); +- return; +- } +- this.handleShapeContextMenu(event, sortedShapes(object)[shapeIndex], shapeIndex); +- } +- +- handleSelectionDragAreaClick(event) { +- event.stopPropagation(); +- if (Date.now() < (this.previewClickSuppressedUntil || 0)) { +- event.preventDefault(); +- return; +- } +- const object = this.selectedObject(); +- const targetIndexes = this.selectedPreviewDragIndexes(object, this.selectedShapeIndex); +- const shapeIndex = this.selectedPreviewHitShapeIndex(object, targetIndexes, this.pointerPreviewPoint(event)); +- if (!Number.isInteger(shapeIndex)) { +- return; +- } +- this.handleShapePointer(event, sortedShapes(object)[shapeIndex], shapeIndex, { source: "selection bounds click" }); +- } +- +- renderGeometryPointHandles(shape) { +- const effectiveShape = this.effectiveShape(shape); +- const renderShape = this.shapeWithObjectScalePreview(effectiveShape, this.objectScalePreviewValueForObject(this.selectedObject())); +- const renderTransform = this.shapeTransform(renderShape); +- const transformOrigin = this.objectTransformOrigin(this.selectedObject()); +- this.geometryPointHandleDefinitions(effectiveShape).forEach((definition) => { +- const position = this.transformedPoint(definition.point, renderTransform, transformOrigin); +- const point = document.createElementNS(SVG_NS, "rect"); +- point.classList.add("object-vector-studio-v2__resize-handle", "object-vector-studio-v2__geometry-point-handle"); +- if (definition.className) { +- point.classList.add(definition.className); +- } +- point.dataset.geometryPointHandle = definition.id; +- point.dataset.geometryPointKind = definition.kind; +- point.dataset.resizeShapeIndex = String(this.selectedShapeIndex); +- if (definition.endpoint) { +- point.dataset.lineEndpoint = definition.endpoint; +- } +- point.setAttribute("x", this.scaleDrawingValue(position.x, OBJECT_PREVIEW_DRAWING_SCALE) - 2); +- point.setAttribute("y", this.scaleDrawingValue(position.y, OBJECT_PREVIEW_DRAWING_SCALE) - 2); +- point.setAttribute("width", 4); +- point.setAttribute("height", 4); +- point.setAttribute("aria-label", definition.label); +- point.addEventListener("pointerdown", (event) => this.startPreviewHandleEdit(event, this.selectedShapeIndex, definition.editOptions)); +- this.elements.renderSurface.append(point); +- }); +- } +- +- geometryPointHandleDefinitions(shape) { +- const geometryTool = shapeGeometryTool(shape); +- const geometry = shape.geometry; +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- return geometry.points.map((point, index) => ({ +- editOptions: { +- mode: "geometry-point", +- point: { ...point }, +- pointIndex: index +- }, +- id: `${geometryTool}-${index}`, +- kind: this.isTriangleShape(shape) ? "triangle-point" : `${geometryTool}-point`, +- label: `Move point ${index + 1}`, +- point +- })); +- } +- if (geometryTool === "line") { +- return [ +- ["start", "point1", linePoint(geometry, "point1")], +- ["end", "point2", linePoint(geometry, "point2")] +- ].map(([endpoint, key, point], index) => ({ +- className: "object-vector-studio-v2__line-endpoint-handle", +- editOptions: { +- endpoint, +- mode: "line-endpoint", +- point: { ...point } +- }, +- endpoint, +- id: `line-${endpoint}`, +- kind: key, +- label: `Move line point ${index + 1}`, +- point +- })); +- } +- if (geometryTool === "circle") { +- return [ +- { +- control: "center", +- id: "circle-center", +- kind: "center", +- label: "Move circle center", +- point: { x: geometry.cx, y: geometry.cy } +- }, +- { +- control: "radius", +- id: "circle-radius", +- kind: "radius", +- label: "Adjust circle radius", +- point: { x: geometry.cx + geometry.r, y: geometry.cy } +- } +- ].map((definition) => ({ +- ...definition, +- editOptions: { +- control: definition.control, +- mode: "geometry-point", +- point: { ...definition.point } +- } +- })); +- } +- if (geometryTool === "ellipse") { +- return [ +- { +- control: "center", +- id: "ellipse-center", +- kind: "center", +- label: "Move ellipse center", +- point: { x: geometry.cx, y: geometry.cy } +- }, +- { +- control: "rx", +- id: "ellipse-rx", +- kind: "rx", +- label: "Adjust ellipse rx", +- point: { x: geometry.cx + geometry.rx, y: geometry.cy } +- }, +- { +- control: "ry", +- id: "ellipse-ry", +- kind: "ry", +- label: "Adjust ellipse ry", +- point: { x: geometry.cx, y: geometry.cy + geometry.ry } +- } +- ].map((definition) => ({ +- ...definition, +- editOptions: { +- control: definition.control, +- mode: "geometry-point", +- point: { ...definition.point } +- } +- })); +- } +- if (geometryTool === "arc") { +- return [ +- { +- control: "center", +- id: "arc-center", +- kind: "center", +- label: "Move arc center", +- point: { x: geometry.cx, y: geometry.cy } +- }, +- { +- control: "start", +- id: "arc-start", +- kind: "startAngle", +- label: "Move arc start point", +- point: this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.startAngle) +- }, +- { +- control: "end", +- id: "arc-end", +- kind: "endAngle", +- label: "Move arc end point", +- point: this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.endAngle) +- } +- ].map((definition) => ({ +- ...definition, +- editOptions: { +- control: definition.control, +- mode: "geometry-point", +- point: { ...definition.point } +- } +- })); +- } +- if (geometryTool === "text") { +- const point = { x: geometry.x, y: geometry.y }; +- return [ +- { +- editOptions: { +- control: "anchor", +- mode: "geometry-point", +- point: { ...point } +- }, +- id: "text-anchor", +- kind: "anchor", +- label: "Move text anchor", +- point +- } +- ]; +- } +- return []; +- } +- +- arcPath(geometry) { +- const start = this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.startAngle); +- const end = this.polarPoint(geometry.cx, geometry.cy, geometry.r, geometry.endAngle); +- const largeArc = Math.abs(geometry.endAngle - geometry.startAngle) > 180 ? 1 : 0; +- return `M ${start.x} ${start.y} A ${geometry.r} ${geometry.r} 0 ${largeArc} 1 ${end.x} ${end.y}`; +- } +- +- polarPoint(cx, cy, radius, degrees) { +- const radians = (degrees - 90) * Math.PI / 180; +- return { +- x: Number((cx + radius * Math.cos(radians)).toFixed(3)), +- y: Number((cy + radius * Math.sin(radians)).toFixed(3)) +- }; +- } +- +- updateViewport() { +- const width = DEFAULT_VIEWPORT.width / this.viewport.zoom; +- const height = DEFAULT_VIEWPORT.height / this.viewport.zoom; +- const viewX = this.formatViewportNumber(this.viewport.x - width / 2); +- const viewY = this.formatViewportNumber(this.viewport.y - height / 2); +- this.elements.renderSurface.setAttribute("viewBox", `${viewX} ${viewY} ${this.formatViewportNumber(width)} ${this.formatViewportNumber(height)}`); +- this.elements.coordinateDisplay.textContent = `Origin: ${this.formatLogicalCoordinate(this.viewport.x)}, ${this.formatLogicalCoordinate(this.viewport.y)} | ${this.canvasOriginDisplayText()} | Zoom ${this.formatZoomPercentage()}%`; +- } +- +- formatViewportNumber(value) { +- const normalized = Number(Number(value).toFixed(3)); +- return Object.is(normalized, -0) ? 0 : normalized; +- } +- +- formatCoordinateInputValue(value) { +- return this.formatViewportNumber(value).toFixed(3); +- } +- +- canvasOriginDisplayText() { +- const x = this.formatLogicalCoordinate(-this.viewport.x); +- const y = this.formatLogicalCoordinate(-this.viewport.y); +- if (x === 0 && y === 0) { +- return "Canvas origin 0,0 centered"; +- } +- return `Canvas origin ${x},${y} from center`; +- } +- +- formatLogicalCoordinate(value) { +- return this.formatViewportNumber(value / GRID_STEP); +- } +- +- formatLogicalPointerCoordinate(value) { +- return Math.round(this.formatLogicalCoordinate(value)); +- } +- +- formatZoomPercentage() { +- return Math.round(this.viewport.zoom * 100); +- } +- +- zoomViewportByStep(step) { +- const nextZoom = Number((this.viewport.zoom + step).toFixed(3)); +- this.zoomViewport(nextZoom); +- } +- +- zoomViewportByStepAtPointer(step, event) { +- const nextZoom = Number((this.viewport.zoom + step).toFixed(3)); +- this.zoomViewportAtPointer(nextZoom, event); +- } +- +- zoomViewport(nextZoom) { +- if (!Number.isFinite(nextZoom)) { +- this.statusLog.write("WARN Viewport zoom skipped: zoom value is invalid."); +- return; +- } +- this.viewport.zoom = this.clampedViewportZoom(nextZoom); +- this.updateViewport(); +- this.renderWorkSurface(); +- this.statusLog.write(`OK Viewport zoom set to ${this.formatZoomPercentage()}%.`); +- } +- +- zoomViewportAtPointer(nextZoom, event) { +- if (!Number.isFinite(nextZoom)) { +- this.statusLog.write("WARN Viewport zoom skipped: zoom value is invalid."); +- return; +- } +- const anchor = this.viewportPointFromEvent(event); +- if (!anchor) { +- this.zoomViewport(nextZoom); +- return; +- } +- const zoom = this.clampedViewportZoom(nextZoom); +- const viewWidth = DEFAULT_VIEWPORT.width / zoom; +- const viewHeight = DEFAULT_VIEWPORT.height / zoom; +- this.viewport.zoom = zoom; +- this.viewport.x = Number((anchor.x - (anchor.ratioX - 0.5) * viewWidth).toFixed(6)); +- this.viewport.y = Number((anchor.y - (anchor.ratioY - 0.5) * viewHeight).toFixed(6)); +- this.updateViewport(); +- this.renderWorkSurface(); +- this.updateCoordinateDisplay(event); +- this.statusLog.write(`OK Viewport zoom set to ${this.formatZoomPercentage()}%.`); +- } +- +- clampedViewportZoom(zoom) { +- return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(zoom.toFixed(3)))); +- } +- +- panViewport(x, y) { +- this.viewport.x = Number((this.viewport.x + x).toFixed(3)); +- this.viewport.y = Number((this.viewport.y + y).toFixed(3)); +- this.updateViewport(); +- this.statusLog.write(`OK Viewport pan set to ${this.viewport.x}, ${this.viewport.y}.`); +- } +- +- toggleCenterOriginMarker() { +- this.centerOriginVisible = !this.centerOriginVisible; +- this.window.sessionStorage?.setItem(CENTER_ORIGIN_SESSION_KEY, this.centerOriginVisible ? "1" : "0"); +- this.applySnapState(); +- this.renderWorkSurface(); +- this.statusLog.write(`OK Center dot ${this.centerOriginVisible ? "visible" : "hidden"}.`); +- } +- +- resetViewport() { +- this.viewport = { ...DEFAULT_VIEWPORT }; +- this.updateViewport(); +- this.statusLog.write(`OK Viewport reset to ${this.formatZoomPercentage()}% at origin 0,0.`); +- } +- +- updateCoordinateDisplay(event) { +- const point = this.viewportPointFromEvent(event); +- if (!point) { +- return; +- } +- this.elements.coordinateDisplay.textContent = `Pointer ${this.formatLogicalPointerCoordinate(point.x)}, ${this.formatLogicalPointerCoordinate(point.y)} | ${this.canvasOriginDisplayText()} | Zoom ${this.formatZoomPercentage()}%`; +- } +- +- viewportPointFromEvent(event) { +- return this.viewportPointFromClient(event.clientX, event.clientY); +- } +- +- viewportPointFromClient(clientX, clientY, zoom = this.viewport.zoom, viewport = this.viewport) { +- const bounds = this.elements.renderSurface.getBoundingClientRect(); +- if (!bounds.width || !bounds.height) { +- return null; +- } +- const ratioX = (clientX - bounds.left) / bounds.width; +- const ratioY = (clientY - bounds.top) / bounds.height; +- const viewWidth = DEFAULT_VIEWPORT.width / zoom; +- const viewHeight = DEFAULT_VIEWPORT.height / zoom; +- return { +- ratioX, +- ratioY, +- x: viewport.x - viewWidth / 2 + ratioX * viewWidth, +- y: viewport.y - viewHeight / 2 + ratioY * viewHeight +- }; +- } +- +- pointerPreviewPoint(event) { +- const point = this.viewportPointFromEvent(event); +- if (!point) { +- return { x: 0, y: 0 }; +- } +- return { +- x: this.formatViewportNumber(point.x / OBJECT_PREVIEW_DRAWING_SCALE), +- y: this.formatViewportNumber(point.y / OBJECT_PREVIEW_DRAWING_SCALE) +- }; +- } +- +- startDrawingMode(tool) { +- if (!PRIMITIVE_TOOLS.includes(tool)) { +- this.statusLog.write(`WARN Drawing mode skipped: ${tool || "unknown"} is not a drawable shape tool.`); +- return; +- } +- this.activeTool = tool; +- this.previewPointerEdit = null; +- this.activeDrawing = this.createDrawingState(tool); +- this.drawingPreviewPoint = null; +- this.drawingHintClientPoint = null; +- this.renderWorkSurface(); +- const objectHint = this.selectedObject() ? "" : " Select a schema-valid object before committing geometry."; +- const drawingHelp = ["polygon", "polyline"].includes(tool) +- ? "Click to add points.\n\nDouble-click to finish." +- : "Click once to start, move to preview, then click again to finish."; +- this.statusLog.write(`OK Drawing mode selected: ${shapeTypeLabel(tool)}. ${drawingHelp}${objectHint}`); +- } +- +- createDrawingState(tool) { +- const flow = ["polygon", "polyline"].includes(tool) +- ? "points" +- : "two-click"; +- return { +- flow, +- points: [], +- preview: null, +- start: null, +- style: this.drawingStyleFromActivePalette(), +- tool +- }; +- } +- +- drawingStyleFromActivePalette() { +- const { pointStyle, startPointStyle, endPointStyle, strokeLinecap, ...styleDefault } = this.schemaDefault("style"); +- const strokeWidth = Number(this.elements.strokeWidth?.value); +- return { +- ...styleDefault, +- fill: TRANSPARENT_STYLE_COLOR, +- fillOpacity: this.selectedFillOpacity, +- stroke: this.selectedStrokeColor || this.currentTargetColor() || "#ffffff", +- strokeOpacity: this.selectedStrokeOpacity, +- strokeWidth: Number.isFinite(strokeWidth) && strokeWidth > 0 ? strokeWidth : styleDefault.strokeWidth +- }; +- } +- +- cancelActiveDrawing(sourceLabel = "cancel", { log = true } = {}) { +- if (!this.activeDrawing) { +- return; +- } +- const tool = this.activeDrawing.tool; +- this.activeDrawing = null; +- this.drawingPreviewPoint = null; +- this.drawingHintClientPoint = null; +- this.activeTool = "select"; +- const selectButton = this.elements.toolToggles.find((candidate) => candidate.dataset.shapeTool === "select") || null; +- this.setActiveToolButton(selectButton); +- this.renderWorkSurface(); +- if (log) { +- this.statusLog.write(`OK Canceled ${shapeTypeLabel(tool)} drawing from ${sourceLabel}; no invalid geometry was committed.`); +- } +- } +- +- handleRenderSurfacePointerDown(event) { +- if (this.activeDrawing) { +- this.handleDrawingPointerDown(event); +- return; +- } +- if (event.button !== 0 || this.previewPointerEdit) { +- return; +- } +- if (this.isEmptyCanvasPointerTarget(event.target)) { +- if (this.startPreviewObjectMove(event)) { +- return; +- } +- if (this.startViewportPan(event)) { +- return; +- } +- this.deselectShape("empty canvas"); +- } +- } +- +- isEmptyCanvasPointerTarget(target) { +- if (target === this.elements.renderSurface) { +- return true; +- } +- return Boolean(target?.classList?.contains("object-vector-studio-v2__svg-grid") +- || target?.closest?.(".object-vector-studio-v2__svg-grid") +- || target?.classList?.contains("object-vector-studio-v2__center-origin-dot")); +- } +- +- canDragSelectedObjectFromEmptyCanvas() { +- const object = this.selectedObject(); +- if (!object || this.activeDrawing || this.activeTool !== "select") { +- return false; +- } +- if (this.selectedShapeIndex >= 0 || this.selectedShapeIndexes.size || this.directSelectedShapeIndexes.size) { +- return false; +- } +- const targetIndexes = this.selectedObjectShapeIndexes(object); +- if (!targetIndexes.length || this.isObjectLocked(object.id)) { +- return false; +- } +- return !targetIndexes.some((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); +- } +- +- canPanViewportFromEmptyCanvas() { +- return !this.activeDrawing +- && !this.previewPointerEdit +- && this.activeTool === "select" +- && this.selectedShapeIndex < 0 +- && !this.selectedShapeIndexes.size +- && !this.directSelectedShapeIndexes.size +- && !this.canDragSelectedObjectFromEmptyCanvas(); +- } +- +- updateRenderSurfaceCursorState() { +- const draggingObject = this.previewPointerEdit?.mode === "move-object"; +- const panningViewport = Boolean(this.viewportPan); +- this.elements.renderSurface.classList.toggle("is-empty-canvas-object-drag-ready", this.canDragSelectedObjectFromEmptyCanvas()); +- this.elements.renderSurface.classList.toggle("is-empty-canvas-object-dragging", draggingObject); +- this.elements.renderSurface.classList.toggle("is-canvas-pan-ready", this.canPanViewportFromEmptyCanvas()); +- this.elements.renderSurface.classList.toggle("is-canvas-panning", panningViewport); +- } +- +- deselectShape(sourceLabel = "selection") { +- if (this.selectedShapeIndex < 0 && !this.selectedShapeIndexes.size && !this.directSelectedShapeIndexes.size) { +- return; +- } +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- this.renderObjectTiles(); +- this.renderSelectedObject(); +- this.renderWorkSurface(); +- this.updateObjectActionState(); +- this.statusLog.write(`OK Deselected shape from ${sourceLabel}.`); +- } +- +- handleDrawingPointerDown(event) { +- const drawing = this.activeDrawing; +- if (!drawing || event.button !== 0) { +- return; +- } +- event.preventDefault(); +- event.stopPropagation(); +- const point = this.snapDrawingPoint(this.pointerPreviewPoint(event), drawing); +- this.drawingPreviewPoint = point; +- this.drawingHintClientPoint = { x: event.clientX, y: event.clientY }; +- if (drawing.flow === "points") { +- if (!drawing.points.length) { +- drawing.style = this.drawingStyleFromActivePalette(); +- } +- drawing.points.push(point); +- drawing.preview = point; +- this.renderWorkSurface(); +- this.statusLog.write(`OK Added ${shapeTypeLabel(drawing.tool)} point ${drawing.points.length}.`); +- return; +- } +- if (drawing.flow === "two-click") { +- if (!drawing.start) { +- drawing.style = this.drawingStyleFromActivePalette(); +- drawing.start = point; +- drawing.preview = null; +- this.renderWorkSurface(); +- this.statusLog.write(`OK Started ${shapeTypeLabel(drawing.tool)} drawing.`); +- return; +- } +- drawing.preview = point; +- const geometry = this.geometryFromDrawingPoints(drawing.tool, [drawing.start, point], point); +- this.commitDrawnShape(drawing.tool, geometry, "canvas click"); +- return; +- } +- this.renderWorkSurface(); +- } +- +- updateDrawingPreview(event) { +- const drawing = this.activeDrawing; +- if (!drawing) { +- return; +- } +- if (drawing.flow === "two-click" && !drawing.start) { +- return; +- } +- const point = this.snapDrawingPoint(this.pointerPreviewPoint(event), drawing); +- if (drawing.preview && Math.abs(point.x - drawing.preview.x) < 0.001 && Math.abs(point.y - drawing.preview.y) < 0.001) { +- return; +- } +- drawing.preview = point; +- this.drawingPreviewPoint = point; +- this.drawingHintClientPoint = { x: event.clientX, y: event.clientY }; +- this.renderWorkSurface(); +- } +- +- finishMultiPointDrawing(sourceLabel = "finish", event = null) { +- const drawing = this.activeDrawing; +- if (!drawing || drawing.flow !== "points" || !["polygon", "polyline"].includes(drawing.tool)) { +- return; +- } +- if (event) { +- event.preventDefault(); +- event.stopPropagation(); +- } +- const geometry = this.geometryFromDrawingPoints(drawing.tool, drawing.points, drawing.preview || drawing.points.at(-1)); +- this.commitDrawnShape(drawing.tool, geometry, sourceLabel); +- } +- +- previewShapeFromDrawing() { +- const drawing = this.activeDrawing; +- if (!drawing) { +- return null; +- } +- const points = [...drawing.points]; +- if (drawing.flow === "points" && drawing.preview && points.length) { +- points.push(drawing.preview); +- } +- if (drawing.flow === "two-click" && drawing.start && drawing.preview) { +- points.push(drawing.start, drawing.preview); +- } +- if (!points.length) { +- return null; +- } +- const geometry = this.geometryFromDrawingPoints(drawing.tool === "polygon" && points.length < 4 ? "polyline" : drawing.tool, points, drawing.preview); +- if (!geometry) { +- return null; +- } +- const tool = drawing.tool === "polygon" && points.length < 4 ? "polyline" : drawing.tool; +- const shape = { +- geometry, +- locked: false, +- order: 0, +- style: { +- ...drawing.style +- }, +- tool, +- transform: defaultShapeTransform(), +- visible: true +- }; +- return shape; +- } +- +- geometryFromDrawingPoints(tool, points, previewPoint = null) { +- const cleanPoints = points +- .filter(Boolean) +- .map((point) => ({ +- x: this.formatViewportNumber(point.x), +- y: this.formatViewportNumber(point.y) +- })); +- if (tool === "line") { +- if (cleanPoints.length < 2) { +- return null; +- } +- return { point1: cleanPoints[0], point2: cleanPoints[1] }; +- } +- if (tool === "polygon" || tool === "polyline") { +- return { points: cleanPoints }; +- } +- if (tool === "rectangle" || tool === "square") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [start, end] = cleanPoints; +- const width = Math.abs(end.x - start.x); +- const height = Math.abs(end.y - start.y); +- if (tool === "square") { +- const size = Math.max(width, height); +- return { +- height: size, +- width: size, +- x: end.x < start.x ? start.x - size : start.x, +- y: end.y < start.y ? start.y - size : start.y +- }; +- } +- return { +- height, +- width, +- x: Math.min(start.x, end.x), +- y: Math.min(start.y, end.y) +- }; +- } +- if (tool === "triangle") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [start, end] = cleanPoints; +- const minX = Math.min(start.x, end.x); +- const maxX = Math.max(start.x, end.x); +- const minY = Math.min(start.y, end.y); +- const maxY = Math.max(start.y, end.y); +- return { +- points: [ +- { x: Number(((minX + maxX) / 2).toFixed(3)), y: minY }, +- { x: maxX, y: maxY }, +- { x: minX, y: maxY } +- ] +- }; +- } +- if (tool === "circle") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [center, edge] = cleanPoints; +- return { +- cx: center.x, +- cy: center.y, +- r: this.radiusFromControlPoint(center, edge) +- }; +- } +- if (tool === "ellipse") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [start, end] = cleanPoints; +- return { +- cx: Number(((start.x + end.x) / 2).toFixed(3)), +- cy: Number(((start.y + end.y) / 2).toFixed(3)), +- rx: Number((Math.max(0, Math.abs(end.x - start.x)) / 2).toFixed(3)), +- ry: Number((Math.max(0, Math.abs(end.y - start.y)) / 2).toFixed(3)) +- }; +- } +- if (tool === "arc") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [center, edge] = cleanPoints; +- return { +- cx: center.x, +- cy: center.y, +- endAngle: 135, +- r: this.radiusFromControlPoint(center, edge), +- startAngle: -135 +- }; +- } +- if (tool === "text") { +- if (cleanPoints.length < 2) { +- return null; +- } +- const [start, end] = cleanPoints; +- const width = Math.abs(end.x - start.x); +- const height = Math.abs(end.y - start.y); +- if (width <= 0 && height <= 0) { +- return null; +- } +- const fontSize = Number(Math.max(1, height || width * 0.5).toFixed(3)); +- return { +- fontSize, +- text: "Text", +- x: Math.min(start.x, end.x), +- y: Number((Math.min(start.y, end.y) + fontSize).toFixed(3)) +- }; +- } +- return null; +- } +- +- validateDrawnGeometry(tool, geometry) { +- if (!geometry) { +- return `${shapeTypeLabel(tool)} drawing has no geometry.`; +- } +- if (tool === "line") { +- const distance = Math.hypot(geometry.point2.x - geometry.point1.x, geometry.point2.y - geometry.point1.y); +- return distance > 0 ? "" : "line requires two distinct points."; +- } +- if (tool === "polyline") { +- return geometry.points.length >= 2 ? "" : "polyline requires at least two points."; +- } +- if (tool === "polygon") { +- return geometry.points.length >= 4 ? "" : "polygon requires at least four points."; +- } +- if (tool === "triangle") { +- return geometry.points.length === 3 ? "" : "triangle requires exactly three points."; +- } +- if (tool === "rectangle" || tool === "square") { +- return geometry.width > 0 && geometry.height > 0 ? "" : `${shapeTypeLabel(tool)} requires non-zero width and height.`; +- } +- if (tool === "circle" || tool === "arc") { +- return geometry.r > 0 ? "" : `${shapeTypeLabel(tool)} requires a non-zero radius.`; +- } +- if (tool === "ellipse") { +- return geometry.rx > 0 && geometry.ry > 0 ? "" : "ellipse requires non-zero rx and ry."; +- } +- if (tool === "text") { +- return geometry.text && geometry.fontSize > 0 ? "" : "text requires two distinct points, content, and a positive font size."; +- } +- return ""; +- } +- +- commitDrawnShape(type, geometry, sourceLabel) { +- const geometryError = this.validateDrawnGeometry(type, geometry); +- if (geometryError) { +- this.statusLog.write(`FAIL Create ${type} blocked: ${geometryError}`); +- return; +- } +- this.createShape(type, { geometry, sourceLabel, style: this.activeDrawing?.style || null }); +- } +- +- startPreviewObjectMove(event) { +- if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || !this.canDragSelectedObjectFromEmptyCanvas()) { +- return false; +- } +- const object = this.selectedObject(); +- const objectShapes = sortedShapes(object); +- const targetIndexes = this.selectedObjectShapeIndexes(object); +- const activeFrame = this.activeFrame(); +- event.preventDefault(); +- event.stopPropagation(); +- this.previewPointerEdit = { +- ...this.previewPointerEditStartMetadata(event), +- mode: "move-object", +- objectId: object.id, +- objectName: object.name, +- originalTransforms: Object.fromEntries(targetIndexes.map((shapeIndex) => { +- const effectiveShape = this.effectiveShapeForFrame(objectShapes[shapeIndex], activeFrame, shapeIndex); +- return [shapeIndex, this.ensureShapeTransform(effectiveShape)]; +- })), +- shapeIndex: -1, +- start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: -1 }), +- targetIndexes +- }; +- this.updateRenderSurfaceCursorState(); +- return true; +- } +- +- startViewportPan(event) { +- if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || !this.canPanViewportFromEmptyCanvas()) { +- return false; +- } +- event.preventDefault(); +- event.stopPropagation(); +- this.viewportPan = { +- dragThresholdMet: false, +- pointerId: event.pointerId, +- startClient: { +- x: Number(event.clientX) || 0, +- y: Number(event.clientY) || 0 +- }, +- startViewport: { ...this.viewport } +- }; +- try { +- this.elements.renderSurface.setPointerCapture?.(event.pointerId); +- } catch { +- // Pointer capture is best-effort for synthetic validation events. +- } +- this.updateRenderSurfaceCursorState(); +- return true; +- } +- +- updateViewportPan(event) { +- const pan = this.viewportPan; +- if (!pan || event.buttons !== 1) { +- return; +- } +- const clientX = Number(event.clientX) || 0; +- const clientY = Number(event.clientY) || 0; +- const distance = Math.hypot(clientX - pan.startClient.x, clientY - pan.startClient.y); +- if (!pan.dragThresholdMet && distance < PREVIEW_DRAG_START_THRESHOLD_PX) { +- return; +- } +- pan.dragThresholdMet = true; +- const bounds = this.elements.renderSurface.getBoundingClientRect(); +- if (!bounds.width || !bounds.height) { +- return; +- } +- const viewWidth = DEFAULT_VIEWPORT.width / pan.startViewport.zoom; +- const viewHeight = DEFAULT_VIEWPORT.height / pan.startViewport.zoom; +- const viewportX = Number((pan.startViewport.x - ((clientX - pan.startClient.x) / bounds.width) * viewWidth).toFixed(3)); +- const viewportY = Number((pan.startViewport.y - ((clientY - pan.startClient.y) / bounds.height) * viewHeight).toFixed(3)); +- if (viewportX === this.viewport.x && viewportY === this.viewport.y) { +- return; +- } +- this.viewport.x = viewportX; +- this.viewport.y = viewportY; +- this.updateViewport(); +- this.updateCoordinateDisplay(event); +- } +- +- finishViewportPan(event) { +- const pan = this.viewportPan; +- if (!pan) { +- return false; +- } +- this.viewportPan = null; +- try { +- this.elements.renderSurface.releasePointerCapture?.(pan.pointerId); +- } catch { +- // Pointer capture is best-effort for synthetic validation events. +- } +- this.updateRenderSurfaceCursorState(); +- if (!pan.dragThresholdMet) { +- this.deselectShape("empty canvas"); +- return true; +- } +- this.updateCoordinateDisplay(event); +- this.statusLog.write(`OK Viewport drag-pan set to ${this.viewport.x}, ${this.viewport.y}.`); +- return true; +- } +- +- startPreviewSelectionBoundsMove(event) { +- this.startPreviewShapeMove(event, this.selectedShapeIndex, { fromSelectionBounds: true }); +- } +- +- startPreviewShapeMove(event, shapeIndex, options = {}) { +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { +- return; +- } +- const object = this.selectedObject(); +- const objectShapes = sortedShapes(object); +- const targetIndexes = this.selectedPreviewDragIndexes(object, normalizedIndex); +- const dragAnchorIndex = options.fromSelectionBounds ? this.selectedShapeIndex : normalizedIndex; +- if (!targetIndexes.includes(dragAnchorIndex)) { +- return; +- } +- const selected = objectShapes[dragAnchorIndex] || null; +- if (!selected || selected.visible === false) { +- return; +- } +- event.preventDefault(); +- event.stopPropagation(); +- const groupId = this.selectedPreviewDragGroupId(objectShapes, targetIndexes); +- if (targetIndexes.length > 1) { +- const lockedIndex = targetIndexes.find((targetIndex) => objectShapes[targetIndex]?.locked); +- if (Number.isInteger(lockedIndex)) { +- this.statusLog.write(`WARN Drag selection skipped: shape row ${lockedIndex} is locked.`); +- return; +- } +- const activeFrame = this.activeFrame(); +- const directSelectedShapeIndexes = this.directSelectedShapeIndexes.size +- ? new Set(Array.from(this.directSelectedShapeIndexes).filter((targetIndex) => targetIndexes.includes(targetIndex))) +- : new Set(targetIndexes); +- this.selectedShapeIndexes = new Set(targetIndexes); +- this.directSelectedShapeIndexes = directSelectedShapeIndexes.size ? directSelectedShapeIndexes : new Set(targetIndexes); +- this.previewPointerEdit = { +- ...this.previewPointerEditStartMetadata(event), +- mode: "move-group", +- groupId, +- directSelectedShapeIndexes: new Set(this.directSelectedShapeIndexes), +- originalTransforms: Object.fromEntries(targetIndexes.map((targetIndex) => { +- const effectiveShape = this.effectiveShapeForFrame(objectShapes[targetIndex], activeFrame, targetIndex); +- return [targetIndex, this.ensureShapeTransform(effectiveShape)]; +- })), +- shapeIndex: dragAnchorIndex, +- start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: dragAnchorIndex }), +- targetIndexes +- }; +- return; +- } +- if (this.selectedShapeIndex !== dragAnchorIndex) { +- return; +- } +- this.previewPointerEdit = { +- ...this.previewPointerEditStartMetadata(event), +- mode: "move", +- originalGeometry: JSON.parse(JSON.stringify(selected.geometry)), +- originalTransform: { ...this.shapeTransform(selected) }, +- shapeIndex: normalizedIndex, +- start: this.snapCanvasPoint(this.pointerPreviewPoint(event), { excludeShapeIndex: normalizedIndex }) +- }; +- } +- +- startPreviewHandleEdit(event, shapeIndex, options) { +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- if (event.button !== 0) { +- return; +- } +- const selected = this.selectedShape(); +- if (!selected || this.selectedShapeIndex !== normalizedIndex) { +- return; +- } +- event.preventDefault(); +- event.stopPropagation(); +- const effectiveSelected = this.effectiveShape(selected, normalizedIndex); +- const originalTransform = { ...this.shapeTransform(effectiveSelected) }; +- const transformOrigin = this.objectTransformOrigin(this.selectedObject()); +- const rawPointer = this.pointerPreviewPoint(event); +- const transformedPoint = options?.point +- ? this.transformedPoint(options.point, originalTransform, transformOrigin) +- : null; +- this.previewPointerEdit = { +- ...this.previewPointerEditStartMetadata(event), +- ...options, +- originalGeometry: JSON.parse(JSON.stringify(selected.geometry)), +- originalTransform, +- transformOrigin, +- pointerOffset: transformedPoint +- ? { +- x: this.formatViewportNumber(rawPointer.x - transformedPoint.x), +- y: this.formatViewportNumber(rawPointer.y - transformedPoint.y) +- } +- : null, +- shapeIndex: normalizedIndex, +- start: transformedPoint || this.snapCanvasPoint(rawPointer, { excludeShapeIndex: normalizedIndex }) +- }; +- } +- +- previewPointerEditStartMetadata(event) { +- return { +- dragThresholdMet: false, +- historyRecorded: false, +- historySnapshot: this.cloneCurrentPayload(), +- lastDelta: { x: 0, y: 0 }, +- startClient: { +- x: Number(event.clientX) || 0, +- y: Number(event.clientY) || 0 +- } +- }; +- } +- +- previewPointerEditPastDragThreshold(edit, event) { +- if (edit.dragThresholdMet) { +- return true; +- } +- if (!edit.startClient) { +- edit.dragThresholdMet = true; +- return true; +- } +- const distance = Math.hypot((Number(event.clientX) || 0) - edit.startClient.x, (Number(event.clientY) || 0) - edit.startClient.y); +- if (distance < PREVIEW_DRAG_START_THRESHOLD_PX) { +- return false; +- } +- edit.dragThresholdMet = true; +- return true; +- } +- +- previewPointerEditDelta(edit, event) { +- const end = this.previewPointerEditTargetPoint(edit, event); +- edit.currentTargetPoint = end; +- return { +- x: this.formatViewportNumber(end.x - edit.start.x), +- y: this.formatViewportNumber(end.y - edit.start.y) +- }; +- } +- +- previewPointerEditTargetPoint(edit, event) { +- const rawEnd = this.pointerPreviewPoint(event); +- const target = edit.pointerOffset +- ? { +- x: this.formatViewportNumber(rawEnd.x - edit.pointerOffset.x), +- y: this.formatViewportNumber(rawEnd.y - edit.pointerOffset.y) +- } +- : rawEnd; +- return this.snapCanvasPoint(target, { excludeShapeIndex: edit.shapeIndex }); +- } +- +- previewPointerEditLocalTarget(edit) { +- const target = edit.currentTargetPoint || edit.start; +- if (!edit.originalTransform) { +- return this.normalizeDrawingPoint(target); +- } +- return this.localPointFromTransformedPoint(target, edit.originalTransform, edit.transformOrigin || { x: 0, y: 0 }); +- } +- +- updatePreviewPointerEdit(event) { +- const edit = this.previewPointerEdit; +- if (!edit || event.buttons !== 1) { +- return; +- } +- if (!this.previewPointerEditPastDragThreshold(edit, event)) { +- return; +- } +- const delta = this.previewPointerEditDelta(edit, event); +- if (Math.abs(delta.x - edit.lastDelta.x) < 0.001 && Math.abs(delta.y - edit.lastDelta.y) < 0.001) { +- return; +- } +- this.recordPreviewPointerEditStart(edit); +- edit.lastDelta = delta; +- edit.didMove = true; +- this.applyPreviewPointerEdit(edit, delta, { live: true }); +- } +- +- finishPreviewPointerEdit(event) { +- const edit = this.previewPointerEdit; +- if (!edit) { +- return; +- } +- this.previewPointerEdit = null; +- this.updateRenderSurfaceCursorState(); +- if (!edit.dragThresholdMet && !this.previewPointerEditPastDragThreshold(edit, event)) { +- return; +- } +- const delta = this.previewPointerEditDelta(edit, event); +- if (Math.abs(delta.x) < 0.001 && Math.abs(delta.y) < 0.001) { +- return; +- } +- this.previewClickSuppressedUntil = Date.now() + 500; +- this.recordPreviewPointerEditStart(edit); +- this.applyPreviewPointerEdit(edit, delta, { live: false }); +- } +- +- recordPreviewPointerEditStart(edit) { +- if (edit.historyRecorded || !edit.historySnapshot) { +- return; +- } +- this.previewUndoStack.push(this.clonePayloadValue(edit.historySnapshot)); +- if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { +- this.previewUndoStack.shift(); +- } +- this.previewRedoStack = []; +- edit.historyRecorded = true; +- this.updatePreviewEditActionState(); +- } +- +- applyPreviewPointerEdit(edit, delta, { live = false } = {}) { +- if (edit.mode === "move-object") { +- this.applyPreviewObjectMove(edit, delta, { live }); +- return; +- } +- +- if (edit.mode === "move") { +- this.updateSelectedShapeTransform("preview move", (shape) => { +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((edit.originalTransform.x + delta.x).toFixed(3)); +- shape.transform.y = Number((edit.originalTransform.y + delta.y).toFixed(3)); +- }, `OK ${live ? "Live " : ""}Dragged shape row ${edit.shapeIndex} by ${delta.x}, ${delta.y}.`, { skipPreviewHistory: true }); +- return; +- } +- +- if (edit.mode === "move-group") { +- this.applyPreviewGroupMove(edit, delta, { live }); +- return; +- } +- +- if (edit.mode === "line-endpoint") { +- this.updateSelectedShapeGeometry("preview line endpoint", (shape) => { +- shape.geometry = this.previewLineEndpointGeometry(shape, edit, delta); +- }, `OK ${live ? "Live " : ""}Moved line ${edit.endpoint} for shape row ${edit.shapeIndex}.`, { skipPreviewHistory: true }); +- return; +- } +- +- if (edit.mode === "geometry-point") { +- this.updateSelectedShapeGeometry("preview point handle", (shape) => { +- shape.geometry = this.previewGeometryPointGeometry(shape, edit, delta); +- }, `OK ${live ? "Live " : ""}Moved geometry point ${edit.control || edit.pointIndex + 1 || "handle"} for shape row ${edit.shapeIndex}.`, { skipPreviewHistory: true }); +- return; +- } +- +- if (edit.mode === "resize") { +- this.updateSelectedShapeGeometry("preview resize", (shape) => { +- shape.geometry = this.previewResizeGeometry(shape, edit, delta); +- }, `OK ${live ? "Live " : ""}Resized shape row ${edit.shapeIndex} with ${edit.handle} handle.`, { skipPreviewHistory: true }); +- } +- } +- +- applyPreviewObjectMove(edit, delta, { live = false } = {}) { +- const object = this.selectedObject(); +- const objectShapes = sortedShapes(object); +- const targetIndexes = Array.from(new Set((edit.targetIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) +- .sort((left, right) => left - right); +- if (!object || object.id !== edit.objectId || !targetIndexes.length) { +- this.statusLog.write("WARN Drag object skipped: selected object is not available."); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const activeFrame = this.activeFrame(); +- try { +- targetIndexes.forEach((shapeIndex) => { +- const originalTransform = edit.originalTransforms?.[shapeIndex]; +- if (!originalTransform) { +- throw new Error(`shape row ${shapeIndex} original transform is not available.`); +- } +- const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); +- const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); +- const shape = override +- ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) +- : baseShape; +- if (!shape) { +- throw new Error(`shape row ${shapeIndex} is not available.`); +- } +- shape.transform = this.ensureShapeTransform({ +- transform: { +- ...originalTransform, +- x: Number((originalTransform.x + delta.x).toFixed(3)), +- y: Number((originalTransform.y + delta.y).toFixed(3)) +- } +- }); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- }); +- } catch (error) { +- this.statusLog.write(`FAIL Invalid object drag rejected for ${object.name}: ${error.message}`); +- return; +- } +- +- this.commitPayloadUpdate(nextPayload, object.id, -1, `OK ${live ? "Live " : ""}Dragged object ${object.name} by ${delta.x}, ${delta.y}.`, "Drag object failed schema validation", { +- directSelectedShapeIndexes: new Set(), +- preserveEmptyShapeSelection: true, +- selectedShapeIndexes: new Set(), +- skipPreviewHistory: true +- }); +- } +- +- applyPreviewGroupMove(edit, delta, { live = false } = {}) { +- const object = this.selectedObject(); +- const objectShapes = sortedShapes(object); +- const targetIndexes = Array.from(new Set((edit.targetIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) +- .sort((left, right) => left - right); +- if (!object || targetIndexes.length < 2) { +- this.statusLog.write("WARN Drag selection skipped: selected shape set is not available."); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const activeFrame = this.activeFrame(); +- try { +- targetIndexes.forEach((shapeIndex) => { +- const originalTransform = edit.originalTransforms?.[shapeIndex]; +- if (!originalTransform) { +- throw new Error(`shape row ${shapeIndex} original transform is not available.`); +- } +- const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); +- const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); +- const shape = override +- ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) +- : baseShape; +- if (!shape) { +- throw new Error(`shape row ${shapeIndex} is not available.`); +- } +- shape.transform = this.ensureShapeTransform({ +- transform: { +- ...originalTransform, +- x: Number((originalTransform.x + delta.x).toFixed(3)), +- y: Number((originalTransform.y + delta.y).toFixed(3)) +- } +- }); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- }); +- } catch (error) { +- this.statusLog.write(`FAIL Invalid drag rejected for ${edit.groupId ? `group ${edit.groupId}` : "selected shapes"}: ${error.message}`); +- return; +- } +- +- const dragSubject = edit.groupId ? `group ${edit.groupId}` : "selected shapes"; +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK ${live ? "Live " : ""}Dragged ${dragSubject} (${targetIndexes.length} shapes) by ${delta.x}, ${delta.y}.`, "Drag selection failed schema validation", { +- directSelectedShapeIndexes: edit.directSelectedShapeIndexes || new Set([edit.shapeIndex]), +- selectedShapeIndexes: new Set(targetIndexes), +- skipPreviewHistory: true +- }); +- } +- +- previewLineEndpointGeometry(shape, edit, delta) { +- const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); +- if (shapeGeometryTool(shape) !== "line") { +- return geometry; +- } +- const movedPoint = this.previewPointerEditLocalTarget(edit); +- if (edit.endpoint === "start") { +- geometry.point1 = movedPoint; +- } else { +- geometry.point2 = movedPoint; +- } +- return geometry; +- } +- +- previewResizeGeometry(shape, edit, delta) { +- const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "rectangle") { +- if (edit.handle.includes("w")) { +- geometry.x = Number((edit.originalGeometry.x + delta.x).toFixed(3)); +- geometry.width = Number((edit.originalGeometry.width - delta.x).toFixed(3)); +- } +- if (edit.handle.includes("e")) { +- geometry.width = Number((edit.originalGeometry.width + delta.x).toFixed(3)); +- } +- if (edit.handle.includes("n")) { +- geometry.y = Number((edit.originalGeometry.y + delta.y).toFixed(3)); +- geometry.height = Number((edit.originalGeometry.height - delta.y).toFixed(3)); +- } +- if (edit.handle.includes("s")) { +- geometry.height = Number((edit.originalGeometry.height + delta.y).toFixed(3)); +- } +- return geometry; +- } +- if (geometryTool === "ellipse") { +- geometry.rx = Number(Math.max(1, edit.originalGeometry.rx + (edit.handle.includes("e") ? delta.x : -delta.x)).toFixed(3)); +- geometry.ry = Number(Math.max(1, edit.originalGeometry.ry + (edit.handle.includes("s") ? delta.y : -delta.y)).toFixed(3)); +- return geometry; +- } +- if (geometryTool === "circle") { +- const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); +- const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); +- const diameter = Math.max(1, Math.min(Math.abs(nextBounds.width), Math.abs(nextBounds.height))); +- geometry.cx = Number((nextBounds.x + nextBounds.width / 2).toFixed(3)); +- geometry.cy = Number((nextBounds.y + nextBounds.height / 2).toFixed(3)); +- geometry.r = Number((diameter / 2).toFixed(3)); +- return geometry; +- } +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); +- const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); +- geometry.points = edit.originalGeometry.points.map((point) => this.mapPointBetweenBounds(point, bounds, nextBounds)); +- return geometry; +- } +- if (geometryTool === "line") { +- const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); +- const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); +- geometry.point1 = this.mapPointBetweenBounds(edit.originalGeometry.point1, bounds, nextBounds); +- geometry.point2 = this.mapPointBetweenBounds(edit.originalGeometry.point2, bounds, nextBounds); +- return geometry; +- } +- if (geometryTool === "arc") { +- const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); +- const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); +- geometry.cx = Number((nextBounds.x + nextBounds.width / 2).toFixed(3)); +- geometry.cy = Number((nextBounds.y + nextBounds.height / 2).toFixed(3)); +- geometry.r = Number((Math.max(1, Math.min(nextBounds.width, nextBounds.height) / 2)).toFixed(3)); +- return geometry; +- } +- if (geometryTool === "text") { +- const bounds = shapeBounds({ ...shape, geometry: edit.originalGeometry }); +- const nextBounds = this.resizedGeometryBounds(bounds, edit.handle, delta); +- const anchor = this.mapPointBetweenBounds({ x: edit.originalGeometry.x, y: edit.originalGeometry.y }, bounds, nextBounds); +- const scaleY = nextBounds.height / Math.max(1, bounds.height); +- geometry.x = anchor.x; +- geometry.y = anchor.y; +- geometry.fontSize = Number(Math.max(1, edit.originalGeometry.fontSize * scaleY).toFixed(3)); +- return geometry; +- } +- return geometry; +- } +- +- resizedGeometryBounds(bounds, handle, delta) { +- const next = { ...bounds }; +- if (handle.includes("w")) { +- next.x = Number((bounds.x + delta.x).toFixed(3)); +- next.width = Number((bounds.width - delta.x).toFixed(3)); +- } +- if (handle.includes("e")) { +- next.width = Number((bounds.width + delta.x).toFixed(3)); +- } +- if (handle.includes("n")) { +- next.y = Number((bounds.y + delta.y).toFixed(3)); +- next.height = Number((bounds.height - delta.y).toFixed(3)); +- } +- if (handle.includes("s")) { +- next.height = Number((bounds.height + delta.y).toFixed(3)); +- } +- if (next.width <= 0 || next.height <= 0) { +- throw new Error("resize would invert or collapse geometry bounds."); +- } +- return next; +- } +- +- mapPointBetweenBounds(point, bounds, nextBounds) { +- const xRatio = bounds.width <= 0 ? 0 : (point.x - bounds.x) / bounds.width; +- const yRatio = bounds.height <= 0 ? 0 : (point.y - bounds.y) / bounds.height; +- return { +- x: Number((nextBounds.x + xRatio * nextBounds.width).toFixed(3)), +- y: Number((nextBounds.y + yRatio * nextBounds.height).toFixed(3)) +- }; +- } +- +- previewGeometryPointGeometry(shape, edit, delta) { +- const geometry = JSON.parse(JSON.stringify(edit.originalGeometry)); +- const geometryTool = shapeGeometryTool(shape); +- const movedPoint = this.previewPointerEditLocalTarget(edit); +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- const pointIndex = normalizeShapeIndex(edit.pointIndex); +- if (pointIndex >= 0 && pointIndex < geometry.points.length) { +- geometry.points[pointIndex] = movedPoint; +- } +- return geometry; +- } +- if (geometryTool === "circle") { +- if (edit.control === "center") { +- geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); +- geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); +- return geometry; +- } +- if (edit.control === "radius") { +- geometry.r = this.radiusFromControlPoint({ x: edit.originalGeometry.cx, y: edit.originalGeometry.cy }, movedPoint); +- } +- return geometry; +- } +- if (geometryTool === "ellipse") { +- if (edit.control === "center") { +- geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); +- geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); +- return geometry; +- } +- if (edit.control === "rx") { +- geometry.rx = this.axisRadiusFromControlPoint(edit.originalGeometry.cx, movedPoint.x); +- } +- if (edit.control === "ry") { +- geometry.ry = this.axisRadiusFromControlPoint(edit.originalGeometry.cy, movedPoint.y); +- } +- return geometry; +- } +- if (geometryTool === "arc") { +- if (edit.control === "center") { +- geometry.cx = Number((edit.originalGeometry.cx + delta.x).toFixed(3)); +- geometry.cy = Number((edit.originalGeometry.cy + delta.y).toFixed(3)); +- return geometry; +- } +- if (edit.control === "start" || edit.control === "end") { +- const center = { x: edit.originalGeometry.cx, y: edit.originalGeometry.cy }; +- geometry.r = this.radiusFromControlPoint(center, movedPoint); +- geometry[edit.control === "start" ? "startAngle" : "endAngle"] = this.angleFromControlPoint(center, movedPoint); +- } +- return geometry; +- } +- if (geometryTool === "text") { +- geometry.x = movedPoint.x; +- geometry.y = movedPoint.y; +- return geometry; +- } +- return geometry; +- } +- +- radiusFromControlPoint(center, point) { +- return Number(Math.max(1, Math.hypot(point.x - center.x, point.y - center.y)).toFixed(3)); +- } +- +- axisRadiusFromControlPoint(centerValue, pointValue) { +- return Number(Math.max(1, Math.abs(pointValue - centerValue)).toFixed(3)); +- } +- +- angleFromControlPoint(center, point) { +- const degrees = (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI + 90; +- return this.normalizeRotationDegrees(degrees); +- } +- +- selectObject(objectId, sourceLabel) { +- if (!this.currentPayload?.objects.some((object) => object.id === objectId)) { +- this.statusLog.write(`WARN Select object skipped: object id ${objectId || "unknown"} is not available.`); +- return; +- } +- +- const scrollState = this.captureLeftPanelScrollState(); +- this.selectedObjectId = objectId; +- this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", "1"); +- const selectedObject = this.selectedObject(); +- this.selectedShapeIndex = sortedShapes(selectedObject).length ? 0 : -1; +- this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- const selectedState = this.objectStates(selectedObject)[0] || null; +- this.selectedStateId = selectedState?.id || ""; +- this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; +- this.selectedFrameId = selectedState ? sortedFrames(selectedState)[0]?.id || "" : ""; +- this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); +- this.setPaletteTarget("stroke", false); +- this.renderPayload(); +- this.restoreLeftPanelScrollState(scrollState); +- const selected = this.selectedObject(); +- this.statusLog.write(`OK Selected object from ${sourceLabel}: ${selected.name}.`); +- } +- +- selectShape(shapeIndex, sourceLabel, options = {}) { +- const object = this.selectedObject(); +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- const shapes = sortedShapes(object); +- if (normalizedIndex < 0 || normalizedIndex >= shapes.length) { +- this.statusLog.write(`WARN Select shape skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); +- return; +- } +- +- const scrollState = this.captureLeftPanelScrollState(); +- this.selectedShapeIndex = normalizedIndex; +- if (options.additive) { +- if (this.selectedShapeIndexes.has(normalizedIndex) && this.selectedShapeIndexes.size > 1) { +- this.selectedShapeIndexes.delete(normalizedIndex); +- this.directSelectedShapeIndexes = new Set(this.selectedShapeIndexes); +- this.selectedShapeIndex = Array.from(this.selectedShapeIndexes).at(-1) ?? -1; +- } else { +- this.selectedShapeIndexes.add(normalizedIndex); +- this.directSelectedShapeIndexes = new Set(this.selectedShapeIndexes); +- } +- } else { +- this.selectedShapeIndexes = new Set([normalizedIndex]); +- this.directSelectedShapeIndexes = new Set([normalizedIndex]); +- } +- this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); +- this.setPaletteTarget("stroke", false); +- this.renderPayload(); +- this.restoreLeftPanelScrollState(scrollState); +- const shape = this.selectedShape(); +- this.statusLog.write(`OK Selected shape from ${sourceLabel}: row ${this.selectedShapeIndex} (${shapeTool(shape)}). Multi-select count: ${this.selectedShapeIndexes.size}.`); +- } +- +- selectShapeGroup(groupId, shapeIndex, sourceLabel = "shape group") { +- const object = this.selectedObject(); +- const shapes = sortedShapes(object); +- const normalizedGroupId = String(groupId || "").trim(); +- if (!normalizedGroupId) { +- this.statusLog.write("WARN Select group skipped: shape group id is not available."); +- return; +- } +- const groupIndexes = shapes +- .map((shape, index) => String(shape?.groupId || "").trim() === normalizedGroupId ? index : -1) +- .filter((index) => index >= 0); +- if (groupIndexes.length < 2) { +- this.statusLog.write(`WARN Select group skipped: group ${normalizedGroupId} has fewer than two shapes.`); +- return; +- } +- +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- const nextSelectedIndex = groupIndexes.includes(normalizedIndex) ? normalizedIndex : groupIndexes[0]; +- const scrollState = this.captureLeftPanelScrollState(); +- this.selectedShapeIndex = nextSelectedIndex; +- this.selectedShapeIndexes = new Set(groupIndexes); +- this.directSelectedShapeIndexes = new Set(groupIndexes); +- this.syncPaletteSelectionFromCurrentShape({ logMissing: true }); +- this.setPaletteTarget("stroke", false); +- this.renderPayload(); +- this.restoreLeftPanelScrollState(scrollState); +- this.statusLog.write(`OK Selected group ${normalizedGroupId} from ${sourceLabel}: ${groupIndexes.length} shapes.`); +- } +- +- shapeSelectionGroupIndexes(shapes, shapeIndex) { +- const shape = shapes[shapeIndex]; +- const groupId = String(shape?.groupId || "").trim(); +- if (!groupId) { +- return [shapeIndex]; +- } +- return shapes +- .map((candidate, index) => String(candidate?.groupId || "").trim() === groupId ? index : -1) +- .filter((index) => index >= 0); +- } +- +- shapeBelongsToValidGroup(object, shapeIndex) { +- const shapes = sortedShapes(object); +- const shape = shapes[normalizeShapeIndex(shapeIndex)]; +- const groupId = String(shape?.groupId || "").trim(); +- if (!groupId) { +- return false; +- } +- return shapes.filter((candidate) => String(candidate?.groupId || "").trim() === groupId).length >= 2; +- } +- +- selectState(stateId, sourceLabel) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN State selection skipped: no object is selected."); +- return; +- } +- if (!stateId) { +- this.selectedStateId = ""; +- this.stateControlStateId = ""; +- this.selectedFrameId = ""; +- this.stopPlaybackTimer(); +- this.renderPayload(); +- this.statusLog.write(`OK State selection cleared from ${sourceLabel}.`); +- return; +- } +- const state = this.objectStates(object).find((candidate) => candidate.id === stateId); +- if (!state) { +- this.statusLog.write(`WARN State selection pending: ${stateId} is not created for ${object.name}. Use Add to create it.`); +- this.selectedStateId = ""; +- this.stateControlStateId = stateId; +- this.selectedFrameId = ""; +- this.renderFrameTimeline(); +- this.updateAnimationActionState(); +- return; +- } +- this.selectedStateId = state.id; +- this.stateControlStateId = state.id; +- this.selectedFrameId = sortedFrames(state)[0]?.id || ""; +- this.stopPlaybackTimer(); +- this.renderPayload(); +- this.statusLog.write(`OK Selected state ${OBJECT_STATE_LABELS[state.id] || state.id} from ${sourceLabel}; active object remains ${object.name}.`); +- } +- +- selectFrame(frameId, sourceLabel) { +- const state = this.selectedState(); +- if (!state) { +- this.statusLog.write("WARN Frame selection skipped: no state is selected."); +- return; +- } +- const frame = sortedFrames(state).find((candidate) => candidate.id === frameId); +- if (!frame) { +- this.statusLog.write(`WARN Frame selection skipped: frame id ${frameId || "unknown"} is not available.`); +- return; +- } +- this.selectedFrameId = frame.id; +- this.renderPayload(); +- this.statusLog.write(`OK Selected frame ${frame.id} from ${sourceLabel}.`); +- } +- +- createSelectedState(stateId = this.stateControlSelectionId(this.selectedObject())) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Create state skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Create state")) { +- return; +- } +- if (!OBJECT_STATE_IDS.includes(stateId)) { +- this.statusLog.write(`FAIL Create state blocked: choose ${OBJECT_STATE_IDS.join(", ")}.`); +- return; +- } +- if (this.objectStates(object).some((state) => state.id === stateId)) { +- this.statusLog.write(`WARN Create state skipped: ${OBJECT_STATE_LABELS[stateId] || stateId} already exists for ${object.name}.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- nextObject.states = this.objectStates(nextObject); +- const frame = this.createFrameSnapshot(nextObject, stateId, "frame-1", 1); +- nextObject.states.push({ +- frames: [frame], +- id: stateId, +- name: OBJECT_STATE_LABELS[stateId] || stateId +- }); +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Created state ${OBJECT_STATE_LABELS[stateId] || stateId} with frame ${frame.id}.`, "Create state failed schema validation", { +- selectedFrameId: frame.id, +- selectedStateId: stateId +- }); +- } +- +- deleteSelectedState(stateId = this.selectedStateId) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Delete state skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Delete state")) { +- return; +- } +- const states = this.objectStates(object); +- const stateIndex = states.findIndex((state) => state.id === stateId); +- if (stateIndex < 0) { +- this.statusLog.write(`WARN Delete state skipped: ${stateId || "unknown"} is not created for ${object.name}.`); +- return; +- } +- if (states.length <= 1) { +- this.statusLog.write(`WARN Delete state skipped: ${stateId} is the only state for ${object.name}.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- nextObject.states = this.objectStates(nextObject).filter((state) => state.id !== stateId); +- const nextState = nextObject.states[Math.min(stateIndex, nextObject.states.length - 1)] || nextObject.states[0] || null; +- const nextFrame = sortedFrames(nextState)[0] || null; +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted state ${stateId} from ${object.name}.`, "Delete state failed schema validation", { +- selectedFrameId: nextFrame?.id || "", +- selectedStateId: nextState?.id || "" +- }); +- } +- +- duplicateSelectedFrame() { +- const object = this.selectedObject(); +- const state = this.selectedState(); +- const frame = this.activeFrame(); +- if (!object || !state || !frame) { +- this.statusLog.write("WARN Duplicate frame skipped: select an object state and frame first."); +- return; +- } +- if (this.guardSelectedObjectMutation("Duplicate frame")) { +- return; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); +- const frameCopy = JSON.parse(JSON.stringify(frame)); +- frameCopy.id = this.uniqueFrameId(nextState); +- frameCopy.order = sortedFrames(nextState).length + 1; +- nextState.frames.push(frameCopy); +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Duplicated frame ${frame.id} as ${frameCopy.id}.`, "Duplicate frame failed schema validation", { +- selectedFrameId: frameCopy.id, +- selectedStateId: state.id +- }); +- } +- +- deleteSelectedFrame() { +- const object = this.selectedObject(); +- const state = this.selectedState(); +- const frame = this.activeFrame(); +- if (!object || !state || !frame) { +- this.statusLog.write("WARN Delete frame skipped: select an object state and frame first."); +- return; +- } +- if (this.guardSelectedObjectMutation("Delete frame")) { +- return; +- } +- const frames = sortedFrames(state); +- if (frames.length <= 1) { +- this.statusLog.write(`WARN Delete frame skipped: frame ${frame.id} is the only frame in ${OBJECT_STATE_LABELS[state.id] || state.id}.`); +- return; +- } +- const index = frames.findIndex((candidate) => candidate.id === frame.id); +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); +- const nextFrames = sortedFrames(nextState).filter((candidate) => candidate.id !== frame.id); +- nextFrames.forEach((candidate, frameIndex) => { +- candidate.order = frameIndex + 1; +- }); +- nextState.frames = nextFrames; +- const nextSelectedFrame = nextFrames[Math.min(index, nextFrames.length - 1)] || nextFrames[0]; +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted frame ${frame.id} from ${OBJECT_STATE_LABELS[state.id] || state.id}.`, "Delete frame failed schema validation", { +- selectedFrameId: nextSelectedFrame.id, +- selectedStateId: state.id +- }); +- } +- +- moveSelectedFrame(direction, controlLabel = direction) { +- const object = this.selectedObject(); +- const state = this.selectedState(); +- const frame = this.activeFrame(); +- if (!object || !state || !frame) { +- this.statusLog.write(`WARN Move frame ${controlLabel} skipped: select an object state and frame first.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Move frame")) { +- return; +- } +- const frames = sortedFrames(state); +- const index = frames.findIndex((candidate) => candidate.id === frame.id); +- const nextIndex = direction === "earlier" ? index - 1 : index + 1; +- if (nextIndex < 0 || nextIndex >= frames.length) { +- this.statusLog.write(`WARN Move frame ${controlLabel} skipped: frame ${frame.id} cannot move ${controlLabel}.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const nextState = this.objectStates(nextObject).find((candidate) => candidate.id === state.id); +- const nextFrames = sortedFrames(nextState); +- const [movedFrame] = nextFrames.splice(index, 1); +- nextFrames.splice(nextIndex, 0, movedFrame); +- nextFrames.forEach((candidate, frameIndex) => { +- candidate.order = frameIndex + 1; +- }); +- nextState.frames = nextFrames; +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Moved frame ${frame.id} ${controlLabel}.`, "Move frame failed schema validation", { +- selectedFrameId: frame.id, +- selectedStateId: state.id +- }); +- } +- +- playAnimation() { +- const state = this.selectedState(); +- const frames = sortedFrames(state); +- if (!state || !frames.length) { +- this.statusLog.write("FAIL Playback blocked: create and select an animation state with at least one frame."); +- return; +- } +- const fps = Number(this.elements.fpsInput.value); +- if (!Number.isInteger(fps) || fps < 1 || fps > 60) { +- this.statusLog.write("FAIL Playback blocked: FPS must be an integer from 1 to 60."); +- return; +- } +- this.stopPlaybackTimer(false); +- this.isAnimationPlaying = true; +- this.updateAnimationActionState(); +- this.playbackTimerId = this.window.setInterval(() => this.advancePlaybackFrame(), Math.round(1000 / fps)); +- this.statusLog.write(`OK Playback started for state ${OBJECT_STATE_LABELS[state.id] || state.id} at ${fps} FPS.`); +- } +- +- pauseAnimation() { +- if (!this.isAnimationPlaying) { +- this.statusLog.write("WARN Playback pause skipped: animation is not playing."); +- return; +- } +- this.stopPlaybackTimer(false); +- this.statusLog.write(`OK Playback paused at frame ${this.selectedFrameId || "none"}.`); +- } +- +- stopAnimation() { +- const state = this.selectedState(); +- this.stopPlaybackTimer(false); +- if (state) { +- this.selectedFrameId = sortedFrames(state)[0]?.id || ""; +- this.renderPayload(); +- } +- this.statusLog.write("OK Playback stopped."); +- } +- +- stopPlaybackTimer(shouldRender = true) { +- if (this.playbackTimerId) { +- this.window.clearInterval(this.playbackTimerId); +- this.playbackTimerId = 0; +- } +- this.isAnimationPlaying = false; +- if (shouldRender) { +- this.updateAnimationActionState(); +- } +- } +- +- advancePlaybackFrame() { +- const state = this.selectedState(); +- const frames = sortedFrames(state); +- if (!state || !frames.length) { +- this.stopPlaybackTimer(); +- this.statusLog.write("FAIL Playback stopped: selected state or frame timeline is missing."); +- return; +- } +- const index = Math.max(0, frames.findIndex((frame) => frame.id === this.selectedFrameId)); +- const nextFrame = frames[index + 1]; +- if (nextFrame) { +- this.selectedFrameId = nextFrame.id; +- this.renderPayload(); +- return; +- } +- if (this.elements.loopToggle.checked) { +- this.selectedFrameId = frames[0].id; +- this.renderPayload(); +- return; +- } +- this.stopPlaybackTimer(); +- this.statusLog.write(`OK Playback completed for state ${OBJECT_STATE_LABELS[state.id] || state.id}.`); +- } +- +- addObject() { +- const name = this.elements.objectNameInput.value.trim(); +- this.pendingAddObjectClick = false; +- if (!this.currentPayload) { +- this.statusLog.write("FAIL Add object blocked: load a schema-valid Object Vector Studio V2 payload before adding objects."); +- return; +- } +- if (!name) { +- this.statusLog.write("FAIL Add object blocked: enter an object name."); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const id = this.uniqueObjectId(name, nextPayload.objects); +- let objectDefault; +- try { +- objectDefault = this.schemaDefault("object"); +- } catch (error) { +- this.statusLog.write(`FAIL Add object blocked: ${error.message}`); +- return; +- } +- nextPayload.objects.push({ +- ...objectDefault, +- id, +- name, +- tags: tagList(this.elements.objectTagInput.value) +- }); +- this.commitPayloadUpdate(nextPayload, id, "", `OK Added object ${name} with object/game/name id ${id}.`, "Add object failed schema validation"); +- } +- +- addTagToSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Tag add skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Tag add")) { +- return; +- } +- const tags = tagList(this.elements.objectTagInput.value); +- if (!tags.length) { +- this.statusLog.write("FAIL Tag add blocked: enter a tag name."); +- return; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((object) => object.id === selected.id); +- const nextTags = tagList(nextObject.tags); +- const addedTags = []; +- tags.forEach((tag) => { +- const nextTag = this.uniqueTag(tag, nextTags); +- nextTags.push(nextTag); +- addedTags.push(nextTag); +- }); +- nextObject.tags = nextTags.sort(); +- this.elements.objectTagInput.value = ""; +- this.commitPayloadUpdate(nextPayload, selected.id, this.selectedShapeIndex, `OK Added tag ${addedTags.join(", ")} to ${selected.name}.`, "Object tag update failed schema validation"); +- } +- +- removeTagFromSelectedObject(tag) { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Tag remove skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Tag remove")) { +- return; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((object) => object.id === selected.id); +- nextObject.tags = tagList(nextObject.tags).filter((candidate) => candidate !== tag); +- this.commitPayloadUpdate(nextPayload, selected.id, this.selectedShapeIndex, `OK Removed tag ${tag} from ${selected.name}.`, "Object tag update failed schema validation"); +- } +- +- renameSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Rename object skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Rename object")) { +- return; +- } +- +- const name = this.elements.objectNameInput.value.trim(); +- if (!name) { +- this.statusLog.write("FAIL Rename object blocked: enter an object name."); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((object) => object.id === selected.id); +- const oldId = nextObject.id; +- const siblingObjects = nextPayload.objects.filter((object) => object.id !== oldId); +- const nextId = this.uniqueObjectId(name, siblingObjects, objectGameSegment(oldId) || this.payloadGameKey()); +- nextPayload.objects.forEach((object) => { +- if (object.baseObjectId === oldId) { +- object.baseObjectId = nextId; +- } +- }); +- nextObject.id = nextId; +- nextObject.name = name; +- this.commitPayloadUpdate(nextPayload, nextId, this.selectedShapeIndex, `OK Renamed object ${oldId} to ${name} and updated object/game/name id to ${nextId}.`, "Rename object failed schema validation"); +- } +- +- duplicateSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Duplicate object skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Duplicate object")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const objectCopy = JSON.parse(JSON.stringify(selected)); +- objectCopy.id = this.uniqueObjectId(`${selected.name} Copy`, nextPayload.objects, objectGameSegment(selected.id) || this.payloadGameKey()); +- objectCopy.name = `${selected.name} Copy`; +- nextPayload.objects.push(objectCopy); +- const selectedShapeIndex = sortedShapes(objectCopy).length ? 0 : -1; +- this.commitPayloadUpdate(nextPayload, objectCopy.id, selectedShapeIndex, `OK Duplicated object ${selected.name} as ${objectCopy.name}.`, "Duplicate object failed schema validation"); +- } +- +- deleteSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Delete object skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Delete object")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- nextPayload.objects = nextPayload.objects.filter((object) => object.id !== selected.id); +- this.removeDeletedObjectReferences(nextPayload, selected.id); +- this.removeDanglingShapeOverrideReferences(nextPayload); +- const selectedObjectId = nextPayload.objects[0]?.id || ""; +- const selectedShapeIndex = nextPayload.objects[0] && sortedShapes(nextPayload.objects[0]).length ? 0 : -1; +- this.commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, `OK Deleted object ${selected.name}.`, "Delete object failed schema validation"); +- } +- +- deleteObjectById(objectId, sourceLabel) { +- const selected = this.currentPayload?.objects.find((object) => object.id === objectId); +- if (!selected) { +- this.statusLog.write(`WARN Delete object skipped: object id ${objectId || "unknown"} is not available.`); +- return; +- } +- if (this.isObjectLocked(selected.id)) { +- this.statusLog.write(`WARN Delete object blocked: object ${selected.name} is locked for this runtime session.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- nextPayload.objects = nextPayload.objects.filter((object) => object.id !== selected.id); +- this.removeDeletedObjectReferences(nextPayload, selected.id); +- this.removeDanglingShapeOverrideReferences(nextPayload); +- const selectedObjectStillExists = nextPayload.objects.some((object) => object.id === this.selectedObjectId); +- const nextSelectedObject = selectedObjectStillExists +- ? nextPayload.objects.find((object) => object.id === this.selectedObjectId) +- : nextPayload.objects[0] || null; +- const selectedObjectId = nextSelectedObject?.id || ""; +- const selectedShapeStillExists = Number.isInteger(this.selectedShapeIndex) && this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(nextSelectedObject).length; +- const selectedShapeIndex = selectedShapeStillExists ? this.selectedShapeIndex : nextSelectedObject && sortedShapes(nextSelectedObject).length ? 0 : -1; +- this.hiddenObjectIds.delete(selected.id); +- this.lockedObjectIds.delete(selected.id); +- this.commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, `OK Deleted object ${selected.name} from ${sourceLabel}.`, "Delete object failed schema validation"); +- } +- +- removeDeletedObjectReferences(payload, objectId) { +- payload.objects.forEach((object) => { +- if (object.baseObjectId === objectId) { +- delete object.baseObjectId; +- } +- }); +- this.removeDirectReferenceEntries(payload, "objectId", objectId); +- } +- +- removeDirectReferenceEntries(value, referenceKey, targetId) { +- if (!value || typeof value !== "object") { +- return; +- } +- if (Array.isArray(value)) { +- for (let index = value.length - 1; index >= 0; index -= 1) { +- const item = value[index]; +- if (item && typeof item === "object" && item[referenceKey] === targetId) { +- value.splice(index, 1); +- } else { +- this.removeDirectReferenceEntries(item, referenceKey, targetId); +- } +- } +- return; +- } +- Object.entries(value).forEach(([key, child]) => { +- if (key === referenceKey && child === targetId) { +- delete value[key]; +- return; +- } +- this.removeDirectReferenceEntries(child, referenceKey, targetId); +- }); +- } +- +- removeDanglingShapeOverrideReferences(payload) { +- const objectsById = new Map(payload.objects.map((object) => [object.id, object])); +- const collectShapeCount = (object, seen = new Set()) => { +- if (!object || seen.has(object.id)) { +- return 0; +- } +- seen.add(object.id); +- const shapeByOrder = object.baseObjectId +- ? new Map(collectShapeRows(objectsById.get(object.baseObjectId), seen)) +- : new Map(); +- object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); +- return shapeByOrder.size; +- }; +- const collectShapeRows = (object, seen = new Set()) => { +- if (!object || seen.has(object.id)) { +- return []; +- } +- seen.add(object.id); +- const shapeByOrder = object.baseObjectId +- ? new Map(collectShapeRows(objectsById.get(object.baseObjectId), seen)) +- : new Map(); +- object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); +- return Array.from(shapeByOrder.entries()); +- }; +- payload.objects.forEach((object) => { +- const shapeCount = collectShapeCount(object); +- this.objectStates(object).forEach((state) => { +- state.frames.forEach((frame) => { +- frame.shapeOverrides = frame.shapeOverrides.filter((override) => override.shapeIndex < shapeCount); +- }); +- }); +- }); +- } +- +- flattenSelectedObject() { +- const selected = this.selectedObject(); +- if (!selected) { +- this.statusLog.write("WARN Flatten object skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Flatten object")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((object) => object.id === selected.id); +- if (!nextObject.shapes.length) { +- this.statusLog.write(`WARN Flatten object skipped: ${selected.name} has no shapes to flatten.`); +- return; +- } +- const rotatedShape = sortedShapes(nextObject).find((shape) => this.shapeTransform(shape).rotation !== 0); +- if (rotatedShape) { +- this.statusLog.write(`FAIL Flatten object blocked: shape ${rotatedShape.id} has rotation ${this.shapeTransform(rotatedShape).rotation}; rotate-safe flatten is not available in this schema pass.`); +- return; +- } +- let flattenedCount = 0; +- const origin = this.objectTransformOrigin(nextObject); +- nextObject.shapes = sortedShapes(nextObject).map((shape, index) => { +- const flattened = this.flattenShapeTransform(shape, origin); +- flattened.order = index + 1; +- flattenedCount += 1; +- return flattened; +- }); +- const selectedShapeIndex = sortedShapes(nextObject).length ? 0 : -1; +- this.commitPayloadUpdate(nextPayload, selected.id, selectedShapeIndex, `OK Flattened object ${selected.name}: baked transforms into ${flattenedCount} shapes.`, "Flatten object failed schema validation"); +- } +- +- schemaDefault(definitionName) { +- if (typeof this.schemaService.getDefinitionDefault !== "function") { +- throw new Error("Object Vector Studio V2 schema default reader is unavailable."); +- } +- return this.schemaService.getDefinitionDefault(definitionName); +- } +- +- createShapeStyleDefault(type, color, styleOverride = null) { +- const { pointStyle, startPointStyle, endPointStyle, strokeLinecap, ...style } = this.schemaDefault("style"); +- const strokeColor = styleOverride?.stroke || this.selectedStrokeColor || color; +- return { +- ...style, +- ...(styleOverride || {}), +- fill: TRANSPARENT_STYLE_COLOR, +- fillOpacity: styleOverride?.fillOpacity ?? this.selectedFillOpacity, +- stroke: strokeColor, +- strokeOpacity: styleOverride?.strokeOpacity ?? this.selectedStrokeOpacity, +- strokeWidth: styleOverride?.strokeWidth ?? style.strokeWidth +- }; +- } +- +- createShapeTransformDefault() { +- const transform = this.schemaDefault("transform"); +- return { +- ...defaultShapeTransform(), +- ...transform +- }; +- } +- +- createShape(type, options = {}) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write(`FAIL Create ${type} blocked: select a schema-valid object first.`); +- return; +- } +- if (this.guardSelectedObjectMutation(`Create ${type}`)) { +- return; +- } +- +- const color = this.firstPaletteColor(); +- if (!color) { +- this.statusLog.write(`FAIL Create ${type} blocked: palette swatches do not provide a usable color value.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const order = nextObject.shapes.length ? Math.max(...nextObject.shapes.map((shape) => shape.order)) + 1 : 1; +- let shape; +- try { +- shape = this.createPrimitiveShape(type, order, color, options.geometry, options.style); +- } catch (error) { +- this.statusLog.write(`FAIL Create ${type} blocked: ${error.message}`); +- return; +- } +- nextObject.shapes.push(shape); +- this.activeDrawing = PRIMITIVE_TOOLS.includes(type) ? this.createDrawingState(type) : null; +- this.drawingPreviewPoint = null; +- this.commitPayloadUpdate(nextPayload, nextObject.id, sortedShapes(nextObject).length - 1, `OK Created ${type} shape on ${nextObject.name} from ${options.sourceLabel || "canvas drawing"}.`, `Create ${type} failed schema validation`); +- } +- +- createPrimitiveShape(type, order, color, geometry = null, styleOverride = null) { +- if (!PRIMITIVE_TOOLS.includes(type)) { +- throw new Error(`unsupported shape tool ${type}.`); +- } +- if (!geometry) { +- throw new Error(`${shapeTypeLabel(type)} requires committed canvas placement geometry.`); +- } +- +- const base = this.schemaDefault("shapeCommon"); +- const shape = { +- ...base, +- geometry: JSON.parse(JSON.stringify(geometry)), +- order, +- style: this.createShapeStyleDefault(type, color, styleOverride), +- tool: type, +- visible: base.visible +- }; +- if (type === "square") { +- const size = Math.min(shape.geometry.width, shape.geometry.height); +- shape.geometry.width = size; +- shape.geometry.height = size; +- } +- shape.transform = this.createShapeTransformDefault(); +- return shape; +- } +- +- flattenShapeTransform(shape, origin = { x: 0, y: 0 }) { +- const nextShape = JSON.parse(JSON.stringify(shape)); +- const transform = this.shapeTransform(nextShape); +- const applyX = (value) => Number((transform.x + origin.x + (value - origin.x) * transform.scaleX).toFixed(3)); +- const applyY = (value) => Number((transform.y + origin.y + (value - origin.y) * transform.scaleY).toFixed(3)); +- const geometryTool = shapeGeometryTool(nextShape); +- if (geometryTool === "rectangle") { +- nextShape.geometry.x = applyX(nextShape.geometry.x); +- nextShape.geometry.y = applyY(nextShape.geometry.y); +- nextShape.geometry.width = Number((nextShape.geometry.width * transform.scaleX).toFixed(3)); +- nextShape.geometry.height = Number((nextShape.geometry.height * transform.scaleY).toFixed(3)); +- } else if (geometryTool === "circle") { +- nextShape.geometry.cx = applyX(nextShape.geometry.cx); +- nextShape.geometry.cy = applyY(nextShape.geometry.cy); +- nextShape.geometry.r = Number((nextShape.geometry.r * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); +- } else if (geometryTool === "ellipse") { +- nextShape.geometry.cx = applyX(nextShape.geometry.cx); +- nextShape.geometry.cy = applyY(nextShape.geometry.cy); +- nextShape.geometry.rx = Number((nextShape.geometry.rx * transform.scaleX).toFixed(3)); +- nextShape.geometry.ry = Number((nextShape.geometry.ry * transform.scaleY).toFixed(3)); +- } else if (geometryTool === "line") { +- nextShape.geometry.point1.x = applyX(nextShape.geometry.point1.x); +- nextShape.geometry.point1.y = applyY(nextShape.geometry.point1.y); +- nextShape.geometry.point2.x = applyX(nextShape.geometry.point2.x); +- nextShape.geometry.point2.y = applyY(nextShape.geometry.point2.y); +- } else if (geometryTool === "polygon" || geometryTool === "polyline") { +- nextShape.geometry.points = nextShape.geometry.points.map((point) => ({ +- x: applyX(point.x), +- y: applyY(point.y) +- })); +- } else if (geometryTool === "arc") { +- nextShape.geometry.cx = applyX(nextShape.geometry.cx); +- nextShape.geometry.cy = applyY(nextShape.geometry.cy); +- nextShape.geometry.r = Number((nextShape.geometry.r * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); +- } else if (geometryTool === "text") { +- nextShape.geometry.x = applyX(nextShape.geometry.x); +- nextShape.geometry.y = applyY(nextShape.geometry.y); +- nextShape.geometry.fontSize = Number((nextShape.geometry.fontSize * Math.max(transform.scaleX, transform.scaleY)).toFixed(3)); +- } +- nextShape.transform = defaultShapeTransform(); +- return nextShape; +- } +- +- currentTargetColor() { +- return this.paletteTarget === "stroke" ? this.selectedStrokeColor : this.selectedFillColor; +- } +- +- paletteSwatchForColor(color) { +- const normalized = String(color || "").trim().toLowerCase(); +- if (!normalized || !this.runtimePalette?.swatches?.length) { +- return null; +- } +- return this.runtimePalette.swatches.find((swatch) => String(swatchColor(swatch)).trim().toLowerCase() === normalized) || null; +- } +- +- directStyleColorLabel(color) { +- const normalized = String(color || "").trim().toLowerCase(); +- if (normalized === TRANSPARENT_STYLE_COLOR || normalized === "transparent") { +- return "transparent"; +- } +- if (normalized === "none") { +- return "none"; +- } +- return ""; +- } +- +- paletteLabelForColor(color, fallbackLabel) { +- const swatch = this.paletteSwatchForColor(color); +- return swatch?.name || swatch?.id || swatch?.symbol || this.directStyleColorLabel(color) || fallbackLabel; +- } +- +- syncPaletteSelectionFromCurrentShape(options = {}) { +- this.syncPaletteSelectionFromShape(this.selectedShape(), options); +- } +- +- syncPaletteSelectionFromShape(shape, options = {}) { +- if (!shape || !this.runtimePalette) { +- return; +- } +- const { logMissing = false, render = true } = options; +- const effectiveShape = this.effectiveShape(shape); +- let changed = false; +- const syncTarget = (target, color) => { +- const normalizedColor = String(color || "").trim(); +- if (!normalizedColor || normalizedColor === "none" || this.directStyleColorLabel(normalizedColor)) { +- return; +- } +- const swatch = this.paletteSwatchForColor(normalizedColor); +- if (!swatch) { +- if (logMissing) { +- this.statusLog.write(`WARN Palette sync skipped for selected ${shapeTool(shape)} ${target} color ${normalizedColor}: color is not in the loaded palette.`); +- } +- return; +- } +- const label = swatch.name || swatch.id || swatch.symbol || shapeDisplayLabel(shape); +- const paletteColor = swatchColor(swatch) || normalizedColor; +- if (target === "stroke") { +- changed = changed || this.selectedStrokeColor !== paletteColor || this.selectedStrokeLabel !== label; +- this.selectedStrokeColor = paletteColor; +- this.selectedStrokeLabel = label; +- return; +- } +- changed = changed || this.selectedFillColor !== paletteColor || this.selectedFillLabel !== label; +- this.selectedFillColor = paletteColor; +- this.selectedFillLabel = label; +- }; +- syncTarget("paint", effectiveShape.style?.fill); +- syncTarget("stroke", effectiveShape.style?.stroke); +- if (changed && render) { +- this.renderPalette(); +- } +- } +- +- opacityInputValue(target) { +- const element = target === "stroke" ? this.elements.strokeOpacity : this.elements.fillOpacity; +- const rawValue = element?.value?.trim() || ""; +- const value = Number(rawValue); +- if (!Number.isInteger(value) || value < 0 || value > 255) { +- const error = `${target === "stroke" ? "Stroke" : "Fill"} opacity must be a whole number between 0 and 255.`; +- this.markInputInvalid(element, error); +- return { ok: false, error }; +- } +- this.clearInputValidity(element); +- return { ok: true, value: this.opacityFromInputByte(value) }; +- } +- +- opacityFromInputByte(value) { +- return Number((value / 255).toFixed(3)); +- } +- +- opacityInputDisplayValue(value) { +- if (!Number.isFinite(value)) { +- return "255"; +- } +- const clamped = Math.min(1, Math.max(0, value)); +- return String(Math.round(clamped * 255)); +- } +- +- changePaletteOpacity(target) { +- const normalizedTarget = target === "stroke" ? "stroke" : "fill"; +- const opacity = this.opacityInputValue(normalizedTarget); +- if (!opacity.ok) { +- this.statusLog.write(`FAIL Palette ${normalizedTarget} opacity rejected: ${opacity.error}`); +- return; +- } +- if (normalizedTarget === "stroke") { +- this.selectedStrokeOpacity = opacity.value; +- } else { +- this.selectedFillOpacity = opacity.value; +- } +- this.updatePaletteModeSwatches(); +- this.statusLog.write(`OK Selected ${normalizedTarget} opacity ${opacity.value}.`); +- } +- +- selectPaletteColor(color, label) { +- if (!color) { +- this.statusLog.write(`FAIL Palette color selection blocked: swatch ${label} has no usable color value.`); +- return false; +- } +- const swatch = this.paletteSwatchForColor(color); +- if (!swatch) { +- this.statusLog.write(`FAIL Palette color selection rejected: ${color} is not in the loaded palette.`); +- return false; +- } +- const paletteLabel = swatch.name || swatch.id || swatch.symbol || label; +- if (this.paletteTarget === "stroke") { +- this.selectedStrokeColor = color; +- this.selectedStrokeLabel = paletteLabel; +- } else { +- this.selectedFillColor = color; +- this.selectedFillLabel = paletteLabel; +- } +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- this.statusLog.write(`OK Selected ${this.paletteTarget === "stroke" ? "stroke" : "paint"} color ${color} from ${paletteLabel}.`); +- return true; +- } +- +- applyPaletteColor(color, label) { +- this.selectPaletteColor(color, label); +- } +- +- applySelectedPaletteColorToShape(shapeIndex, target, sourceLabel) { +- const selectedIndex = normalizeShapeIndex(shapeIndex); +- const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; +- if (!selected) { +- this.statusLog.write("FAIL Palette color application blocked: select a shape first."); +- return; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Palette color application skipped: shape row ${selectedIndex} is locked.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Palette color application")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const shape = this.findShapeInPayload(nextPayload, selectedIndex); +- const strokeWidth = Number(this.elements.strokeWidth.value); +- const shouldApplyStroke = target === "stroke"; +- const color = shouldApplyStroke ? this.selectedStrokeColor : this.selectedFillColor; +- const label = shouldApplyStroke ? this.selectedStrokeLabel : this.selectedFillLabel; +- if (!color) { +- this.statusLog.write(`FAIL Palette color application blocked: no ${shouldApplyStroke ? "stroke" : "paint"} color is selected.`); +- return; +- } +- const swatch = this.paletteSwatchForColor(color); +- const directLabel = this.directStyleColorLabel(color); +- if (!swatch && !directLabel) { +- this.statusLog.write(`FAIL Palette color application rejected: ${color} is not in the loaded palette.`); +- return; +- } +- const paletteLabel = swatch?.name || swatch?.id || swatch?.symbol || directLabel || label; +- if (shouldApplyStroke) { +- shape.style.stroke = color; +- shape.style.strokeWidth = Number.isFinite(strokeWidth) && strokeWidth > 0 ? strokeWidth : 2; +- shape.style.strokeOpacity = this.selectedStrokeOpacity; +- } else { +- shape.style.fill = color; +- shape.style.fillOpacity = this.selectedFillOpacity; +- } +- const targetLabel = shouldApplyStroke ? `Target: stroke width ${shape.style.strokeWidth}, opacity ${shape.style.strokeOpacity}.` : `Target: paint opacity ${shape.style.fillOpacity}.`; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, selectedIndex, `OK Applied palette color ${color} from ${paletteLabel} to shape row ${selectedIndex} by ${sourceLabel}. ${targetLabel}`, "Palette color application failed schema validation", { +- syncPaletteSelection: false +- }); +- } +- +- applyTransparentStyleToShape(shapeIndex, target, sourceLabel) { +- const selectedIndex = normalizeShapeIndex(shapeIndex); +- const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; +- if (!selected) { +- this.statusLog.write(`WARN Transparent style skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); +- return; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Transparent style skipped: shape row ${selectedIndex} is locked.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Transparent style application")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const shape = this.findShapeInPayload(nextPayload, selectedIndex); +- const shouldApplyStroke = target === "stroke"; +- if (shouldApplyStroke) { +- shape.style.stroke = TRANSPARENT_STYLE_COLOR; +- } else { +- shape.style.fill = TRANSPARENT_STYLE_COLOR; +- } +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Applied transparent ${shouldApplyStroke ? "stroke" : "fill"} to shape row ${selectedIndex} by ${sourceLabel}.`, "Transparent style application failed schema validation", { +- syncPaletteSelection: false +- }); +- } +- +- sampleShapeStyle(shapeIndex) { +- const selectedIndex = normalizeShapeIndex(shapeIndex); +- const selected = sortedShapes(this.selectedObject())[selectedIndex] || null; +- if (!selected) { +- this.statusLog.write(`WARN Picker skipped: shape row ${shapeIndex ?? "unknown"} is not available.`); +- return; +- } +- const effectiveShape = this.effectiveShape(selected, selectedIndex); +- const style = effectiveShape.style || {}; +- if (style.fill) { +- this.selectedFillColor = style.fill; +- this.selectedFillLabel = this.paletteLabelForColor(style.fill, shapeDisplayLabel(selected)); +- } +- if (style.stroke) { +- this.selectedStrokeColor = style.stroke; +- this.selectedStrokeLabel = this.paletteLabelForColor(style.stroke, shapeDisplayLabel(selected)); +- } +- if (Number.isFinite(style.fillOpacity)) { +- this.selectedFillOpacity = style.fillOpacity; +- this.elements.fillOpacity.value = this.opacityInputDisplayValue(style.fillOpacity); +- this.clearInputValidity(this.elements.fillOpacity); +- } +- if (Number.isFinite(style.strokeOpacity)) { +- this.selectedStrokeOpacity = style.strokeOpacity; +- this.elements.strokeOpacity.value = this.opacityInputDisplayValue(style.strokeOpacity); +- this.clearInputValidity(this.elements.strokeOpacity); +- } +- if (Number.isFinite(style.strokeWidth) && style.strokeWidth > 0) { +- this.elements.strokeWidth.value = String(style.strokeWidth); +- } +- this.updatePaletteModeSwatches(); +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- this.statusLog.write(`OK Picker sampled shape row ${selectedIndex}: fill ${style.fill || "none"}, stroke ${style.stroke || "none"}, fill opacity ${Number.isFinite(style.fillOpacity) ? style.fillOpacity : "n/a"}, stroke opacity ${Number.isFinite(style.strokeOpacity) ? style.strokeOpacity : "n/a"}, stroke width ${Number.isFinite(style.strokeWidth) ? style.strokeWidth : "n/a"}.`); +- } +- +- swapFillStrokeColors() { +- const nextFill = this.selectedStrokeColor; +- const nextFillLabel = this.selectedStrokeLabel; +- this.selectedStrokeColor = this.selectedFillColor; +- this.selectedStrokeLabel = this.selectedFillLabel; +- this.selectedFillColor = nextFill; +- this.selectedFillLabel = nextFillLabel; +- if (this.runtimePalette) { +- this.renderPalette(); +- } +- this.statusLog.write(`OK Swapped fill and stroke colors: fill ${this.selectedFillColor}, stroke ${this.selectedStrokeColor}.`); +- } +- +- restoreDefaultColors() { +- const fillColor = this.firstPaletteColor(); +- const strokeColor = this.secondPaletteColor(fillColor); +- if (!fillColor || !strokeColor) { +- this.statusLog.write("FAIL Default colors blocked: a loaded palette color is required."); +- return; +- } +- this.selectedFillColor = fillColor; +- this.selectedStrokeColor = strokeColor; +- this.selectedFillLabel = this.paletteLabelForColor(fillColor, "default fill"); +- this.selectedStrokeLabel = this.paletteLabelForColor(strokeColor, "default stroke"); +- this.renderPalette(); +- this.statusLog.write(`OK Restored default paint/stroke colors from loaded palette: fill ${fillColor}, stroke ${strokeColor}.`); +- } +- +- toggleSelectedShapeVisibility() { +- this.toggleShapeVisibilityByIndex(this.selectedShapeIndex, this.selectedObjectId); +- } +- +- toggleShapeVisibilityByIndex(shapeIndex, objectId = this.selectedObjectId) { +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId) || null; +- const shape = sortedShapes(object)[normalizedIndex] || null; +- if (!object || !shape) { +- this.statusLog.write("WARN Shape visibility skipped: no shape is selected."); +- return; +- } +- if (shape.locked) { +- this.statusLog.write(`WARN Shape visibility skipped: shape row ${normalizedIndex} is locked.`); +- return; +- } +- if (this.isObjectLocked(object.id)) { +- this.statusLog.write(`WARN Shape visibility blocked: object ${object.name} is locked for this runtime session.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const targetIsSelectedObject = object.id === this.selectedObjectId; +- const state = targetIsSelectedObject ? this.objectStates(nextObject).find((candidate) => candidate.id === this.selectedStateId) : null; +- const frame = targetIsSelectedObject ? sortedFrames(state).find((candidate) => candidate.id === this.selectedFrameId) : null; +- if (frame && !Array.isArray(frame.shapeOverrides)) { +- frame.shapeOverrides = []; +- } +- let override = frame?.shapeOverrides.find((entry) => entry.shapeIndex === normalizedIndex) || null; +- if (frame) { +- const baseShape = sortedShapes(nextObject)[normalizedIndex]; +- if (!override) { +- override = { +- shapeIndex: normalizedIndex, +- transform: this.shapeTransform(baseShape), +- visible: baseShape.visible +- }; +- frame.shapeOverrides.push(override); +- } +- const nextVisible = !this.effectiveShapeForFrame(shape, this.activeFrame(), normalizedIndex).visible; +- override.visible = nextVisible; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK State ${this.selectedStateId} frame ${this.selectedFrameId} shape row ${normalizedIndex} visibility set to ${nextVisible ? "visible" : "hidden"}.`, "State frame visibility failed schema validation", { +- directSelectedShapeIndexes: this.directSelectedShapeIndexes, +- selectedShapeIndexes: this.selectedShapeIndexes +- }); +- return; +- } +- const nextShape = sortedShapes(nextObject)[normalizedIndex]; +- nextShape.visible = !nextShape.visible; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Shape row ${normalizedIndex} visibility set to ${nextShape.visible ? "visible" : "hidden"}.`, "Shape visibility failed schema validation", { +- directSelectedShapeIndexes: this.directSelectedShapeIndexes, +- selectedShapeIndexes: this.selectedShapeIndexes +- }); +- } +- +- toggleSelectedShapeLock() { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Shape lock skipped: no shape is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Shape lock")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const shape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- shape.locked = !shape.locked; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Shape row ${this.selectedShapeIndex} lock set to ${shape.locked ? "locked" : "unlocked"}.`, "Shape lock failed schema validation"); +- } +- +- changeSelectedShapeOrder(action) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Shape order skipped: no shape is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Shape order")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); +- const shapes = sortedShapes(object); +- const selectedIndexes = Array.from(new Set(Array.from(this.selectedShapeIndexes || []) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < shapes.length))) +- .sort((left, right) => left - right); +- if (!selectedIndexes.length && this.selectedShapeIndex >= 0 && this.selectedShapeIndex < shapes.length) { +- selectedIndexes.push(this.selectedShapeIndex); +- } +- const lockedIndex = selectedIndexes.find((shapeIndex) => shapes[shapeIndex]?.locked); +- if (Number.isInteger(lockedIndex)) { +- this.statusLog.write(`WARN Shape order skipped: shape row ${lockedIndex} is locked.`); +- return; +- } +- if (shapes.length < 2) { +- this.statusLog.write(`WARN Shape order skipped: shape row ${this.selectedShapeIndex} is the only shape in ${object.name}.`); +- return; +- } +- +- const selectedIndexSet = new Set(selectedIndexes); +- const oldIndexByShape = new Map(shapes.map((shape, shapeIndex) => [shape, shapeIndex])); +- const reorderedShapes = shapes.slice(); +- if (action === "back") { +- reorderedShapes.sort((left, right) => { +- const leftIndex = oldIndexByShape.get(left); +- const rightIndex = oldIndexByShape.get(right); +- const leftSelected = selectedIndexSet.has(leftIndex); +- const rightSelected = selectedIndexSet.has(rightIndex); +- if (leftSelected === rightSelected) { +- return leftIndex - rightIndex; +- } +- return leftSelected ? -1 : 1; +- }); +- } else if (action === "front") { +- reorderedShapes.sort((left, right) => { +- const leftIndex = oldIndexByShape.get(left); +- const rightIndex = oldIndexByShape.get(right); +- const leftSelected = selectedIndexSet.has(leftIndex); +- const rightSelected = selectedIndexSet.has(rightIndex); +- if (leftSelected === rightSelected) { +- return leftIndex - rightIndex; +- } +- return leftSelected ? 1 : -1; +- }); +- } else if (action === "backward") { +- for (let index = 1; index < reorderedShapes.length; index += 1) { +- const currentOldIndex = oldIndexByShape.get(reorderedShapes[index]); +- const previousOldIndex = oldIndexByShape.get(reorderedShapes[index - 1]); +- if (selectedIndexSet.has(currentOldIndex) && !selectedIndexSet.has(previousOldIndex)) { +- [reorderedShapes[index - 1], reorderedShapes[index]] = [reorderedShapes[index], reorderedShapes[index - 1]]; +- } +- } +- } else if (action === "forward") { +- for (let index = reorderedShapes.length - 2; index >= 0; index -= 1) { +- const currentOldIndex = oldIndexByShape.get(reorderedShapes[index]); +- const nextOldIndex = oldIndexByShape.get(reorderedShapes[index + 1]); +- if (selectedIndexSet.has(currentOldIndex) && !selectedIndexSet.has(nextOldIndex)) { +- [reorderedShapes[index], reorderedShapes[index + 1]] = [reorderedShapes[index + 1], reorderedShapes[index]]; +- } +- } +- } else { +- this.statusLog.write(`WARN Shape z-order skipped: unsupported action ${action}.`); +- return; +- } +- +- const oldShapes = shapes.slice(); +- if (oldShapes.every((shape, shapeIndex) => reorderedShapes[shapeIndex] === shape)) { +- const subject = selectedIndexes.length > 1 ? `selected shape rows ${selectedIndexes.join(", ")}` : `shape row ${this.selectedShapeIndex}`; +- this.statusLog.write(`WARN Shape z-order skipped: ${subject} cannot move ${action}.`); +- return; +- } +- const oldIndexToNextIndex = new Map(); +- oldShapes.forEach((shape, oldIndex) => { +- oldIndexToNextIndex.set(oldIndex, reorderedShapes.indexOf(shape)); +- }); +- reorderedShapes.forEach((shape, shapeIndex) => { +- shape.order = shapeIndex + 1; +- }); +- object.shapes = reorderedShapes; +- this.remapShapeOverrideIndexes(object, oldShapes, reorderedShapes); +- const selectedShapeIndexes = new Set(selectedIndexes +- .map((shapeIndex) => oldIndexToNextIndex.get(shapeIndex)) +- .filter((shapeIndex) => Number.isInteger(shapeIndex) && shapeIndex >= 0)); +- const directSelectedShapeIndexes = new Set(Array.from(this.directSelectedShapeIndexes || []) +- .map((shapeIndex) => oldIndexToNextIndex.get(normalizeShapeIndex(shapeIndex))) +- .filter((shapeIndex) => Number.isInteger(shapeIndex) && shapeIndex >= 0)); +- if (!directSelectedShapeIndexes.size) { +- selectedShapeIndexes.forEach((shapeIndex) => directSelectedShapeIndexes.add(shapeIndex)); +- } +- const nextSelectedShapeIndex = oldIndexToNextIndex.get(this.selectedShapeIndex); +- const primaryShapeIndex = Number.isInteger(nextSelectedShapeIndex) && nextSelectedShapeIndex >= 0 +- ? nextSelectedShapeIndex +- : Math.min(...selectedShapeIndexes); +- const okMessage = selectedIndexes.length > 1 +- ? `OK Shape rows ${selectedIndexes.join(", ")} z-order ${action}.` +- : `OK Shape row ${this.selectedShapeIndex} z-order ${action}.`; +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, primaryShapeIndex, okMessage, "Shape z-order failed schema validation", { +- directSelectedShapeIndexes, +- selectedShapeIndexes +- }); +- } +- +- objectTransformOrigin(object) { +- const origin = normalizeObjectVectorOrigin(object?.objectOrigin); +- return { +- x: this.formatViewportNumber(origin.x), +- y: this.formatViewportNumber(origin.y) +- }; +- } +- +- updateSelectedObjectOrigin(origin, okMessage) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Object Transform origin skipped: no object is selected."); +- return false; +- } +- if (this.guardSelectedObjectMutation("Object Transform origin")) { +- return false; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- nextObject.objectOrigin = { +- x: Number(origin.x.toFixed(3)), +- y: Number(origin.y.toFixed(3)) +- }; +- return this.commitPayloadUpdate( +- nextPayload, +- object.id, +- this.selectedShapeIndex, +- okMessage, +- "Object Transform origin failed schema validation", +- { +- directSelectedShapeIndexes: this.directSelectedShapeIndexes, +- selectedShapeIndexes: this.selectedShapeIndexes +- } +- ); +- } +- +- selectedObjectShapeIndexes(object = this.selectedObject()) { +- return sortedShapes(object).map((shape, shapeIndex) => shapeIndex); +- } +- +- readObjectOriginInputs() { +- const originX = this.numberInputValue("objectVectorStudioV2ObjectOriginXInput", "Object Origin X"); +- const originY = this.numberInputValue("objectVectorStudioV2ObjectOriginYInput", "Object Origin Y"); +- if (!originX.ok || !originY.ok) { +- return { +- error: originX.error || originY.error, +- ok: false, +- value: { x: 0, y: 0 } +- }; +- } +- return { +- error: "", +- ok: true, +- value: { +- x: Number(originX.value.toFixed(3)), +- y: Number(originY.value.toFixed(3)) +- } +- }; +- } +- +- updateSelectedObjectTransforms(operation, updater, okMessage, failPrefix = `Object Transform ${operation} failed schema validation`, options = {}) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write(`WARN Object Transform ${operation} skipped: no object is selected.`); +- return false; +- } +- if (this.guardSelectedObjectMutation(`Object Transform ${operation}`)) { +- return false; +- } +- +- const targetIndexes = this.selectedObjectShapeIndexes(object); +- if (!targetIndexes.length) { +- this.statusLog.write(`WARN Object Transform ${operation} skipped: object ${object.name} has no shapes.`); +- return false; +- } +- const lockedIndex = targetIndexes.find((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); +- if (Number.isInteger(lockedIndex)) { +- this.statusLog.write(`WARN Object Transform ${operation} skipped: shape row ${lockedIndex} is locked.`); +- return false; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const activeFrame = this.activeFrame(); +- try { +- targetIndexes.forEach((shapeIndex) => { +- const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); +- const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); +- const shape = override +- ? this.effectiveShapeForFrame(baseShape, activeFrame, shapeIndex) +- : baseShape; +- if (!shape) { +- throw new Error(`shape row ${shapeIndex} is not available.`); +- } +- updater(shape, shapeIndex); +- shape.transform = this.ensureShapeTransform(shape); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- }); +- } catch (error) { +- this.statusLog.write(`FAIL Invalid object transform rejected for ${object.name}: ${error.message}`); +- return false; +- } +- +- return this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, okMessage, failPrefix, { +- directSelectedShapeIndexes: this.directSelectedShapeIndexes, +- dirtyReason: options.dirtyReason, +- selectedShapeIndexes: this.selectedShapeIndexes +- }); +- } +- +- rotateSelectedObject() { +- const input = this.objectRotateInputValue(); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); +- return; +- } +- const origin = this.readObjectOriginInputs(); +- if (!origin.ok) { +- this.statusLog.write(`FAIL Invalid object transform rejected: ${origin.error}`); +- return; +- } +- const rotation = this.snapAngle(input.value); +- const snapAngleStatus = this.formatSnapAngleRotateStatus(input.value, rotation); +- const object = this.selectedObject(); +- const activeFrame = this.activeFrame(); +- const targetIndexes = this.selectedObjectShapeIndexes(object); +- const nextTransforms = new Map(targetIndexes.map((shapeIndex) => { +- const effectiveShape = this.effectiveShapeForFrame(sortedShapes(object)[shapeIndex], activeFrame, shapeIndex); +- const transform = this.ensureShapeTransform(effectiveShape); +- const translatedOrigin = { +- x: this.formatViewportNumber(origin.value.x + transform.x), +- y: this.formatViewportNumber(origin.value.y + transform.y) +- }; +- const rotatedTranslatedOrigin = this.rotatePointAround(translatedOrigin, origin.value, rotation); +- return [shapeIndex, { +- ...transform, +- rotation: this.normalizeRotationDegrees(transform.rotation + rotation), +- x: this.formatViewportNumber(rotatedTranslatedOrigin.x - origin.value.x), +- y: this.formatViewportNumber(rotatedTranslatedOrigin.y - origin.value.y) +- }]; +- })); +- this.updateSelectedObjectTransforms("rotate", (shape, shapeIndex) => { +- shape.transform = this.ensureShapeTransform({ transform: nextTransforms.get(shapeIndex) }); +- }, `OK Rotated object ${object?.name || "selected object"} (${targetIndexes.length} shapes) by ${rotation} degrees around origin ${origin.value.x}, ${origin.value.y}.${snapAngleStatus}`); +- } +- +- applySelectedObjectOrigin() { +- const origin = this.readObjectOriginInputs(); +- if (!origin.ok) { +- this.statusLog.write(`FAIL Invalid object transform rejected: ${origin.error}`); +- return; +- } +- const object = this.selectedObject(); +- this.updateSelectedObjectOrigin(origin.value, `OK Updated object ${object?.name || "selected object"} origin/pivot to ${origin.value.x}, ${origin.value.y}.`); +- } +- +- autoOriginSelectedObjectPivot() { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Auto Origin skipped: no object is selected."); +- return; +- } +- const visibleShapes = sortedShapes(object) +- .map((shape, shapeIndex) => this.effectiveShapeForFrame(shape, this.activeFrame(), shapeIndex)) +- .filter((shape) => shape.visible !== false); +- if (!visibleShapes.length) { +- this.statusLog.write(`FAIL Auto Origin blocked: object ${object.name} has no visible geometry.`); +- return; +- } +- const bounds = this.rawVisibleObjectGeometryBounds(object); +- if (!bounds) { +- this.statusLog.write(`FAIL Auto Origin blocked: object ${object.name} has no raw visible geometry bounds.`); +- return; +- } +- const balancedCenter = { +- x: this.formatViewportNumber(bounds.x + bounds.width / 2), +- y: this.formatViewportNumber(bounds.y + bounds.height / 2) +- }; +- this.transformInputValues.set("objectVectorStudioV2ObjectOriginXInput", String(balancedCenter.x)); +- this.transformInputValues.set("objectVectorStudioV2ObjectOriginYInput", String(balancedCenter.y)); +- this.updateSelectedObjectOrigin(balancedCenter, `OK Auto Origin updated object ${object.name} origin/pivot from raw visible geometry bounds ${balancedCenter.x}, ${balancedCenter.y}.`); +- } +- +- moveSelectedShape() { +- if (!this.canUseShapeTransform()) { +- this.statusLog.write("WARN Shape Transform move skipped: select exactly one shape."); +- return; +- } +- const x = this.numberInputValue("objectVectorStudioV2MoveXInput", "Move X"); +- const y = this.numberInputValue("objectVectorStudioV2MoveYInput", "Move Y"); +- if (!x.ok || !y.ok) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${x.error || y.error}`); +- return; +- } +- const moveX = this.snapDistance(x.value); +- const moveY = this.snapDistance(y.value); +- const object = this.selectedObject(); +- if (this.shapeBelongsToValidGroup(object, this.selectedShapeIndex)) { +- const targetIndexes = this.shapeSelectionGroupIndexes(sortedShapes(object), this.selectedShapeIndex); +- const groupId = String(sortedShapes(object)[this.selectedShapeIndex]?.groupId || "").trim(); +- this.moveSelectedShapeGroup(targetIndexes, moveX, moveY, groupId); +- return; +- } +- this.updateSelectedShapeTransform("move", (shape) => { +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); +- shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); +- }, `OK Moved shape row ${this.selectedShapeIndex} by ${moveX}, ${moveY}.`); +- } +- +- moveSelectedShapeGroup(shapeIndexes, moveX, moveY, groupId) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Transform move skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Transform move")) { +- return; +- } +- +- const objectShapes = sortedShapes(object); +- const targetIndexes = Array.from(new Set(shapeIndexes +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0 && shapeIndex < objectShapes.length))) +- .sort((left, right) => left - right); +- if (targetIndexes.length < 2) { +- this.updateSelectedShapeTransform("move", (shape) => { +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); +- shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); +- }, `OK Moved shape row ${this.selectedShapeIndex} by ${moveX}, ${moveY}.`); +- return; +- } +- +- const lockedIndex = targetIndexes.find((shapeIndex) => objectShapes[shapeIndex]?.locked); +- if (Number.isInteger(lockedIndex)) { +- this.statusLog.write(`WARN Transform move skipped: shape row ${lockedIndex} is locked.`); +- return; +- } +- +- this.selectedShapeIndexes = new Set(targetIndexes); +- const directShapeIndexes = this.directSelectedShapeIndexes.size +- ? new Set(Array.from(this.directSelectedShapeIndexes).filter((shapeIndex) => targetIndexes.includes(shapeIndex))) +- : new Set([this.selectedShapeIndex]); +- if (!directShapeIndexes.size && this.selectedShapeIndex >= 0) { +- directShapeIndexes.add(this.selectedShapeIndex); +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- try { +- targetIndexes.forEach((shapeIndex) => { +- const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: true }); +- const shape = override +- ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, shapeIndex), this.activeFrame(), shapeIndex) +- : this.findShapeInPayload(nextPayload, shapeIndex); +- if (!shape) { +- throw new Error(`shape row ${shapeIndex} is not available.`); +- } +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((shape.transform.x + moveX).toFixed(3)); +- shape.transform.y = Number((shape.transform.y + moveY).toFixed(3)); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- }); +- } catch (error) { +- this.statusLog.write(`FAIL Invalid transform rejected for group ${groupId || "selected group"}: ${error.message}`); +- return; +- } +- +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, `OK Moved group ${groupId || "selected group"} (${targetIndexes.length} shapes) by ${moveX}, ${moveY}.`, "Transform move failed schema validation", { +- directSelectedShapeIndexes: directShapeIndexes, +- selectedShapeIndexes: new Set(targetIndexes) +- }); +- } +- +- rotateSelectedShape() { +- if (!this.canUseShapeTransform()) { +- this.statusLog.write("WARN Shape Transform rotate skipped: select exactly one shape."); +- return; +- } +- const input = this.rotateInputValue(); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); +- return; +- } +- const rotation = this.snapAngle(input.value); +- const snapAngleStatus = this.formatSnapAngleRotateStatus(input.value, rotation); +- const object = this.selectedObject(); +- const origin = this.objectTransformOrigin(object); +- this.updateSelectedShapeGeometry("rotate", (shape) => { +- this.rotateShapeGeometryAroundOrigin(shape, origin, rotation); +- shape.transform = { +- ...this.ensureShapeTransform(shape), +- rotation: 0 +- }; +- }, `OK Rotated shape row ${this.selectedShapeIndex} geometry by ${rotation} degrees around object origin ${origin.x}, ${origin.y}.${snapAngleStatus}`); +- } +- +- formatSnapAngleRotateStatus(rawRotation, appliedRotation) { +- if (this.angleSnapEnabled) { +- return ` Snap Angle active: ${rawRotation} -> ${appliedRotation}.`; +- } +- return " Snap Angle disabled: raw rotation applied."; +- } +- +- rotateInputValue() { +- if (!this.angleSnapEnabled) { +- const input = this.numberInputValue("objectVectorStudioV2RotateInput", "Rotate"); +- return { ...input, rawValue: String(input.value) }; +- } +- const select = this.window.document.getElementById("objectVectorStudioV2RotateSnapSelect"); +- const rawValue = select?.value?.trim() || ""; +- const value = Number(rawValue); +- if (!select || rawValue === "" || !Number.isFinite(value)) { +- const error = "Rotate Snap Angle must be a valid dropdown value."; +- this.markInputInvalid(select, error); +- return { error, ok: false, rawValue, value: 0 }; +- } +- this.clearInputValidity(select); +- return { error: "", ok: true, rawValue, value }; +- } +- +- objectRotateInputValue() { +- if (!this.angleSnapEnabled) { +- const input = this.numberInputValue("objectVectorStudioV2ObjectRotateInput", "Object Rotate"); +- return { ...input, rawValue: String(input.value) }; +- } +- const select = this.window.document.getElementById("objectVectorStudioV2ObjectRotateSnapSelect"); +- const rawValue = select?.value?.trim() || ""; +- const value = Number(rawValue); +- if (!select || rawValue === "" || !Number.isFinite(value)) { +- const error = "Object Rotate Snap Angle must be a valid dropdown value."; +- this.markInputInvalid(select, error); +- return { error, ok: false, rawValue, value: 0 }; +- } +- this.clearInputValidity(select); +- return { error: "", ok: true, rawValue, value }; +- } +- +- rotatePointAround(point, pivot, rotation) { +- return rotateSharedPointAround(point, pivot, rotation, "degrees"); +- } +- +- rotateGeometryPoint(point, origin, rotation) { +- const rotated = this.rotatePointAround(point, origin, rotation); +- return { +- x: this.formatViewportNumber(rotated.x), +- y: this.formatViewportNumber(rotated.y) +- }; +- } +- +- rotateShapeGeometryAroundOrigin(shape, origin, rotation) { +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "polygon" || geometryTool === "polyline") { +- shape.geometry.points = shape.geometry.points.map((point) => this.rotateGeometryPoint(point, origin, rotation)); +- return; +- } +- if (geometryTool === "line") { +- shape.geometry.point1 = this.rotateGeometryPoint(shape.geometry.point1, origin, rotation); +- shape.geometry.point2 = this.rotateGeometryPoint(shape.geometry.point2, origin, rotation); +- return; +- } +- if (geometryTool === "rectangle") { +- const center = this.rotateGeometryPoint({ +- x: shape.geometry.x + shape.geometry.width / 2, +- y: shape.geometry.y + shape.geometry.height / 2 +- }, origin, rotation); +- shape.geometry.x = this.formatViewportNumber(center.x - shape.geometry.width / 2); +- shape.geometry.y = this.formatViewportNumber(center.y - shape.geometry.height / 2); +- return; +- } +- if (geometryTool === "circle") { +- const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); +- shape.geometry.cx = center.x; +- shape.geometry.cy = center.y; +- return; +- } +- if (geometryTool === "ellipse") { +- const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); +- shape.geometry.cx = center.x; +- shape.geometry.cy = center.y; +- return; +- } +- if (geometryTool === "arc") { +- const center = this.rotateGeometryPoint({ x: shape.geometry.cx, y: shape.geometry.cy }, origin, rotation); +- shape.geometry.cx = center.x; +- shape.geometry.cy = center.y; +- shape.geometry.startAngle = this.normalizeRotationDegrees(shape.geometry.startAngle + rotation); +- shape.geometry.endAngle = this.normalizeRotationDegrees(shape.geometry.endAngle + rotation); +- return; +- } +- if (geometryTool === "text") { +- const anchor = this.rotateGeometryPoint({ x: shape.geometry.x, y: shape.geometry.y }, origin, rotation); +- shape.geometry.x = anchor.x; +- shape.geometry.y = anchor.y; +- } +- } +- +- normalizeRotationDegrees(value) { +- return normalizeSharedRotationDegrees(value); +- } +- +- formatScaleInputValue(value) { +- return String(Number(Number(value).toFixed(3))); +- } +- +- readScaleInput(inputId = "objectVectorStudioV2ScaleInput", label = "Scale") { +- const input = this.numberInputValue(inputId, label); +- if (!input.ok) { +- return input; +- } +- if (input.value <= 0) { +- this.markInputInvalid(this.window.document.getElementById(inputId), `${label} must be greater than 0.`); +- return { error: "scale must be greater than 0.", ok: false, value: input.value }; +- } +- return { error: "", ok: true, value: Number(input.value.toFixed(3)) }; +- } +- +- applyScaleInputLive(inputElement) { +- const rawValue = inputElement?.value?.trim() || ""; +- if (!rawValue) { +- this.markInputInvalid(inputElement, "Scale must be a finite number."); +- return; +- } +- const input = this.readScaleInput(); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); +- return; +- } +- this.applySelectedShapeScaleValue(input.value, { +- okMessage: `OK Scale preview set to ${this.formatScaleInputValue(input.value)} for shape row ${this.selectedShapeIndex}.`, +- renderControls: false +- }); +- } +- +- adjustSelectedShapeScale(delta) { +- const input = this.readScaleInput(); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); +- return; +- } +- const nextScale = Number((input.value + delta).toFixed(3)); +- if (nextScale <= 0) { +- this.markInputInvalid(this.window.document.getElementById("objectVectorStudioV2ScaleInput"), "Scale must be greater than 0."); +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: scale must be greater than 0.`); +- return; +- } +- const inputElement = this.window.document.getElementById("objectVectorStudioV2ScaleInput"); +- if (inputElement) { +- inputElement.value = this.formatScaleInputValue(nextScale); +- this.transformInputValues.set(inputElement.id, inputElement.value); +- } +- this.applySelectedShapeScaleValue(nextScale, { +- okMessage: `OK Scale preview set to ${this.formatScaleInputValue(nextScale)} for shape row ${this.selectedShapeIndex}.`, +- renderControls: false +- }); +- } +- +- resetSelectedShapeScale() { +- const inputElement = this.window.document.getElementById("objectVectorStudioV2ScaleInput"); +- if (inputElement) { +- inputElement.value = "1"; +- this.transformInputValues.set(inputElement.id, "1"); +- this.clearInputValidity(inputElement); +- } +- this.applySelectedShapeScaleValue(1, { +- okMessage: `OK Scale reset to 1 for shape row ${this.selectedShapeIndex}.`, +- renderControls: false +- }); +- } +- +- applySelectedShapeScaleValue(scale, { okMessage, renderControls = false } = {}) { +- if (!this.canUseShapeTransform()) { +- this.statusLog.write("WARN Transform scale skipped: select exactly one shape."); +- return false; +- } +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Transform scale skipped: no shape is selected."); +- return false; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Transform scale skipped: shape row ${this.selectedShapeIndex} is locked.`); +- return false; +- } +- if (this.guardSelectedObjectMutation("Transform scale")) { +- return false; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: true }); +- const shape = override +- ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, this.selectedShapeIndex), this.activeFrame(), this.selectedShapeIndex) +- : this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.scaleX = Number(scale.toFixed(3)); +- shape.transform.scaleY = Number(scale.toFixed(3)); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); +- return false; +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- +- const validation = this.schemaService.validatePayload(nextPayload); +- if (!validation.ok) { +- this.statusLog.write(`FAIL Transform scale failed schema validation: ${validation.errors.join(" ")}`); +- return false; +- } +- const hasPayloadChange = this.hasPayloadChanged(validation.payload); +- const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { +- changedKeys: ["data.objects"], +- reason: "object-vector-transform-scale" +- }); +- if (!workspaceSync.ok) { +- this.statusLog.write(`FAIL Transform scale failed schema validation: ${workspaceSync.message}`); +- return false; +- } +- +- this.recordPreviewHistorySnapshot(hasPayloadChange); +- this.currentPayload = validation.payload; +- this.actionNav.setJsonPayloadActionsEnabled(true); +- this.ensureSelectedFrame(); +- if (renderControls) { +- this.renderPayload(); +- } else { +- this.renderObjectTiles(); +- this.renderShapeTiles(); +- this.renderWorkSurface(); +- this.updateTransformSummaryText(); +- this.updateSelectedObjectJsonDetails(); +- this.updateObjectActionState(); +- } +- if (workspaceSync.changed) { +- this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); +- } +- if (okMessage) { +- this.statusLog.write(okMessage); +- } +- return true; +- } +- +- applyObjectScaleInputLive(inputElement) { +- const rawValue = inputElement?.value?.trim() || ""; +- if (!rawValue) { +- this.markInputInvalid(inputElement, "Object Scale must be a finite number."); +- return; +- } +- const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); +- return; +- } +- this.applySelectedObjectScaleValue(input.value, { +- okMessage: `OK Object resize scale set to ${this.formatScaleInputValue(input.value)} for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` +- }); +- } +- +- adjustSelectedObjectScale(delta) { +- const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); +- if (!input.ok) { +- this.statusLog.write(`FAIL Invalid object transform rejected: ${input.error}`); +- return; +- } +- const nextScale = Number((input.value + delta).toFixed(3)); +- if (nextScale <= 0) { +- this.markInputInvalid(this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"), "Object Scale must be greater than 0."); +- this.statusLog.write("FAIL Invalid object transform rejected: scale must be greater than 0."); +- return; +- } +- const inputElement = this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"); +- if (inputElement) { +- inputElement.value = this.formatScaleInputValue(nextScale); +- this.transformInputValues.set(inputElement.id, inputElement.value); +- } +- this.applySelectedObjectScaleValue(nextScale, { +- okMessage: `OK Object resize scale set to ${this.formatScaleInputValue(nextScale)} for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` +- }); +- } +- +- resetSelectedObjectScale() { +- const inputElement = this.window.document.getElementById("objectVectorStudioV2ObjectScaleInput"); +- if (inputElement) { +- inputElement.value = "1"; +- this.transformInputValues.set(inputElement.id, "1"); +- this.clearInputValidity(inputElement); +- } +- this.applySelectedObjectScaleValue(1, { +- okMessage: `OK Object resize scale reset to 1 for ${this.selectedObject()?.name || "selected object"}; object transform scale remains 1 until Resize rewrites geometry.` +- }); +- } +- +- applySelectedObjectScaleValue(scale, { okMessage } = {}) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Object scale skipped: no object is selected."); +- return false; +- } +- this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", this.formatScaleInputValue(scale)); +- this.renderWorkSurface(); +- this.updateTransformSummaryText(); +- this.updateSelectedObjectJsonDetails(); +- if (okMessage) { +- this.statusLog.write(okMessage); +- } else { +- this.statusLog.write(`OK Object resize scale set to ${this.formatScaleInputValue(scale)} for ${object.name}; object transform scale remains 1 until Resize rewrites geometry.`); +- } +- return true; +- } +- +- resizeSelectedObject() { +- const input = this.readScaleInput("objectVectorStudioV2ObjectScaleInput", "Object Scale"); +- if (!input.ok) { +- this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${input.error}`); +- return; +- } +- const origin = this.readObjectOriginInputs(); +- if (!origin.ok) { +- this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${origin.error}`); +- return; +- } +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Resize Geometry skipped: no object is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Resize Geometry")) { +- return; +- } +- +- const targetIndexes = this.selectedObjectShapeIndexes(object); +- const lockedIndex = targetIndexes.find((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked); +- if (Number.isInteger(lockedIndex)) { +- this.statusLog.write(`WARN Resize Geometry skipped: shape row ${lockedIndex} is locked.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- try { +- targetIndexes.forEach((shapeIndex) => { +- const baseShape = this.findShapeInPayload(nextPayload, shapeIndex); +- const override = this.frameOverrideInPayload(nextPayload, shapeIndex, { create: false }); +- const transformTarget = override?.transform ? { transform: override.transform, geometry: baseShape.geometry } : baseShape; +- const transform = this.ensureShapeTransform(transformTarget); +- transform.scaleX = input.value; +- transform.scaleY = input.value; +- this.resizeShapeGeometryByTransformScale(baseShape, transform, origin.value); +- transform.scaleX = 1; +- transform.scaleY = 1; +- if (override?.transform) { +- override.transform = transform; +- } else { +- baseShape.transform = transform; +- } +- const transformErrors = this.validateShapeForTransform(baseShape); +- if (transformErrors.length) { +- throw new Error(`shape row ${shapeIndex}: ${transformErrors.join(" ")}`); +- } +- }); +- } catch (error) { +- this.statusLog.write(`FAIL Resize Geometry rejected for selected object: ${error.message}`); +- return; +- } +- +- this.transformInputValues.set("objectVectorStudioV2ObjectScaleInput", "1"); +- this.commitPayloadUpdate( +- nextPayload, +- object.id, +- this.selectedShapeIndex, +- `OK Resize Geometry applied scale ${this.formatScaleInputValue(input.value)} to object ${object.name} (${targetIndexes.length} shapes); transform scale reset to 1.`, +- "Resize Geometry failed schema validation", +- { +- directSelectedShapeIndexes: this.directSelectedShapeIndexes, +- selectedShapeIndexes: this.selectedShapeIndexes +- } +- ); +- } +- +- resizeSelectedShape() { +- if (!this.canUseShapeTransform()) { +- this.statusLog.write("WARN Resize Geometry skipped: select exactly one shape."); +- return; +- } +- const input = this.readScaleInput(); +- if (!input.ok) { +- this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${input.error}`); +- return; +- } +- +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Resize Geometry skipped: no shape is selected."); +- return; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Resize Geometry skipped: shape row ${this.selectedShapeIndex} is locked.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Resize Geometry")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const baseShape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: false }); +- const transformTarget = override?.transform ? { transform: override.transform, geometry: baseShape.geometry } : baseShape; +- const transform = this.ensureShapeTransform(transformTarget); +- transform.scaleX = input.value; +- transform.scaleY = input.value; +- +- try { +- this.resizeShapeGeometryByTransformScale(baseShape, transform, this.objectTransformOrigin(this.selectedObject())); +- transform.scaleX = 1; +- transform.scaleY = 1; +- if (override?.transform) { +- override.transform = transform; +- } else { +- baseShape.transform = transform; +- } +- const transformErrors = this.validateShapeForTransform(baseShape); +- if (transformErrors.length) { +- this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); +- return; +- } +- } catch (error) { +- this.statusLog.write(`FAIL Resize Geometry rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); +- return; +- } +- +- this.transformInputValues.set("objectVectorStudioV2ScaleInput", "1"); +- this.commitPayloadUpdate( +- nextPayload, +- this.selectedObjectId, +- this.selectedShapeIndex, +- `OK Resize Geometry applied scale ${this.formatScaleInputValue(input.value)} to shape row ${this.selectedShapeIndex}; transform scale reset to 1.`, +- "Resize Geometry failed schema validation" +- ); +- } +- +- groupSelectedShapes() { +- const object = this.selectedObject(); +- if (!object || this.selectedShapeIndexes.size < 2) { +- this.statusLog.write("WARN Group shapes skipped: select at least two shapes."); +- return; +- } +- if (this.guardSelectedObjectMutation("Group shapes")) { +- return; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const groupId = this.uniqueGroupId(nextObject); +- const directIndexes = Array.from(this.directSelectedShapeIndexes) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => this.selectedShapeIndexes.has(shapeIndex)); +- const selectedIndexes = new Set((directIndexes.length >= 2 ? directIndexes : Array.from(this.selectedShapeIndexes)) +- .map((shapeIndex) => normalizeShapeIndex(shapeIndex)) +- .filter((shapeIndex) => shapeIndex >= 0)); +- if (selectedIndexes.size < 2) { +- this.statusLog.write("WARN Group shapes skipped: select at least two shapes."); +- return; +- } +- sortedShapes(nextObject).forEach((shape, shapeIndex) => { +- if (selectedIndexes.has(shapeIndex)) { +- shape.groupId = groupId; +- } +- }); +- this.pruneSingleMemberShapeGroups(nextObject); +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Grouped ${selectedIndexes.size} shapes into ${groupId}.`, "Group shapes failed schema validation", { +- directSelectedShapeIndexes: selectedIndexes, +- selectedShapeIndexes: selectedIndexes +- }); +- } +- +- ungroupSelectedShapes() { +- const object = this.selectedObject(); +- const selected = this.selectedShape(); +- if (!object || !selected) { +- this.statusLog.write("WARN Ungroup skipped: no shape is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Ungroup shapes")) { +- return; +- } +- const selectedIndexes = new Set(this.selectedShapeIndexes); +- const selectedGroupIds = new Set(sortedShapes(object) +- .filter((shape, shapeIndex) => selectedIndexes.has(shapeIndex) && shape.groupId) +- .map((shape) => shape.groupId)); +- if (!selectedGroupIds.size) { +- this.statusLog.write("WARN Ungroup skipped: selected shapes are not grouped."); +- return; +- } +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- sortedShapes(nextObject).forEach((shape, shapeIndex) => { +- if (selectedIndexes.has(shapeIndex) && selectedGroupIds.has(shape.groupId)) { +- delete shape.groupId; +- } +- }); +- this.pruneSingleMemberShapeGroups(nextObject); +- this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Ungrouped ${selectedIndexes.size} selected shapes from ${Array.from(selectedGroupIds).join(", ")}.`, "Ungroup shapes failed schema validation", { +- directSelectedShapeIndexes: selectedIndexes, +- selectedShapeIndexes: selectedIndexes +- }); +- } +- +- pruneSingleMemberShapeGroups(object) { +- const groupCounts = new Map(); +- sortedShapes(object).forEach((shape) => { +- const groupId = String(shape.groupId || "").trim(); +- if (!groupId) { +- return; +- } +- groupCounts.set(groupId, (groupCounts.get(groupId) || 0) + 1); +- }); +- sortedShapes(object).forEach((shape) => { +- const groupId = String(shape.groupId || "").trim(); +- if (groupId && groupCounts.get(groupId) < 2) { +- delete shape.groupId; +- } +- }); +- } +- +- applyShapeGeometryEdits(options = {}) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Geometry edit skipped: no shape is selected."); +- return false; +- } +- if (!this.canUseShapeGeometry()) { +- this.statusLog.write("WARN Geometry edit skipped: select exactly one shape."); +- return false; +- } +- if (this.guardSelectedObjectMutation("Geometry edit")) { +- return false; +- } +- const fields = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-shape-geometry-field]")); +- const geometry = this.readShapeGeometryFields(selected, fields); +- if (!geometry.ok) { +- this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); +- return false; +- } +- return this.updateSelectedShapeGeometry("geometry edit", (shape) => { +- shape.geometry = geometry.value; +- if (shapeGeometryTool(shape) === "polygon" || shapeGeometryTool(shape) === "polyline") { +- const pointRounding = this.currentPointRoundingRows(geometry.value.points.length); +- this.applyPointRoundingToShape(shape, pointRounding); +- } +- shape.transform = this.ensureShapeTransform(shape); +- }, options.okMessage || `OK Auto-applied geometry edits to shape row ${this.selectedShapeIndex}.`); +- } +- +- addPolygonPointAfterRow(pointIndex, sourceButton = null) { +- const selected = this.selectedShape(); +- if (!selected || !["polygon", "polyline"].includes(shapeGeometryTool(selected))) { +- this.statusLog.write("WARN Add point skipped: no editable point-list shape is selected."); +- return; +- } +- if (!this.canUseShapeGeometry()) { +- this.statusLog.write("WARN Add point skipped: select exactly one shape."); +- return; +- } +- if (!this.isEditablePolygonShape(selected)) { +- this.statusLog.write(`WARN Add point skipped for shape row ${this.selectedShapeIndex}: triangle point count is fixed.`); +- return; +- } +- const geometry = this.readCurrentPolygonGeometry(selected); +- if (!geometry.ok) { +- this.markPolygonPointAddInvalid(sourceButton, geometry.error); +- this.statusLog.write(`FAIL Add point rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); +- return; +- } +- const points = geometry.value.points; +- const pointRounding = this.currentPointRoundingRows(points.length); +- const insertAfterIndex = Number(pointIndex); +- if (!Number.isInteger(insertAfterIndex) || insertAfterIndex < 0 || insertAfterIndex >= points.length) { +- const message = `point ${insertAfterIndex + 1} is not available.`; +- this.markPolygonPointAddInvalid(sourceButton, message); +- this.statusLog.write(`FAIL Add point rejected for shape row ${this.selectedShapeIndex}: ${message}`); +- return; +- } +- const currentPoint = points[insertAfterIndex]; +- const nextPoint = { +- x: Number(currentPoint.x), +- y: Number(currentPoint.y) +- }; +- points.splice(insertAfterIndex + 1, 0, nextPoint); +- pointRounding.splice(insertAfterIndex + 1, 0, pointRounding[insertAfterIndex] === true); +- this.rebuildPolygonPointList(points, pointRounding); +- this.clearPolygonPointSelections(); +- this.clearPolygonPointAddValidity(); +- this.clearPolygonPointDeleteValidity(); +- this.applyShapeGeometryEdits({ +- okMessage: `OK Added copied point after point ${insertAfterIndex + 1} for shape row ${this.selectedShapeIndex}.` +- }); +- } +- +- deletePolygonPointRow(pointIndex, sourceButton = null) { +- const selected = this.selectedShape(); +- if (!selected || !["polygon", "polyline"].includes(shapeGeometryTool(selected))) { +- this.statusLog.write("WARN Delete point skipped: no editable point-list shape is selected."); +- return; +- } +- if (!this.canUseShapeGeometry()) { +- this.statusLog.write("WARN Delete point skipped: select exactly one shape."); +- return; +- } +- if (!this.isEditablePolygonShape(selected)) { +- this.statusLog.write(`WARN Delete point skipped for shape row ${this.selectedShapeIndex}: triangle point count is fixed.`); +- return; +- } +- const geometry = this.readCurrentPolygonGeometry(selected); +- if (!geometry.ok) { +- this.markPolygonPointDeleteInvalid(sourceButton, geometry.error); +- this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${geometry.error}`); +- return; +- } +- const normalizedIndex = Number(pointIndex); +- if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= geometry.value.points.length) { +- const message = `point ${normalizedIndex + 1} is not available.`; +- this.markPolygonPointDeleteInvalid(sourceButton, message); +- this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); +- return; +- } +- const minPointCount = shapeGeometryTool(selected) === "polyline" ? 2 : 4; +- if (geometry.value.points.length - 1 < minPointCount) { +- const message = `${shapeGeometryTool(selected)} must keep at least ${minPointCount} points.`; +- this.markPolygonPointDeleteInvalid(sourceButton, message); +- this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); +- return; +- } +- const nextPoints = geometry.value.points.filter((_, index) => index !== normalizedIndex); +- const nextPointRounding = this.currentPointRoundingRows(geometry.value.points.length).filter((_, index) => index !== normalizedIndex); +- if (nextPoints.length < minPointCount) { +- const message = `${shapeGeometryTool(selected)} must keep at least ${minPointCount} points.`; +- this.markPolygonPointDeleteInvalid(sourceButton, message); +- this.statusLog.write(`FAIL Delete point rejected for shape row ${this.selectedShapeIndex}: ${message}`); +- return; +- } +- this.rebuildPolygonPointList(nextPoints, nextPointRounding); +- this.clearPolygonPointSelections(); +- this.clearPolygonPointAddValidity(); +- this.clearPolygonPointDeleteValidity(); +- this.applyShapeGeometryEdits({ +- okMessage: `OK Deleted point ${normalizedIndex + 1} from shape row ${this.selectedShapeIndex}.` +- }); +- } +- +- readCurrentPolygonGeometry(shape) { +- const fields = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-shape-geometry-field='points']")); +- return this.readShapeGeometryFields(shape, fields); +- } +- +- clearPolygonPointSelections() { +- this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-action-selected='true']").forEach((row) => { +- delete row.dataset.polygonPointActionSelected; +- row.classList.remove("is-action-selected"); +- row.setAttribute("aria-pressed", "false"); +- }); +- } +- +- markPolygonPointAddInvalid(sourceButton, message) { +- if (!sourceButton) { +- return; +- } +- sourceButton.dataset.validationState = "invalid"; +- sourceButton.setAttribute("aria-invalid", "true"); +- sourceButton.title = message; +- } +- +- clearPolygonPointAddValidity() { +- this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-add='true']").forEach((button) => { +- delete button.dataset.validationState; +- button.removeAttribute("aria-invalid"); +- button.title = `Add point after point ${Number(button.dataset.polygonPointIndex) + 1}`; +- }); +- } +- +- markPolygonPointDeleteInvalid(sourceButton, message) { +- if (!sourceButton) { +- return; +- } +- sourceButton.dataset.validationState = "invalid"; +- sourceButton.setAttribute("aria-invalid", "true"); +- sourceButton.title = message; +- } +- +- clearPolygonPointDeleteValidity() { +- this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-delete='true']").forEach((button) => { +- delete button.dataset.validationState; +- button.removeAttribute("aria-invalid"); +- button.title = `Delete point ${Number(button.dataset.polygonPointIndex) + 1}`; +- }); +- } +- +- currentPointRoundingRows(expectedCount = null) { +- const checkboxes = Array.from(this.elements.shapeGeometryDetails.querySelectorAll("[data-polygon-point-round='true']")); +- const pointRounding = checkboxes +- .sort((left, right) => Number(left.dataset.polygonPointIndex) - Number(right.dataset.polygonPointIndex)) +- .map((checkbox) => checkbox.checked === true); +- const count = Number.isInteger(expectedCount) ? expectedCount : pointRounding.length; +- return Array.from({ length: count }, (_, index) => pointRounding[index] === true); +- } +- +- normalizedPointRounding(shape, pointCount = this.shapeGeometryPointCount(shape)) { +- const values = this.shapePointRoundingValues(shape); +- return Array.from({ length: pointCount }, (_, index) => values[index] === true); +- } +- +- applyPointRoundingToShape(shape, pointRounding) { +- const normalized = Array.from({ length: this.shapeGeometryPointCount(shape) || pointRounding.length }, (_, index) => pointRounding[index] === true); +- shape.style = { ...shape.style, pointRounding: normalized }; +- delete shape.style.strokeLinecap; +- shape.style.startPointStyle = normalized[0] ? "round" : "square"; +- shape.style.endPointStyle = normalized.at(-1) ? "round" : "square"; +- shape.style.pointStyle = "square"; +- } +- +- rebuildPolygonPointList(points, pointRounding = null) { +- const list = this.elements.shapeGeometryDetails.querySelector(".object-vector-studio-v2__polygon-point-list"); +- if (!list) { +- return; +- } +- const selected = this.selectedShape(); +- const selectable = this.isEditablePolygonShape(selected); +- const rounding = pointRounding || this.normalizedPointRounding(selected, points.length); +- list.replaceChildren(this.createPointRowHeader({ addable: selectable, deletable: selectable }), ...points.map((point, index) => this.createPolygonPointRow(point, index, { +- addable: selectable, +- deletable: selectable, +- rounded: rounding[index] === true, +- selectable: false +- }))); +- } +- +- reindexPolygonPointRows() { +- this.elements.shapeGeometryDetails.querySelectorAll(".object-vector-studio-v2__polygon-point-field").forEach((row, index) => { +- const caption = row.querySelector(".object-vector-studio-v2__polygon-point-label"); +- if (caption) { +- caption.textContent = `Point ${index + 1}`; +- } +- row.querySelectorAll("[data-polygon-point-index]").forEach((input) => { +- input.dataset.polygonPointIndex = String(index); +- }); +- row.dataset.polygonPointIndex = String(index); +- const roundCheckbox = row.querySelector("[data-polygon-point-round='true']"); +- if (roundCheckbox) { +- roundCheckbox.setAttribute("aria-label", `Round point ${index + 1}`); +- } +- const addButton = row.querySelector("[data-polygon-point-add='true']"); +- if (addButton) { +- addButton.setAttribute("aria-label", `Add point after point ${index + 1}`); +- addButton.title = `Add point after point ${index + 1}`; +- } +- const deleteButton = row.querySelector("[data-polygon-point-delete='true']"); +- if (deleteButton) { +- deleteButton.setAttribute("aria-label", `Delete point ${index + 1}`); +- deleteButton.title = `Delete point ${index + 1}`; +- } +- }); +- } +- +- readShapeGeometryFields(shape, fields) { +- try { +- if (shapeGeometryTool(shape) === "polygon" || shapeGeometryTool(shape) === "polyline") { +- const pointInputs = fields.filter((input) => input.dataset.shapeGeometryField === "points"); +- const geometryTool = shapeGeometryTool(shape); +- const requiredPointCount = this.isTriangleShape(shape) ? 3 : geometryTool === "polyline" ? 2 : 4; +- const shapeLabel = this.isTriangleShape(shape) ? "triangle" : geometryTool; +- if (pointInputs.length < requiredPointCount) { +- throw new Error(`${shapeLabel} points must contain at least ${requiredPointCount} point rows.`); +- } +- const pointRows = new Map(); +- pointInputs.forEach((input) => { +- const index = Number(input.dataset.polygonPointIndex); +- const axis = input.dataset.polygonPointAxis; +- if (!pointRows.has(index)) { +- pointRows.set(index, {}); +- } +- pointRows.get(index)[axis] = input; +- }); +- if (pointRows.size < requiredPointCount) { +- throw new Error(`${shapeLabel} points must contain at least ${requiredPointCount} point rows.`); +- } +- if (this.isTriangleShape(shape) && pointRows.size !== 3) { +- throw new Error("triangle points must contain exactly three point rows."); +- } +- const points = []; +- for (const index of [...pointRows.keys()].sort((left, right) => left - right)) { +- const row = pointRows.get(index); +- const pointNumber = index + 1; +- const x = this.numberInputElementValue(row.x, `Point ${pointNumber} X`); +- const y = this.numberInputElementValue(row.y, `Point ${pointNumber} Y`); +- if (!x.ok || !y.ok) { +- return { error: x.error || y.error, ok: false, value: null }; +- } +- points.push({ x: x.value, y: y.value }); +- } +- return { ok: true, value: { points } }; +- } +- const geometry = {}; +- for (const input of fields) { +- const key = input.dataset.shapeGeometryField; +- if (input.type === "number") { +- const parsed = this.numberInputElementValue(input, key); +- if (!parsed.ok) { +- return { error: parsed.error, ok: false, value: null }; +- } +- this.writeGeometryFieldValue(geometry, key, parsed.value); +- } else { +- this.clearInputValidity(input); +- this.writeGeometryFieldValue(geometry, key, input.value); +- } +- } +- if (shapeTool(shape) === "square") { +- const sizeInput = fields.find((input) => input.dataset.shapeGeometryField === "size") || null; +- if (!(geometry.size > 0)) { +- const message = "Size must be greater than 0."; +- if (sizeInput) { +- this.markInputInvalid(sizeInput, message); +- } +- return { error: message, ok: false, value: null }; +- } +- return { +- ok: true, +- value: { +- height: geometry.size, +- width: geometry.size, +- x: geometry.x, +- y: geometry.y +- } +- }; +- } +- return { ok: true, value: geometry }; +- } catch (error) { +- if (error.input) { +- this.markInputInvalid(error.input, error.message); +- } else { +- const pointsInput = fields.find((input) => input.dataset.shapeGeometryField === "points"); +- if (pointsInput) { +- this.markInputInvalid(pointsInput, error.message); +- } +- } +- return { error: error.message, ok: false, value: null }; +- } +- } +- +- writeGeometryFieldValue(geometry, key, value) { +- if (!key.includes(".")) { +- geometry[key] = value; +- return; +- } +- const [parentKey, childKey] = key.split("."); +- geometry[parentKey] = { +- ...(geometry[parentKey] || {}), +- [childKey]: value +- }; +- } +- +- updateSelectedShapeGeometry(operation, updater, okMessage, options = {}) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write(`WARN Geometry ${operation} skipped: no shape is selected.`); +- return false; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Geometry ${operation} skipped: shape row ${this.selectedShapeIndex} is locked.`); +- return false; +- } +- if (this.guardSelectedObjectMutation(`Geometry ${operation}`)) { +- return false; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const shape = this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- try { +- updater(shape); +- shape.transform = this.ensureShapeTransform(shape); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); +- return false; +- } +- } catch (error) { +- this.statusLog.write(`FAIL Invalid geometry rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); +- return false; +- } +- return this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, okMessage, `Geometry ${operation} failed schema validation`, options); +- } +- +- updateSelectedShapeTransform(operation, updater, okMessage, options = {}) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write(`WARN Transform ${operation} skipped: no shape is selected.`); +- return; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Transform ${operation} skipped: shape row ${this.selectedShapeIndex} is locked.`); +- return; +- } +- if (this.guardSelectedObjectMutation(`Transform ${operation}`)) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const override = this.frameOverrideInPayload(nextPayload, this.selectedShapeIndex, { create: true }); +- const shape = override +- ? this.effectiveShapeForFrame(this.findShapeInPayload(nextPayload, this.selectedShapeIndex), this.activeFrame(), this.selectedShapeIndex) +- : this.findShapeInPayload(nextPayload, this.selectedShapeIndex); +- try { +- updater(shape); +- const transformErrors = this.validateShapeForTransform(shape); +- if (transformErrors.length) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${transformErrors.join(" ")}`); +- return; +- } +- } catch (error) { +- this.statusLog.write(`FAIL Invalid transform rejected for shape row ${this.selectedShapeIndex}: ${error.message}`); +- return; +- } +- if (override) { +- override.transform = this.ensureShapeTransform(shape); +- } +- this.commitPayloadUpdate(nextPayload, this.selectedObjectId, this.selectedShapeIndex, okMessage, `Transform ${operation} failed schema validation`, options); +- } +- +- duplicateSelectedShape() { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write("WARN Duplicate shape skipped: no shape is selected."); +- return; +- } +- if (this.guardSelectedObjectMutation("Duplicate shape")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); +- const shape = JSON.parse(JSON.stringify(selected)); +- shape.order = object.shapes.length ? Math.max(...object.shapes.map((candidate) => candidate.order)) + 1 : 1; +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((shape.transform.x + 10).toFixed(3)); +- shape.transform.y = Number((shape.transform.y + 10).toFixed(3)); +- object.shapes.push(shape); +- this.commitPayloadUpdate(nextPayload, object.id, sortedShapes(object).length - 1, `OK Duplicated shape row ${this.selectedShapeIndex}.`, "Duplicate shape failed schema validation"); +- } +- +- deleteSelectedShape(sourceLabel) { +- const selected = this.selectedShape(); +- if (!selected) { +- this.statusLog.write(`WARN Delete shape skipped from ${sourceLabel}: no shape is selected.`); +- return; +- } +- if (selected.locked) { +- this.statusLog.write(`WARN Delete shape skipped: shape row ${this.selectedShapeIndex} is locked.`); +- return; +- } +- if (this.guardSelectedObjectMutation("Delete shape")) { +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const object = nextPayload.objects.find((candidate) => candidate.id === this.selectedObjectId); +- const deleteIndex = this.selectedShapeIndex; +- object.shapes = sortedShapes(object).filter((shape, shapeIndex) => shapeIndex !== deleteIndex) +- .map((shape, index) => ({ ...shape, order: index + 1 })); +- this.pruneSingleMemberShapeGroups(object); +- this.removeDeletedShapeReferences(object, deleteIndex); +- this.removeDanglingShapeOverrideReferences(nextPayload); +- const selectedShapeIndex = sortedShapes(object).length ? 0 : -1; +- this.commitPayloadUpdate(nextPayload, object.id, selectedShapeIndex, `OK Deleted shape row ${deleteIndex} from ${sourceLabel}.`, "Delete shape failed schema validation"); +- } +- +- deleteShapeByIndex(shapeIndex, sourceLabel, objectId = this.selectedObjectId) { +- const deleteIndex = normalizeShapeIndex(shapeIndex); +- const object = this.currentPayload?.objects.find((candidate) => candidate.id === objectId) || null; +- const shape = sortedShapes(object)[deleteIndex] || null; +- if (!object || !shape) { +- this.statusLog.write(`WARN Delete shape skipped from ${sourceLabel}: shape row ${shapeIndex ?? "unknown"} is not available.`); +- return; +- } +- if (shape.locked) { +- this.statusLog.write(`WARN Delete shape skipped: shape row ${deleteIndex} is locked.`); +- return; +- } +- if (this.isObjectLocked(object.id)) { +- this.statusLog.write(`WARN Delete shape blocked: object ${object.name} is locked for this runtime session.`); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- nextObject.shapes = sortedShapes(nextObject).filter((candidate, index) => index !== deleteIndex) +- .map((candidate, index) => ({ ...candidate, order: index + 1 })); +- this.pruneSingleMemberShapeGroups(nextObject); +- this.removeDeletedShapeReferences(nextObject, deleteIndex); +- this.removeDanglingShapeOverrideReferences(nextPayload); +- const selectedShapeStillExists = this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(nextObject).length; +- const selectedShapeIndex = selectedShapeStillExists ? this.selectedShapeIndex : sortedShapes(nextObject).length ? 0 : -1; +- this.commitPayloadUpdate(nextPayload, object.id, selectedShapeIndex, `OK Deleted shape row ${deleteIndex} from ${sourceLabel}.`, "Delete shape failed schema validation"); +- } +- +- removeDeletedShapeReferences(object, deletedShapeIndex) { +- this.objectStates(object).forEach((state) => { +- state.frames.forEach((frame) => { +- frame.shapeOverrides = frame.shapeOverrides +- .filter((override) => override.shapeIndex !== deletedShapeIndex) +- .map((override) => ({ +- ...override, +- shapeIndex: override.shapeIndex > deletedShapeIndex ? override.shapeIndex - 1 : override.shapeIndex +- })); +- }); +- }); +- } +- +- remapShapeOverrideIndexes(object, oldShapes, nextShapes) { +- const oldIndexToNextIndex = new Map(); +- oldShapes.forEach((shape, oldIndex) => { +- oldIndexToNextIndex.set(oldIndex, nextShapes.indexOf(shape)); +- }); +- this.objectStates(object).forEach((state) => { +- state.frames.forEach((frame) => { +- frame.shapeOverrides = frame.shapeOverrides +- .map((override) => { +- const nextIndex = oldIndexToNextIndex.get(override.shapeIndex); +- return Number.isInteger(nextIndex) && nextIndex >= 0 +- ? { ...override, shapeIndex: nextIndex } +- : null; +- }) +- .filter(Boolean); +- }); +- }); +- } +- +- numberInputValue(id, label) { +- const element = this.window.document.getElementById(id); +- return this.numberInputElementValue(element, label); +- } +- +- numberInputElementValue(element, label) { +- const rawValue = element?.value?.trim() || ""; +- const value = Number(rawValue); +- if (!element || rawValue === "" || !Number.isFinite(value)) { +- const error = `${label} must be a finite number.`; +- this.markInputInvalid(element, error); +- return { error, ok: false, value: 0 }; +- } +- this.clearInputValidity(element); +- const normalizedValue = this.formatViewportNumber(value); +- element.value = String(normalizedValue); +- return { error: "", ok: true, value: normalizedValue }; +- } +- +- markInputInvalid(element, message) { +- if (!element) { +- return; +- } +- element.classList.add("is-invalid"); +- element.setAttribute("aria-invalid", "true"); +- element.setCustomValidity(message); +- element.reportValidity(); +- } +- +- clearInputValidity(element) { +- if (!element) { +- return; +- } +- element.classList.remove("is-invalid"); +- element.removeAttribute("aria-invalid"); +- element.setCustomValidity(""); +- } +- +- snapDistance(value) { +- return value; +- } +- +- snapAngle(value) { +- if (!this.angleSnapEnabled) { +- return value; +- } +- const step = ANGLE_SNAP_STEPS.includes(this.angleSnapStep) ? this.angleSnapStep : 15; +- return Math.round(value / step) * step; +- } +- +- ensureShapeTransform(shape) { +- if (!shape.transform) { +- return defaultShapeTransform(); +- } +- const transform = shape.transform; +- return { +- rotation: this.formatViewportNumber(Number(transform.rotation ?? 0)), +- scaleX: this.formatViewportNumber(Number(transform.scaleX ?? 1)), +- scaleY: this.formatViewportNumber(Number(transform.scaleY ?? 1)), +- x: this.formatViewportNumber(Number(transform.x ?? 0)), +- y: this.formatViewportNumber(Number(transform.y ?? 0)) +- }; +- } +- +- resizeShapeGeometryByTransformScale(shape, transform, origin = { x: 0, y: 0 }) { +- if (!Number.isFinite(transform.scaleX) || !Number.isFinite(transform.scaleY) || transform.scaleX <= 0 || transform.scaleY <= 0) { +- throw new Error("scale must be greater than 0."); +- } +- const scaleX = transform.scaleX; +- const scaleY = transform.scaleY; +- const uniformScale = Number(((scaleX + scaleY) / 2).toFixed(3)); +- const requiresUniformScale = (label) => { +- if (Math.abs(scaleX - scaleY) > 0.001) { +- throw new Error(`${label} geometry cannot bake non-uniform scale; set matching X/Y scale first.`); +- } +- return uniformScale; +- }; +- const applyX = (value) => Number((origin.x + (value - origin.x) * scaleX).toFixed(3)); +- const applyY = (value) => Number((origin.y + (value - origin.y) * scaleY).toFixed(3)); +- const geometryTool = shapeGeometryTool(shape); +- if (geometryTool === "rectangle") { +- shape.geometry.x = applyX(shape.geometry.x); +- shape.geometry.y = applyY(shape.geometry.y); +- shape.geometry.width = Number((shape.geometry.width * scaleX).toFixed(3)); +- shape.geometry.height = Number((shape.geometry.height * scaleY).toFixed(3)); +- return; +- } +- if (geometryTool === "circle") { +- const scale = requiresUniformScale("circle"); +- shape.geometry.cx = applyX(shape.geometry.cx); +- shape.geometry.cy = applyY(shape.geometry.cy); +- shape.geometry.r = Number((shape.geometry.r * scale).toFixed(3)); +- return; +- } +- if (geometryTool === "ellipse") { +- shape.geometry.cx = applyX(shape.geometry.cx); +- shape.geometry.cy = applyY(shape.geometry.cy); +- shape.geometry.rx = Number((shape.geometry.rx * scaleX).toFixed(3)); +- shape.geometry.ry = Number((shape.geometry.ry * scaleY).toFixed(3)); +- return; +- } +- if (geometryTool === "line") { +- shape.geometry.point1.x = applyX(shape.geometry.point1.x); +- shape.geometry.point1.y = applyY(shape.geometry.point1.y); +- shape.geometry.point2.x = applyX(shape.geometry.point2.x); +- shape.geometry.point2.y = applyY(shape.geometry.point2.y); +- return; +- } +- if (geometryTool === "arc") { +- const scale = requiresUniformScale("arc"); +- shape.geometry.cx = applyX(shape.geometry.cx); +- shape.geometry.cy = applyY(shape.geometry.cy); +- shape.geometry.r = Number((shape.geometry.r * scale).toFixed(3)); +- return; +- } +- if (geometryTool === "text") { +- const scale = requiresUniformScale("text"); +- shape.geometry.x = applyX(shape.geometry.x); +- shape.geometry.y = applyY(shape.geometry.y); +- shape.geometry.fontSize = Number((shape.geometry.fontSize * scale).toFixed(3)); +- return; +- } +- shape.geometry.points = shape.geometry.points.map((point) => ({ +- x: applyX(point.x), +- y: applyY(point.y) +- })); +- } +- +- validateShapeForTransform(shape) { +- const errors = []; +- try { +- const bounds = shapeBounds(shape); +- if (bounds.width <= 0 || bounds.height <= 0) { +- errors.push("shape bounds must remain positive."); +- } +- } catch (error) { +- errors.push(error.message); +- } +- const transform = shape.transform; +- if (!transform) { +- return errors; +- } +- ["x", "y", "rotation", "scaleX", "scaleY"].forEach((key) => { +- if (!Number.isFinite(transform[key])) { +- errors.push(`transform.${key} must be a finite number.`); +- } +- }); +- if (Number.isFinite(transform.scaleX) && transform.scaleX <= 0) { +- errors.push("scaleX must be greater than 0."); +- } +- if (Number.isFinite(transform.scaleY) && transform.scaleY <= 0) { +- errors.push("scaleY must be greater than 0."); +- } +- return errors; +- } +- +- commitPayloadUpdate(nextPayload, selectedObjectId, selectedShapeIndex, okMessage, failPrefix, options = {}) { +- const validation = this.schemaService.validatePayload(nextPayload); +- if (!validation.ok) { +- this.statusLog.write(`FAIL ${failPrefix}: ${validation.errors.join(" ")}`); +- return false; +- } +- const hasPayloadChange = this.hasPayloadChanged(validation.payload); +- +- const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { +- changedKeys: options.changedKeys, +- reason: options.dirtyReason +- }); +- if (!workspaceSync.ok) { +- this.statusLog.write(`FAIL ${failPrefix}: ${workspaceSync.message}`); +- return false; +- } +- +- this.recordPreviewHistorySnapshot(hasPayloadChange, options); +- this.currentPayload = validation.payload; +- this.selectedObjectId = selectedObjectId; +- this.selectedShapeIndex = normalizeShapeIndex(selectedShapeIndex); +- this.selectedShapeIndexes = options.selectedShapeIndexes +- ? new Set(Array.from(options.selectedShapeIndexes).map((index) => normalizeShapeIndex(index)).filter((index) => index >= 0)) +- : new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- this.directSelectedShapeIndexes = options.directSelectedShapeIndexes +- ? new Set(Array.from(options.directSelectedShapeIndexes).map((index) => normalizeShapeIndex(index)).filter((index) => index >= 0)) +- : (options.selectedShapeIndexes +- ? new Set(this.selectedShapeIndexes) +- : new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : [])); +- this.selectedStateId = options.selectedStateId ?? this.selectedStateId; +- this.stateControlStateId = this.selectedStateId || this.stateControlStateId; +- this.selectedFrameId = options.selectedFrameId ?? this.selectedFrameId; +- this.ensureSelectedFrame(); +- this.actionNav.setJsonPayloadActionsEnabled(true); +- this.renderPayload({ +- preserveEmptyShapeSelection: options.preserveEmptyShapeSelection === true, +- syncPaletteSelection: options.syncPaletteSelection !== false +- }); +- if (workspaceSync.changed) { +- this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); +- } +- this.statusLog.write(okMessage); +- return true; +- } +- +- syncWorkspaceToolSessionPayload(payload, { changedKeys = ["data.objects"], reason = "object-vector-updated" } = {}) { +- if (!this.actionNav.isWorkspaceLaunch()) { +- return { changed: false, ok: true }; +- } +- const rawSession = this.window.sessionStorage?.getItem(WORKSPACE_TOOL_SESSION_KEY) || ""; +- if (!rawSession) { +- return { ok: false, message: `workspace dirty tracking failed: missing ${WORKSPACE_TOOL_SESSION_KEY}.` }; +- } +- +- let session; +- try { +- session = JSON.parse(rawSession); +- } catch (error) { +- return { ok: false, message: `workspace dirty tracking failed: ${WORKSPACE_TOOL_SESSION_KEY} is invalid JSON: ${error.message}.` }; +- } +- if (!isPlainObject(session) +- || !isPlainObject(session.schema) +- || !isPlainObject(session.workspace) +- || !Object.prototype.hasOwnProperty.call(session, "data") +- || !isPlainObject(session.dirty)) { +- return { ok: false, message: `workspace dirty tracking failed: ${WORKSPACE_TOOL_SESSION_KEY} must use the normalized schema/workspace/data/dirty object shape.` }; +- } +- +- const dataChanged = JSON.stringify(session.data) !== JSON.stringify(payload); +- if (!dataChanged) { +- return { changed: false, ok: true }; +- } +- +- const normalizedKeys = this.normalizedWorkspaceDirtyKeys(changedKeys); +- const previousKeys = session.dirty.isDirty === true +- ? this.normalizedWorkspaceDirtyKeys(session.dirty.changedKeys) +- : []; +- const mergedChangedKeys = Array.from(new Set([...previousKeys, ...normalizedKeys])); +- const dirtyReason = String(reason || "object-vector-updated").trim() || "object-vector-updated"; +- const nextSession = { +- ...session, +- data: payload, +- dirty: { +- isDirty: true, +- reason: dirtyReason, +- changedAt: new Date().toISOString(), +- changedKeys: mergedChangedKeys +- } +- }; +- try { +- this.window.sessionStorage?.setItem(WORKSPACE_TOOL_SESSION_KEY, JSON.stringify(nextSession)); +- } catch (error) { +- return { ok: false, message: `workspace dirty tracking failed: unable to write ${WORKSPACE_TOOL_SESSION_KEY}: ${error.message}.` }; +- } +- return { +- changed: true, +- changedKeys: mergedChangedKeys, +- ok: true, +- reason: dirtyReason, +- session: nextSession +- }; +- } +- +- normalizedWorkspaceDirtyKeys(changedKeys) { +- const keys = Array.isArray(changedKeys) ? changedKeys : ["data.objects"]; +- const normalized = keys +- .map((key) => String(key || "").trim()) +- .filter(Boolean); +- return normalized.length ? normalized : ["data.objects"]; +- } +- +- hasPayloadChanged(nextPayload) { +- return Boolean(this.currentPayload) && JSON.stringify(this.currentPayload) !== JSON.stringify(nextPayload); +- } +- +- recordPreviewHistorySnapshot(hasPayloadChange, options = {}) { +- if (!hasPayloadChange || options.skipPreviewHistory || !this.currentPayload) { +- return; +- } +- this.previewUndoStack.push(this.cloneCurrentPayload()); +- if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { +- this.previewUndoStack.shift(); +- } +- this.previewRedoStack = []; +- } +- +- cloneCurrentPayload() { +- return this.clonePayloadValue(this.currentPayload); +- } +- +- clonePayloadValue(payload) { +- if (typeof this.window.structuredClone === "function") { +- return this.window.structuredClone(payload); +- } +- return JSON.parse(JSON.stringify(payload)); +- } +- +- selectedObject() { +- return this.currentPayload?.objects.find((object) => object.id === this.selectedObjectId) || null; +- } +- +- guardSelectedObjectMutation(actionLabel) { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write(`WARN ${actionLabel} skipped: no object is selected.`); +- return true; +- } +- if (!this.isObjectLocked(object.id)) { +- return false; +- } +- this.statusLog.write(`WARN ${actionLabel} blocked: object ${object.name} is locked for this runtime session.`); +- return true; +- } +- +- resolveObjectForPayload(payload, objectId, chain = []) { +- const object = payload?.objects.find((candidate) => candidate.id === objectId) || null; +- if (!object) { +- return null; +- } +- if (chain.includes(object.id)) { +- return null; +- } +- if (!object.baseObjectId) { +- return JSON.parse(JSON.stringify(object)); +- } +- const baseObject = this.resolveObjectForPayload(payload, object.baseObjectId, [...chain, object.id]); +- if (!baseObject) { +- return null; +- } +- const shapeByOrder = new Map(baseObject.shapes.map((shape) => [shape.order, JSON.parse(JSON.stringify(shape))])); +- object.shapes.forEach((shape) => { +- shapeByOrder.set(shape.order, JSON.parse(JSON.stringify(shape))); +- }); +- const stateById = new Map((baseObject.states || []).map((state) => [state.id, JSON.parse(JSON.stringify(state))])); +- (object.states || []).forEach((state) => { +- stateById.set(state.id, JSON.parse(JSON.stringify(state))); +- }); +- const resolved = { +- ...baseObject, +- ...JSON.parse(JSON.stringify(object)), +- inheritedFrom: object.baseObjectId, +- shapes: Array.from(shapeByOrder.values()).sort((left, right) => left.order - right.order) +- }; +- if (stateById.size) { +- resolved.states = Array.from(stateById.values()); +- } else { +- delete resolved.states; +- } +- return resolved; +- } +- +- selectedShape() { +- return sortedShapes(this.selectedObject())[this.selectedShapeIndex] || null; +- } +- +- ensureSelectedShape() { +- const object = this.selectedObject(); +- if (!object) { +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- return; +- } +- if (this.selectedShapeIndex >= 0 && this.selectedShapeIndex < sortedShapes(object).length) { +- if (!this.selectedShapeIndexes.size) { +- this.selectedShapeIndexes = new Set([this.selectedShapeIndex]); +- } +- this.directSelectedShapeIndexes = new Set(Array.from(this.directSelectedShapeIndexes) +- .map((index) => normalizeShapeIndex(index)) +- .filter((index) => index >= 0 && index < sortedShapes(object).length)); +- if (!this.directSelectedShapeIndexes.size) { +- this.directSelectedShapeIndexes = new Set([this.selectedShapeIndex]); +- } +- return; +- } +- this.selectedShapeIndex = sortedShapes(object).length ? 0 : -1; +- this.selectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- this.directSelectedShapeIndexes = new Set(this.selectedShapeIndex >= 0 ? [this.selectedShapeIndex] : []); +- } +- +- ensureSelectedFrame() { +- const object = this.selectedObject(); +- if (!object) { +- this.selectedStateId = ""; +- this.selectedFrameId = ""; +- return; +- } +- const states = this.objectStates(object); +- const state = states.find((candidate) => candidate.id === this.selectedStateId) || states[0] || null; +- this.selectedStateId = state?.id || ""; +- if (!OBJECT_STATE_IDS.includes(this.stateControlStateId)) { +- this.stateControlStateId = this.selectedStateId || OBJECT_STATE_IDS[0]; +- } +- if (!state) { +- this.selectedFrameId = ""; +- return; +- } +- const frames = sortedFrames(state); +- const frame = frames.find((candidate) => candidate.id === this.selectedFrameId) || frames[0] || null; +- this.selectedFrameId = frame?.id || ""; +- } +- +- objectStates(object) { +- return Array.isArray(object?.states) ? object.states : []; +- } +- +- groupColor(groupId) { +- const text = String(groupId || ""); +- const hash = Array.from(text).reduce((total, character) => total + character.codePointAt(0), 0); +- return GROUP_COLOR_SWATCHES[hash % GROUP_COLOR_SWATCHES.length]; +- } +- +- selectedState() { +- return this.objectStates(this.selectedObject()).find((state) => state.id === this.selectedStateId) || null; +- } +- +- activeFrame() { +- return sortedFrames(this.selectedState()).find((frame) => frame.id === this.selectedFrameId) || null; +- } +- +- previousFrame() { +- const state = this.selectedState(); +- const frame = this.activeFrame(); +- if (!state || !frame) { +- return null; +- } +- const frames = sortedFrames(state); +- const index = frames.findIndex((candidate) => candidate.id === frame.id); +- return index > 0 ? frames[index - 1] : null; +- } +- +- createFrameSnapshot(object, stateId, frameId, order) { +- return { +- durationFrames: 1, +- id: frameId, +- order, +- shapeOverrides: sortedShapes(object).map((shape, shapeIndex) => ({ +- shapeIndex, +- transform: this.shapeTransform(shape), +- visible: shape.visible +- })) +- }; +- } +- +- frameOverrideInPayload(payload, shapeIndex, { create = false } = {}) { +- const normalizedIndex = normalizeShapeIndex(shapeIndex); +- const object = payload.objects.find((candidate) => candidate.id === this.selectedObjectId); +- const state = this.objectStates(object).find((candidate) => candidate.id === this.selectedStateId); +- const frame = sortedFrames(state).find((candidate) => candidate.id === this.selectedFrameId); +- if (!object || !state || !frame) { +- return null; +- } +- let override = frame.shapeOverrides.find((entry) => entry.shapeIndex === normalizedIndex); +- if (!override && create) { +- const baseShape = sortedShapes(object)[normalizedIndex]; +- if (!baseShape) { +- return null; +- } +- override = { +- shapeIndex: normalizedIndex, +- transform: this.shapeTransform(baseShape), +- visible: baseShape.visible +- }; +- frame.shapeOverrides.push(override); +- } +- return override || null; +- } +- +- findShapeInPayload(payload, shapeIndex) { +- const object = payload.objects.find((candidate) => candidate.id === this.selectedObjectId); +- return sortedShapes(object)[normalizeShapeIndex(shapeIndex)] || null; +- } +- +- firstPaletteColor() { +- const swatch = this.runtimePalette?.swatches.find((candidate) => swatchColor(candidate)); +- return swatchColor(swatch); +- } +- +- secondPaletteColor(firstColor) { +- const swatch = this.runtimePalette?.swatches.find((candidate) => swatchColor(candidate) && swatchColor(candidate) !== firstColor); +- return swatchColor(swatch) || firstColor; +- } +- +- uniqueObjectId(name, objects, gameKey = this.payloadGameKey()) { +- const baseId = `object.${gameKey}.${canonicalObjectSlug(name, gameKey)}`; +- const usedIds = new Set(objects.map((object) => object.id)); +- if (!usedIds.has(baseId)) { +- return baseId; +- } +- +- let suffix = 2; +- let candidate = `${baseId}-${suffix}`; +- while (usedIds.has(candidate)) { +- suffix += 1; +- candidate = `${baseId}-${suffix}`; +- } +- return candidate; +- } +- +- payloadGameKey() { +- const counts = new Map(); +- (this.currentPayload?.objects || []).forEach((object) => { +- const segment = objectGameSegment(object.id); +- if (segment) { +- counts.set(segment, (counts.get(segment) || 0) + 1); +- } +- }); +- const existingGame = [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0]; +- return existingGame || payloadGameSlugFromName(this.currentPayload?.name); +- } +- +- uniqueGroupId(object) { +- const usedIds = new Set((object?.shapes || []).map((shape) => shape.groupId).filter(Boolean)); +- let suffix = usedIds.size + 1; +- let candidate = `group-${suffix}`; +- while (usedIds.has(candidate)) { +- suffix += 1; +- candidate = `group-${suffix}`; +- } +- return candidate; +- } +- +- uniqueTag(tag, tags) { +- const baseTag = slugifyObjectName(tag); +- const usedTags = new Set(tags); +- if (!usedTags.has(baseTag)) { +- return baseTag; +- } +- +- let suffix = 2; +- let candidate = `${baseTag}-${suffix}`; +- while (usedTags.has(candidate)) { +- suffix += 1; +- candidate = `${baseTag}-${suffix}`; +- } +- return candidate; +- } +- +- uniqueFrameId(state) { +- const usedIds = new Set(sortedFrames(state).map((frame) => frame.id)); +- let suffix = 1; +- let candidate = `frame-${suffix}`; +- while (usedIds.has(candidate)) { +- suffix += 1; +- candidate = `frame-${suffix}`; +- } +- return candidate; +- } +- +- clearPreviewEditState({ clipboard = false } = {}) { +- this.previewUndoStack = []; +- this.previewRedoStack = []; +- if (clipboard) { +- this.previewClipboardShape = null; +- } +- } +- +- copyPreviewSelection() { +- const shape = this.selectedShape(); +- if (!shape) { +- this.statusLog.write("WARN Copy skipped: no Object Preview shape is selected."); +- this.updatePreviewEditActionState(); +- return; +- } +- this.previewClipboardShape = this.clonePayloadValue(shape); +- this.updatePreviewEditActionState(); +- this.statusLog.write(`OK Copied shape row ${this.selectedShapeIndex} to Object Preview clipboard.`); +- } +- +- pastePreviewClipboard() { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("WARN Paste skipped: no object is selected."); +- this.updatePreviewEditActionState(); +- return; +- } +- if (!this.previewClipboardShape) { +- this.statusLog.write("WARN Paste skipped: Object Preview clipboard is empty."); +- this.updatePreviewEditActionState(); +- return; +- } +- if (this.guardSelectedObjectMutation("Paste shape")) { +- this.updatePreviewEditActionState(); +- return; +- } +- +- const nextPayload = this.cloneCurrentPayload(); +- const nextObject = nextPayload.objects.find((candidate) => candidate.id === object.id); +- const shape = this.clonePayloadValue(this.previewClipboardShape); +- shape.order = nextObject.shapes.length ? Math.max(...nextObject.shapes.map((candidate) => candidate.order)) + 1 : 1; +- shape.transform = this.ensureShapeTransform(shape); +- shape.transform.x = Number((shape.transform.x + 10).toFixed(3)); +- shape.transform.y = Number((shape.transform.y + 10).toFixed(3)); +- nextObject.shapes.push(shape); +- this.commitPayloadUpdate(nextPayload, nextObject.id, sortedShapes(nextObject).length - 1, `OK Pasted copied shape into ${nextObject.name}.`, "Paste shape failed schema validation"); +- } +- +- undoPreviewEdit() { +- if (!this.previewUndoStack.length) { +- this.statusLog.write("WARN Undo skipped: no Object Preview edit history is available."); +- this.updatePreviewEditActionState(); +- return; +- } +- const targetPayload = this.previewUndoStack.at(-1); +- const currentPayload = this.cloneCurrentPayload(); +- if (!this.applyPreviewHistoryPayload(targetPayload, "Undo")) { +- this.updatePreviewEditActionState(); +- return; +- } +- this.previewUndoStack.pop(); +- this.previewRedoStack.push(currentPayload); +- if (this.previewRedoStack.length > PREVIEW_HISTORY_LIMIT) { +- this.previewRedoStack.shift(); +- } +- this.updatePreviewEditActionState(); +- } +- +- redoPreviewEdit() { +- if (!this.previewRedoStack.length) { +- this.statusLog.write("WARN Redo skipped: no Object Preview redo history is available."); +- this.updatePreviewEditActionState(); +- return; +- } +- const targetPayload = this.previewRedoStack.at(-1); +- const currentPayload = this.cloneCurrentPayload(); +- if (!this.applyPreviewHistoryPayload(targetPayload, "Redo")) { +- this.updatePreviewEditActionState(); +- return; +- } +- this.previewRedoStack.pop(); +- this.previewUndoStack.push(currentPayload); +- if (this.previewUndoStack.length > PREVIEW_HISTORY_LIMIT) { +- this.previewUndoStack.shift(); +- } +- this.updatePreviewEditActionState(); +- } +- +- applyPreviewHistoryPayload(payload, actionLabel) { +- const validation = this.schemaService.validatePayload(payload); +- if (!validation.ok) { +- this.statusLog.write(`FAIL ${actionLabel} failed schema validation: ${validation.errors.join(" ")}`); +- return false; +- } +- const workspaceSync = this.syncWorkspaceToolSessionPayload(validation.payload, { +- changedKeys: ["data.objects"], +- reason: `object-vector-preview-${actionLabel.toLowerCase()}` +- }); +- if (!workspaceSync.ok) { +- this.statusLog.write(`FAIL ${actionLabel} failed schema validation: ${workspaceSync.message}`); +- return false; +- } +- this.currentPayload = validation.payload; +- if (!this.currentPayload.objects.some((object) => object.id === this.selectedObjectId)) { +- this.selectedObjectId = this.currentPayload.objects[0]?.id || ""; +- this.selectedShapeIndex = -1; +- this.selectedShapeIndexes.clear(); +- this.directSelectedShapeIndexes.clear(); +- } +- this.actionNav.setJsonPayloadActionsEnabled(true); +- this.renderPayload({ syncPaletteSelection: false }); +- if (workspaceSync.changed) { +- this.statusLog.write(`OK Object Vector Studio V2 workspace dirty state: true; reason=${workspaceSync.reason}; changedKeys=${workspaceSync.changedKeys.join(", ")}.`); +- } +- this.statusLog.write(`OK ${actionLabel} applied to Object Preview edits.`); +- return true; +- } +- +- updatePreviewEditActionState() { +- const object = this.selectedObject(); +- const shape = this.selectedShape(); +- const noShapeReason = "Disabled until a schema-valid shape is selected."; +- const noObjectReason = "Disabled until a schema-valid object is selected."; +- const lockedReason = object ? `Disabled because ${object.name} is locked for this runtime session.` : noObjectReason; +- const isLocked = Boolean(object && this.isObjectLocked(object.id)); +- this.setControlDisabled(this.elements.previewUndoButton, !this.previewUndoStack.length, "Disabled until an Object Preview edit can be undone.", "Undo the last Object Preview edit."); +- this.setControlDisabled(this.elements.previewRedoButton, !this.previewRedoStack.length, "Disabled until an Object Preview edit can be redone.", "Redo the last undone Object Preview edit."); +- this.setControlDisabled(this.elements.previewCopyButton, !shape, noShapeReason, "Copy the selected shape."); +- this.setControlDisabled(this.elements.previewPasteButton, !object || !this.previewClipboardShape || isLocked, !object ? noObjectReason : (isLocked ? lockedReason : "Disabled until a shape has been copied."), "Paste the copied shape into the selected object."); +- } +- +- updateObjectActionState() { +- const hasSelectedObject = Boolean(this.selectedObject()); +- const selectedObject = this.selectedObject(); +- const noObjectReason = "Disabled until a schema-valid object is selected."; +- const lockedReason = selectedObject ? `Disabled because ${selectedObject.name} is locked for this runtime session.` : noObjectReason; +- const isLocked = Boolean(selectedObject && this.isObjectLocked(selectedObject.id)); +- this.setControlDisabled(this.elements.renameObjectButton, !hasSelectedObject || isLocked, lockedReason, "Rename the selected object."); +- this.setControlDisabled(this.elements.duplicateObjectButton, !hasSelectedObject || isLocked, lockedReason, "Duplicate the selected object."); +- if (this.elements.deleteObjectButton) { +- this.setControlDisabled(this.elements.deleteObjectButton, !hasSelectedObject || isLocked, lockedReason, "Delete the selected object."); +- } +- this.setControlDisabled(this.elements.exportSvgButton, !hasSelectedObject, noObjectReason, "Export the selected object as SVG."); +- this.setControlDisabled(this.elements.runtimePreviewButton, !hasSelectedObject, noObjectReason, "Preview the selected object through the runtime asset renderer."); +- this.updatePreviewEditActionState(); +- this.updateAnimationActionState(); +- } +- +- updateAnimationActionState() { +- const state = this.selectedState(); +- const frames = sortedFrames(state); +- const hasFrame = Boolean(state && this.activeFrame()); +- const frameIndex = frames.findIndex((frame) => frame.id === this.selectedFrameId); +- const noFrameReason = "Disabled until the selected object has an active state frame."; +- const object = this.selectedObject(); +- const isLocked = Boolean(object && this.isObjectLocked(object.id)); +- const lockedReason = object ? `Disabled because ${object.name} is locked for this runtime session.` : noFrameReason; +- this.setControlDisabled(this.elements.duplicateFrameButton, !hasFrame || isLocked, isLocked ? lockedReason : noFrameReason, "Duplicate the active animation frame."); +- this.setControlDisabled(this.elements.deleteFrameButton, !hasFrame || isLocked || frames.length <= 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected state needs at least one frame." : noFrameReason), "Delete the selected animation frame."); +- this.setControlDisabled(this.elements.frameLeftButton, !hasFrame || isLocked || frameIndex <= 0, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already first." : noFrameReason), "Move the selected frame left."); +- this.setControlDisabled(this.elements.frameEarlierButton, !hasFrame || isLocked || frameIndex <= 0, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already first." : noFrameReason), "Move the selected frame earlier."); +- this.setControlDisabled(this.elements.frameLaterButton, !hasFrame || isLocked || frameIndex >= frames.length - 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already last." : noFrameReason), "Move the selected frame later."); +- this.setControlDisabled(this.elements.frameRightButton, !hasFrame || isLocked || frameIndex >= frames.length - 1, isLocked ? lockedReason : (hasFrame ? "Disabled because the selected frame is already last." : noFrameReason), "Move the selected frame right."); +- this.setControlDisabled(this.elements.playButton, !hasFrame || this.isAnimationPlaying, this.isAnimationPlaying ? "Disabled while playback is already running." : noFrameReason, "Play the selected state timeline."); +- this.setControlDisabled(this.elements.pauseButton, !this.isAnimationPlaying, "Disabled until playback starts.", "Pause animation playback."); +- this.setControlDisabled(this.elements.stopButton, !hasFrame, noFrameReason, "Stop animation playback."); +- } +- +- setControlDisabled(element, isDisabled, disabledReason, enabledTitle) { +- element.disabled = isDisabled; +- element.title = isDisabled ? disabledReason : enabledTitle; +- element.setAttribute("aria-disabled", String(isDisabled)); +- if (isDisabled) { +- element.dataset.disabledReason = disabledReason; +- return; +- } +- delete element.dataset.disabledReason; +- } +- +- async copyJson() { +- const payload = this.validatedJsonActionPayload("Copy JSON"); +- if (!payload) { +- return; +- } +- +- const json = JSON.stringify(payload, null, 2); +- if (typeof this.window.navigator?.clipboard?.writeText !== "function") { +- this.statusLog.write("WARN Clipboard API unavailable. JSON Details remains read-only on the active object."); +- return; +- } +- +- try { +- await this.window.navigator.clipboard.writeText(json); +- this.statusLog.write(`OK Object Vector Studio V2 JSON copied with ${payload.objects.length} objects.`); +- } catch (error) { +- this.statusLog.write(`FAIL Copy JSON failed: ${error.message}`); +- } +- } +- +- async previewRuntimeAsset() { +- const object = this.selectedObject(); +- const payload = this.validatedJsonActionPayload("Runtime Preview"); +- if (!object || !payload) { +- this.statusLog.write("FAIL Runtime Preview blocked: select a schema-valid Object Vector Studio V2 object first."); +- return; +- } +- if (!this.runtimePalette) { +- this.loadPaletteFromSessionKey(RUNTIME_PALETTE_SESSION_KEY) || this.loadPaletteFromWorkspaceSession(); +- } +- if (!this.runtimePalette) { +- this.statusLog.write("FAIL Runtime Preview blocked: runtime palette is required from workspace/session and must not be embedded in object JSON."); +- return; +- } +- +- const assetSet = await this.runtimeAssetService.loadPayload(payload, { +- sourceLabel: "Object Vector Studio V2 runtime preview" +- }); +- if (!assetSet) { +- return; +- } +- const result = this.runtimeAssetService.createSvgString(assetSet, { +- elapsedMs: 0, +- frameId: this.selectedFrameId, +- objectId: object.id, +- stateId: this.selectedStateId +- }); +- if (!result.ok) { +- return; +- } +- +- this.elements.renderSurface.dataset.runtimePreview = "true"; +- this.elements.renderSurface.dataset.runtimePreviewFrame = this.selectedFrameId || ""; +- this.elements.renderSurface.dataset.runtimePreviewState = this.selectedStateId || ""; +- const message = `Runtime preview launched for ${object.name}${this.selectedStateId ? ` state ${this.selectedStateId}` : ""}${this.selectedFrameId ? ` frame ${this.selectedFrameId}` : ""}.`; +- this.statusLog.write(`OK ${message} Runtime palette source ${this.runtimePaletteSource}; object JSON remains palette-free.`); +- } +- +- exportJson() { +- const payload = this.validatedJsonActionPayload("Export"); +- if (!payload) { +- return; +- } +- +- downloadTextFile(JSON.stringify(payload, null, 2), "object-vector-studio-v2.json", { +- documentRef: this.window.document, +- windowRef: this.window +- }); +- this.statusLog.write(`OK Export JSON prepared for ${payload.objects.length} Object Vector Studio V2 objects.`); +- } +- +- exportSelectedObjectSvg() { +- const object = this.selectedObject(); +- if (!object) { +- this.statusLog.write("FAIL Export SVG blocked: no Object Vector Studio V2 object is selected."); +- return; +- } +- +- try { +- const svg = this.createObjectSvgExport(object); +- downloadTextFile(svg, `${slugifyObjectName(object.name)}.svg`, { +- documentRef: this.window.document, +- mimeType: "image/svg+xml", +- windowRef: this.window +- }); +- const visibleShapeCount = sortedShapes(object).filter((shape) => shape.visible).length; +- this.statusLog.write(`OK Export SVG generated for ${object.name}: ${visibleShapeCount} visible shapes.`); +- } catch (error) { +- this.statusLog.write(`FAIL Export SVG failed for ${object.name}: ${error.message}`); +- } +- } +- +- createObjectSvgExport(object) { +- const validation = this.schemaService.validatePayload(this.currentPayload); +- if (!validation.ok) { +- throw new Error(`schema validation failed: ${validation.errors.join(" ")}`); +- } +- const visibleShapes = sortedShapes(object).filter((shape) => this.effectiveShape(shape).visible); +- if (!visibleShapes.length) { +- throw new Error("selected object has no visible shapes."); +- } +- const bounds = this.objectBounds(object, { includeInvisible: false }); +- const padding = 12; +- const svg = this.window.document.createElementNS(SVG_NS, "svg"); +- svg.setAttribute("xmlns", SVG_NS); +- svg.setAttribute("role", "img"); +- svg.setAttribute("aria-label", `${object.name} Object Vector Studio V2 export`); +- if (this.selectedStateId) { +- svg.dataset.objectState = this.selectedStateId; +- } +- if (this.selectedFrameId) { +- svg.dataset.objectFrame = this.selectedFrameId; +- } +- svg.setAttribute("viewBox", `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`); +- const transformOrigin = this.objectTransformOrigin(object); +- if (this.selectedStateId || this.selectedFrameId) { +- const metadata = this.window.document.createElementNS(SVG_NS, "metadata"); +- metadata.textContent = JSON.stringify({ +- frameId: this.selectedFrameId || null, +- stateId: this.selectedStateId || null, +- toolId: "object-vector-studio-v2" +- }); +- svg.append(metadata); +- } +- visibleShapes.forEach((shape) => { +- const element = this.createSvgShape(this.effectiveShape(shape), { transformOrigin }); +- element.removeAttribute("tabindex"); +- element.removeAttribute("class"); +- svg.append(element); +- this.appendShapePointStyleCaps(svg, this.effectiveShape(shape), { transformOrigin }); +- }); +- return new this.window.XMLSerializer().serializeToString(svg); +- } +- +- validatedJsonActionPayload(actionLabel) { +- if (!this.currentPayload) { +- this.statusLog.write(`FAIL ${actionLabel} blocked: no schema-valid Object Vector Studio V2 payload is loaded.`); +- return null; +- } +- const payload = this.cloneCurrentPayload(); +- const validation = this.schemaService.validatePayload(payload); +- if (!validation.ok) { +- this.statusLog.write(`FAIL ${actionLabel} blocked by Object Vector Studio V2 schema: ${validation.errors.join(" ")}`); +- return null; +- } +- return validation.payload; +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js +deleted file mode 100644 +index 9f52e34a9..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/bootstrap.js ++++ /dev/null +@@ -1,114 +0,0 @@ +-import { ToolStarterApp } from "./ToolStarterApp.js"; +-import { ActionNavControl } from "./controls/ActionNavControl.js"; +-import { AccordionSection } from "./controls/AccordionSection.js"; +-import { StatusLogControl } from "./controls/StatusLogControl.js"; +-import { ToolStarterShellControl } from "./controls/ToolStarterShellControl.js"; +-import { ObjectVectorStudioV2SchemaService } from "./services/ObjectVectorStudioV2SchemaService.js"; +- +-function requireElement(selector) { +- const element = document.querySelector(selector); +- if (!element) { +- throw new Error(`Missing required starter template element: ${selector}`); +- } +- return element; +-} +- +-function optionalElement(selector) { +- return document.querySelector(selector); +-} +- +-window.addEventListener("DOMContentLoaded", () => { +- const accordions = Array.from(document.querySelectorAll(".accordion-v2"), (section) => new AccordionSection(section)); +- const statusLog = new StatusLogControl({ +- log: requireElement("#statusLog"), +- clearButton: requireElement("#clearStatusButton") +- }); +- const app = new ToolStarterApp({ +- accordions, +- actionNav: new ActionNavControl({ +- returnToWorkspaceButton: requireElement("#returnToWorkspaceButton"), +- toolCopyJsonButton: requireElement("#objectVectorStudioV2CopyJsonButton"), +- toolExportJsonButton: requireElement("#objectVectorStudioV2ExportJsonButton"), +- toolImportJsonButton: requireElement("#objectVectorStudioV2ImportJsonButton"), +- toolImportJsonInput: requireElement("#objectVectorStudioV2ImportJsonInput"), +- toolNav: requireElement(".tool-starter__tool__menu"), +- workspaceNav: requireElement(".tool-starter__workspace__menu") +- }), +- elements: { +- addObjectButton: requireElement("#objectVectorStudioV2AddObjectButton"), +- addTagButton: requireElement("#objectVectorStudioV2AddTagButton"), +- angleSnapButton: requireElement("#objectVectorStudioV2AngleSnapButton"), +- centerDotButton: requireElement("#objectVectorStudioV2CenterDotButton"), +- coordinateDisplay: requireElement("#objectVectorStudioV2CoordinateDisplay"), +- deleteFrameButton: requireElement("#objectVectorStudioV2DeleteFrameButton"), +- deleteObjectButton: optionalElement("#objectVectorStudioV2DeleteObjectButton"), +- dependencyGraph: requireElement("#objectVectorStudioV2DependencyGraph"), +- duplicateFrameButton: requireElement("#objectVectorStudioV2DuplicateFrameButton"), +- duplicateObjectButton: requireElement("#objectVectorStudioV2DuplicateObjectButton"), +- exportSvgButton: requireElement("#objectVectorStudioV2ExportSvgButton"), +- fillOpacity: requireElement("#objectVectorStudioV2FillOpacity"), +- fpsInput: requireElement("#objectVectorStudioV2FpsInput"), +- frameEarlierButton: requireElement("#objectVectorStudioV2FrameEarlierButton"), +- frameLaterButton: requireElement("#objectVectorStudioV2FrameLaterButton"), +- frameLeftButton: requireElement("#objectVectorStudioV2FrameLeftButton"), +- frameRightButton: requireElement("#objectVectorStudioV2FrameRightButton"), +- frameTimeline: requireElement("#objectVectorStudioV2FrameTimeline"), +- gridRenderButton: requireElement("#objectVectorStudioV2GridRenderButton"), +- jsonDetails: requireElement("#objectVectorStudioV2JsonDetails"), +- leftPanel: requireElement(".tool-starter__panel--left"), +- loopToggle: requireElement("#objectVectorStudioV2LoopToggle"), +- shapeGeometryDetails: requireElement("#objectVectorStudioV2ShapeGeometryDetails"), +- shapeGeometryName: requireElement("#objectVectorStudioV2ShapeGeometryName"), +- shapeGeometrySummary: requireElement("#objectVectorStudioV2ShapeGeometrySummary"), +- shapeTransform: requireElement("#objectVectorStudioV2ShapeTransform"), +- objectNameInput: requireElement("#objectVectorStudioV2ObjectNameInput"), +- objectPreviewFooter: requireElement("#objectVectorStudioV2ObjectPreviewFooter"), +- objectTagInput: requireElement("#objectVectorStudioV2ObjectTagInput"), +- objectTagList: requireElement("#objectVectorStudioV2ObjectTagList"), +- objectTransform: requireElement("#objectVectorStudioV2ObjectTransform"), +- objectsContent: requireElement("#objectVectorStudioV2ObjectsContent"), +- objectsCount: requireElement("#objectVectorStudioV2ObjectsCount"), +- objectTiles: requireElement("#objectVectorStudioV2ObjectTiles"), +- paintModeButton: requireElement("#objectVectorStudioV2PaintModeButton"), +- paletteSortButtons: Array.from(document.querySelectorAll("[data-palette-sort]")), +- paletteSummary: requireElement("#objectVectorStudioV2PaletteSummary"), +- paletteSwatchCount: requireElement("#objectVectorStudioV2PaletteSwatchCount"), +- panDownButton: requireElement("#objectVectorStudioV2PanDownButton"), +- panLeftButton: requireElement("#objectVectorStudioV2PanLeftButton"), +- panRightButton: requireElement("#objectVectorStudioV2PanRightButton"), +- panUpButton: requireElement("#objectVectorStudioV2PanUpButton"), +- onionSkinToggle: requireElement("#objectVectorStudioV2OnionSkinToggle"), +- pauseButton: requireElement("#objectVectorStudioV2PauseButton"), +- playButton: requireElement("#objectVectorStudioV2PlayButton"), +- previewCopyButton: requireElement("#objectVectorStudioV2PreviewCopyButton"), +- previewPasteButton: requireElement("#objectVectorStudioV2PreviewPasteButton"), +- previewRedoButton: requireElement("#objectVectorStudioV2PreviewRedoButton"), +- previewUndoButton: requireElement("#objectVectorStudioV2PreviewUndoButton"), +- copyJsonDetailsButton: requireElement("#objectVectorStudioV2CopyJsonDetailsButton"), +- renameObjectButton: requireElement("#objectVectorStudioV2RenameObjectButton"), +- renderSurface: requireElement("#objectVectorStudioV2RenderSurface"), +- resetViewButton: requireElement("#objectVectorStudioV2ResetViewButton"), +- runtimePreviewButton: requireElement("#objectVectorStudioV2RuntimePreviewButton"), +- searchFilter: requireElement("#objectVectorStudioV2SearchFilter"), +- snapModeButton: requireElement("#objectVectorStudioV2SnapModeButton"), +- stopButton: requireElement("#objectVectorStudioV2StopButton"), +- strokeModeButton: requireElement("#objectVectorStudioV2StrokeModeButton"), +- strokeOpacity: requireElement("#objectVectorStudioV2StrokeOpacity"), +- strokeWidth: requireElement("#objectVectorStudioV2StrokeWidth"), +- tagFilter: requireElement("#objectVectorStudioV2TagFilter"), +- toolLabelModeButton: requireElement("#objectVectorStudioV2ToolLabelModeButton"), +- toolToggleGrid: requireElement("#objectVectorStudioV2ToolToggleGrid"), +- toolToggles: Array.from(document.querySelectorAll("[data-shape-tool]")), +- zoomInButton: requireElement("#objectVectorStudioV2ZoomInButton"), +- zoomOutButton: requireElement("#objectVectorStudioV2ZoomOutButton") +- }, +- schemaService: new ObjectVectorStudioV2SchemaService(), +- shell: new ToolStarterShellControl(), +- statusLog, +- windowRef: window +- }); +- +- app.start().catch((error) => { +- statusLog.write(`FAIL Object Vector Studio V2 schema contract failed to load: ${error.message}`); +- }); +-}); +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js +deleted file mode 100644 +index 1e03257e1..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/AccordionSection.js ++++ /dev/null +@@ -1,30 +0,0 @@ +-export class AccordionSection { +- constructor(section) { +- this.section = section; +- this.header = section?.querySelector(".accordion-v2__header") || null; +- this.content = section?.querySelector(".accordion-v2__content") || null; +- this.icon = this.header?.querySelector(".accordion-v2__icon") || null; +- } +- +- mount() { +- if (!this.section || !this.header || !this.content || this.header.dataset.accordionV2Bound === "true") { +- return; +- } +- +- this.header.dataset.accordionV2Bound = "true"; +- this.setOpen(this.section.dataset.accordionV2Open !== "false"); +- this.header.addEventListener("click", () => { +- this.setOpen(!this.section.classList.contains("is-open")); +- }); +- } +- +- setOpen(isOpen) { +- this.section.classList.toggle("is-open", isOpen); +- this.section.dataset.accordionV2Open = String(isOpen); +- this.header.setAttribute("aria-expanded", String(isOpen)); +- this.content.hidden = !isOpen; +- if (this.icon) { +- this.icon.dataset.accordionV2IconState = isOpen ? "open" : "closed"; +- } +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js +deleted file mode 100644 +index 8d704b10c..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ActionNavControl.js ++++ /dev/null +@@ -1,84 +0,0 @@ +-export class ActionNavControl { +- constructor({ +- locationRef = window.location, +- returnToWorkspaceButton, +- windowRef = window, +- toolCopyJsonButton, +- toolExportJsonButton, +- toolImportJsonButton, +- toolImportJsonInput, +- toolNav, +- workspaceNav +- }) { +- this.location = locationRef; +- this.returnToWorkspaceButton = returnToWorkspaceButton; +- this.toolCopyJsonButton = toolCopyJsonButton; +- this.toolExportJsonButton = toolExportJsonButton; +- this.toolImportJsonButton = toolImportJsonButton; +- this.toolImportJsonInput = toolImportJsonInput; +- this.toolNav = toolNav; +- this.workspaceNav = workspaceNav; +- this.window = windowRef; +- } +- +- mount({ +- onCopyJson, +- onExportJson, +- onImportJson, +- onReturnToWorkspace +- }) { +- this.applyLaunchMode(); +- this.toolImportJsonButton.addEventListener("click", () => { +- this.toolImportJsonInput.click(); +- }); +- this.toolImportJsonInput.addEventListener("change", () => { +- const file = this.toolImportJsonInput.files?.[0] || null; +- onImportJson(file); +- this.toolImportJsonInput.value = ""; +- }); +- this.toolCopyJsonButton.addEventListener("click", onCopyJson); +- this.toolExportJsonButton.addEventListener("click", onExportJson); +- this.returnToWorkspaceButton.addEventListener("click", () => { +- onReturnToWorkspace(this.workspaceManagerUrl()); +- }); +- } +- +- applyLaunchMode() { +- const params = new URLSearchParams(this.location.search); +- const isWorkspaceManagerLaunch = params.get("launch") === "workspace" +- && params.get("fromTool") === "workspace-manager-v2" +- && Boolean(params.get("hostContextId")); +- this.toolNav.hidden = isWorkspaceManagerLaunch; +- this.workspaceNav.hidden = !isWorkspaceManagerLaunch; +- } +- +- setJsonPayloadActionsEnabled(isEnabled) { +- this.toolCopyJsonButton.disabled = !isEnabled; +- this.toolExportJsonButton.disabled = !isEnabled; +- this.toolCopyJsonButton.title = isEnabled ? "Copy schema-valid Object Vector Studio V2 JSON." : "Disabled until a schema-valid Object Vector Studio V2 payload is loaded."; +- this.toolExportJsonButton.title = isEnabled ? "Export schema-valid Object Vector Studio V2 JSON." : "Disabled until a schema-valid Object Vector Studio V2 payload is loaded."; +- } +- +- setImportEnabled(isEnabled) { +- this.toolImportJsonButton.disabled = !isEnabled; +- this.toolImportJsonInput.disabled = !isEnabled; +- this.toolImportJsonButton.title = isEnabled ? "Import Object Vector Studio V2 JSON." : "Disabled while schema is loading or workspace launch owns the payload source."; +- } +- +- isWorkspaceLaunch() { +- return this.workspaceNav.hidden === false; +- } +- +- workspaceManagerUrl() { +- const url = new URL("../workspace-manager-v2/index.html", this.location.href); +- const params = new URLSearchParams(this.location.search); +- const hostContextId = params.get("hostContextId") || ""; +- if (hostContextId) { +- url.searchParams.set("hostContextId", hostContextId); +- } +- if (params.get("workspaceMode")?.toLowerCase() === "uat") { +- url.searchParams.set("workspace", "uat"); +- } +- return url.href; +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js +deleted file mode 100644 +index d89b7abb4..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/InspectorControl.js ++++ /dev/null +@@ -1,10 +0,0 @@ +-export class InspectorControl { +- constructor(output) { +- this.output = output; +- } +- +- showObject(value) { +- this.output.textContent = JSON.stringify(value, null, 2); +- } +-} +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js +deleted file mode 100644 +index 56f3eca72..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/PreviewPanelControl.js ++++ /dev/null +@@ -1,30 +0,0 @@ +-export class PreviewPanelControl { +- constructor(output) { +- this.output = output; +- } +- +- clear() { +- this.output.replaceChildren(this.createEmptyState()); +- } +- +- render(payload) { +- const card = document.createElement("article"); +- card.className = "tool-starter__preview-card"; +- +- const title = document.createElement("h2"); +- title.textContent = "Rendered Preview"; +- +- const body = document.createElement("p"); +- body.textContent = payload.sourceValue; +- +- card.append(title, body); +- this.output.replaceChildren(card); +- } +- +- createEmptyState() { +- const emptyState = document.createElement("p"); +- emptyState.textContent = "No preview rendered yet."; +- return emptyState; +- } +-} +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js +deleted file mode 100644 +index 42990f590..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/SourceInputControl.js ++++ /dev/null +@@ -1,40 +0,0 @@ +-export class SourceInputControl { +- constructor({ input, validationMessage }) { +- this.input = input; +- this.validationMessage = validationMessage; +- } +- +- mount({ onChange }) { +- this.input.addEventListener("input", onChange); +- } +- +- clear() { +- this.input.value = ""; +- this.showMessage("Input is required before Export can process.", false); +- } +- +- getValue() { +- return this.input.value.trim(); +- } +- +- hasValue() { +- return this.getValue().length > 0; +- } +- +- validate() { +- const value = this.getValue(); +- if (!value) { +- const message = "Source value is required."; +- this.showMessage(message, true); +- return { message, valid: false, value: "" }; +- } +- +- this.showMessage("Source value is ready.", false); +- return { message: "Source value is ready.", valid: true, value }; +- } +- +- showMessage(message, isError) { +- this.validationMessage.textContent = message; +- this.validationMessage.classList.toggle("is-error", isError); +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js +deleted file mode 100644 +index d5be2f9e9..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/StatusLogControl.js ++++ /dev/null +@@ -1,28 +0,0 @@ +-export class StatusLogControl { +- constructor({ log, clearButton }) { +- this.log = log; +- this.clearButton = clearButton; +- } +- +- mount() { +- this.clearButton.addEventListener("click", (event) => { +- event.stopPropagation(); +- this.clear(); +- }); +- } +- +- clear() { +- this.log.value = ""; +- } +- +- error(message) { +- this.write(`FAIL ${message}`); +- } +- +- write(message) { +- const timestamp = new Date().toLocaleTimeString(); +- const nextLine = `[${timestamp}] ${message}`; +- this.log.value = this.log.value ? `${this.log.value}\n${nextLine}` : nextLine; +- this.log.scrollTop = this.log.scrollHeight; +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js +deleted file mode 100644 +index 7c9b039c8..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/controls/ToolStarterShellControl.js ++++ /dev/null +@@ -1,130 +0,0 @@ +-export class ToolStarterShellControl { +- constructor({ documentRef = document } = {}) { +- this.document = documentRef; +- this.toolId = "object-vector-studio-v2"; +- this.toolName = "Object Vector Studio V2"; +- this.toolDescription = "First-Class Tools Surface V2"; +- this.headerText = `${this.toolName} - ${this.toolDescription}`; +- this.introText = this.toolDescription; +- this.suppressFullscreenSync = false; +- } +- +- mount() { +- this.document.body.classList.add("tools-platform-surface", "tool-starter-local-shell"); +- this.applyFullscreenState(Boolean(this.document.fullscreenElement)); +- this.bindHeaderDetails(); +- this.updateSummary(); +- } +- +- getHeaderDetails() { +- return this.document.querySelector(".is-collapsible"); +- } +- +- getSummaryElement() { +- return this.document.querySelector("[data-tool-starter-summary]"); +- } +- +- applyFullscreenState(isActive) { +- this.document.body.classList.toggle("tools-platform-fullscreen-active", isActive); +- this.document.documentElement.classList.toggle("tools-platform-fullscreen-active", isActive); +- if (isActive) { +- this.document.body.setAttribute("data-tools-platform-fullscreen", "1"); +- this.document.documentElement.setAttribute("data-tools-platform-fullscreen", "1"); +- return; +- } +- +- this.document.body.removeAttribute("data-tools-platform-fullscreen"); +- this.document.documentElement.removeAttribute("data-tools-platform-fullscreen"); +- } +- +- updateSummary() { +- const details = this.getHeaderDetails(); +- const summary = this.getSummaryElement(); +- if (!(details instanceof HTMLDetailsElement) || !(summary instanceof HTMLElement)) { +- return; +- } +- +- const isFullscreen = Boolean(this.document.fullscreenElement) +- || this.document.body.classList.contains("tools-platform-fullscreen-active"); +- const isExpanded = details.open === true; +- summary.textContent = isExpanded +- ? "Hide Header and Details" +- : (isFullscreen ? this.headerText : "Show Header and Details"); +- +- if (isFullscreen) { +- summary.style.maxWidth = "calc(100vw - 24px)"; +- summary.style.whiteSpace = "nowrap"; +- summary.style.overflow = "hidden"; +- summary.style.textOverflow = "ellipsis"; +- } else { +- summary.style.removeProperty("max-width"); +- summary.style.removeProperty("white-space"); +- summary.style.removeProperty("overflow"); +- summary.style.removeProperty("text-overflow"); +- } +- +- summary.setAttribute("data-tools-platform-summary-active", "1"); +- summary.setAttribute("data-tools-platform-summary-error", "0"); +- summary.setAttribute("data-tools-platform-summary-state", isExpanded ? "expanded" : "collapsed"); +- summary.setAttribute("data-tools-platform-summary-mode", isFullscreen ? "fullscreen" : "normal"); +- summary.setAttribute("data-tool-id", this.toolId); +- summary.setAttribute("title", `${this.headerText}\n${this.introText}`); +- } +- +- async enterFullscreenIfAvailable() { +- if (this.document.fullscreenElement || !this.document.fullscreenEnabled) { +- return; +- } +- if (typeof this.document.documentElement?.requestFullscreen !== "function") { +- return; +- } +- try { +- await this.document.documentElement.requestFullscreen(); +- } catch { +- // Collapsed header remains usable when fullscreen is unavailable. +- } +- } +- +- async exitFullscreenIfActive() { +- if (!this.document.fullscreenElement || typeof this.document.exitFullscreen !== "function") { +- return; +- } +- try { +- await this.document.exitFullscreen(); +- } catch { +- // The fullscreenchange listener will resync if the browser exits later. +- } +- } +- +- bindHeaderDetails() { +- const details = this.getHeaderDetails(); +- if (!(details instanceof HTMLDetailsElement)) { +- return; +- } +- +- details.addEventListener("toggle", () => { +- this.updateSummary(); +- if (this.suppressFullscreenSync) { +- this.suppressFullscreenSync = false; +- return; +- } +- +- if (details.open) { +- void this.exitFullscreenIfActive(); +- return; +- } +- +- void this.enterFullscreenIfAvailable(); +- }); +- +- this.document.addEventListener("fullscreenchange", () => { +- const isFullscreen = Boolean(this.document.fullscreenElement); +- this.applyFullscreenState(isFullscreen); +- if (!isFullscreen && !details.open) { +- this.suppressFullscreenSync = true; +- details.open = true; +- } +- this.updateSummary(); +- }); +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js +deleted file mode 100644 +index 644653b13..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ObjectVectorStudioV2SchemaService.js ++++ /dev/null +@@ -1,545 +0,0 @@ +-import { isPlainObject } from "../../../../src/shared/object/objects.js"; +-import { deepClone } from "../../../../src/shared/json/clone.js"; +- +-const SCHEMA_URL = new URL("../../../schemas/tools/object-vector-studio-v2.schema.json", import.meta.url); +- +-function isObjectIdentityId(value) { +- return /^object\.[a-z0-9-]+\.[a-z0-9][a-z0-9-]*$/.test(String(value || "")); +-} +- +-function withoutShapeOrigin(transform) { +- if (!isPlainObject(transform)) { +- return transform; +- } +- const { shapeOrigin, ...nextTransform } = transform; +- return nextTransform; +-} +- +-function typeMatches(expectedType, value) { +- if (expectedType === "array") { +- return Array.isArray(value); +- } +- if (expectedType === "object") { +- return isPlainObject(value); +- } +- if (expectedType === "integer") { +- return Number.isInteger(value); +- } +- if (expectedType === "number") { +- return typeof value === "number" && Number.isFinite(value); +- } +- return typeof value === expectedType; +-} +- +-function typeDescription(expectedType) { +- if (expectedType === "boolean") { +- return "true or false"; +- } +- return expectedType === "object" ? "an object" : `a ${expectedType}`; +-} +- +-function resolveSchemaRef(schemaRoot, ref) { +- if (typeof ref !== "string" || !ref.startsWith("#/")) { +- throw new Error(`unsupported schema reference ${ref || "unknown"}`); +- } +- return ref.slice(2).split("/").reduce((current, segment) => current?.[segment], schemaRoot); +-} +- +-export class ObjectVectorStudioV2SchemaService { +- constructor({ fetchRef = (...args) => fetch(...args), schemaUrl = SCHEMA_URL } = {}) { +- this.fetchRef = fetchRef; +- this.schema = null; +- this.schemaUrl = schemaUrl; +- } +- +- get schemaPath() { +- return this.schemaUrl.pathname || String(this.schemaUrl); +- } +- +- async loadSchema() { +- const response = await this.fetchRef(this.schemaUrl, { cache: "no-store" }); +- if (!response.ok) { +- throw new Error(`${this.schemaPath} returned ${response.status}`); +- } +- const schema = await response.json(); +- const schemaErrors = []; +- this.validateSchemaShape(schema, schemaErrors); +- if (schemaErrors.length) { +- throw new Error(schemaErrors.join(" ")); +- } +- this.schema = schema; +- return schema; +- } +- +- getDefinitionDefault(definitionName) { +- if (!this.schema) { +- throw new Error("Object Vector Studio V2 schema is not loaded."); +- } +- const definition = this.schema.$defs?.[definitionName]; +- if (!isPlainObject(definition) || !Object.prototype.hasOwnProperty.call(definition, "default")) { +- throw new Error(`Object Vector Studio V2 schema default is missing for $defs.${definitionName}.default.`); +- } +- return deepClone(definition.default); +- } +- +- validateSchemaShape(schema, errors) { +- if (!isPlainObject(schema)) { +- errors.push("Object Vector Studio V2 schema root must be an object."); +- return; +- } +- if (schema.$id !== "object-vector-studio-v2.schema.json") { +- errors.push("Object Vector Studio V2 schema id must be object-vector-studio-v2.schema.json."); +- } +- if (schema.additionalProperties !== false) { +- errors.push("Object Vector Studio V2 schema root must reject unknown properties."); +- } +- const requiredRootFields = ["version", "toolId", "name", "objects"]; +- if (!Array.isArray(schema.required) || requiredRootFields.some((key) => !schema.required.includes(key))) { +- errors.push("Object Vector Studio V2 schema root must require version, toolId, name, and objects."); +- } +- ["palette", "selection", "viewport", "export", "vectorMaps"].forEach((key) => { +- if (Object.prototype.hasOwnProperty.call(schema.properties || {}, key)) { +- errors.push(`Object Vector Studio V2 schema root must not define ${key}.`); +- } +- if (Object.prototype.hasOwnProperty.call(schema.$defs || {}, key === "export" ? "exportPayload" : key)) { +- errors.push(`Object Vector Studio V2 schema definitions must not define ${key}.`); +- } +- }); +- if (schema.properties?.toolId?.const !== "object-vector-studio-v2") { +- errors.push("Object Vector Studio V2 schema toolId must be object-vector-studio-v2."); +- } +- if (!isPlainObject(schema.$defs?.shape) || !Array.isArray(schema.$defs.shape.oneOf)) { +- errors.push("Object Vector Studio V2 schema must define shape variants."); +- } +- if (Object.prototype.hasOwnProperty.call(schema.$defs?.object?.properties || {}, "type")) { +- errors.push("Object Vector Studio V2 object schema must not define object type."); +- } +- if (Object.prototype.hasOwnProperty.call(schema.properties || {}, "assetLibrary")) { +- errors.push("Object Vector Studio V2 schema must not define assetLibrary."); +- } +- ["id", "shapeKey", "label", "type"].forEach((key) => { +- if (Object.prototype.hasOwnProperty.call(schema.$defs?.shapeCommon?.properties || {}, key)) { +- errors.push(`Object Vector Studio V2 shape schema must not define deprecated shape field ${key}.`); +- } +- }); +- const requiredShapeFields = schema.$defs?.shapeCommon?.required || []; +- if (!requiredShapeFields.includes("tool")) { +- errors.push("Object Vector Studio V2 shape schema must require tool."); +- } +- const overrideFields = schema.$defs?.shapeFrameOverride?.required || []; +- if (!overrideFields.includes("shapeIndex")) { +- errors.push("Object Vector Studio V2 shape frame override schema must require shapeIndex."); +- } +- [ +- "object", +- "shapeCommon", +- "style", +- "transform" +- ].forEach((definitionName) => { +- if (!Object.prototype.hasOwnProperty.call(schema.$defs?.[definitionName] || {}, "default")) { +- errors.push(`Object Vector Studio V2 schema must define $defs.${definitionName}.default.`); +- } +- }); +- [ +- "rectangleGeometry", +- "circleGeometry", +- "ellipseGeometry", +- "lineGeometry", +- "triangleGeometry", +- "polygonGeometry", +- "polylineGeometry", +- "arcGeometry", +- "textGeometry" +- ].forEach((definitionName) => { +- if (Object.prototype.hasOwnProperty.call(schema.$defs?.[definitionName] || {}, "default")) { +- errors.push(`Object Vector Studio V2 schema must not define pre-positioned $defs.${definitionName}.default geometry.`); +- } +- }); +- } +- +- validatePayload(payload) { +- if (!this.schema) { +- return { +- errors: ["Object Vector Studio V2 schema is not loaded."], +- ok: false, +- payload: null +- }; +- } +- +- const payloadForValidation = this.stripLegacyShapeOrigins(payload); +- const errors = []; +- this.validateLegacyRootFields(payloadForValidation, errors); +- this.validateValue(this.schema, payloadForValidation, "root", errors); +- if (!errors.length) { +- this.validatePayloadReferences(payloadForValidation, errors); +- } +- if (errors.length) { +- return { errors, ok: false, payload: null }; +- } +- +- return { +- errors: [], +- ok: true, +- payload: this.normalizePayload(payloadForValidation) +- }; +- } +- +- validateLegacyRootFields(payload, errors) { +- if (!isPlainObject(payload)) { +- return; +- } +- if (Object.prototype.hasOwnProperty.call(payload, "vectorMaps")) { +- errors.push("root.vectorMaps is deprecated legacy vector-map data. Remove root.vectorMaps and import Object Vector Studio V2 payloads with root.objects[].tags and root.objects[].shapes only."); +- } +- } +- +- validatePayloadReferences(payload, errors) { +- const objects = Array.isArray(payload?.objects) ? payload.objects : []; +- const objectsById = new Map(); +- objects.forEach((object, index) => { +- if (!isObjectIdentityId(object.id)) { +- errors.push(`root.objects[${index}].id ${object.id} must follow object.game.name.`); +- } +- if (objectsById.has(object.id)) { +- errors.push(`root.objects[${index}].id ${object.id} duplicates an existing object id.`); +- return; +- } +- objectsById.set(object.id, object); +- }); +- +- objects.forEach((object, index) => { +- this.validateInheritanceChain(object, objectsById, `root.objects[${index}]`, errors); +- const localShapeOrders = new Set(); +- object.shapes.forEach((shape, shapeIndex) => { +- if (localShapeOrders.has(shape.order)) { +- errors.push(`root.objects[${index}].shapes[${shapeIndex}].order ${shape.order} duplicates a local shape order.`); +- } +- localShapeOrders.add(shape.order); +- }); +- }); +- +- objects.forEach((object, index) => { +- const shapeCount = this.collectInheritedShapeCount(object, objectsById, new Set(), errors, `root.objects[${index}]`); +- (object.states || []).forEach((state, stateIndex) => { +- state.frames.forEach((frame, frameIndex) => { +- frame.shapeOverrides.forEach((override, overrideIndex) => { +- if (override.shapeIndex >= shapeCount) { +- errors.push(`root.objects[${index}].states[${stateIndex}].frames[${frameIndex}].shapeOverrides[${overrideIndex}].shapeIndex ${override.shapeIndex} must reference a local or inherited sorted shape row.`); +- } +- }); +- }); +- }); +- }); +- +- } +- +- validateInheritanceChain(object, objectsById, path, errors) { +- const seen = new Set([object.id]); +- let current = object; +- while (current?.baseObjectId) { +- const baseId = current.baseObjectId; +- if (seen.has(baseId)) { +- errors.push(`${path}.baseObjectId creates a circular inheritance chain at ${baseId}.`); +- return; +- } +- const baseObject = objectsById.get(baseId); +- if (!baseObject) { +- errors.push(`${path}.baseObjectId ${baseId} must reference an existing base object.`); +- return; +- } +- seen.add(baseId); +- current = baseObject; +- } +- } +- +- collectInheritedShapeCount(object, objectsById, seen, errors, path) { +- return this.collectInheritedShapesByOrder(object, objectsById, seen, errors, path).size; +- } +- +- collectInheritedShapesByOrder(object, objectsById, seen, errors, path) { +- const shapeByOrder = new Map(); +- if (!object || seen.has(object.id)) { +- if (object) { +- errors.push(`${path}.baseObjectId creates a circular shape inheritance chain at ${object.id}.`); +- } +- return shapeByOrder; +- } +- seen.add(object.id); +- if (object.baseObjectId) { +- const baseObject = objectsById.get(object.baseObjectId); +- if (baseObject) { +- this.collectInheritedShapesByOrder(baseObject, objectsById, seen, errors, path) +- .forEach((shape, order) => shapeByOrder.set(order, shape)); +- } +- } +- object.shapes.forEach((shape) => shapeByOrder.set(shape.order, shape)); +- return shapeByOrder; +- } +- +- validateRuntimePalette(palette, sourceLabel = "runtime palette") { +- const errors = []; +- if (!isPlainObject(palette)) { +- errors.push(`${sourceLabel} must be a palette object.`); +- return { errors, ok: false, palette: null }; +- } +- if (!Array.isArray(palette.swatches) || !palette.swatches.length) { +- errors.push(`${sourceLabel}.swatches must contain at least one swatch.`); +- return { errors, ok: false, palette: null }; +- } +- +- palette.swatches.forEach((swatch, index) => { +- if (!isPlainObject(swatch)) { +- errors.push(`${sourceLabel}.swatches[${index}] must be an object.`); +- return; +- } +- const label = swatch.id || swatch.name || swatch.symbol; +- if (typeof label !== "string" || !label.trim()) { +- errors.push(`${sourceLabel}.swatches[${index}] must provide id, name, or symbol.`); +- } +- const color = swatch.value || swatch.hex || swatch.color; +- if (typeof color !== "string" || !color.trim()) { +- errors.push(`${sourceLabel}.swatches[${index}] must provide value, hex, or color.`); +- } +- }); +- +- if (errors.length) { +- return { errors, ok: false, palette: null }; +- } +- return { +- errors: [], +- ok: true, +- palette: deepClone(palette) +- }; +- } +- +- stripLegacyShapeOrigins(payload) { +- if (!isPlainObject(payload)) { +- return payload; +- } +- const normalized = deepClone(payload); +- if (!Array.isArray(normalized?.objects)) { +- return normalized; +- } +- normalized.objects.forEach((object) => { +- if (Array.isArray(object.shapes)) { +- object.shapes.forEach((shape) => { +- shape.transform = withoutShapeOrigin(shape.transform); +- }); +- } +- if (Array.isArray(object.states)) { +- object.states.forEach((state) => { +- (state.frames || []).forEach((frame) => { +- (frame.shapeOverrides || []).forEach((override) => { +- if (isPlainObject(override.transform)) { +- override.transform = withoutShapeOrigin(override.transform); +- } +- }); +- }); +- }); +- } +- }); +- return normalized; +- } +- +- validateValue(schemaNode, value, path, errors) { +- const schema = schemaNode?.$ref ? resolveSchemaRef(this.schema, schemaNode.$ref) : schemaNode; +- if (!schema) { +- errors.push(`${path} references an unavailable Object Vector Studio V2 schema definition.`); +- return; +- } +- +- if (Array.isArray(schema.allOf)) { +- schema.allOf.forEach((entry) => this.validateValue(entry, value, path, errors)); +- return; +- } +- +- if (Array.isArray(schema.anyOf)) { +- const matched = schema.anyOf.some((entry) => { +- const optionErrors = []; +- this.validateValue(entry, value, path, optionErrors); +- return optionErrors.length === 0; +- }); +- if (!matched) { +- errors.push(`${path} must match at least one allowed schema option.`); +- } +- return; +- } +- +- if (Array.isArray(schema.oneOf)) { +- const matchedCount = schema.oneOf.reduce((count, entry) => { +- const optionErrors = []; +- this.validateValue(entry, value, path, optionErrors); +- return optionErrors.length === 0 ? count + 1 : count; +- }, 0); +- if (matchedCount !== 1) { +- errors.push(`${path} must match exactly one Object Vector Studio V2 shape schema.`); +- } +- return; +- } +- +- this.validateConst(schema, value, path, errors); +- this.validateEnum(schema, value, path, errors); +- if (schema.type && !this.validateType(schema, value, path, errors)) { +- return; +- } +- this.validateString(schema, value, path, errors); +- this.validateNumber(schema, value, path, errors); +- this.validateArray(schema, value, path, errors); +- this.validateObject(schema, value, path, errors); +- } +- +- validateConst(schema, value, path, errors) { +- if (!Object.prototype.hasOwnProperty.call(schema, "const")) { +- return; +- } +- if (value !== schema.const) { +- errors.push(`${path} must be ${schema.const}.`); +- } +- } +- +- validateEnum(schema, value, path, errors) { +- if (!Array.isArray(schema.enum)) { +- return; +- } +- if (!schema.enum.includes(value)) { +- errors.push(`${path} must be one of ${schema.enum.join(", ")}.`); +- } +- } +- +- validateType(schema, value, path, errors) { +- if (Array.isArray(schema.type)) { +- const matched = schema.type.some((expectedType) => typeMatches(expectedType, value)); +- if (!matched) { +- errors.push(`${path} must be one of ${schema.type.join(", ")}.`); +- } +- return matched; +- } +- const matched = typeMatches(schema.type, value); +- if (!matched) { +- errors.push(`${path} must be ${typeDescription(schema.type)}.`); +- } +- return matched; +- } +- +- validateString(schema, value, path, errors) { +- if (typeof value !== "string") { +- return; +- } +- if (Number.isInteger(schema.minLength) && value.trim().length < schema.minLength) { +- errors.push(`${path} must contain at least ${schema.minLength} characters.`); +- } +- } +- +- validateNumber(schema, value, path, errors) { +- if (typeof value !== "number" || !Number.isFinite(value)) { +- return; +- } +- if (typeof schema.minimum === "number" && value < schema.minimum) { +- errors.push(`${path} must be at least ${schema.minimum}.`); +- } +- if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) { +- errors.push(`${path} must be greater than ${schema.exclusiveMinimum}.`); +- } +- if (typeof schema.maximum === "number" && value > schema.maximum) { +- errors.push(`${path} must be at most ${schema.maximum}.`); +- } +- } +- +- validateArray(schema, value, path, errors) { +- if (!Array.isArray(value)) { +- return; +- } +- if (Number.isInteger(schema.minItems) && value.length < schema.minItems) { +- errors.push(`${path} must contain at least ${schema.minItems} items.`); +- } +- if (Number.isInteger(schema.maxItems) && value.length > schema.maxItems) { +- errors.push(`${path} must contain no more than ${schema.maxItems} items.`); +- } +- if (schema.items) { +- value.forEach((item, index) => this.validateValue(schema.items, item, `${path}[${index}]`, errors)); +- } +- } +- +- validateObject(schema, value, path, errors) { +- if (!isPlainObject(value)) { +- return; +- } +- if (Array.isArray(schema.required)) { +- schema.required.forEach((key) => { +- if (!Object.prototype.hasOwnProperty.call(value, key)) { +- errors.push(`${path}.${key} is required.`); +- } +- }); +- } +- const properties = isPlainObject(schema.properties) ? schema.properties : {}; +- Object.entries(properties).forEach(([key, childSchema]) => { +- if (Object.prototype.hasOwnProperty.call(value, key)) { +- this.validateValue(childSchema, value[key], `${path}.${key}`, errors); +- } +- }); +- if (isPlainObject(schema.additionalProperties)) { +- Object.entries(value).forEach(([key, child]) => { +- if (!Object.prototype.hasOwnProperty.call(properties, key)) { +- this.validateValue(schema.additionalProperties, child, `${path}.${key}`, errors); +- } +- }); +- } +- if (schema.additionalProperties === false) { +- Object.keys(value).forEach((key) => { +- if (!Object.prototype.hasOwnProperty.call(properties, key)) { +- errors.push(`${path}.${key} is not allowed.`); +- } +- }); +- } +- } +- +- normalizePayload(payload) { +- const normalized = deepClone(payload); +- normalized.name = normalized.name.trim(); +- normalized.objects = normalized.objects.map((object) => ({ +- ...object, +- baseObjectId: typeof object.baseObjectId === "string" ? object.baseObjectId.trim() : undefined, +- id: object.id.trim(), +- name: object.name.trim(), +- objectOrigin: isPlainObject(object.objectOrigin) +- ? { x: Number(object.objectOrigin.x), y: Number(object.objectOrigin.y) } +- : undefined, +- tags: Array.isArray(object.tags) ? object.tags.map((tag) => tag.trim()).filter(Boolean) : undefined, +- states: Array.isArray(object.states) +- ? object.states.map((state) => ({ +- ...state, +- id: state.id.trim().toLowerCase(), +- name: state.name.trim(), +- frames: state.frames.map((frame) => ({ +- ...frame, +- id: frame.id.trim(), +- shapeOverrides: frame.shapeOverrides.map((override) => ({ +- ...override, +- shapeIndex: override.shapeIndex +- })) +- })) +- })) +- : undefined, +- shapes: object.shapes.map((shape) => ({ +- ...shape, +- tool: shape.tool.trim().toLowerCase() +- })) +- })); +- normalized.objects = normalized.objects.map((object) => { +- if (object.states === undefined) { +- delete object.states; +- } +- if (object.baseObjectId === undefined) { +- delete object.baseObjectId; +- } +- if (object.objectOrigin === undefined) { +- delete object.objectOrigin; +- } +- if (object.tags === undefined) { +- delete object.tags; +- } +- return object; +- }); +- return normalized; +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js +deleted file mode 100644 +index 61ff8202f..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/js/services/ToolStateSerializer.js ++++ /dev/null +@@ -1,15 +0,0 @@ +-export class ToolStateSerializer { +- constructor(toolId) { +- this.toolId = toolId; +- } +- +- createToolState({ sourceValue }) { +- return { +- toolId: this.toolId, +- payload: { +- sourceValue +- } +- }; +- } +-} +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs +deleted file mode 100644 +index 3a5ab3bf7..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/playwright.config.mjs ++++ /dev/null +@@ -1,10 +0,0 @@ +-export default { +- testDir: "../../tests/playwright/tools", +- testMatch: "ObjectVectorStudioV2FirstClassToolStarter.spec.mjs", +- timeout: 30000, +- outputDir: "./tests/results", +- use: { +- headless: true, +- trace: "retain-on-failure" +- } +-}; +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt +deleted file mode 100644 +index ff08fbe09..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/possible.future.adds.txt ++++ /dev/null +@@ -1,31 +0,0 @@ +-Object Vector Studio V2 possible future geometry additions +- +-Architecture notes from recent discussion: +- +-- Object Vector Studio V2 should stay focused on reusable atomic vector objects. +-- Future World Vector or Scene layers should instance Object Vector objects instead of absorbing Object Vector editing concerns. +-- Keep Object Vector and World Vector separate so object authoring, scene composition, and runtime placement do not collapse into one contract. +-- Future 3D vector workflows can build from Object Vector concepts without changing the 2D object contract. +-- Future 3D concepts to explore separately: +- - point3d +- - camera +- - projection +- - mesh +- +-- polyline +- - Most likely next geometry addition. +- - Would support connected open point sequences without requiring polygon closure. +- +-- spline +- - Possible future addition for smooth point-driven curves. +- - Would need explicit control-point, interpolation, and runtime tessellation contracts before implementation. +- +-- path +- - Possible future addition for richer vector authoring. +- - Would require a stricter schema/editor contract before implementation. +- +-- point/marker +- - Possible future addition for anchors, control points, or lightweight markers. +- - Would need clear runtime/editor semantics before implementation. +- +-Tracking note: this file is informational only. No new geometry shape is implemented by this PR. +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css +deleted file mode 100644 +index d13d86d72..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/styles/toolStarter.css ++++ /dev/null +@@ -1,2408 +0,0 @@ +-@font-face { +- font-family: "0xProto Nerd Font"; +- src: url("../../../src/assets/fonts/0xProtoNerdFont/0xProtoNerdFontMono-Regular.ttf") format("truetype"); +- font-display: swap; +- font-style: normal; +- font-weight: 400; +-} +- +-:root { +- --tool-starter-bg: var(--bg-gradient, linear-gradient(180deg, #c4b5fd 0%, #a78bfa 18%, #8b5cf6 36%, #7c3aed 54%, #6d28d9 74%, #5b21b6 88%, #4c1d95 100%)); +- --tool-starter-panel: var(--panel, rgba(30, 24, 48, 0.92)); +- --tool-starter-surface: var(--surface-inline, rgba(18, 16, 28, 0.78)); +- --tool-starter-panel-strong: rgba(0, 0, 0, 0.24); +- --tool-starter-input-surface: var(--surface-inline, rgba(43, 16, 91, 0.9)); +- --tool-starter-line: var(--line, rgba(221, 214, 254, 0.26)); +- --tool-starter-surface-border: var(--surface-border, rgba(221, 214, 254, 0.22)); +- --tool-starter-text: var(--text, #f7f4ff); +- --tool-starter-muted: var(--muted, #c4b5fd); +- --tool-starter-header-muted: var(--muted, #e9ddff); +- --tool-starter-accent: var(--accent, #ddd6fe); +- --tool-starter-danger: #ffb4b4; +- --tool-starter-shadow: 0 18px 36px rgba(0, 0, 0, 0.22); +- --tool-starter-shadow-strong: 0 20px 44px rgba(0, 0, 0, 0.26); +- color-scheme: dark; +- font-family: Inter, Segoe UI, system-ui, sans-serif; +-} +- +-* { +- box-sizing: border-box; +-} +- +-body { +- min-height: 100vh; +- min-height: 100dvh; +- margin: 0; +- background: var(--tool-starter-bg); +- color: var(--tool-starter-text); +- overflow-x: hidden; +-} +- +-button, +-input, +-select, +-textarea { +- font: inherit; +-} +- +-button { +- border: 1px solid var(--tool-starter-line) !important; +- border-radius: 10px !important; +- background: ButtonFace; +- color: ButtonText; +- cursor: pointer; +- padding: 2px 6px; +-} +- +-.object-vector-studio-v2 { +- --object-vector-studio-v2-icon-scale: 1.75; +- --object-vector-studio-v2-snap-disabled-icon-color: #ef4444; +- --object-vector-studio-v2-snap-point-color: #fbbf24; +-} +- +-button:disabled { +- cursor: not-allowed; +- opacity: 0.62; +- filter: saturate(0.72); +- box-shadow: none; +-} +- +-button:focus-visible, +-input:focus-visible, +-select:focus-visible, +-textarea:focus-visible { +- outline: 2px solid var(--tool-starter-accent); +- outline-offset: 2px; +-} +- +-input, +-select, +-textarea { +- border: 1px solid var(--tool-starter-surface-border); +- border-radius: 10px !important; +- background: var(--tool-starter-input-surface); +- color: var(--tool-starter-text); +-} +- +-input:hover, +-select:hover, +-textarea:hover { +- border-color: var(--card-hover-border, #c4b5fd); +-} +- +-.is-collapsible { +- border-bottom: 1px solid var(--tool-starter-line); +- background: transparent; +-} +- +-.is-collapsible__summary { +- min-height: 36px; +- display: flex; +- align-items: center; +- cursor: pointer; +- color: var(--tool-starter-header-muted); +- font-weight: 700; +- padding: 8px 18px; +-} +- +-.is-collapsible__content { +- display: grid; +- gap: 0; +-} +- +-#shared-theme-header { +- min-height: 0; +-} +- +-.tool-starter__header { +- background: transparent; +- padding: 10px 16px; +-} +- +-.tool-starter__local-shell-frame { +- --tools-platform-frame-surface: var(--tool-starter-panel); +- --tools-platform-frame-line: var(--tool-starter-line); +- border: 1px solid var(--tool-starter-line); +- border-radius: 18px; +- background: var(--tool-starter-bg); +- box-shadow: var(--tool-starter-shadow); +- color: var(--tool-starter-text); +-} +- +-.tool-starter__local-shell-frame .tools-platform-frame__accordion-content { +- display: grid; +- gap: 10px; +- padding: 12px 14px; +-} +- +-.tool-starter__local-shell-frame .tools-platform-frame__accordion-summary { +- display: flex; +- align-items: center; +- justify-content: space-between; +- gap: 12px; +- border-radius: 12px; +- background: #4c1d95; +- padding: 12px 14px; +-} +- +-.tool-starter__local-shell-frame .tools-platform-frame__title { +- margin: 0; +- color: #ffffff; +-} +- +-.tool-starter__local-shell-frame .tools-platform-frame__eyebrow, +-.tool-starter__local-shell-frame .tools-platform-frame__meta, +-.tool-starter__local-shell-frame .tools-platform-frame__description { +- margin: 0; +- color: var(--tool-starter-header-muted); +-} +- +-.tool-starter__menu { +- box-sizing: border-box; +- width: calc(100% - 32px); +- display: flex; +- align-items: center; +- justify-content: center; +- flex-wrap: wrap; +- gap: 12px; +- margin: 16px 16px 0; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel); +- padding: 10px 14px; +-} +- +-.tool-starter__menu[hidden] { +- display: none; +-} +- +-.tool-starter.app-shell { +- height: calc(100vh - 137px); +- min-height: 0; +- width: min(1600px, calc(100% - 32px)); +- margin: 0 auto 16px; +- display: grid; +- grid-template-columns: 340px minmax(0, 1fr) 360px; +- gap: 10px; +- border: 1px solid var(--tool-starter-line); +- border-radius: 20px; +- box-shadow: var(--tool-starter-shadow-strong); +- overflow: hidden; +- padding: 12px; +-} +- +-.tool-starter__panel { +- min-width: 0; +- min-height: 0; +- display: flex; +- flex-direction: column; +- gap: 5px; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel); +- overflow: hidden; +- padding: 8px; +-} +- +-.tool-starter__panel--center { +- min-height: 0; +-} +- +-.tool-starter__panel--left { +- overflow-y: auto; +-} +- +-.tool-starter__panel--right { +- overflow-y: auto; +-} +- +-.tool-starter__panel--left > .accordion-v2.is-open { +- flex: 1 0 220px; +-} +- +-.tool-starter__panel--left > .accordion-v2.tool-starter__accordion--compact.is-open { +- flex: 0 0 auto; +-} +- +-.tool-starter__panel--left > .accordion-v2.tool-starter__accordion--compact.is-open > .accordion-v2__content { +- flex: 0 0 auto; +-} +- +-.accordion-v2 { +- --accordion-v2-accent: var(--tool-starter-accent); +- --accordion-v2-line: var(--tool-starter-line); +- --accordion-v2-surface: var(--tool-starter-surface); +- min-width: 0; +- min-height: 0; +- display: flex; +- flex-direction: column; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-surface); +- box-shadow: 0 14px 30px rgba(0, 0, 0, 0.12); +- overflow: hidden; +-} +- +-.accordion-v2.is-open { +- flex: 1 1 0; +-} +- +-.accordion-v2:not(.is-open) { +- flex: 0 0 auto; +-} +- +-.accordion-v2__header { +- width: 100%; +- min-height: 36px; +- display: flex; +- align-items: center; +- justify-content: space-between; +- gap: 8px; +- border: 0; +- border-radius: 0; +- background: transparent; +- color: inherit; +- font-weight: 700; +- padding: 6px 10px; +- text-align: left; +- cursor: pointer; +-} +- +-.accordion-v2__icon { +- width: 22px; +- height: 22px; +- display: inline-grid; +- place-items: center; +- border: 1px solid var(--tool-starter-line); +- border-radius: 999px; +- line-height: 1; +-} +- +-.accordion-v2.is-open .accordion-v2__icon { +- transform: rotate(45deg); +-} +- +-.accordion-v2__content { +- min-height: 0; +- display: flex; +- flex: 1 1 auto; +- flex-direction: column; +- gap: 6px; +- border-top: 1px solid var(--tool-starter-line); +- overflow: auto; +- padding: 8px; +-} +- +-.accordion-v2__content[hidden] { +- display: none; +-} +- +-.tool-starter__status-accordion-header { +- flex: 0 0 36px; +- min-width: 0; +-} +- +-.tool-starter__status-header-actions { +- flex: 0 0 auto; +- display: inline-flex; +- align-items: center; +- gap: 8px; +-} +- +-.tool-starter__status-clear-button { +- flex: 0 0 auto; +- margin-right: 0; +- border-color: var(--tool-starter-line) !important; +- border-radius: 10px !important; +- background: ButtonFace; +- color: ButtonText; +- padding: 2px 6px; +-} +- +-.tool-starter__status-clear-button:hover { +- border-color: var(--tool-starter-line) !important; +- background: ButtonFace; +- color: ButtonText; +-} +- +-.tool-starter__accordion--fill { +- flex: 1 1 auto; +-} +- +-.tool-starter__accordion--compact.is-open { +- flex: 0 0 auto; +-} +- +-.tool-starter__accordion--compact .accordion-v2__content { +- flex: 0 0 auto; +-} +- +-.tool-starter__field { +- min-width: 0; +- display: grid; +- grid-template-columns: 74px minmax(0, 1fr); +- align-items: center; +- gap: 8px; +- font-weight: 700; +-} +- +-.tool-starter__field--compact { +- grid-template-columns: 96px minmax(0, 1fr); +-} +- +-.tool-starter__field--stacked { +- grid-template-columns: 1fr; +- gap: 6px; +-} +- +-.tool-starter__field-patterns { +- display: grid; +- gap: 8px; +-} +- +-.tool-starter__field input { +- width: 100%; +- min-width: 0; +- padding: 7px 9px; +-} +- +-#objectVectorStudioV2ObjectContent .tool-starter__field--compact > span { +- white-space: nowrap; +-} +- +-.tool-starter__field input[readonly] { +- color: var(--tool-starter-muted); +- cursor: default; +-} +- +-.tool-starter__field--not-editable input { +- padding-block: 0; +-} +- +-.tool-starter__field--editable input { +- background: var(--tool-starter-input-surface); +- color: var(--tool-starter-text); +-} +- +-.tool-starter__editable-tags-box { +- min-height: 36px; +- border-radius: 8px !important; +- background: var(--tool-starter-panel-strong); +- padding: 6px; +-} +- +-.tool-starter__hint { +- margin: 0; +- color: var(--tool-starter-muted); +- font-size: 0.9rem; +-} +- +-.tool-starter__hint.is-error { +- color: var(--tool-starter-danger); +-} +- +-.object-vector-studio-v2__callout, +-.object-vector-studio-v2__details { +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel-strong); +- color: var(--tool-starter-text); +- padding: 10px; +-} +- +-.object-vector-studio-v2__scroll-content { +- min-height: 0; +- overflow: auto; +-} +- +-#objectVectorStudioV2ObjectsContent { +- overflow-y: auto; +-} +- +-#objectVectorStudioV2ShapeToolsContent, +-#objectVectorStudioV2PaletteContent { +- flex: 0 1 auto; +- max-height: 260px; +-} +- +-#objectVectorStudioV2PaletteContent { +- flex: 0 0 auto; +- max-height: none; +-} +- +-#objectVectorStudioV2ShapeToolsContent { +- flex: 0 0 auto; +- max-height: none; +- min-height: 0; +-} +- +-.tool-starter__panel--right > .accordion-v2.is-open { +- flex: 0 0 auto; +-} +- +-.tool-starter__panel--right > .accordion-v2.is-open > .accordion-v2__content { +- flex: 0 0 auto; +-} +- +-.tool-starter__panel--right > .accordion-v2.is-open > .accordion-v2__content:not(#objectVectorStudioV2ShapeToolsContent) { +- max-height: 260px; +-} +- +-.tool-starter__panel--right > .object-vector-studio-v2__shape-geometry-accordion.is-open { +- flex: 1 1 320px; +- min-height: 220px; +-} +- +-.tool-starter__panel--right > .accordion-v2.object-vector-studio-v2__shape-geometry-accordion.is-open > .accordion-v2__content#objectVectorStudioV2ShapeGeometryContent { +- flex: 1 1 auto; +- max-height: none; +-} +- +-#objectVectorStudioV2ShapeGeometryDetails { +- flex: 1 1 auto; +- min-height: 0; +-} +- +-#objectVectorStudioV2ShapeGeometryDetails > .object-vector-studio-v2__object-detail-stack { +- min-height: 100%; +-} +- +-.object-vector-studio-v2__palette-swatch-list { +- display: grid; +- flex: 0 0 auto; +- grid-template-columns: repeat(auto-fill, 32px); +- gap: 8px; +- align-content: start; +- max-height: 176px; +- min-height: 0; +- overflow-y: auto; +-} +- +-.object-vector-studio-v2__palette-swatch-list:not(:has(.object-vector-studio-v2__palette-swatch)) { +- display: block; +- max-height: none; +- overflow: visible; +- color: var(--tool-starter-muted); +- font-size: 0.85rem; +- line-height: 1.25; +- text-align: center; +-} +- +-.object-vector-studio-v2__palette-swatch { +- position: relative; +- width: 32px; +- height: 32px; +- min-height: 32px; +- aspect-ratio: 1; +- display: block; +- padding: 0 !important; +- overflow: visible; +- background: var(--object-vector-studio-v2-swatch, transparent) !important; +- border-radius: 4px !important; +- color: transparent !important; +- font-size: 0; +-} +- +-.object-vector-studio-v2__palette-swatch.is-selected { +- border-color: #ffffff !important; +- outline: 3px solid var(--tool-starter-accent-strong); +- outline-offset: 2px; +- box-shadow: 0 0 0 2px #020617, 0 0 0 6px rgba(20, 184, 166, 0.34); +-} +- +-.object-vector-studio-v2__palette-swatch::before { +- display: none; +-} +- +-.object-vector-studio-v2__palette-swatch.is-selected::before { +- content: ""; +- position: absolute; +- left: 11px; +- top: 7px; +- display: block; +- width: 8px; +- height: 14px; +- border: solid #ffffff; +- border-width: 0 3px 3px 0; +- filter: drop-shadow(0 1px 2px #020617); +- transform: rotate(45deg); +-} +- +-.object-vector-studio-v2__palette-swatch:hover::after, +-.object-vector-studio-v2__palette-swatch:focus-visible::after { +- content: attr(data-palette-details); +- position: absolute; +- left: 50%; +- bottom: calc(100% + 6px); +- z-index: 5; +- min-width: 96px; +- transform: translateX(-50%); +- border: 1px solid var(--tool-starter-line); +- border-radius: 6px; +- background: rgba(15, 23, 42, 0.96); +- color: var(--tool-starter-text); +- font-size: 0.74rem; +- line-height: 1.25; +- padding: 5px 7px; +- text-align: center; +- white-space: pre-line; +- text-indent: 0; +-} +- +-.object-vector-studio-v2__object-actions, +-.object-vector-studio-v2__tool-display-actions, +-.object-vector-studio-v2__snap-actions, +-.object-vector-studio-v2__z-order-actions, +-.object-vector-studio-v2__viewport-controls, +-.object-vector-studio-v2__preview-edit-toolbar, +-.object-vector-studio-v2__palette-controls, +-.object-vector-studio-v2__json-actions { +- display: flex; +- align-items: center; +- justify-content: center; +- flex-wrap: wrap; +- gap: 8px; +-} +- +-.object-vector-studio-v2__control-group { +- display: grid; +- gap: 8px; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel-strong); +- padding: 10px; +-} +- +-#objectVectorStudioV2ShapeToolsContent .object-vector-studio-v2__snap-actions { +- display: grid; +- grid-template-columns: repeat(2, minmax(0, 1fr)); +- align-items: stretch; +-} +- +-#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions { +- display: grid; +- grid-template-columns: repeat(auto-fit, 34px); +- align-items: stretch; +- justify-content: center; +- gap: 4px; +- border-top: 1px solid var(--tool-starter-line); +- margin-top: 3px; +- padding-top: 4px; +-} +- +-#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__shape-list-actions { +- width: 100%; +- grid-column: 1 / -1; +- margin-top: 2px; +-} +- +-.object-vector-studio-v2__z-action { +- width: auto; +- min-height: 0; +- min-width: 86px; +- position: relative; +- display: inline-flex; +- flex-direction: column; +- align-items: center; +- justify-content: center; +- gap: 3px; +- overflow: hidden; +- padding: 4px !important; +- text-align: center; +-} +- +-.object-vector-studio-v2__control-group h3, +-.object-vector-studio-v2__object-detail-stack h3 { +- margin: 0; +- font-size: 0.95rem; +- line-height: 1.25; +-} +- +-.object-vector-studio-v2__object-actions button { +- min-width: 68px; +- min-height: 32px; +-} +- +-.object-vector-studio-v2__tool-toggle-grid { +- display: grid; +- grid-template-columns: repeat(auto-fill, 76px); +- align-items: center; +- justify-content: center; +- gap: 8px; +-} +- +-.object-vector-studio-v2__tool-toggle { +- width: auto; +- min-height: 0; +- min-width: 76px; +- display: inline-flex; +- flex-direction: column; +- align-items: center; +- justify-content: center; +- gap: 4px; +- overflow: hidden; +- padding: 5px 4px !important; +- text-align: center; +-} +- +-.object-vector-studio-v2__tool-toggle--future { +- cursor: not-allowed; +-} +- +-.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__shape-icon, +-.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__tool-label { +- color: var(--muted); +-} +- +-.object-vector-studio-v2__tool-label { +- max-width: 100%; +- overflow: hidden; +- font-size: 0.68rem; +- line-height: 1; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__z-label { +- max-width: 100%; +- overflow: hidden; +- font-size: 0.68rem; +- line-height: 1; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__shape-icon { +- width: 24px; +- height: 24px; +- display: inline-grid; +- place-items: center; +- font-size: 1.08rem; +- color: var(--tool-starter-text); +-} +- +-.object-vector-studio-v2__tool-toggle-grid.is-icon-only { +- grid-template-columns: repeat(auto-fill, 42px); +-} +- +-.object-vector-studio-v2__tool-toggle-grid.is-icon-only .object-vector-studio-v2__tool-toggle { +- width: 100%; +- min-width: 0; +- min-height: 0; +- aspect-ratio: 1; +- gap: 0; +- padding: 4px; +-} +- +-.object-vector-studio-v2__tool-toggle-grid.is-icon-only .object-vector-studio-v2__tool-label { +- width: 1px; +- height: 1px; +- position: absolute; +- overflow: hidden; +- clip: rect(0 0 0 0); +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__z-order-actions.is-icon-only { +- grid-template-columns: repeat(auto-fill, 42px); +-} +- +-#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions.is-icon-only { +- grid-template-columns: repeat(auto-fit, 34px); +-} +- +-.object-vector-studio-v2__z-order-actions.is-icon-only .object-vector-studio-v2__z-action { +- width: 100%; +- min-width: 0; +- min-height: 0; +- aspect-ratio: 1; +- padding: 2px !important; +-} +- +-.object-vector-studio-v2__z-order-actions.is-icon-only .object-vector-studio-v2__z-label { +- width: 1px; +- height: 1px; +- position: absolute; +- overflow: hidden; +- clip: rect(0 0 0 0); +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__z-icon { +- width: 24px; +- height: 24px; +- position: relative; +- display: inline-grid; +- place-items: center; +- color: var(--tool-starter-text); +-} +- +-.object-vector-studio-v2__tool-toggle.is-active, +-.object-vector-studio-v2__tool-toggle[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); +-} +- +-.object-vector-studio-v2__object-tiles { +- min-height: 0; +- flex: 1 0 auto; +- display: grid; +- align-content: start; +- gap: 8px; +- overflow: visible; +- padding-right: 2px; +-} +- +-.object-vector-studio-v2__object-actions { +- display: grid; +- grid-template-columns: repeat(3, minmax(0, 1fr)); +- gap: 4px; +- margin-top: 0; +- padding-top: 0; +-} +- +-.object-vector-studio-v2__object-actions button { +- height: 28px; +- min-width: 0; +- min-height: 28px; +- padding: 2px 5px !important; +- font-size: 0.78rem; +- line-height: 1; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__library-panel, +-.object-vector-studio-v2__asset-library { +- min-height: 0; +- gap: 8px; +-} +- +-.object-vector-studio-v2__library-panel { +- display: grid; +-} +- +-.object-vector-studio-v2__asset-library { +- display: flex; +- flex-direction: column; +- overflow: visible; +-} +- +-.object-vector-studio-v2__object-tile { +- width: 100%; +- min-height: 52px; +- display: grid; +- grid-template-columns: minmax(0, 1fr) auto; +- align-items: center; +- gap: 6px; +- border: 1px solid var(--tool-starter-line); +- background: var(--tool-starter-panel-strong); +- border-radius: 8px !important; +- padding: 8px 10px; +- text-align: left; +-} +- +-.object-vector-studio-v2__asset-tile { +- position: relative; +- z-index: 1; +- width: 100%; +- flex: 0 0 auto; +- min-height: 54px; +- display: grid; +- gap: 4px; +- border-radius: 8px !important; +- padding: 8px 10px; +- text-align: left; +-} +- +-.object-vector-studio-v2__asset-tile--empty { +- color: var(--tool-starter-muted); +-} +- +-.object-vector-studio-v2__object-tile.is-selected, +-.object-vector-studio-v2__object-tile[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- background: var(--tool-starter-input-surface); +-} +- +-.object-vector-studio-v2__object-tile.is-hidden { +- opacity: 0.62; +-} +- +-.object-vector-studio-v2__object-tile.is-locked { +- box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.58); +-} +- +-.object-vector-studio-v2__object-tile--empty { +- grid-template-columns: 1fr; +- color: var(--tool-starter-muted); +-} +- +-.object-vector-studio-v2__object-tile-copy { +- min-width: 0; +- display: grid; +- gap: 2px; +-} +- +-.object-vector-studio-v2__object-select { +- min-width: 0; +- width: 100%; +- display: grid; +- grid-template-columns: 50px minmax(0, 1fr); +- align-items: center; +- gap: 8px; +- background: transparent; +- text-align: left; +- justify-items: start; +-} +- +-.object-vector-studio-v2__object-tile-controls { +- align-self: center; +- justify-self: end; +- position: relative; +- z-index: 2; +- display: flex; +- flex-direction: column; +- flex-wrap: nowrap; +- justify-content: center; +- align-items: center; +- gap: 2px; +-} +- +-.object-vector-studio-v2__object-tile-control { +- position: relative; +- z-index: 3; +- width: 26px; +- height: 26px; +- min-height: 26px; +- padding: 0 !important; +-} +- +-.object-vector-studio-v2__object-tile-control--delete { +- color: var(--tool-starter-danger); +-} +- +-.object-vector-studio-v2__object-state-panel { +- grid-column: 1 / -1; +- display: grid; +- gap: 4px; +- border-top: 1px solid var(--tool-starter-line); +- margin-top: 2px; +- padding-top: 4px; +-} +- +-.object-vector-studio-v2__object-state-controls { +- display: grid; +- grid-template-columns: auto minmax(74px, 1fr) auto 26px; +- align-items: center; +- gap: 4px; +-} +- +-.object-vector-studio-v2__object-state-controls button, +-.object-vector-studio-v2__object-state-controls select { +- min-height: 26px; +- padding: 2px 5px !important; +- font-size: 0.72rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__object-state-controls select { +- min-width: 0; +-} +- +-.object-vector-studio-v2__object-state-help { +- width: 26px; +- min-width: 26px; +- padding-inline: 0 !important; +- font-weight: 900; +-} +- +-.object-vector-studio-v2__object-state-tiles { +- display: flex; +- flex-wrap: wrap; +- gap: 4px; +-} +- +-.object-vector-studio-v2__object-state-tiles button { +- min-height: 24px; +- min-width: 46px; +- padding: 2px 6px !important; +- font-size: 0.68rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__object-state-tiles button.is-selected, +-.object-vector-studio-v2__object-state-tiles button[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); +-} +- +-.object-vector-studio-v2__object-tile-shapes { +- grid-column: 1 / -1; +- display: grid; +- gap: 1px; +- border-top: 1px solid var(--tool-starter-line); +- padding-top: 4px; +-} +- +-.object-vector-studio-v2__object-tile-shape-row { +- display: grid; +- grid-template-columns: minmax(0, 1fr) auto; +- align-items: center; +- gap: 4px; +- font-size: 0.7rem; +- line-height: 1.08; +-} +- +-.object-vector-studio-v2__shape-select { +- width: 100% !important; +- min-height: 22px; +- position: relative; +- z-index: 3; +- display: flex; +- align-items: center; +- justify-content: flex-start; +- gap: 6px; +- padding: 2px 5px !important; +- text-align: left; +-} +- +-.object-vector-studio-v2__shape-select-label { +- min-width: 0; +- display: inline-flex; +- align-items: center; +- gap: 4px; +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__shape-inline-actions { +- display: inline-flex; +- align-items: center; +- justify-content: flex-end; +- gap: 2px; +-} +- +-.object-vector-studio-v2__shape-inline-button { +- width: 24px; +- height: 24px; +- min-height: 24px; +- display: inline-grid; +- place-items: center; +- padding: 0 !important; +-} +- +-.object-vector-studio-v2__shape-group-indicator[data-ovs-icon] { +- font-size: 0.78rem; +-} +- +-.object-vector-studio-v2__object-tile-shape-row.is-selected .object-vector-studio-v2__shape-select, +-.object-vector-studio-v2__shape-select[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.2); +-} +- +-.object-vector-studio-v2__tile-icon { +- width: 14px; +- height: 14px; +- position: relative; +- display: inline-grid; +- place-items: center; +- color: var(--tool-starter-text); +- pointer-events: none; +-} +- +-.object-vector-studio-v2__tile-icon.is-off { +- opacity: 0.42; +-} +- +-.object-vector-studio-v2 [data-ovs-icon]::before { +- content: attr(data-ovs-icon); +- display: inline-block; +- font-family: "0xProto Nerd Font", "Cascadia Mono", monospace; +- font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75)); +- font-style: normal; +- font-weight: 400; +- line-height: 1; +- text-rendering: geometricPrecision; +-} +- +-.object-vector-studio-v2 button[data-ovs-icon] { +- display: inline-flex; +- align-items: center; +- justify-content: center; +- gap: 4px; +-} +- +-.object-vector-studio-v2__shape-icon[data-ovs-icon], +-.object-vector-studio-v2__z-icon[data-ovs-icon], +-.object-vector-studio-v2__tile-icon[data-ovs-icon] { +- display: inline-grid; +- place-items: center; +- border: 0; +- outline: 0; +- background: transparent; +- clip-path: none; +- color: var(--tool-starter-text); +- transform: none; +-} +- +-.object-vector-studio-v2__snap-actions button[data-ovs-icon]::before { +- color: inherit; +-} +- +-#objectVectorStudioV2SnapModeButton[data-snap-mode="point"][data-ovs-icon]::before { +- color: var(--object-vector-studio-v2-snap-point-color); +-} +- +-#objectVectorStudioV2SnapModeButton[data-snap-mode="none"][data-ovs-icon]::before, +-#objectVectorStudioV2AngleSnapButton[aria-pressed="false"][data-ovs-icon]::before, +-#objectVectorStudioV2GridRenderButton[aria-pressed="false"][data-ovs-icon]::before { +- color: var(--object-vector-studio-v2-snap-disabled-icon-color); +-} +- +-.object-vector-studio-v2__shape-icon[data-ovs-icon] { +- width: 24px; +- height: 24px; +- font-size: 1.08rem; +-} +- +-.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__shape-icon[data-ovs-icon], +-.object-vector-studio-v2__tool-toggle--future .object-vector-studio-v2__tool-label { +- color: var(--muted); +-} +- +-.object-vector-studio-v2__shape-icon--select[data-ovs-icon]::before { +- font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.125); +-} +- +-.object-vector-studio-v2__shape-icon--triangle[data-ovs-icon]::before { +- font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.25); +-} +- +-.object-vector-studio-v2__shape-icon--rectangle[data-ovs-icon]::before { +- font-size: calc(1em * var(--object-vector-studio-v2-icon-scale, 1.75) * 1.25); +-} +- +-.object-vector-studio-v2__z-icon[data-ovs-icon] { +- width: 24px; +- height: 24px; +- position: relative; +- font-size: 1.05rem; +-} +- +-.object-vector-studio-v2__tile-icon[data-ovs-icon] { +- width: 14px; +- height: 14px; +- position: relative; +- font-size: 0.84rem; +-} +- +-.object-vector-studio-v2__shape-icon[data-ovs-icon]::before, +-.object-vector-studio-v2__z-icon[data-ovs-icon]::before, +-.object-vector-studio-v2__tile-icon[data-ovs-icon]::before { +- position: static; +- width: auto; +- height: auto; +- border: 0; +- background: transparent; +- border-radius: 0; +- transform-origin: center; +-} +- +-.object-vector-studio-v2__shape-icon[data-ovs-icon]::after, +-.object-vector-studio-v2__z-icon[data-ovs-icon]::after, +-.object-vector-studio-v2__tile-icon[data-ovs-icon]::after { +- content: none; +- display: none; +-} +- +-.object-vector-studio-v2__shape-group-indicator.object-vector-studio-v2__tile-icon[data-ovs-icon], +-#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__shape-group-indicator[data-ovs-icon] { +- color: var(--object-vector-studio-v2-shape-group-color, var(--tool-starter-accent)); +-} +- +-.object-vector-studio-v2__object-tile-control--delete .object-vector-studio-v2__tile-icon[data-ovs-icon], +-.object-vector-studio-v2__shape-delete-inline .object-vector-studio-v2__tile-icon[data-ovs-icon] { +- color: var(--tool-starter-danger); +-} +- +-.object-vector-studio-v2__shape-delete-inline { +- color: var(--tool-starter-danger); +-} +- +-.object-vector-studio-v2__object-thumbnail { +- width: 44px; +- height: 36px; +- display: block; +- border: 1px solid var(--tool-starter-line); +- border-radius: 4px; +- background: rgba(0, 0, 0, 0.18); +-} +- +-.object-vector-studio-v2__object-thumbnail-shape { +- pointer-events: none; +-} +- +-.object-vector-studio-v2__object-thumbnail-empty { +- fill: none; +- stroke: var(--tool-starter-muted); +- stroke-dasharray: 4 3; +- stroke-width: 2; +-} +- +-.object-vector-studio-v2__object-tile-name { +- font-weight: 800; +- line-height: 1.14; +-} +- +-.object-vector-studio-v2__object-tile-meta { +- color: var(--tool-starter-muted); +- font-size: 0.78rem; +- line-height: 1.16; +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__work-area { +- position: relative; +- min-height: 0; +- flex: 0 0 auto; +- display: grid; +- grid-template-rows: auto auto auto auto auto auto auto; +- place-items: center; +- gap: 8px; +- border: 1px dashed var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel-strong); +- color: var(--tool-starter-muted); +- padding: 12px; +- text-align: center; +-} +- +-.object-vector-studio-v2__separator { +- width: min(720px, 100%); +- border: 0; +- border-top: 1px solid var(--tool-starter-line); +- margin: 0; +-} +- +-.object-vector-studio-v2__viewport-controls button { +- min-height: 30px; +-} +- +-.object-vector-studio-v2__snap-actions button, +-.object-vector-studio-v2__z-order-actions button, +-.object-vector-studio-v2__palette-sort button { +- min-height: 42px; +-} +- +-#objectVectorStudioV2ObjectsContent .object-vector-studio-v2__z-order-actions button { +- min-height: 34px; +-} +- +-.object-vector-studio-v2__animation-controls { +- width: min(720px, 100%); +- display: flex; +- align-items: center; +- justify-content: center; +- flex-wrap: wrap; +- gap: 8px; +-} +- +-.object-vector-studio-v2__inline-field { +- display: inline-flex; +- align-items: center; +- gap: 6px; +- color: var(--tool-starter-muted); +- font-weight: 800; +-} +- +-.object-vector-studio-v2__inline-field input[type="number"] { +- width: 72px; +- padding: 5px 7px; +-} +- +-.object-vector-studio-v2__frame-timeline { +- width: min(720px, 100%); +- min-height: 52px; +- display: flex; +- align-items: stretch; +- gap: 8px; +- overflow-x: auto; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-surface); +- padding: 8px; +-} +- +-.object-vector-studio-v2__frame-tile { +- min-width: 108px; +- display: grid; +- gap: 4px; +- justify-items: center; +- border: 1px solid var(--tool-starter-line) !important; +- border-radius: 8px !important; +- background: ButtonFace; +- color: ButtonText; +- cursor: pointer; +- padding: 6px; +- text-align: center; +-} +- +-.object-vector-studio-v2__frame-tile.is-selected, +-.object-vector-studio-v2__frame-tile[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); +-} +- +-.object-vector-studio-v2__frame-thumbnail { +- width: 84px; +- height: 44px; +- border: 1px solid var(--tool-starter-line); +- border-radius: 4px; +- background: rgba(0, 0, 0, 0.18); +-} +- +-.object-vector-studio-v2__coordinate-display, +-.object-vector-studio-v2__selection-metrics { +- width: min(520px, 100%); +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-surface); +- color: var(--tool-starter-muted); +- padding: 8px 10px; +- font-size: 0.9rem; +-} +- +-.object-vector-studio-v2__header-count { +- color: var(--tool-starter-muted); +- font-size: 0.82rem; +- font-weight: 700; +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__geometry-header-title { +- min-width: 0; +- display: grid; +- gap: 1px; +- line-height: 1.05; +-} +- +-.object-vector-studio-v2__geometry-header-line { +- min-width: 0; +-} +- +-.object-vector-studio-v2__geometry-header-name { +- min-width: 0; +- color: var(--tool-starter-muted); +- font-size: 0.78rem; +- font-weight: 700; +- overflow: hidden; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__shape-list { +- display: grid; +- flex: 0 0 auto; +- gap: 6px; +- max-height: 132px; +- overflow-y: auto; +-} +- +-.object-vector-studio-v2__shape-list-button { +- min-height: 30px; +- text-align: left; +-} +- +-.object-vector-studio-v2__shape-list-button.is-selected, +-.object-vector-studio-v2__shape-list-button[aria-pressed="true"], +-.object-vector-studio-v2__palette-controls button.is-active, +-.object-vector-studio-v2__palette-controls button[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); +-} +- +-.object-vector-studio-v2__shape-list-empty { +- color: var(--tool-starter-muted); +- font-size: 0.9rem; +-} +- +-.object-vector-studio-v2__palette-controls .object-vector-studio-v2__inline-field input { +- width: 46px; +- padding-inline: 5px; +-} +- +-#objectVectorStudioV2StrokeWidth { +- width: 58px; +-} +- +-.object-vector-studio-v2__palette-controls { +- display: grid; +- gap: 5px; +- justify-items: center; +-} +- +-.object-vector-studio-v2__palette-controls button { +- min-width: 0; +- min-height: 30px; +- padding: 3px 5px !important; +-} +- +-.object-vector-studio-v2__palette-picker-button { +- width: 30px; +- min-width: 30px; +- height: 30px; +- padding: 0 !important; +-} +- +-.object-vector-studio-v2__palette-picker-button .object-vector-studio-v2__shape-icon { +- width: 20px; +- height: 20px; +-} +- +-.object-vector-studio-v2__palette-primary-row, +-.object-vector-studio-v2__palette-opacity-row { +- display: flex; +- align-items: center; +- justify-content: center; +- flex-wrap: nowrap; +- gap: 5px; +- width: 100%; +-} +- +-.object-vector-studio-v2__palette-controls .object-vector-studio-v2__inline-field { +- flex: 0 0 auto; +- gap: 3px; +- font-size: 0.78rem; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__palette-opacity-row .object-vector-studio-v2__inline-field { +- font-size: 0.72rem; +-} +- +-.object-vector-studio-v2__palette-opacity-heading { +- flex: 0 0 auto; +- font-size: 0.74rem; +- font-weight: 700; +- line-height: 1; +-} +- +-.object-vector-studio-v2__palette-opacity-row .object-vector-studio-v2__inline-field input { +- width: 54px; +-} +- +-.object-vector-studio-v2__palette-controls button[data-ovs-icon]::before { +- order: 2; +-} +- +-.object-vector-studio-v2__palette-mode-swatch { +- order: 1; +- width: 13px; +- height: 13px; +- flex: 0 0 13px; +- border: 1px solid rgba(255, 255, 255, 0.72); +- border-radius: 2px; +- background: var(--object-vector-studio-v2-mode-swatch, transparent); +- opacity: var(--object-vector-studio-v2-mode-opacity, 1); +- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25); +-} +- +-.object-vector-studio-v2__palette-mode-label { +- order: 3; +-} +- +-.object-vector-studio-v2__palette-sort { +- display: grid; +- grid-template-columns: repeat(5, minmax(0, 1fr)); +- align-items: center; +- gap: 2px; +- width: 100%; +- border-top: 1px solid var(--tool-starter-line); +- padding-top: 4px; +-} +- +-.object-vector-studio-v2__palette-sort button { +- min-width: 0; +- min-height: 24px; +- gap: 1px; +- padding: 1px 2px !important; +- font-size: 0.61rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__palette-sort button[data-ovs-icon]::before { +- font-size: 1.05em; +-} +- +-.object-vector-studio-v2__palette-sort button[aria-pressed="true"] { +- border-color: var(--tool-starter-accent) !important; +- box-shadow: 0 0 0 2px rgba(221, 214, 254, 0.25); +-} +- +-.object-vector-studio-v2__palette-sort-caret { +- margin-left: 1px; +- font-size: 0.62rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__selection-metrics { +- color: var(--tool-starter-text); +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__selected-visibility { +- width: min(520px, 100%); +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-surface); +- color: var(--tool-starter-text); +- padding: 18px; +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__render-surface { +- width: 100%; +- max-width: 100%; +- height: auto; +- min-height: 0; +- aspect-ratio: 16 / 11; +- align-self: stretch; +- justify-self: stretch; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: rgba(0, 0, 0, 0.16); +- background-size: 20px 20px; +-} +- +-.object-vector-studio-v2__render-surface.is-grid-visible { +- background: rgba(0, 0, 0, 0.16); +-} +- +-.object-vector-studio-v2__svg-grid { +- stroke: rgba(255, 255, 255, 0.18); +- stroke-width: 1; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__shape { +- cursor: pointer; +- transition: filter 120ms ease, opacity 120ms ease; +-} +- +-.object-vector-studio-v2__shape-hit-layer { +- pointer-events: auto; +-} +- +-.object-vector-studio-v2__shape-hit-area { +- cursor: pointer; +- fill-opacity: 0; +- stroke-opacity: 0; +- pointer-events: all; +-} +- +-.object-vector-studio-v2__render-surface.is-drawing-mode { +- cursor: crosshair; +-} +- +-.object-vector-studio-v2__render-surface.is-empty-canvas-object-drag-ready, +-.object-vector-studio-v2__render-surface.is-canvas-pan-ready { +- cursor: grab; +-} +- +-.object-vector-studio-v2__render-surface.is-empty-canvas-object-dragging, +-.object-vector-studio-v2__render-surface.is-canvas-panning { +- cursor: grabbing; +-} +- +-.object-vector-studio-v2__drawing-preview { +- opacity: 0.78; +- stroke-dasharray: 5 4; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__snap-line { +- fill: none; +- stroke: var(--object-vector-studio-v2-snap-point-color); +- stroke-opacity: 0.88; +- stroke-width: 1.35; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__drawing-hint { +- position: absolute; +- z-index: 6; +- max-width: min(260px, calc(100% - 24px)); +- border: 1px solid rgba(221, 214, 254, 0.45); +- border-radius: 6px; +- background: rgba(2, 6, 23, 0.92); +- color: var(--tool-starter-text); +- font-family: "0xProto Nerd Font", "Cascadia Mono", monospace; +- font-size: 12px; +- line-height: 1.2; +- padding: 4px 7px; +- white-space: nowrap; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__snap-targets { +- pointer-events: none; +-} +- +-.object-vector-studio-v2__snap-target { +- fill: none; +- stroke: var(--object-vector-studio-v2-snap-point-color); +- stroke-width: 1.25; +- vector-effect: non-scaling-stroke; +-} +- +-.object-vector-studio-v2__shape:hover, +-.object-vector-studio-v2__shape.is-selected { +- filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.92)); +-} +- +-.object-vector-studio-v2__selection-box { +- fill: none; +- stroke: var(--tool-starter-accent); +- stroke-dasharray: 5 4; +- stroke-width: 0.75; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__selection-drag-area { +- cursor: move; +- fill: transparent; +- pointer-events: all; +- stroke: none; +-} +- +-.object-vector-studio-v2__object-bounds { +- fill: none; +- stroke: #67e8f9; +- stroke-dasharray: 8 5; +- stroke-width: 1.5; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__onion-skin { +- opacity: 0.28; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__onion-skin * { +- stroke-dasharray: 5 4; +- vector-effect: non-scaling-stroke; +-} +- +-.object-vector-studio-v2__resize-handle { +- fill: var(--tool-starter-accent); +- stroke: #ffffff; +- stroke-width: 0.75; +- vector-effect: non-scaling-stroke; +- cursor: nwse-resize; +- pointer-events: auto; +-} +- +-.object-vector-studio-v2__line-endpoint-handle { +- cursor: crosshair; +-} +- +-.object-vector-studio-v2__geometry-point-handle:not([data-resize-handle]) { +- cursor: crosshair; +-} +- +-.object-vector-studio-v2__center-origin-dot { +- fill: var(--tool-starter-accent-strong, #fbbf24); +- stroke: #020617; +- stroke-width: 1.5; +- vector-effect: non-scaling-stroke; +- pointer-events: none; +-} +- +-.object-vector-studio-v2__object-origin line { +- stroke: #f8fafc; +- stroke-width: 1.2; +- pointer-events: none; +- vector-effect: non-scaling-stroke; +-} +- +-.object-vector-studio-v2__object-origin line { +- stroke: var(--tool-starter-accent-strong, #fbbf24); +- stroke-width: 1.5; +-} +- +-.object-vector-studio-v2__object-detail-grid { +- display: grid; +- grid-template-columns: 104px minmax(0, 1fr); +- gap: 8px 10px; +-} +- +-.object-vector-studio-v2__shape-group-summary { +- align-items: center; +- display: grid; +- grid-template-columns: 104px minmax(0, 1fr); +- gap: 8px 10px; +-} +- +-.object-vector-studio-v2__shape-group-summary-value { +- align-items: center; +- display: inline-flex; +- gap: 6px; +- min-width: 0; +-} +- +-.object-vector-studio-v2__object-detail-stack, +-.object-vector-studio-v2__shape-panel, +-.object-vector-studio-v2__edit-panel { +- display: grid; +- gap: 12px; +- margin-top: 0; +-} +- +-.object-vector-studio-v2__object-detail-stack { +- align-content: start; +- gap: 6px; +-} +- +-.object-vector-studio-v2 .object-vector-studio-v2__shape-panel, +-.object-vector-studio-v2 .object-vector-studio-v2__edit-panel { +- margin-top: 0; +-} +- +-.object-vector-studio-v2__shape-panel { +- border-top: 1px solid var(--tool-starter-line); +- padding-top: 12px; +-} +- +-.object-vector-studio-v2__shape-panel h3, +-.object-vector-studio-v2__shape-panel h4, +-.object-vector-studio-v2__shape-panel p { +- margin: 0; +-} +- +-.object-vector-studio-v2__edit-grid { +- display: grid; +- grid-template-columns: repeat(2, minmax(0, 1fr)); +- gap: 8px; +-} +- +-.object-vector-studio-v2__polygon-point-list { +- display: grid; +- gap: 5px; +- max-height: none; +- overflow: visible; +- padding-right: 0; +-} +- +-.object-vector-studio-v2__edit-panel--polygon { +- gap: 5px; +-} +- +-.object-vector-studio-v2__edit-panel--polygon h4 { +- margin: 0; +-} +- +-.object-vector-studio-v2__edit-grid--ellipse { +- grid-template-columns: repeat(2, minmax(0, 1fr)); +-} +- +-.object-vector-studio-v2__edit-panel--geometry { +- gap: 5px; +-} +- +-.object-vector-studio-v2__edit-panel--geometry + .object-vector-studio-v2__shape-panel { +- padding-top: 6px; +-} +- +-.object-vector-studio-v2__edit-panel--geometry h4 { +- margin: 0; +- font-size: 0.86rem; +- line-height: 1.1; +-} +- +-.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-grid { +- gap: 4px 6px; +-} +- +-.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-field { +- grid-template-columns: max-content minmax(0, 1fr); +- align-items: center; +- gap: 4px; +- font-size: 0.78rem; +-} +- +-.object-vector-studio-v2__edit-field--wide { +- grid-column: 1 / -1; +-} +- +-.object-vector-studio-v2__edit-field { +- min-width: 0; +- display: grid; +- gap: 4px; +- color: var(--tool-starter-muted); +- font-size: 0.88rem; +- font-weight: 800; +- text-align: left; +-} +- +-.object-vector-studio-v2__polygon-point-header, +-.object-vector-studio-v2__polygon-point-field { +- display: grid; +- grid-template-columns: 50px minmax(72px, 1fr) minmax(72px, 1fr) 32px 21px 21px; +- align-items: center; +- gap: 4px; +-} +- +-.object-vector-studio-v2__polygon-point-header { +- color: var(--tool-starter-muted); +- font-size: 0.66rem; +- font-weight: 900; +- line-height: 1; +- text-align: center; +- text-transform: uppercase; +-} +- +-.object-vector-studio-v2__polygon-point-field[data-polygon-point-selectable="true"] { +- cursor: pointer; +- border-radius: 6px; +-} +- +-.object-vector-studio-v2__polygon-point-field.is-action-selected { +- background: color-mix(in srgb, var(--tool-starter-accent) 16%, transparent); +- outline: 1px solid color-mix(in srgb, var(--tool-starter-accent) 48%, transparent); +- outline-offset: 2px; +-} +- +-.object-vector-studio-v2__polygon-point-label { +- color: var(--tool-starter-muted); +- font-size: 0.76rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__polygon-point-axis { +- min-width: 0; +- display: grid; +- grid-template-columns: auto minmax(0, 1fr); +- align-items: center; +- gap: 3px; +- color: var(--tool-starter-muted); +- font-size: 0.76rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__edit-field input { +- min-width: 0; +- width: 100%; +- padding: 6px 8px; +-} +- +-.object-vector-studio-v2__edit-panel--geometry .object-vector-studio-v2__edit-field input { +- min-height: 24px; +- padding: 3px 5px; +-} +- +-.object-vector-studio-v2__polygon-point-axis input { +- appearance: textfield; +- -moz-appearance: textfield; +- min-width: 0; +- width: 100%; +- padding: 3px 4px; +-} +- +-.object-vector-studio-v2__polygon-point-axis input::-webkit-inner-spin-button, +-.object-vector-studio-v2__polygon-point-axis input::-webkit-outer-spin-button { +- margin: 0; +- -webkit-appearance: none; +-} +- +-.object-vector-studio-v2__rounding-radius-field { +- display: grid; +- grid-template-columns: max-content minmax(0, 74px); +- align-items: center; +- justify-content: start; +- gap: 6px; +- width: 100%; +- color: var(--tool-starter-muted); +- font-size: 0.74rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__rounding-radius-field input { +- min-width: 0; +- width: 100%; +- min-height: 22px; +- padding: 2px 5px; +-} +- +-.object-vector-studio-v2__polygon-point-axis--readonly { +- grid-template-columns: auto minmax(0, 1fr); +-} +- +-.object-vector-studio-v2__polygon-point-value { +- min-height: 24px; +- display: inline-flex; +- align-items: center; +- padding: 3px 5px; +- border: 1px solid var(--tool-starter-border); +- border-radius: 6px; +- background: var(--tool-starter-surface); +- color: var(--tool-starter-text); +- font-size: 0.78rem; +-} +- +-.object-vector-studio-v2__polygon-point-rounding { +- display: inline-grid; +- grid-template-columns: 14px; +- align-items: center; +- justify-content: center; +- color: var(--tool-starter-muted); +- font-size: 0.7rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__polygon-point-rounding span { +- display: none; +-} +- +-.object-vector-studio-v2__polygon-point-rounding input { +- width: 14px; +- height: 14px; +- margin: 0; +- accent-color: var(--tool-starter-accent); +-} +- +-.object-vector-studio-v2__polygon-point-add, +-.object-vector-studio-v2__polygon-point-delete { +- width: 21px; +- height: 22px; +- min-height: 22px; +- padding: 0 !important; +- color: var(--tool-starter-danger); +-} +- +-.object-vector-studio-v2__polygon-point-add { +- color: var(--tool-starter-text); +-} +- +-.object-vector-studio-v2__polygon-point-add[data-ovs-icon]::before, +-.object-vector-studio-v2__polygon-point-delete[data-ovs-icon]::before { +- font-size: 0.92rem; +-} +- +-.object-vector-studio-v2__edit-field--inline { +- grid-template-columns: 78px minmax(0, 1fr); +- align-items: center; +- gap: 8px; +-} +- +-.object-vector-studio-v2__edit-panel--transform { +- gap: 6px; +-} +- +-.object-vector-studio-v2__edit-panel--transform h4 { +- margin: 0; +- font-size: 0.86rem; +- line-height: 1.1; +-} +- +-.object-vector-studio-v2__transform-control-row { +- display: grid; +- grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) max-content max-content; +- align-items: center; +- gap: 3px; +- min-width: 0; +- width: 100%; +- white-space: nowrap; +- --object-vector-studio-v2-icon-scale: 1.05; +-} +- +-.object-vector-studio-v2__transform-control-row--rotate { +- grid-template-columns: 50px minmax(42px, 1fr) max-content; +-} +- +-.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled { +- grid-template-columns: 50px minmax(54px, 1fr) minmax(54px, max-content) max-content; +-} +- +-.object-vector-studio-v2__transform-control-row--auto-origin { +- grid-template-columns: 50px minmax(0, 1fr); +-} +- +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateInput, +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateSnapSelect, +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateInput, +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateSnapSelect { +- grid-column: 2; +- grid-row: 1; +-} +- +-.object-vector-studio-v2__transform-control-row--rotate .object-vector-studio-v2__snap-angle-step-field { +- grid-column: 3; +- grid-row: 1; +-} +- +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2RotateShapeButton, +-.object-vector-studio-v2__transform-control-row--rotate #objectVectorStudioV2ObjectRotateButton { +- grid-column: 3; +- grid-row: 1; +-} +- +-.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled #objectVectorStudioV2RotateShapeButton, +-.object-vector-studio-v2__transform-control-row--rotate.is-angle-snap-enabled #objectVectorStudioV2ObjectRotateButton { +- grid-column: 4; +-} +- +-.object-vector-studio-v2__transform-control-label { +- color: var(--tool-starter-text); +- font-size: 0.78rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__transform-axis-field { +- min-width: 0; +- display: grid; +- grid-template-columns: max-content minmax(0, 1fr); +- align-items: center; +- gap: 3px; +- font-size: 0.72rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__transform-control-row input { +- min-width: 0; +- width: 100%; +- min-height: 22px; +- padding: 2px 3px; +-} +- +-.object-vector-studio-v2__transform-control-row select { +- min-width: 0; +- width: 100%; +- min-height: 22px; +- padding: 1px 2px; +- font-size: 0.72rem; +-} +- +-.object-vector-studio-v2__snap-angle-step-field { +- min-width: 0; +- display: grid; +- grid-template-columns: max-content minmax(0, 1fr); +- align-items: center; +- gap: 2px; +- font-size: 0.7rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__transform-control-row button { +- min-width: 0; +- min-height: 22px; +- padding: 1px 4px; +- font-size: 0.72rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-grid { +- gap: 4px 6px; +-} +- +-.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-field--inline { +- grid-template-columns: 62px minmax(0, 1fr); +- gap: 4px; +- font-size: 0.78rem; +-} +- +-.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__edit-field input { +- min-height: 24px; +- padding: 3px 5px; +-} +- +-.object-vector-studio-v2__edit-panel--transform .object-vector-studio-v2__shape-actions { +- gap: 4px; +-} +- +-.object-vector-studio-v2__scale-control-row { +- display: grid; +- grid-template-columns: max-content 22px 21px minmax(58px, 64px) 21px 22px minmax(58px, max-content) 22px; +- align-items: center; +- gap: 2px; +- min-width: 0; +- width: 100%; +- white-space: nowrap; +- --object-vector-studio-v2-icon-scale: 1.05; +-} +- +-.object-vector-studio-v2__scale-control-label { +- color: var(--tool-starter-text); +- font-size: 0.78rem; +- font-weight: 800; +-} +- +-.object-vector-studio-v2__scale-control-row button { +- min-width: 0; +- min-height: 22px; +- padding: 1px 2px; +- font-size: 0.72rem; +- line-height: 1; +-} +- +-.object-vector-studio-v2__scale-control-row .object-vector-studio-v2__scale-resize-button { +- gap: 2px; +- padding-inline: 4px; +-} +- +-.object-vector-studio-v2__scale-control-row .object-vector-studio-v2__scale-reset-button { +- font-weight: 900; +-} +- +-.object-vector-studio-v2__scale-input { +- appearance: textfield; +- -moz-appearance: textfield; +- min-width: 0; +- width: 100%; +- min-height: 22px; +- padding: 2px 3px; +- text-align: center; +-} +- +-.object-vector-studio-v2__scale-input::-webkit-inner-spin-button, +-.object-vector-studio-v2__scale-input::-webkit-outer-spin-button { +- margin: 0; +- -webkit-appearance: none; +-} +- +-.object-vector-studio-v2__transform-summary { +- margin: 0; +- color: var(--tool-starter-text); +- font-size: 0.86rem; +- font-weight: 800; +- line-height: 1.2; +- text-align: center; +- width: 100%; +-} +- +-.object-vector-studio-v2__object-detail-stack.is-disabled, +-.object-vector-studio-v2__shape-panel.is-disabled { +- opacity: 0.58; +-} +- +-.object-vector-studio-v2__object-detail-stack.is-disabled input, +-.object-vector-studio-v2__object-detail-stack.is-disabled select, +-.object-vector-studio-v2__object-detail-stack.is-disabled button, +-.object-vector-studio-v2__shape-panel.is-disabled input, +-.object-vector-studio-v2__shape-panel.is-disabled select, +-.object-vector-studio-v2__shape-panel.is-disabled button { +- cursor: not-allowed; +-} +- +-.object-vector-studio-v2__edit-field input.is-invalid, +-.object-vector-studio-v2__transform-control-row input.is-invalid, +-.object-vector-studio-v2__scale-input.is-invalid, +-.object-vector-studio-v2__polygon-point-axis input.is-invalid { +- border-color: var(--tool-starter-danger); +- box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.22); +-} +- +-.object-vector-studio-v2 [data-validation-state="invalid"] { +- border-color: var(--tool-starter-danger) !important; +- box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.22); +-} +- +-.object-vector-studio-v2__shape-actions { +- display: flex; +- flex-wrap: wrap; +- justify-content: center; +- gap: 8px; +-} +- +-.object-vector-studio-v2__details-actions { +- width: min(720px, 100%); +- min-height: 32px; +- display: flex; +- align-items: center; +- justify-content: center; +- flex-wrap: wrap; +- gap: 8px; +- color: var(--tool-starter-muted); +-} +- +-.object-vector-studio-v2__preview-footer { +- width: min(720px, 100%); +- color: var(--tool-starter-muted); +- font-size: 0.88rem; +- overflow-wrap: anywhere; +-} +- +-.object-vector-studio-v2__preview-edit-toolbar { +- width: min(720px, 100%); +- justify-content: center; +-} +- +-.object-vector-studio-v2__preview-edit-toolbar button { +- min-width: 72px; +-} +- +-.object-vector-studio-v2__tag-editor, +-.object-vector-studio-v2__tag-list { +- display: grid; +- gap: 8px; +-} +- +-.object-vector-studio-v2__tag-editor { +- grid-template-columns: minmax(0, 1fr) auto; +- align-items: center; +- gap: 5px; +-} +- +-.object-vector-studio-v2__tag-editor input { +- width: 100%; +-} +- +-#objectVectorStudioV2AddTagButton { +- width: 77px; +- min-height: 28px; +- font-size: 0.78rem; +- line-height: 1; +- padding: 3px 7px !important; +-} +- +-.object-vector-studio-v2__tag-list { +- grid-column: 1 / -1; +- grid-template-columns: repeat(auto-fill, 77px); +- justify-content: start; +-} +- +-.object-vector-studio-v2__tag-list [data-object-tag] { +- width: 77px; +- max-width: 77px; +- min-width: 77px; +- overflow: hidden; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-.object-vector-studio-v2__detail-label { +- color: var(--tool-starter-muted); +- font-weight: 800; +-} +- +-.object-vector-studio-v2__detail-value { +- min-width: 0; +- overflow-wrap: anywhere; +-} +- +-.tool-starter__preview { +- min-height: 240px; +- flex: 1 1 auto; +- display: grid; +- place-items: center; +- border: 1px dashed var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel-strong); +- color: var(--tool-starter-muted); +- padding: 16px; +- text-align: center; +-} +- +-.tool-starter__preview-card { +- width: min(420px, 100%); +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px; +- background: var(--tool-starter-panel-strong); +- padding: 18px; +- text-align: left; +-} +- +-.tool-starter__preview-card h2, +-.tool-starter__preview-card p { +- margin: 0; +-} +- +-.tool-starter__preview-card p { +- margin-top: 8px; +- color: var(--tool-starter-muted); +- overflow-wrap: anywhere; +-} +- +-.tool-starter__output, +-#statusLog { +- width: 100%; +- min-height: 0; +- height: 100%; +- margin: 0; +- border: 1px solid var(--tool-starter-line); +- border-radius: 8px !important; +- background: var(--tool-starter-panel-strong); +- color: var(--tool-starter-text); +- overflow: auto; +- padding: 12px; +- white-space: pre-wrap; +-} +- +-#statusLog { +- flex: 1 1 auto; +- resize: none; +-} +- +-@media (max-width: 900px) { +- .tool-starter.app-shell { +- height: auto; +- min-height: calc(100vh - 137px); +- grid-template-columns: 1fr; +- overflow: visible; +- } +- +- .tool-starter__header { +- min-width: 0; +- } +-} +- +-html.tools-platform-fullscreen-active, +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"], +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] { +- height: 100vh; +- max-height: 100vh; +- min-height: 100vh; +- overflow-x: hidden; +- overflow-y: auto; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"], +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] { +- display: flex; +- flex-direction: column; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .is-collapsible, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .is-collapsible { +- flex: 0 0 auto; +- width: 100%; +- margin: 0; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .is-collapsible > .is-collapsible__summary[data-tools-platform-summary-active="1"], +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .is-collapsible > .is-collapsible__summary[data-tools-platform-summary-active="1"] { +- width: calc(100vw - 24px); +- max-width: calc(100vw - 24px); +- margin: 8px auto; +- overflow: hidden; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .tool-starter__menu, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .tool-starter__menu { +- flex: 0 0 auto; +- margin: 8px auto; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] > .tool-starter.app-shell, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] > .tool-starter.app-shell { +- flex: 1 1 0; +- grid-template-columns: 340px minmax(0, 1fr) 360px; +- align-items: stretch; +- gap: 16px; +- height: 0; +- width: 100%; +- max-width: none; +- min-height: 0; +- margin: 0; +- padding: 8px 12px 12px; +- overflow: hidden; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel { +- max-height: 100%; +- min-height: 0; +- overflow: hidden; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--left, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--left { +- justify-self: start; +- width: 340px; +- max-width: 340px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--right, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--right { +- justify-self: end; +- width: 360px; +- max-width: 360px; +- overflow-x: hidden; +- overflow-y: auto; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--center, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__panel--center { +- align-self: stretch; +- height: 100%; +- max-height: none; +- min-height: 0; +- overflow-x: hidden; +- overflow-y: auto; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__render-surface, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__render-surface { +- width: min(100%, calc(34vh * 16 / 11)); +- justify-self: center; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__work-area, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__work-area { +- gap: 6px; +- padding: 8px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__animation-controls, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__animation-controls { +- gap: 6px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-timeline, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-timeline { +- min-height: 48px; +- padding: 6px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-tile, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-tile { +- min-width: 96px; +- gap: 2px; +- padding: 4px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-thumbnail, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .object-vector-studio-v2__frame-thumbnail { +- width: 72px; +- height: 34px; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2.is-open, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2.is-open { +- flex: 1 1 0; +- min-height: 0; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2:not(.is-open), +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2:not(.is-open) { +- flex: 0 0 auto; +- max-height: 48px; +- min-height: 0; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .accordion-v2__content, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .accordion-v2__content { +- min-height: 0; +- overflow: auto; +-} +- +-body.tools-platform-surface.tools-platform-fullscreen-active[data-tool-id="object-vector-studio-v2"] .tool-starter__accordion--compact.is-open, +-html:fullscreen body.tools-platform-surface[data-tool-id="object-vector-studio-v2"] .tool-starter__accordion--compact.is-open { +- flex: 0 0 auto; +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md +deleted file mode 100644 +index dae3f2aac..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/README.md ++++ /dev/null +@@ -1,17 +0,0 @@ +-# Vector Native Arcade Template +- +-This template is the reusable vector-native starting point for future arcade-style games. +- +-## Baseline +- +-- first-class vector assets are the required visual contract +-- strict validation, packaging, runtime, CI, export, and publishing boundaries remain active +-- sprite fallback is not part of the runtime baseline +-- runtime bootstrap remains compatible with `games/Asteroids/main.js#bootAsteroids` as a starter handoff seam +- +-## Structure +- +-- `assets/` contains palette, vector, tilemap, and parallax starter content +-- `config/` contains starter project configuration +-- `runtime/` contains starter runtime bootstrap metadata +-- `docs/` contains starter guidance and rollback notes +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json +deleted file mode 100644 +index 61897ec6b..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/palettes/vector-native-primary.palette.json ++++ /dev/null +@@ -1,29 +0,0 @@ +-{ +- "$schema": "../../../../../schemas/palette.schema.json", +- "schema": "html-js-gaming.palette", +- "version": 1, +- "name": "Vector Native Primary Palette", +- "source": "custom", +- "swatches": [ +- { +- "symbol": "!", +- "hex": "#05070D", +- "name": "Swatch 1" +- }, +- { +- "symbol": "#", +- "hex": "#E8F0FF", +- "name": "Swatch 2" +- }, +- { +- "symbol": "$", +- "hex": "#6FD3FF", +- "name": "Swatch 3" +- }, +- { +- "symbol": "%", +- "hex": "#FFB347", +- "name": "Swatch 4" +- } +- ] +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json +deleted file mode 100644 +index 330bee626..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.parallax.json ++++ /dev/null +@@ -1,5 +0,0 @@ +-{ +- "name": "Template Backdrop Layer", +- "role": "backdrop", +- "imageId": "image.template.backdrop" +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg +deleted file mode 100644 +index 77a8feb76..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/parallax/template-backdrop.svg ++++ /dev/null +@@ -1,7 +0,0 @@ +- +- +- +- +- +- +- +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json +deleted file mode 100644 +index 2135cd960..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-arena.tilemap.json ++++ /dev/null +@@ -1,5 +0,0 @@ +-{ +- "name": "Vector Native Arcade Arena", +- "states": ["title", "start", "gameplay", "restart"], +- "extensionPoints": ["spawn-patterns", "score-rules", "victory-conditions"] +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json +deleted file mode 100644 +index 917f694b3..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/tilemaps/template-ui.tileset.json ++++ /dev/null +@@ -1,6 +0,0 @@ +-{ +- "name": "Template UI Tileset", +- "tileWidth": 8, +- "tileHeight": 8, +- "tiles": ["score-panel", "wave-panel", "status-panel"] +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json +deleted file mode 100644 +index 5b82aa292..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json ++++ /dev/null +@@ -1,23 +0,0 @@ +-{ +- "id": "vector.template.ui.hud", +- "type": "vector", +- "source": { +- "kind": "svg", +- "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-hud.vector.json" +- }, +- "geometry": { +- "viewBox": "0 0 160 32", +- "paths": [ +- { +- "d": "M 4 4 L 156 4 L 156 28 L 4 28 Z" +- }, +- { +- "d": "M 18 10 L 28 10 L 28 22 L 18 22 Z" +- } +- ] +- }, +- "style": { +- "stroke": true, +- "fill": false +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json +deleted file mode 100644 +index 2ef6a9f51..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json ++++ /dev/null +@@ -1,20 +0,0 @@ +-{ +- "id": "vector.template.obstacle.large", +- "type": "vector", +- "source": { +- "kind": "svg", +- "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-obstacle-large.vector.json" +- }, +- "geometry": { +- "viewBox": "-34 -34 68 68", +- "paths": [ +- { +- "d": "M -20 -14 L 0 -24 L 22 -18 L 28 6 L 14 24 L -10 22 L -26 4 Z" +- } +- ] +- }, +- "style": { +- "stroke": true, +- "fill": false +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json +deleted file mode 100644 +index feaf4fe2c..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json ++++ /dev/null +@@ -1,20 +0,0 @@ +-{ +- "id": "vector.template.obstacle.small", +- "type": "vector", +- "source": { +- "kind": "svg", +- "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-obstacle-small.vector.json" +- }, +- "geometry": { +- "viewBox": "-18 -18 36 36", +- "paths": [ +- { +- "d": "M -8 -8 L 2 -12 L 10 -2 L 6 10 L -6 12 L -12 0 Z" +- } +- ] +- }, +- "style": { +- "stroke": true, +- "fill": false +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json +deleted file mode 100644 +index 572784f53..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json ++++ /dev/null +@@ -1,23 +0,0 @@ +-{ +- "id": "vector.template.player", +- "type": "vector", +- "source": { +- "kind": "svg", +- "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-player.vector.json" +- }, +- "geometry": { +- "viewBox": "-20 -20 40 40", +- "paths": [ +- { +- "d": "M 0 -16 L 12 12 L 0 6 L -12 12 Z" +- }, +- { +- "d": "M -4 10 L 0 4 L 4 10" +- } +- ] +- }, +- "style": { +- "stroke": true, +- "fill": false +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json +deleted file mode 100644 +index 16bb56320..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json ++++ /dev/null +@@ -1,23 +0,0 @@ +-{ +- "id": "vector.template.ui.title", +- "type": "vector", +- "source": { +- "kind": "svg", +- "path": "tools/templates-v2/vector-native-arcade/assets/data/vectors/template-title.vector.json" +- }, +- "geometry": { +- "viewBox": "0 0 220 48", +- "paths": [ +- { +- "d": "M 8 40 L 24 8 L 40 40 Z" +- }, +- { +- "d": "M 56 40 L 56 8 L 84 8 L 84 18 L 68 18 L 68 22 L 82 22 L 82 32 L 68 32 L 68 40 Z" +- } +- ] +- }, +- "style": { +- "stroke": true, +- "fill": false +- } +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json +deleted file mode 100644 +index a451abbdd..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/config/template.project.json ++++ /dev/null +@@ -1,6 +0,0 @@ +-{ +- "templateId": "vector-native-arcade", +- "schema": "toolbox.template/1", +- "visualContract": "vector-only", +- "runtimeEntry": "games/Asteroids/main.js#bootAsteroids" +-} +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md +deleted file mode 100644 +index c8028670d..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/ROLLBACK_NOTES.md ++++ /dev/null +@@ -1,7 +0,0 @@ +-# Rollback Notes +- +-Rollback guidance is documentation only. +- +-- The vector-native template baseline does not package sprite fallback assets. +-- If a project needs a temporary rollback during local experimentation, document that outside the active template runtime contract. +-- Do not reintroduce sprite fallback as an active packaged dependency for the template baseline. +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md +deleted file mode 100644 +index 02a428d54..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/docs/STARTER_GUIDE.md ++++ /dev/null +@@ -1,10 +0,0 @@ +-# Starter Guide +- +-Use this template when a new game should begin with vector-first visuals and strict platform boundaries already in place. +- +-## Starter flow +- +-1. Replace the starter vectors under `assets/vectors/` with game-specific shapes. +-2. Keep the registry IDs stable or update them consistently in the config/runtime files. +-3. Validate, package, export, and publish through the accepted shared platform systems. +-4. Extend gameplay logic from the runtime handoff seam without changing engine core APIs. +diff --git a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json b/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json +deleted file mode 100644 +index 0348549ef..000000000 +--- a/dev/archive/v1-v2/tools/old_object-vector-studio-v2/vector-native-arcade/runtime/bootstrap.runtime.json ++++ /dev/null +@@ -1,6 +0,0 @@ +-{ +- "modulePath": "games/Asteroids/main.js", +- "exportName": "bootAsteroids", +- "canvasId": "game", +- "requiredVectorIds": ["vector.template.player", "vector.template.obstacle.large", "vector.template.obstacle.small", "vector.template.ui.title", "vector.template.ui.hud"] +-} diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md -index 524ef94fb..1e65998c6 100644 +index 1e65998c6..d9f4bf6ed 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md @@ -117,7 +117,7 @@ Rules: ## Current Version/Date --- Project Instructions Version: 2026.06.28.019 -+- Project Instructions Version: 2026.06.28.020 +-- Project Instructions Version: 2026.06.28.020 ++- Project Instructions Version: 2026.06.28.021 - Date: 2026-06-28 - Owner: OWNER diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md -index 8f53d469f..f809be6a3 100644 +index f809be6a3..e4f81134e 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md @@ -1,6 +1,6 @@ # Project Instructions Version --Current Project Instructions Version: 2026.06.28.019 -+Current Project Instructions Version: 2026.06.28.020 +-Current Project Instructions Version: 2026.06.28.020 ++Current Project Instructions Version: 2026.06.28.021 Last Updated: 2026-06-28 -@@ -27,6 +27,7 @@ Last Updated: 2026-06-28 - - Removed the local demo `.gfsp` artifact and recorded a file-by-file `src/` destination audit for future scoped migrations. +@@ -28,6 +28,7 @@ Last Updated: 2026-06-28 - Moved PR017-audited browser/www-owned legacy `src/` files into `www/src/` while preserving public `/src/...` browser route compatibility under the `www` web root. - Moved PR019a-audited active browser/runtime shared contracts and tool schemas into `www/src/shared/` while preserving public `/src/shared/...` browser route compatibility. -+- Removed obsolete root `src/` legacy placeholders, stale schema README files, and archived-only debug helpers while preserving active contracts and validation schemas. + - Removed obsolete root `src/` legacy placeholders, stale schema README files, and archived-only debug helpers while preserving active contracts and validation schemas. ++- Removed inactive manifest-era `www/src/tools/common` helpers and archived old Object Vector/Parallax tool samples while preserving currently referenced archived games/samples. - Codex must read this version file and the latest repository copy of `PROJECT_INSTRUCTIONS.md` before performing work. - Codex must discard previously remembered Project Instructions and treat the repository copy as authoritative. - Codex must validate canonical report and ZIP paths, branching model, and legacy path avoidance before work proceeds. diff --git a/dev/build/ProjectInstructions/PROJECT_STATE.md b/dev/build/ProjectInstructions/PROJECT_STATE.md -index dd9f058ab..8bd72875d 100644 +index 8bd72875d..87738d6f2 100644 --- a/dev/build/ProjectInstructions/PROJECT_STATE.md +++ b/dev/build/ProjectInstructions/PROJECT_STATE.md @@ -1,7 +1,7 @@ # Project State ```yaml --project_state_version: "2026.06.28.019" -+project_state_version: "2026.06.28.020" +-project_state_version: "2026.06.28.020" ++project_state_version: "2026.06.28.021" last_updated: "2026-06-28" current_main_commit: "40de767476d70cadfd1292c916844c2f31b6f185" repository_status: @@ -48,12 +17475,12 @@ index dd9f058ab..8bd72875d 100644 runtime_database: "Postgres" creator_metadata: "API to Postgres" creator_assets: "API to R2" --project_instructions_version: "2026.06.28.019" --repository_structure_version: "2026.06.28.019" --canonical_layout_version: "2026.06.28.019" -+project_instructions_version: "2026.06.28.020" -+repository_structure_version: "2026.06.28.020" -+canonical_layout_version: "2026.06.28.020" +-project_instructions_version: "2026.06.28.020" +-repository_structure_version: "2026.06.28.020" +-canonical_layout_version: "2026.06.28.020" ++project_instructions_version: "2026.06.28.021" ++repository_structure_version: "2026.06.28.021" ++canonical_layout_version: "2026.06.28.021" active_teams: - "Owner" - "Alfa" @@ -61,520 +17488,457 @@ index dd9f058ab..8bd72875d 100644 - "Charlie" - "Delta" - "Golf" --latest_owner_pr: "PR_26180_OWNER_019b-move-browser-shared-schemas-to-www" --latest_structure_pr: "PR_26180_OWNER_019b-move-browser-shared-schemas-to-www" -+latest_owner_pr: "PR_26180_OWNER_020-src-legacy-teardown" -+latest_structure_pr: "PR_26180_OWNER_020-src-legacy-teardown" +-latest_owner_pr: "PR_26180_OWNER_020-src-legacy-teardown" +-latest_structure_pr: "PR_26180_OWNER_020-src-legacy-teardown" ++latest_owner_pr: "PR_26180_OWNER_021-archive-legacy-games-samples-teardown" ++latest_structure_pr: "PR_26180_OWNER_021-archive-legacy-games-samples-teardown" valid_top_level_folders: - "api/" - "deploy/" -@@ -41,7 +41,6 @@ valid_dev_folders: - - "dev/workspace/" +@@ -42,5 +42,6 @@ valid_dev_folders: known_technical_debt: - "Remaining API/server-owned contracts, validation-only schemas, and project data store modules remain under src/shared/ until scoped follow-up PRs move them to api/ or dev/." -- - "Developer/source reference files remain under src/ until PR_26180_OWNER_020 moves them to dev/." -- - "src/dev-runtime/admin/.gitkeep remains as a placeholder until PR_26180_OWNER_021 retires the empty root src folder." -+ - "Remaining actively validated contracts, validation schemas, and project data store modules remain under src/shared/ until scoped follow-up PRs move them to api/ or dev/." + - "Remaining actively validated contracts, validation schemas, and project data store modules remain under src/shared/ until scoped follow-up PRs move them to api/ or dev/." ++ - "Archived games and samples under dev/archive/v1-v2 remain until active runtime/test/script references are migrated away." - "repository_directory_standard.md and codex_project_instructions_startup.md are superseded pointers retained for compatibility." ``` diff --git a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md -index 2f73709cb..cfbad05b6 100644 +index cfbad05b6..15d68a7da 100644 --- a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md +++ b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md @@ -40,7 +40,7 @@ The backlog is the authoritative source for determining the next logical PRs. - Product Area: Repository Architecture Simplification - Status: Active - Percent Complete: 99% --- Active PR: PR_26180_OWNER_019b-move-browser-shared-schemas-to-www -+- Active PR: PR_26180_OWNER_020-src-legacy-teardown +-- Active PR: PR_26180_OWNER_020-src-legacy-teardown ++- Active PR: PR_26180_OWNER_021-archive-legacy-games-samples-teardown - Next Milestone: Move remaining API/server-owned source to api/ - Source / Reference: `dev/build/ProjectInstructions/repository/repository_layout_architecture_plan.md`; `dev/build/ProjectInstructions/repository/www_migration_map.md` diff --git a/dev/build/ProjectInstructions/repository/canonical_repository_structure.md b/dev/build/ProjectInstructions/repository/canonical_repository_structure.md -index 41be18217..d60ba6140 100644 +index d60ba6140..43cccd10f 100644 --- a/dev/build/ProjectInstructions/repository/canonical_repository_structure.md +++ b/dev/build/ProjectInstructions/repository/canonical_repository_structure.md -@@ -90,7 +90,6 @@ Legacy transition buckets: +@@ -85,7 +85,6 @@ Legacy transition buckets: + - www/src/shared/ + - www/src/shared/contracts/ + - www/src/shared/schemas/ +-- www/src/tools/ + - www/src/dev-runtime/admin/ - src/shared/contracts/ - src/shared/schemas/ - - src/shared/projectDataStore/ --- src/dev-runtime/admin/.gitkeep - - The `www/src/` legacy transition buckets preserve public `/src/...` browser import compatibility during source retirement. Remaining root `src/` buckets may remain only until explicit stacked migration PRs move them to `api/` or `dev/`. - -diff --git a/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_branch-validation.md b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_branch-validation.md +diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md new file mode 100644 -index 000000000..759f6b6d0 +index 000000000..439a027f3 --- /dev/null -+++ b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_branch-validation.md ++++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_branch-validation.md @@ -0,0 +1,13 @@ -+# PR_26180_OWNER_020 Branch Validation ++# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Branch Validation + +| Check | Status | Notes | +|-------|--------|-------| -+| Current branch | PASS | `PR_26180_OWNER_020-src-legacy-teardown` | -+| Expected base | PASS | Stacked on `PR_26180_OWNER_019b-move-browser-shared-schemas-to-www` workstream branch. | -+| Worktree scope | PASS | Changes limited to obsolete root `src/` deletions, Project Instructions/backlog status updates, guard baseline alignment, and PR reports. | -+| Runtime/API/UI/database changes | PASS | No active runtime, API, UI, or database implementation files changed. | -+| Protected dev workspace paths | PASS | No protected `dev/workspace/generated`, `dev/workspace/zips`, or `dev/workspace/tmp` files moved or deleted. | ++| Current branch | PASS | `PR_26180_OWNER_021-archive-legacy-games-samples-teardown` | ++| Stack base | PASS | Created from `PR_26180_OWNER_020-src-legacy-teardown`. | ++| Worktree scope | PASS | Deletes limited to inactive manifest-era helpers and archived old tool/sample folders, plus governance/report updates. | ++| Runtime/API/UI/database changes | PASS | No active implementation behavior changed. | ++| Protected generated workspace | PASS | `dev/workspace/generated/tool-images/**` untouched. | + +## Branch Decision + +PASS. Branch is valid for the stacked Owner Repository Architecture Simplification workstream. -diff --git a/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_manual-validation-notes.md b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_manual-validation-notes.md +diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md new file mode 100644 -index 000000000..b4f1af5a7 +index 000000000..0eb228746 --- /dev/null -+++ b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_manual-validation-notes.md ++++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_manual-validation-notes.md @@ -0,0 +1,19 @@ -+# PR_26180_OWNER_020 Manual Validation Notes ++# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Manual Validation Notes + +## Manual Review + -+- Confirmed current branch is `PR_26180_OWNER_020-src-legacy-teardown`. -+- Confirmed deleted files are limited to obsolete root `src/` placeholders, README notes, and debug helpers with no active references. -+- Confirmed remaining root `src/shared/contracts/**`, `src/shared/projectDataStore/**`, and active schema files are still needed by current validation/test references and were not removed. -+- Confirmed archive-only v1-v2 references to deleted debug helpers remain historical and are not active runtime blockers. -+- Confirmed the shared extraction guard baseline change removes only the deleted `src/shared/debug/network.js` entry. ++- Confirmed current branch is `PR_26180_OWNER_021-archive-legacy-games-samples-teardown`. ++- Confirmed `www/src/tools/common` had no active imports beyond shared-extraction baseline entries. ++- Confirmed archived `old_object-vector-studio-v2` and `old_Parallax Scene Studio` had no active runtime/test/script references outside archive/report/history. ++- Confirmed active archive game/sample paths remain referenced by current runtime/test/dev tooling and were not deleted. ++- Confirmed `dev/workspace/generated/tool-images/**` was not touched. + -+## Manual Runtime Notes ++## Product Impact + -+No runtime/UI/API/database behavior was changed. Playwright was not required because this PR removes unreferenced legacy source files and documentation placeholders only. ++No product behavior, public URL behavior, API behavior, database behavior, or active UI behavior was changed. + -+## ZIP Notes ++## ZIP + -+The repo-structured ZIP for this PR is generated under: ++Repo-structured ZIP path: + -+`dev/workspace/zips/PR_26180_OWNER_020-src-legacy-teardown_delta.zip` -diff --git a/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_report.md b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_report.md ++`dev/workspace/zips/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_delta.zip` +diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md new file mode 100644 -index 000000000..cf243ad3b +index 000000000..36dcb6f94 --- /dev/null -+++ b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_report.md -@@ -0,0 +1,56 @@ -+# PR_26180_OWNER_020-src-legacy-teardown Report ++++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_report.md +@@ -0,0 +1,40 @@ ++# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Report + +## Executive Summary + -+PR_26180_OWNER_020 continues the root `src/` retirement work by removing legacy files that no longer have active runtime, API, dev script, validation, or test references. The PR preserves active root `src/shared/contracts/**`, `src/shared/projectDataStore/**`, and active schema files because they are still used by current dev validation/tests and require separate follow-up ownership moves. -+ -+No browser, API, database, or product behavior changes are introduced. -+ -+## Source Of Truth ++PR021 removes legacy manifest-era and archived tool sample surfaces that have no active `www/`, `api/`, `dev/scripts`, `dev/tests`, `dev/tools`, package, or CI references. + -+- Base branch: `PR_26180_OWNER_019b-move-browser-shared-schemas-to-www` -+- Workstream: Repository Architecture Simplification -+- Audit source: PR017 src destination audit and PR019a contracts/schemas obsolescence audit -+- Project Instructions version updated to `2026.06.28.020` ++Deleted groups: + -+## Deleted Legacy Files ++- `www/src/tools/common/` (2 files): inactive manifest-era browser helpers. ++- `dev/archive/v1-v2/tools/old_object-vector-studio-v2/` (35 files): archived old Object Vector tool implementation superseded by current Object Vector runtime/schema/tests under `www/` and `dev/tests/`. ++- `dev/archive/v1-v2/tools/old_Parallax Scene Studio/` (22 files): archived old Parallax tool/sample folder with no active runtime/test/script references. + -+| File | Decision | Reason | -+|------|----------|--------| -+| `src/dev-runtime/admin/.gitkeep` | Deleted | Obsolete placeholder. Admin compatibility runtime now lives under `www/src/dev-runtime/admin/`. | -+| `src/engine/README.md` | Deleted | Legacy empty-engine-era note, not an active architecture source. | -+| `src/shared/debug/config.js` | Deleted | Old debug helper referenced only by archived v1-v2 material. No active runtime/test/script references. | -+| `src/shared/debug/network.js` | Deleted | Old debug helper referenced only by archived v1-v2 material. No active runtime/test/script references. | -+| `src/shared/debug/noopDevConsoleIntegration.js` | Deleted | Old debug helper referenced only by archived v1-v2 material. No active runtime/test/script references. | -+| `src/shared/schemas/README.md` | Deleted | Legacy schema README superseded by active Project Instructions/governance. | -+| `src/shared/schemas/tools/README.md` | Deleted | Legacy tool schema README superseded by active Project Instructions/governance. | ++Preserved groups: + -+## Preserved Root src Files ++- `dev/archive/v1-v2/games/`: preserved because current `www/`, `dev/scripts`, and `dev/tests` still reference archived game paths. ++- `dev/archive/v1-v2/samples/`: preserved because current dev tooling and validation baselines still reference archived sample paths. ++- `dev/archive/v1-v2/tools/SpriteEditor_old_keep/`: preserved because governance/reference docs still mark it as keep-policy material. ++- `dev/workspace/generated/tool-images/**`: untouched. + -+| Path | Decision | Reason | -+|------|----------|--------| -+| `src/shared/contracts/**` | Preserved | Still actively imported by `dev/tests/shared/**` contract validation. | -+| `src/shared/projectDataStore/**` | Preserved | Still actively imported by current project data store validation tests. | -+| `src/shared/schemas/game.manifest.schema.json` | Preserved | Still actively referenced by current validation/tests. | -+| `src/shared/schemas/samples/sample.tool-payload.schema.json` | Preserved | Still actively referenced by current validation/tests. | ++## Evidence Review + -+## Legacy Pattern Audit ++| Candidate | Status | Decision | ++|-----------|--------|----------| ++| `old_games/**` | No exact tracked `old_games` folder found. | No deletion required. | ++| `old_samples/**` | Only exact tracked `old_samples` match is under `SpriteEditor_old_keep`. | Preserved due explicit keep-policy references. | ++| `dev/archive/v1-v2/games/**` | Active references remain in `www/src/shared/toolbox/*`, dev scripts, and dev tests. | Preserved; deletion blocked until references migrate. | ++| `dev/archive/v1-v2/samples/**` | Active references remain in dev tools, dev scripts, dev tests, and validation baselines. | Preserved; deletion blocked until references migrate. | ++| `www/src/tools/**` | `www/src/tools/common` had no active imports beyond extraction baseline entries. | Deleted. | ++| Old Object Vector archive | No active runtime/test/script references outside archive/report/history. | Deleted. | ++| Old Parallax archive samples | No active runtime/test/script references outside archive/report/history. | Deleted. | + -+| Pattern | Result | -+|---------|--------| -+| `src/shared/debug/*` active references | PASS: no active references outside archive/report/workspace folders. | -+| `src/engine/README` active references | PASS: no active references outside archive/report/workspace folders. | -+| `src/shared/schemas/README` active references | PASS: no active references outside archive/report/workspace folders. | -+| `src/dev-runtime/admin/.gitkeep` active references | PASS: no active references outside archive/report/workspace folders. | -+| `www/src/tools/**` | Not present in this PR scope. | -+| Protected dev workspace paths | PASS: no protected `dev/workspace/generated`, `zips`, or `tmp` files moved. | ++## Runtime Boundary + -+Archive-only v1-v2 references to the removed debug helpers remain under `dev/archive/v1-v2/**`. They are historical reference material, not active runtime or validation dependencies. ++No active `www/`, `api/`, `dev/scripts`, `dev/tests`, package, or GitHub workflow reference remains to the deleted PR021 paths. + -+## Shared Extraction Guard ++The PR does not change product behavior, API behavior, database behavior, or active UI behavior. + -+`dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json` was updated by removing the single baseline entry for deleted file `src/shared/debug/network.js`. No broad baseline regeneration was committed. ++## Follow-Up + -+## Owner Recommendation -+ -+Proceed with this teardown PR. Remaining root `src/` files require follow-up PRs because active validation currently depends on them. -diff --git a/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_requirement-checklist.md b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_requirement-checklist.md ++Future PRs must migrate active archive game/sample references before deleting `dev/archive/v1-v2/games/**` or `dev/archive/v1-v2/samples/**`. +diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md new file mode 100644 -index 000000000..abd58749e +index 000000000..bf1ac59d1 --- /dev/null -+++ b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_requirement-checklist.md -@@ -0,0 +1,19 @@ -+# PR_26180_OWNER_020 Requirement Checklist ++++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_requirement-checklist.md +@@ -0,0 +1,17 @@ ++# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Requirement Checklist + +| Requirement | Status | Evidence | +|-------------|--------|----------| -+| Base from `PR_26180_OWNER_019b-move-browser-shared-schemas-to-www` | PASS | Branch `PR_26180_OWNER_020-src-legacy-teardown` was created from the PR019b stack. | -+| Do not copy files again | PASS | PR deletes obsolete legacy files only; no duplicate copies were added. | -+| Delete/archive obsolete root `src/` files only after replacement or proof of legacy status | PASS | Seven files removed after active-reference scan found no active runtime/script/test dependencies. | -+| Preserve runtime behavior | PASS | Removed files had no active references outside archive/report/workspace paths. | -+| Hard stop if active `www/api/dev` runtime or validation imports removed files | PASS | Active-reference scan found no active imports for removed files. | -+| Preserve active root `src/shared/contracts/**` and schemas still needed by validation | PASS | Active contract/schema/project data store files remain in root `src/` for follow-up PRs. | -+| Remove `src/dev-runtime/admin/.gitkeep` if obsolete | PASS | Obsolete placeholder removed. | -+| Audit legacy debug/schema/readme patterns | PASS | Active-reference scan completed; archive-only historical references documented. | -+| Do not move protected developer workspace files | PASS | No `dev/workspace/generated`, `dev/workspace/zips`, or `dev/workspace/tmp` files moved. | -+| Required reports under `dev/reports/` | PASS | PR-specific report, checklist, validation report, manual notes, branch validation, diff, and changed-file report created. | -+| Required ZIP under `dev/workspace/zips/` | PASS | Repo-structured ZIP generated for the PR outcome. | -+ -+## Remaining Follow-Up -+ -+Root `src/shared/contracts/**`, `src/shared/projectDataStore/**`, and active schema files remain because they are still required by current validation/test lanes. -diff --git a/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_validation-report.md b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_validation-report.md ++| Base from PR020 | PASS | Branch created from `PR_26180_OWNER_020-src-legacy-teardown`. | ++| Delete archived `old_games/**` if only archive/legacy | PASS | No exact tracked `old_games` path exists. Active archive games were preserved due current references. | ++| Delete archived `old_samples/**` if only archive/legacy | PASS WITH PRESERVATION | Only exact `old_samples` file is under `SpriteEditor_old_keep`, which has active keep-policy governance references. Old Parallax sample archive was deleted. | ++| Delete/archive old manifest-era code | PASS | Removed inactive `www/src/tools/common` manifest-era helpers. | ++| Remove references to non-existent `games/**` | PASS | No deleted active game path references introduced; active archive-game references remain intentionally preserved. | ++| Remove references to `samples/**` unless active use is proven | PASS | Active sample references remain only where current validation/dev tools prove use. | ++| Audit `www/src/tools/**` | PASS | Deleted inactive `www/src/tools/common`; `www/src/tools` no longer exists in working tree. | ++| Audit old Object Vector files | PASS | Deleted archived `old_object-vector-studio-v2`; current Object Vector runtime/schema/test paths remain. | ++| Preserve active `www/`, `api/`, `dev/tests`, Project Instructions behavior | PASS | No active references remain to deleted PR021 paths. | ++| Do not touch `dev/workspace/generated/tool-images/**` | PASS | Protected generated tool-image workspace untouched. | ++| Hard stop before deleting files used by active runtime/API/validation/tests | PASS | Active-reference scan passed before deletion scope was finalized. | ++| Required reports under `dev/reports/` | PASS | PR-specific report, checklist, validation report, manual notes, branch validation, diff, and changed-file report generated. | ++| Required ZIP under `dev/workspace/zips/` | PASS | Repo-structured delta ZIP generated. | +diff --git a/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md new file mode 100644 -index 000000000..fa866c961 +index 000000000..3c6ca8111 --- /dev/null -+++ b/dev/reports/PR_26180_OWNER_020-src-legacy-teardown_validation-report.md -@@ -0,0 +1,33 @@ -+# PR_26180_OWNER_020 Validation Report ++++ b/dev/reports/PR_26180_OWNER_021-archive-legacy-games-samples-teardown_validation-report.md +@@ -0,0 +1,34 @@ ++# PR_26180_OWNER_021-archive-legacy-games-samples-teardown Validation Report + -+## Validation Commands ++## Required Validation + +| Command | Status | Notes | +|---------|--------|-------| +| `git diff --check` | PASS | No whitespace errors. | +| `npm run validate:canonical-structure` | PASS | Canonical repository structure guardrail passed with 0 blocking violations. | -+| `node ./dev/scripts/run-node-test-files.mjs dev/tests/dev-runtime/AdminNotesBoundary.test.mjs dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs dev/tests/tools/ToolManifestBoundary.test.mjs` | PASS | 3/3 targeted node test files passed. | -+| `node dev/tools/toolbox-dev/checkSharedExtractionGuard.mjs` | PASS | Baseline aligned after removing the deleted `src/shared/debug/network.js` entry. | -+| Active legacy reference scan | PASS | No active references to deleted paths found outside archive/report/workspace folders. | ++| `node dev/tools/toolbox-dev/checkSharedExtractionGuard.mjs` | PASS | Baseline updated for deleted `www/src/tools/common` helper entries. | ++| Deleted path active-reference scan | PASS | No active runtime/script/test/package/CI references to deleted PR021 paths. | ++| `node ./dev/scripts/run-node-test-files.mjs dev/tests/tools/ToolManifestBoundary.test.mjs` | PASS | Tool manifest boundary test passed. | + -+## Active Legacy Reference Scan ++## Exploratory Broader Test Notes + -+Command: ++The following broader probes were run and failed for pre-existing stack issues outside PR021 deletion scope: + -+```powershell -+rg -n "src/shared/debug/(config|network|noopDevConsoleIntegration)|src/engine/README|src/shared/schemas/README|src/shared/schemas/tools/README|src/dev-runtime/admin/.gitkeep" www api dev/scripts dev/tests dev/tools package.json .github --glob '!dev/archive/**' --glob '!dev/reports/**' --glob '!dev/workspace/**' -+``` ++- `dev/tests/shared/GetStateVariantClassification.test.mjs`: existing classifier returns `runtime` for `src/shared/toolbox/test.js`; test expects `tool`. ++- `dev/tests/core/BackgroundImageAndFullscreenBezel.test.mjs`: existing fullscreen target assertion mismatch unrelated to deleted files. ++- Object Vector broader test lane: existing `www/toolbox/toolRegistry.js` Node import resolves `../../src/shared/toolbox/tool-metadata-inventory.js`, while the file exists under `www/src/shared/toolbox/tool-metadata-inventory.js`. + -+Result: no matches. ++No deleted PR021 path appears in those failures. + -+## Archive References ++## Active Reference Scan + -+Historical references remain under `dev/archive/v1-v2/**`. These are intentionally excluded from active validation blockers because `dev/archive/` is historical reference material only. ++Deleted path scan covered: + -+## Product Impact ++- `old_Parallax Scene Studio` ++- `old_object-vector-studio-v2` ++- `www/src/tools/common` ++- `GameManifestLoader` ++- `WorkspaceDirtyNotifier` ++- `notifyWorkspaceToolDirty` + -+- Runtime behavior changed: No -+- UI changed: No -+- API changed: No -+- Database changed: No -+- Product feature changed: No ++Result: PASS outside allowed Project Instructions/report references. diff --git a/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json b/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json -index c4a7df71b..3572073e6 100644 +index 3572073e6..4170339f5 100644 --- a/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json +++ b/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json -@@ -3216,11 +3216,6 @@ +@@ -3756,46 +3756,6 @@ "type": "inline-helper-clone", "match": "rule:number-is-finite-usage" }, - { -- "file": "src/shared/debug/network.js", -- "type": "inline-helper-clone", -- "match": "rule:number-is-finite-usage" +- "file": "www/src/tools/common/GameManifestLoader.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-1" +- }, +- { +- "file": "www/src/tools/common/GameManifestLoader.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-1" +- }, +- { +- "file": "www/src/tools/common/GameManifestLoader.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-2" +- }, +- { +- "file": "www/src/tools/common/GameManifestLoader.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-2" +- }, +- { +- "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-1" +- }, +- { +- "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-1" +- }, +- { +- "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-2" +- }, +- { +- "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", +- "type": "direct-shared-relative-import", +- "match": "rule:relative-shared-depth-2" - }, { - "file": "www/src/shared/game/highScores.js", + "file": "toolbox/tools-page-accordions.js", "type": "inline-helper-clone", -diff --git a/src/dev-runtime/admin/.gitkeep b/src/dev-runtime/admin/.gitkeep -deleted file mode 100644 -index d0bef0afc..000000000 ---- a/src/dev-runtime/admin/.gitkeep -+++ /dev/null -@@ -1 +0,0 @@ --# dev-runtime admin placeholder -diff --git a/src/engine/README.md b/src/engine/README.md -deleted file mode 100644 -index 19c1a3f23..000000000 ---- a/src/engine/README.md -+++ /dev/null -@@ -1,27 +0,0 @@ --# Engine -- --Shared framework used by deprecated `archive/v1-v2/samples/` and deprecated `archive/v1-v2/games/`. All modules are ES modules and browser-ready; no build step is required. -- --## Import Policy --- Bootstrap via `src/engine/core/index.js` (or `src/engine/core/Engine.js` for legacy direct imports). --- For subsystems, prefer the public barrel `src/engine//index.js` when it exists. -- --## Key Areas --- `core/` timing, orchestration, metrics --- `scene/` scene lifecycle and transitions --- `rendering/` canvas renderers and helpers --- `input/` keyboard, mouse, gamepad, action mapping --- `audio/` audio services and backends --- `physics/` reusable physics primitives and helpers --- `systems/` reusable runtime system helpers --- `events/` event routing primitives --- `camera/` camera and world-to-screen integration --- `ui/` shared hub and overlay components --- `world/`, `ecs/`, `collision/`, `utils/` shared math and world helpers --- `persistence/` serialization and storage --- `fx/` effects such as `ParticleSystem` --- platform and pipelines: `runtime/`, `automation/`, `release/`, `security/`, `pipeline/`, `editor/` +diff --git a/www/src/tools/common/GameManifestLoader.js b/www/src/tools/common/GameManifestLoader.js +deleted file mode 100644 +index 335d1fc78..000000000 +--- a/www/src/tools/common/GameManifestLoader.js ++++ /dev/null +@@ -1,102 +0,0 @@ +-import { asPositiveInteger } from "../../shared/number/numbers.js"; +-import { readFileText } from '../../engine/persistence/FilePersistenceService.js'; +-import SessionStorageService from '../../engine/persistence/SessionStorageService.js'; +-import { isRecord } from "../../shared/object/objects.js"; - --## Validation --- Automated: `npm test` (81/81 passing as of 03/25/2026) --- In-repo usage: numbered samples and shipped games (Asteroids, Space Duel, Space Invaders, Pacman Lite/Full AI, AI Target Dummy) -diff --git a/src/shared/debug/config.js b/src/shared/debug/config.js -deleted file mode 100644 -index 1b9580f7a..000000000 ---- a/src/shared/debug/config.js -+++ /dev/null -@@ -1,96 +0,0 @@ --import LocalStorageService from '../../engine/persistence/LocalStorageService.js'; --import { safeTrim, toLowerSafe } from '../string/strings.js'; --const BUILD_DEBUG_MODE = 'prod'; --const BUILD_DEBUG_ENABLED = false; -- --export function parseBooleanFlag(value, fallback) { -- const normalized = toLowerSafe(value); -- if (!normalized) { -- return fallback; -- } -- if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { -- return true; -- } -- if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { -- return false; +-export { isRecord }; +- +-function parseJson(rawValue, sourceLabel) { +- try { +- const parsed = JSON.parse(rawValue); +- return isRecord(parsed) +- ? { ok: true, manifest: parsed, sourceLabel } +- : { ok: false, message: `${sourceLabel} must contain a JSON object.` }; +- } catch (error) { +- return { ok: false, message: `${sourceLabel} contains invalid JSON: ${error.message}` }; - } -- return fallback; -} - --export function normalizeDebugMode(value, fallback = 'prod') { -- const normalized = toLowerSafe(value); -- if (normalized === 'dev' || normalized === 'qa' || normalized === 'prod') { -- return normalized; +-export function resolveManifestScreenDimensions(manifest) { +- const screen = isRecord(manifest?.screen) ? manifest.screen : null; +- const width = asPositiveInteger(screen?.width, 0); +- const height = asPositiveInteger(screen?.height, 0); +- if (!width || !height) { +- return { +- ok: false, +- message: "Manifest screen dimensions are required at root.screen.width and root.screen.height." +- }; - } -- return fallback; +- return { ok: true, width, height }; -} - --export function readStoredBoolean(key) { -- if (!key) { -- return null; +-export class GameManifestLoader { +- constructor({ +- fetchRef = null, +- pathParams = ["manifestPath", "gameManifestPath"], +- sessionStorageRef = null, +- windowRef = window +- } = {}) { +- this.fetch = fetchRef || windowRef.fetch?.bind(windowRef) || null; +- this.pathParams = pathParams; +- this.sessionStorage = new SessionStorageService(sessionStorageRef || windowRef.sessionStorage || null); +- this.window = windowRef; - } - -- const value = new LocalStorageService().getItem(key, null); -- if (value === '1') { -- return true; -- } -- if (value === '0') { -- return false; +- isWorkspaceLaunch(params = new URLSearchParams(this.window.location.search || "")) { +- return params.get("launch") === "workspace" +- && params.get("fromTool") === "workspace-manager-v2" +- && Boolean(params.get("hostContextId")); - } - -- return null; --} -- --export function writeStoredBoolean(key, value) { -- if (!key) { -- return; +- async loadInitialManifest() { +- const params = new URLSearchParams(this.window.location.search || ""); +- if (this.isWorkspaceLaunch(params)) { +- return this.loadFromWorkspaceContext(params.get("hostContextId") || ""); +- } +- const manifestPath = this.pathParams.map((key) => params.get(key) || "").find(Boolean) || ""; +- if (manifestPath) { +- return this.loadFromPath(manifestPath, "URL manifest path"); +- } +- return { ok: false, skipped: true }; - } - -- new LocalStorageService().setItem(key, value ? '1' : '0'); --} -- --export function isLocalDebugEnvironment(documentRef) { -- const protocol = safeTrim(documentRef?.location?.protocol) || safeTrim(globalThis?.location?.protocol); -- const hostname = safeTrim(documentRef?.location?.hostname) || safeTrim(globalThis?.location?.hostname); -- -- if (protocol === 'file:') { -- return true; +- loadFromWorkspaceContext(hostContextId) { +- if (!hostContextId) { +- return { ok: false, message: "Workspace launch did not include hostContextId." }; +- } +- const rawValue = this.sessionStorage.getItem(hostContextId, "") || ""; +- if (!rawValue) { +- return { ok: false, message: `Workspace manifest context was not found in sessionStorage: ${hostContextId}.` }; +- } +- return parseJson(rawValue, `workspace:${hostContextId}`); - } - -- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; --} -- --export function resolveDebugConfig(documentRef, options = {}) { -- const debugStateStorageKey = safeTrim(options?.storageKey); -- const search = safeTrim(documentRef?.location?.search) || safeTrim(globalThis?.location?.search); -- const searchParams = new URLSearchParams(search); -- const queryMode = searchParams.get('debugMode'); -- const queryEnabled = searchParams.get('debug'); -- const queryRemember = searchParams.get('rememberDebug'); -- const queryDemo = searchParams.get('debugDemo'); -- const localDebugEnvironment = isLocalDebugEnvironment(documentRef); -- const rememberDebugState = parseBooleanFlag(queryRemember, false); -- const demoMode = parseBooleanFlag(queryDemo, false); -- const defaultMode = localDebugEnvironment -- ? 'dev' -- : normalizeDebugMode(BUILD_DEBUG_MODE, 'prod'); -- const debugMode = normalizeDebugMode(queryMode, demoMode ? 'qa' : defaultMode); -- const fallbackEnabled = (BUILD_DEBUG_ENABLED === true || localDebugEnvironment) && debugMode !== 'prod'; -- const storedDebugEnabled = debugStateStorageKey && rememberDebugState && queryEnabled === null -- ? readStoredBoolean(debugStateStorageKey) -- : null; -- const debugEnabled = demoMode -- ? true -- : parseBooleanFlag(queryEnabled, storedDebugEnabled ?? fallbackEnabled); -- -- if (debugStateStorageKey && rememberDebugState) { -- writeStoredBoolean(debugStateStorageKey, debugEnabled); +- async loadFromPath(manifestPath, sourceLabel = manifestPath) { +- if (typeof this.fetch !== "function") { +- return { ok: false, message: "Fetch API is unavailable for manifest loading." }; +- } +- try { +- const response = await this.fetch(manifestPath, { cache: "no-store" }); +- if (!response.ok) { +- return { ok: false, message: `Manifest load failed from ${manifestPath}: ${response.status} ${response.statusText}` }; +- } +- const manifest = await response.json(); +- return isRecord(manifest) +- ? { ok: true, manifest, sourceLabel: sourceLabel || manifestPath } +- : { ok: false, message: `${sourceLabel || manifestPath} must contain a JSON object.` }; +- } catch (error) { +- return { ok: false, message: `Manifest load failed from ${manifestPath}: ${error.message}` }; +- } - } - -- return { -- debugMode, -- debugEnabled, -- rememberDebugState, -- demoMode -- }; +- async loadFromFile(file) { +- if (!file) { +- return { ok: false, skipped: true }; +- } +- try { +- return parseJson(await readFileText(file), file.name || "selected manifest file"); +- } catch (error) { +- return { ok: false, message: `Manifest file could not be read: ${error.message}` }; +- } +- } -} -diff --git a/src/shared/debug/network.js b/src/shared/debug/network.js +diff --git a/www/src/tools/common/WorkspaceDirtyNotifier.js b/www/src/tools/common/WorkspaceDirtyNotifier.js deleted file mode 100644 -index 80760171b..000000000 ---- a/src/shared/debug/network.js +index da5e319a5..000000000 +--- a/www/src/tools/common/WorkspaceDirtyNotifier.js +++ /dev/null -@@ -1,67 +0,0 @@ --import { asNumber } from "../math/numberNormalization.js"; --import { asArray } from "../array/arrays.js"; --import { asObject } from "../object/objects.js"; --import { safeTrim } from "../string/strings.js"; -- --function toSafeKey(value) { -- return safeTrim(value); --} +@@ -1,97 +0,0 @@ +-import { deepClone } from "../../shared/json/clone.js"; +-import { isPlainObject } from "../../shared/object/objects.js"; - --export { asNumber, asArray, asObject }; --export const sanitizeText = safeTrim; +-const WORKSPACE_TOOL_SESSION_KEY_PREFIX = "workspace.tools."; - --export function toNetworkSnapshot(snapshot, sampleKey) { -- const key = toSafeKey(sampleKey); -- if (!key) { -- return {}; -- } -- return asObject(snapshot?.assets?.[key]); +-function workspaceToolSessionKey(toolId) { +- return `${WORKSPACE_TOOL_SESSION_KEY_PREFIX}${toolId}`; -} - --export function getCommandSnapshot(context, sampleKey) { -- const key = toSafeKey(sampleKey); -- if (!key) { -- return {}; +-function readJson(sessionStorageRef, key) { +- const rawValue = sessionStorageRef?.getItem(key); +- if (!rawValue) { +- return { ok: false, message: `${key} was not found in sessionStorage.` }; - } -- return asObject(context?.assets?.[key]); --} -- --export function commandLinesForTrace(context, args = [], options = {}) { -- const normalizedOptions = asObject(options); -- const sanitize = typeof normalizedOptions?.sanitizeText === "function" -- ? normalizedOptions.sanitizeText -- : sanitizeText; -- const formatNumber = typeof normalizedOptions?.formatNumber === "function" -- ? normalizedOptions.formatNumber -- : asNumber; -- -- const snapshot = getCommandSnapshot(context, normalizedOptions?.sampleKey); -- const trace = asObject(snapshot.trace); -- const events = asArray(trace.events); -- -- const requestedCount = Number.parseInt(args?.[0], 10); -- const count = Number.isFinite(requestedCount) -- ? Math.min(20, Math.max(1, requestedCount)) -- : 8; -- -- if (events.length === 0) { -- return ["No network trace events recorded."]; +- try { +- const value = JSON.parse(rawValue); +- return isPlainObject(value) +- ? { ok: true, value } +- : { ok: false, message: `${key} must contain a JSON object.` }; +- } catch (error) { +- return { ok: false, message: `${key} contains invalid JSON: ${error.message}` }; - } -- -- const phaseField = toSafeKey(normalizedOptions?.phaseField) || "phase"; -- return events -- .slice(-count) -- .reverse() -- .map((event) => { -- const source = asObject(event); -- const details = asObject(source.details); -- const detailsText = Object.keys(details) -- .slice(0, 2) -- .map((key) => `${key}=${String(details[key])}`) -- .join(" "); -- const eventType = sanitize(source.type) || "EVENT"; -- const phase = sanitize(source[phaseField]) || "unknown"; -- const prefix = `${formatNumber(source.timestampMs, 0)}ms ${eventType} phase=${phase}`; -- return detailsText ? `${prefix} ${detailsText}` : prefix; -- }); -} -diff --git a/src/shared/debug/noopDevConsoleIntegration.js b/src/shared/debug/noopDevConsoleIntegration.js -deleted file mode 100644 -index 086fffeb8..000000000 ---- a/src/shared/debug/noopDevConsoleIntegration.js -+++ /dev/null -@@ -1,26 +0,0 @@ --export function createNoopDevConsoleIntegration() { -- const runtime = Object.freeze({ -- showOverlay() {}, -- hideOverlay() {}, -- showConsole() {}, -- hideConsole() {}, -- }); - -- return Object.freeze({ -- update() {}, -- render() {}, -- dispose() {}, -- executeCommand() { -- return { status: 'disabled', output: [] }; -- }, -- getRuntime() { -- return runtime; -- }, -- getState() { -- return { -- consoleVisible: false, -- overlayVisible: false, -- }; -- }, -- }); +-function changedKeysForDirty(nextChangedKeys, previousDirty = {}) { +- return Array.from(new Set([ +- ...(Array.isArray(previousDirty.changedKeys) ? previousDirty.changedKeys : []), +- ...(Array.isArray(nextChangedKeys) ? nextChangedKeys : ["data"]) +- ] +- .map((key) => String(key || "").trim()) +- .filter(Boolean))); -} -diff --git a/src/shared/schemas/README.md b/src/shared/schemas/README.md -deleted file mode 100644 -index bd1afe5c1..000000000 ---- a/src/shared/schemas/README.md -+++ /dev/null -@@ -1,16 +0,0 @@ --# Tool Schemas -- --JSON schema contracts for tools, samples, and palettes belong here. Workspace Manager V2 uses game manifest validation plus manifest/toolState contract checks instead of a separate Workspace validation contract. - --Root-level schema files should not be added. -- --## Layout +-export function notifyWorkspaceToolDirty({ +- changedKeys = ["data"], +- payload, +- reason = "tool-payload-updated", +- toolId, +- windowRef = window +-} = {}) { +- const params = new URLSearchParams(windowRef.location?.search || ""); +- if (params.get("launch") !== "workspace" || params.get("fromTool") !== "workspace-manager-v2") { +- return { ok: true, skipped: true, message: "Not launched from Workspace Manager V2." }; +- } - --```text --src/shared/schemas/ -- palette.schema.json -- samples -- sample.tool-payload.schema.json -- toolbox/ -- .schema.json --``` -diff --git a/src/shared/schemas/tools/README.md b/src/shared/schemas/tools/README.md -deleted file mode 100644 -index 3aa74a489..000000000 ---- a/src/shared/schemas/tools/README.md -+++ /dev/null -@@ -1,15 +0,0 @@ --# Tool-Specific Schemas +- const normalizedToolId = String(toolId || "").trim(); +- const hostContextId = String(params.get("hostContextId") || "").trim(); +- if (!normalizedToolId) { +- return { ok: false, message: "Workspace dirty notification requires a tool id." }; +- } +- if (!hostContextId) { +- return { ok: false, message: "Workspace dirty notification requires hostContextId." }; +- } - --Each active tool gets one payload schema here. +- const sessionStorageRef = windowRef.sessionStorage; +- const sessionKey = workspaceToolSessionKey(normalizedToolId); +- const sessionResult = readJson(sessionStorageRef, sessionKey); +- if (!sessionResult.ok) { +- return sessionResult; +- } +- const session = sessionResult.value; +- if (!isPlainObject(session.schema) +- || !isPlainObject(session.workspace) +- || !Object.prototype.hasOwnProperty.call(session, "data") +- || !isPlainObject(session.dirty)) { +- return { ok: false, message: `${sessionKey} must use the normalized schema/workspace/data/dirty object shape.` }; +- } +- if (session.workspace.source !== "workspace-manager-v2" || session.workspace.toolId !== normalizedToolId) { +- return { ok: false, message: `${sessionKey}.workspace must be for ${normalizedToolId}.` }; +- } - --Examples: +- const nextData = deepClone(payload); +- if (JSON.stringify(session.data) === JSON.stringify(nextData)) { +- return { ok: true, changed: false, key: sessionKey, session: deepClone(session) }; +- } - --```text --object-vector-studio-v2.schema.json --svg-asset-studio.schema.json --sprite-editor.schema.json --``` +- const nextSession = { +- ...session, +- data: nextData, +- dirty: { +- isDirty: true, +- reason, +- changedAt: new Date().toISOString(), +- changedKeys: changedKeysForDirty(changedKeys, session.dirty) +- } +- }; +- sessionStorageRef.setItem(sessionKey, JSON.stringify(nextSession)); - --Deprecated historical schemas may remain in this directory for reference, but active workspace manifests should not select them for new tool payload ownership. +- const contextResult = readJson(sessionStorageRef, hostContextId); +- if (contextResult.ok && isPlainObject(contextResult.value.tools)) { +- const nextContext = deepClone(contextResult.value); +- nextContext.tools[normalizedToolId] = deepClone(nextData); +- sessionStorageRef.setItem(hostContextId, JSON.stringify(nextContext)); +- } - --Samples should validate against the same schema used by the tool manifest. +- return { ok: true, changed: true, key: sessionKey, session: deepClone(nextSession) }; +-} diff --git a/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json b/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json index 3572073e6..4170339f5 100644 --- a/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json +++ b/dev/tools/toolbox-dev/checkSharedExtractionGuard.baseline.json @@ -3756,46 +3756,6 @@ "type": "inline-helper-clone", "match": "rule:number-is-finite-usage" }, - { - "file": "www/src/tools/common/GameManifestLoader.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-1" - }, - { - "file": "www/src/tools/common/GameManifestLoader.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-1" - }, - { - "file": "www/src/tools/common/GameManifestLoader.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-2" - }, - { - "file": "www/src/tools/common/GameManifestLoader.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-2" - }, - { - "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-1" - }, - { - "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-1" - }, - { - "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-2" - }, - { - "file": "www/src/tools/common/WorkspaceDirtyNotifier.js", - "type": "direct-shared-relative-import", - "match": "rule:relative-shared-depth-2" - }, { "file": "toolbox/tools-page-accordions.js", "type": "inline-helper-clone", diff --git a/www/src/tools/common/GameManifestLoader.js b/www/src/tools/common/GameManifestLoader.js deleted file mode 100644 index 335d1fc78..000000000 --- a/www/src/tools/common/GameManifestLoader.js +++ /dev/null @@ -1,102 +0,0 @@ -import { asPositiveInteger } from "../../shared/number/numbers.js"; -import { readFileText } from '../../engine/persistence/FilePersistenceService.js'; -import SessionStorageService from '../../engine/persistence/SessionStorageService.js'; -import { isRecord } from "../../shared/object/objects.js"; - -export { isRecord }; - -function parseJson(rawValue, sourceLabel) { - try { - const parsed = JSON.parse(rawValue); - return isRecord(parsed) - ? { ok: true, manifest: parsed, sourceLabel } - : { ok: false, message: `${sourceLabel} must contain a JSON object.` }; - } catch (error) { - return { ok: false, message: `${sourceLabel} contains invalid JSON: ${error.message}` }; - } -} - -export function resolveManifestScreenDimensions(manifest) { - const screen = isRecord(manifest?.screen) ? manifest.screen : null; - const width = asPositiveInteger(screen?.width, 0); - const height = asPositiveInteger(screen?.height, 0); - if (!width || !height) { - return { - ok: false, - message: "Manifest screen dimensions are required at root.screen.width and root.screen.height." - }; - } - return { ok: true, width, height }; -} - -export class GameManifestLoader { - constructor({ - fetchRef = null, - pathParams = ["manifestPath", "gameManifestPath"], - sessionStorageRef = null, - windowRef = window - } = {}) { - this.fetch = fetchRef || windowRef.fetch?.bind(windowRef) || null; - this.pathParams = pathParams; - this.sessionStorage = new SessionStorageService(sessionStorageRef || windowRef.sessionStorage || null); - this.window = windowRef; - } - - isWorkspaceLaunch(params = new URLSearchParams(this.window.location.search || "")) { - return params.get("launch") === "workspace" - && params.get("fromTool") === "workspace-manager-v2" - && Boolean(params.get("hostContextId")); - } - - async loadInitialManifest() { - const params = new URLSearchParams(this.window.location.search || ""); - if (this.isWorkspaceLaunch(params)) { - return this.loadFromWorkspaceContext(params.get("hostContextId") || ""); - } - const manifestPath = this.pathParams.map((key) => params.get(key) || "").find(Boolean) || ""; - if (manifestPath) { - return this.loadFromPath(manifestPath, "URL manifest path"); - } - return { ok: false, skipped: true }; - } - - loadFromWorkspaceContext(hostContextId) { - if (!hostContextId) { - return { ok: false, message: "Workspace launch did not include hostContextId." }; - } - const rawValue = this.sessionStorage.getItem(hostContextId, "") || ""; - if (!rawValue) { - return { ok: false, message: `Workspace manifest context was not found in sessionStorage: ${hostContextId}.` }; - } - return parseJson(rawValue, `workspace:${hostContextId}`); - } - - async loadFromPath(manifestPath, sourceLabel = manifestPath) { - if (typeof this.fetch !== "function") { - return { ok: false, message: "Fetch API is unavailable for manifest loading." }; - } - try { - const response = await this.fetch(manifestPath, { cache: "no-store" }); - if (!response.ok) { - return { ok: false, message: `Manifest load failed from ${manifestPath}: ${response.status} ${response.statusText}` }; - } - const manifest = await response.json(); - return isRecord(manifest) - ? { ok: true, manifest, sourceLabel: sourceLabel || manifestPath } - : { ok: false, message: `${sourceLabel || manifestPath} must contain a JSON object.` }; - } catch (error) { - return { ok: false, message: `Manifest load failed from ${manifestPath}: ${error.message}` }; - } - } - - async loadFromFile(file) { - if (!file) { - return { ok: false, skipped: true }; - } - try { - return parseJson(await readFileText(file), file.name || "selected manifest file"); - } catch (error) { - return { ok: false, message: `Manifest file could not be read: ${error.message}` }; - } - } -} diff --git a/www/src/tools/common/WorkspaceDirtyNotifier.js b/www/src/tools/common/WorkspaceDirtyNotifier.js deleted file mode 100644 index da5e319a5..000000000 --- a/www/src/tools/common/WorkspaceDirtyNotifier.js +++ /dev/null @@ -1,97 +0,0 @@ -import { deepClone } from "../../shared/json/clone.js"; -import { isPlainObject } from "../../shared/object/objects.js"; - -const WORKSPACE_TOOL_SESSION_KEY_PREFIX = "workspace.tools."; - -function workspaceToolSessionKey(toolId) { - return `${WORKSPACE_TOOL_SESSION_KEY_PREFIX}${toolId}`; -} - -function readJson(sessionStorageRef, key) { - const rawValue = sessionStorageRef?.getItem(key); - if (!rawValue) { - return { ok: false, message: `${key} was not found in sessionStorage.` }; - } - try { - const value = JSON.parse(rawValue); - return isPlainObject(value) - ? { ok: true, value } - : { ok: false, message: `${key} must contain a JSON object.` }; - } catch (error) { - return { ok: false, message: `${key} contains invalid JSON: ${error.message}` }; - } -} - -function changedKeysForDirty(nextChangedKeys, previousDirty = {}) { - return Array.from(new Set([ - ...(Array.isArray(previousDirty.changedKeys) ? previousDirty.changedKeys : []), - ...(Array.isArray(nextChangedKeys) ? nextChangedKeys : ["data"]) - ] - .map((key) => String(key || "").trim()) - .filter(Boolean))); -} - -export function notifyWorkspaceToolDirty({ - changedKeys = ["data"], - payload, - reason = "tool-payload-updated", - toolId, - windowRef = window -} = {}) { - const params = new URLSearchParams(windowRef.location?.search || ""); - if (params.get("launch") !== "workspace" || params.get("fromTool") !== "workspace-manager-v2") { - return { ok: true, skipped: true, message: "Not launched from Workspace Manager V2." }; - } - - const normalizedToolId = String(toolId || "").trim(); - const hostContextId = String(params.get("hostContextId") || "").trim(); - if (!normalizedToolId) { - return { ok: false, message: "Workspace dirty notification requires a tool id." }; - } - if (!hostContextId) { - return { ok: false, message: "Workspace dirty notification requires hostContextId." }; - } - - const sessionStorageRef = windowRef.sessionStorage; - const sessionKey = workspaceToolSessionKey(normalizedToolId); - const sessionResult = readJson(sessionStorageRef, sessionKey); - if (!sessionResult.ok) { - return sessionResult; - } - const session = sessionResult.value; - if (!isPlainObject(session.schema) - || !isPlainObject(session.workspace) - || !Object.prototype.hasOwnProperty.call(session, "data") - || !isPlainObject(session.dirty)) { - return { ok: false, message: `${sessionKey} must use the normalized schema/workspace/data/dirty object shape.` }; - } - if (session.workspace.source !== "workspace-manager-v2" || session.workspace.toolId !== normalizedToolId) { - return { ok: false, message: `${sessionKey}.workspace must be for ${normalizedToolId}.` }; - } - - const nextData = deepClone(payload); - if (JSON.stringify(session.data) === JSON.stringify(nextData)) { - return { ok: true, changed: false, key: sessionKey, session: deepClone(session) }; - } - - const nextSession = { - ...session, - data: nextData, - dirty: { - isDirty: true, - reason, - changedAt: new Date().toISOString(), - changedKeys: changedKeysForDirty(changedKeys, session.dirty) - } - }; - sessionStorageRef.setItem(sessionKey, JSON.stringify(nextSession)); - - const contextResult = readJson(sessionStorageRef, hostContextId); - if (contextResult.ok && isPlainObject(contextResult.value.tools)) { - const nextContext = deepClone(contextResult.value); - nextContext.tools[normalizedToolId] = deepClone(nextData); - sessionStorageRef.setItem(hostContextId, JSON.stringify(nextContext)); - } - - return { ok: true, changed: true, key: sessionKey, session: deepClone(nextSession) }; -}