pixelweaver 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.development +1 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +18 -0
- package/.prettierignore +5 -0
- package/.prettierrc +16 -0
- package/.python-version +1 -0
- package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
- package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
- package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/bases/CHANGELOG.md +5 -0
- package/.rlsbl/bases/LICENSE +21 -0
- package/.rlsbl/changes/.validated +1 -0
- package/.rlsbl/changes/0.1.0.jsonl +85 -0
- package/.rlsbl/changes/0.1.0.md +13 -0
- package/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/config.json +6 -0
- package/.rlsbl/hashes.json +14 -0
- package/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/releases/unreleased.toml +0 -0
- package/.rlsbl/releases/v0.1.0.toml +3 -0
- package/.rlsbl/version +1 -0
- package/.selfdoc/hashes/hashes.json +146 -0
- package/.strictcli/schema.json +227 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +100 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/assets/icon.png +0 -0
- package/docs/_README.md +117 -0
- package/docs/cli-config.md +35 -0
- package/docs/cli-dev.md +21 -0
- package/docs/cli-diagnose.md +12 -0
- package/docs/cli-index.md +30 -0
- package/docs/cli-list.md +18 -0
- package/docs/cli-mcp.md +18 -0
- package/docs/cli-new.md +26 -0
- package/docs/cli-open.md +21 -0
- package/docs/cli-serve.md +21 -0
- package/docs/cli-stop.md +12 -0
- package/docs/gen-index.md +36 -0
- package/docs/index.md +13 -0
- package/docs/server-src-pixelweaver-__main__.md +12 -0
- package/docs/server-src-pixelweaver-autosave.md +12 -0
- package/docs/server-src-pixelweaver-bridge.md +12 -0
- package/docs/server-src-pixelweaver-cli.md +12 -0
- package/docs/server-src-pixelweaver-config.md +12 -0
- package/docs/server-src-pixelweaver-connections.md +12 -0
- package/docs/server-src-pixelweaver-main.md +12 -0
- package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
- package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
- package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
- package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
- package/docs/server-src-pixelweaver-mcp_server.md +12 -0
- package/docs/server-src-pixelweaver-protocol.md +12 -0
- package/docs/server-src-pixelweaver-state.md +12 -0
- package/docs/server-src-pixelweaver-storage.md +12 -0
- package/docs/server-src-pixelweaver-websocket.md +12 -0
- package/docs/server-src-pixelweaver.md +12 -0
- package/e2e/app-launch.test.ts +35 -0
- package/e2e/menus.test.ts +26 -0
- package/e2e/tools.test.ts +27 -0
- package/e2e/undo-redo.test.ts +11 -0
- package/eslint.config.js +62 -0
- package/index.html +13 -0
- package/package.json +48 -0
- package/playwright.config.ts +19 -0
- package/plugins/builtin/.gitkeep +0 -0
- package/plugins/builtin/advanced-fill-tool.ts +146 -0
- package/plugins/builtin/circle-tool.ts +186 -0
- package/plugins/builtin/diamond-tool.ts +182 -0
- package/plugins/builtin/dither-tool.ts +186 -0
- package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
- package/plugins/builtin/drawing-utils.test.ts +495 -0
- package/plugins/builtin/drawing-utils.ts +431 -0
- package/plugins/builtin/effects/blur.ts +97 -0
- package/plugins/builtin/effects/color-effects.ts +278 -0
- package/plugins/builtin/effects/flip.ts +83 -0
- package/plugins/builtin/effects/glow.ts +118 -0
- package/plugins/builtin/effects/outline.ts +110 -0
- package/plugins/builtin/effects/rotate.ts +357 -0
- package/plugins/builtin/effects/scale.ts +258 -0
- package/plugins/builtin/effects/shadow.ts +111 -0
- package/plugins/builtin/effects/sharpen.ts +102 -0
- package/plugins/builtin/effects.test.ts +715 -0
- package/plugins/builtin/eraser-tool.ts +23 -0
- package/plugins/builtin/eyedropper-tool.ts +83 -0
- package/plugins/builtin/fill-tool.ts +93 -0
- package/plugins/builtin/gradient-tool.ts +204 -0
- package/plugins/builtin/gradient.test.ts +142 -0
- package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
- package/plugins/builtin/importers/aseprite-parser.ts +497 -0
- package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
- package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
- package/plugins/builtin/line-tool.ts +267 -0
- package/plugins/builtin/make-stroke-tool.ts +271 -0
- package/plugins/builtin/noise-dither.test.ts +151 -0
- package/plugins/builtin/noise-tool.ts +131 -0
- package/plugins/builtin/pattern-stamp-tool.ts +162 -0
- package/plugins/builtin/pencil-tool.ts +25 -0
- package/plugins/builtin/rect-tool.ts +179 -0
- package/plugins/builtin/selection-tool.ts +388 -0
- package/plugins/builtin/selection.test.ts +195 -0
- package/plugins/builtin/tool-plugins.test.ts +529 -0
- package/public/favicon.svg +7 -0
- package/public/sw.js +91 -0
- package/pyproject.toml +49 -0
- package/scripts/eslint-wave-a-list.sh +24 -0
- package/scripts/eslint-wave-a-status.sh +25 -0
- package/scripts/fix-index-signature-access.py +127 -0
- package/scripts/fix-unchecked-index.py +128 -0
- package/scripts/fix-unchecked-vars.py +127 -0
- package/scripts/fix-wave-a-bangs.py +36 -0
- package/scripts/fix-wave-a-templates.py +108 -0
- package/scripts/fix-wave-b-void-expr.py +167 -0
- package/scripts/generate-command-params.py +540 -0
- package/scripts/migrate-single-frame-to-multi.py +171 -0
- package/scripts/smoke-test.sh +77 -0
- package/selfdoc.json +10 -0
- package/server/README.md +0 -0
- package/server/src/pixelweaver/__init__.py +1 -0
- package/server/src/pixelweaver/__main__.py +4 -0
- package/server/src/pixelweaver/autosave.py +114 -0
- package/server/src/pixelweaver/bridge.py +127 -0
- package/server/src/pixelweaver/cli.py +199 -0
- package/server/src/pixelweaver/config.py +24 -0
- package/server/src/pixelweaver/connections.py +54 -0
- package/server/src/pixelweaver/main.py +271 -0
- package/server/src/pixelweaver/mcp_bridge.py +189 -0
- package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
- package/server/src/pixelweaver/mcp_export_tools.py +291 -0
- package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
- package/server/src/pixelweaver/mcp_history_tools.py +106 -0
- package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
- package/server/src/pixelweaver/mcp_lock.py +37 -0
- package/server/src/pixelweaver/mcp_project_tools.py +302 -0
- package/server/src/pixelweaver/mcp_read_tools.py +163 -0
- package/server/src/pixelweaver/mcp_registry.py +247 -0
- package/server/src/pixelweaver/mcp_resources.py +312 -0
- package/server/src/pixelweaver/mcp_server.py +234 -0
- package/server/src/pixelweaver/protocol.py +219 -0
- package/server/src/pixelweaver/state.py +267 -0
- package/server/src/pixelweaver/storage.py +293 -0
- package/server/src/pixelweaver/websocket.py +282 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/conftest.py +17 -0
- package/server/tests/test_api.py +96 -0
- package/server/tests/test_bridge.py +161 -0
- package/server/tests/test_health.py +9 -0
- package/server/tests/test_integration.py +86 -0
- package/server/tests/test_mcp_bridge.py +293 -0
- package/server/tests/test_mcp_lock.py +34 -0
- package/server/tests/test_mcp_registry.py +679 -0
- package/server/tests/test_mcp_resources.py +648 -0
- package/server/tests/test_protocol.py +125 -0
- package/server/tests/test_state.py +87 -0
- package/server/tests/test_storage.py +306 -0
- package/server/tests/test_websocket.py +275 -0
- package/src/App.svelte +107 -0
- package/src/app.css +215 -0
- package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
- package/src/lib/animation/animation-commands.test.ts +228 -0
- package/src/lib/animation/animation-commands.ts +540 -0
- package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
- package/src/lib/animation/animation-preview.svelte.ts +151 -0
- package/src/lib/animation/clipboard.ts +134 -0
- package/src/lib/animation/frame-model.svelte.ts +437 -0
- package/src/lib/animation/frame-model.test.ts +314 -0
- package/src/lib/animation/frame-selection.svelte.ts +77 -0
- package/src/lib/animation/frame-tags.svelte.ts +238 -0
- package/src/lib/animation/frame-tags.test.ts +136 -0
- package/src/lib/animation/import.test.ts +141 -0
- package/src/lib/animation/import.ts +112 -0
- package/src/lib/animation/spritesheet-export.test.ts +239 -0
- package/src/lib/animation/spritesheet-export.ts +314 -0
- package/src/lib/canvas/CanvasViewport.svelte +216 -0
- package/src/lib/canvas/canvas-init-plugin.ts +178 -0
- package/src/lib/canvas/canvas-renderer.ts +408 -0
- package/src/lib/canvas/canvas-state.svelte.ts +232 -0
- package/src/lib/canvas/canvas-state.test.ts +139 -0
- package/src/lib/canvas/input-handler.ts +221 -0
- package/src/lib/canvas/input-plugin.ts +150 -0
- package/src/lib/canvas/onion-skin.ts +94 -0
- package/src/lib/canvas/pixel-buffer.test.ts +249 -0
- package/src/lib/canvas/pixel-buffer.ts +151 -0
- package/src/lib/canvas/render-state.svelte.ts +18 -0
- package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
- package/src/lib/canvas/tile-mode.test.ts +53 -0
- package/src/lib/canvas/tile-mode.ts +92 -0
- package/src/lib/canvas/viewport-utils.ts +24 -0
- package/src/lib/canvas/zoom-utils.ts +31 -0
- package/src/lib/color/.gitkeep +0 -0
- package/src/lib/color/color-commands.ts +87 -0
- package/src/lib/color/color-state.svelte.ts +98 -0
- package/src/lib/color/color-state.test.ts +91 -0
- package/src/lib/color/color-utils.test.ts +220 -0
- package/src/lib/color/color-utils.ts +243 -0
- package/src/lib/color/palette-state.svelte.ts +127 -0
- package/src/lib/color/palette-state.test.ts +154 -0
- package/src/lib/color/palette.ts +79 -0
- package/src/lib/core/bootstrap.ts +66 -0
- package/src/lib/core/command-params.generated.ts +1549 -0
- package/src/lib/core/command-params.ts +20 -0
- package/src/lib/core/command-runner.ts +79 -0
- package/src/lib/core/commands.ts +134 -0
- package/src/lib/core/dispatcher.test.ts +548 -0
- package/src/lib/core/dispatcher.ts +361 -0
- package/src/lib/core/index.test.ts +7 -0
- package/src/lib/core/notification-state.svelte.ts +119 -0
- package/src/lib/core/plugin-api.ts +210 -0
- package/src/lib/core/plugin-discovery.test.ts +53 -0
- package/src/lib/core/plugin-discovery.ts +65 -0
- package/src/lib/core/plugin-loader.test.ts +159 -0
- package/src/lib/core/plugin-loader.ts +240 -0
- package/src/lib/core/plugin-types.ts +286 -0
- package/src/lib/core/registries.svelte.ts +74 -0
- package/src/lib/core/tool-options-state.svelte.ts +61 -0
- package/src/lib/dock/DockLayout.svelte +375 -0
- package/src/lib/dock/TabAddMenu.svelte +90 -0
- package/src/lib/dock/dock-persistence.ts +46 -0
- package/src/lib/dock/dock-plugin.ts +49 -0
- package/src/lib/dock/dock-presets.ts +156 -0
- package/src/lib/dock/dock-state.svelte.ts +77 -0
- package/src/lib/dock/dock-types.ts +2 -0
- package/src/lib/dock/dockview-theme.css +226 -0
- package/src/lib/dock/dockview-theme.ts +17 -0
- package/src/lib/dock/header-action-renderer.ts +77 -0
- package/src/lib/edit/clipboard-state.ts +34 -0
- package/src/lib/export/download.ts +201 -0
- package/src/lib/export/png-metadata.ts +181 -0
- package/src/lib/history/ActionLogPanel.svelte +418 -0
- package/src/lib/history/action-log-panel-plugin.ts +24 -0
- package/src/lib/history/action-log.svelte.ts +172 -0
- package/src/lib/history/action-log.test.ts +168 -0
- package/src/lib/history/history-commands.ts +403 -0
- package/src/lib/history/macros.svelte.ts +320 -0
- package/src/lib/history/macros.test.ts +224 -0
- package/src/lib/history/replay-engine.test.ts +241 -0
- package/src/lib/history/replay-engine.ts +149 -0
- package/src/lib/history/spec-format.test.ts +250 -0
- package/src/lib/history/spec-format.ts +210 -0
- package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
- package/src/lib/iso/iso-math.test.ts +77 -0
- package/src/lib/iso/iso-math.ts +77 -0
- package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
- package/src/lib/iso/seam-checker.test.ts +126 -0
- package/src/lib/iso/seam-checker.ts +93 -0
- package/src/lib/iso/tessellation.ts +67 -0
- package/src/lib/layers/compositor.test.ts +193 -0
- package/src/lib/layers/compositor.ts +175 -0
- package/src/lib/layers/layer-commands.test.ts +263 -0
- package/src/lib/layers/layer-commands.ts +429 -0
- package/src/lib/layers/layer-tree.svelte.ts +516 -0
- package/src/lib/layers/layer-tree.test.ts +383 -0
- package/src/lib/layers/layer-types.ts +56 -0
- package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
- package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
- package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
- package/src/lib/leveleditor/depth-sort.test.ts +70 -0
- package/src/lib/leveleditor/depth-sort.ts +39 -0
- package/src/lib/leveleditor/level-editor-commands.ts +353 -0
- package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
- package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/map-state.svelte.ts +372 -0
- package/src/lib/leveleditor/map-state.test.ts +243 -0
- package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
- package/src/lib/leveleditor/tiled-export.test.ts +144 -0
- package/src/lib/leveleditor/tiled-export.ts +374 -0
- package/src/lib/pywebview.d.ts +15 -0
- package/src/lib/recovery/.gitkeep +0 -0
- package/src/lib/recovery/idb-store.ts +118 -0
- package/src/lib/recovery/recovery-manager.test.ts +321 -0
- package/src/lib/recovery/recovery-manager.ts +115 -0
- package/src/lib/recovery/recovery-plugin.ts +55 -0
- package/src/lib/recovery/recovery-state.svelte.ts +21 -0
- package/src/lib/recovery/sw-plugin.ts +18 -0
- package/src/lib/recovery/sw-register.ts +17 -0
- package/src/lib/save/directory-format.ts +42 -0
- package/src/lib/save/project-snapshot.ts +139 -0
- package/src/lib/save/recent-projects.ts +56 -0
- package/src/lib/save/save-state.svelte.ts +35 -0
- package/src/lib/save/storage.ts +167 -0
- package/src/lib/save/zip-format.ts +45 -0
- package/src/lib/shortcuts/.gitkeep +0 -0
- package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
- package/src/lib/shortcuts/default-bindings.ts +61 -0
- package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
- package/src/lib/shortcuts/shortcut-init.ts +380 -0
- package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
- package/src/lib/shortcuts/shortcut-manager.ts +281 -0
- package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
- package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
- package/src/lib/sync/patch-applicator.ts +300 -0
- package/src/lib/sync/patch-types.ts +65 -0
- package/src/lib/sync/project-manager.ts +108 -0
- package/src/lib/sync/sync-init.ts +152 -0
- package/src/lib/sync/sync-plugin.ts +19 -0
- package/src/lib/sync/sync-state.svelte.ts +56 -0
- package/src/lib/sync/ws-client.test.ts +604 -0
- package/src/lib/sync/ws-client.ts +574 -0
- package/src/lib/tools/.gitkeep +0 -0
- package/src/lib/ui/.gitkeep +0 -0
- package/src/lib/ui/AboutDialog.svelte +113 -0
- package/src/lib/ui/ColorPicker.svelte +761 -0
- package/src/lib/ui/ContextMenu.svelte +216 -0
- package/src/lib/ui/ExportDialog.svelte +747 -0
- package/src/lib/ui/FrameStrip.svelte +854 -0
- package/src/lib/ui/LayerPanel.svelte +810 -0
- package/src/lib/ui/MenuBar.svelte +590 -0
- package/src/lib/ui/NewProjectDialog.svelte +803 -0
- package/src/lib/ui/PluginManagerPanel.svelte +475 -0
- package/src/lib/ui/PromptDialog.svelte +252 -0
- package/src/lib/ui/RecoveryDialog.svelte +295 -0
- package/src/lib/ui/ResizeDialog.svelte +416 -0
- package/src/lib/ui/StatusBar.svelte +145 -0
- package/src/lib/ui/ToolbarPanel.svelte +488 -0
- package/src/lib/ui/animation-menu-commands.ts +194 -0
- package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
- package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
- package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
- package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
- package/src/lib/ui/dialog-state.svelte.ts +70 -0
- package/src/lib/ui/edit-commands.ts +271 -0
- package/src/lib/ui/file-commands.ts +275 -0
- package/src/lib/ui/file-open.ts +99 -0
- package/src/lib/ui/help-commands.ts +93 -0
- package/src/lib/ui/image-commands.ts +181 -0
- package/src/lib/ui/layer-menu-commands.ts +420 -0
- package/src/lib/ui/menu-builder.ts +224 -0
- package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
- package/src/lib/ui/notifications/notification-plugin.ts +29 -0
- package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
- package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
- package/src/lib/ui/plugin-state.svelte.ts +62 -0
- package/src/lib/ui/select-commands.ts +75 -0
- package/src/lib/ui/theme-plugin.ts +18 -0
- package/src/lib/ui/theme.svelte.ts +45 -0
- package/src/lib/ui/theme.test.ts +51 -0
- package/src/lib/ui/toolbar-config.ts +90 -0
- package/src/lib/ui/toolbar-plugin.ts +39 -0
- package/src/lib/ui/view-commands.ts +629 -0
- package/src/lib/variants/BisectionExportDialog.svelte +500 -0
- package/src/lib/variants/VariantPanel.svelte +822 -0
- package/src/lib/variants/bisection-export.test.ts +113 -0
- package/src/lib/variants/bisection-export.ts +148 -0
- package/src/lib/variants/palette-extraction.test.ts +111 -0
- package/src/lib/variants/palette-extraction.ts +84 -0
- package/src/lib/variants/palette-interpolation.test.ts +113 -0
- package/src/lib/variants/palette-interpolation.ts +87 -0
- package/src/lib/variants/palette-swap.test.ts +101 -0
- package/src/lib/variants/palette-swap.ts +114 -0
- package/src/lib/variants/variant-commands.ts +594 -0
- package/src/lib/variants/variant-panel-plugin.ts +27 -0
- package/src/lib/variants/variant-randomizer.ts +101 -0
- package/src/lib/variants/variant-state.svelte.ts +166 -0
- package/src/lib/variants/variant-state.test.ts +138 -0
- package/src/main.ts +14 -0
- package/src/vite-env.d.ts +3 -0
- package/svelte.config.js +2 -0
- package/todo/.done/audit-design-decisions.md +812 -0
- package/todo/.done/audit-implementation-plan.md +1235 -0
- package/todo/.done/happy-path-polish.md +177 -0
- package/todo/.done/pixelweaver-full-build.md +937 -0
- package/todo/.done/server-multi-frame-design.md +405 -0
- package/todo/.done/typed-dispatcher-design.md +435 -0
- package/todo/.done/unified-toolbar-and-action-system.md +323 -0
- package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
- package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
- package/todo/comprehensive-audit.md +1085 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +20 -0
- package/uv.lock +1167 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
3
|
+
import { exportGroupSpritesheet, exportVariantAtlas } from './bisection-export.js';
|
|
4
|
+
import type { Layer } from '../layers/layer-types.js';
|
|
5
|
+
import type { Frame } from '../animation/frame-model.svelte.js';
|
|
6
|
+
import type { VariantPreset } from './variant-state.svelte.js';
|
|
7
|
+
|
|
8
|
+
/** Build a simple test group with one pixel layer. */
|
|
9
|
+
function makeTestScene() {
|
|
10
|
+
const layer1: Layer = { id: 'l1', name: 'Body', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
11
|
+
const layer2: Layer = { id: 'l2', name: 'Outside', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
12
|
+
const group: Layer = {
|
|
13
|
+
id: 'g1', name: 'Character', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
|
|
14
|
+
children: [layer1], expanded: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// l1 has a red pixel; l2 (outside group) has a green pixel
|
|
18
|
+
const buf1 = new PixelBuffer(4, 4);
|
|
19
|
+
buf1.setPixel(1, 1, 255, 0, 0, 255);
|
|
20
|
+
|
|
21
|
+
const buf2 = new PixelBuffer(4, 4);
|
|
22
|
+
buf2.setPixel(2, 2, 0, 255, 0, 255);
|
|
23
|
+
|
|
24
|
+
const frame: Frame = {
|
|
25
|
+
id: 'f1', index: 0, durationMs: null,
|
|
26
|
+
pixelData: new Map([['l1', buf1], ['l2', buf2]]),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return { group, layer2, frame, layerTree: [layer2, group] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('exportGroupSpritesheet', () => {
|
|
33
|
+
it('should only include pixels from the group\'s layers', () => {
|
|
34
|
+
const { frame, layerTree } = makeTestScene();
|
|
35
|
+
const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4);
|
|
36
|
+
|
|
37
|
+
expect(frames).toHaveLength(1);
|
|
38
|
+
const buf = frames[0];
|
|
39
|
+
if (!buf) throw new Error("missing buffer");
|
|
40
|
+
// Red pixel from l1 (inside group) should be present
|
|
41
|
+
expect(buf.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
|
|
42
|
+
// Green pixel from l2 (outside group) should NOT be present
|
|
43
|
+
expect(buf.getPixel(2, 2)).toEqual([0, 0, 0, 0]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should leave non-group pixels transparent', () => {
|
|
47
|
+
const { frame, layerTree } = makeTestScene();
|
|
48
|
+
const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4);
|
|
49
|
+
const buf = frames[0];
|
|
50
|
+
if (!buf) throw new Error("missing buffer");
|
|
51
|
+
|
|
52
|
+
// Most pixels should be transparent
|
|
53
|
+
expect(buf.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
|
|
54
|
+
expect(buf.getPixel(3, 3)).toEqual([0, 0, 0, 0]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should apply variant during export', () => {
|
|
58
|
+
const { frame, layerTree } = makeTestScene();
|
|
59
|
+
const variant: VariantPreset = {
|
|
60
|
+
id: 'v1', name: 'Blue',
|
|
61
|
+
groupOverrides: new Map([
|
|
62
|
+
['g1', new Map([['#FF0000', '#0000FF']])], // red -> blue
|
|
63
|
+
]),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4, { variant });
|
|
67
|
+
const buf = frames[0];
|
|
68
|
+
if (!buf) throw new Error("missing buffer");
|
|
69
|
+
// Red pixel should now be blue
|
|
70
|
+
expect(buf.getPixel(1, 1)).toEqual([0, 0, 255, 255]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should throw for non-existent group', () => {
|
|
74
|
+
expect(() => exportGroupSpritesheet('bad', [], [], 4, 4)).toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('exportVariantAtlas', () => {
|
|
79
|
+
it('should create atlas with correct dimensions (rows=variants, cols=frames)', () => {
|
|
80
|
+
const layer: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
81
|
+
const group: Layer = {
|
|
82
|
+
id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
|
|
83
|
+
children: [layer], expanded: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const buf = new PixelBuffer(4, 4);
|
|
87
|
+
buf.setPixel(0, 0, 255, 0, 0, 255);
|
|
88
|
+
|
|
89
|
+
const frame1: Frame = { id: 'f1', index: 0, durationMs: null, pixelData: new Map([['l1', buf]]) };
|
|
90
|
+
const frame2: Frame = { id: 'f2', index: 1, durationMs: null, pixelData: new Map([['l1', buf.clone()]]) };
|
|
91
|
+
|
|
92
|
+
const v1: VariantPreset = { id: 'v1', name: 'Original', groupOverrides: new Map() };
|
|
93
|
+
const v2: VariantPreset = {
|
|
94
|
+
id: 'v2', name: 'Blue',
|
|
95
|
+
groupOverrides: new Map([['g1', new Map([['#FF0000', '#0000FF']])]]),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const atlas = exportVariantAtlas('g1', [group], [frame1, frame2], [v1, v2], 4, 4);
|
|
99
|
+
// 2 frames x 4px = 8 wide, 2 variants x 4px = 8 tall
|
|
100
|
+
expect(atlas.width).toBe(8);
|
|
101
|
+
expect(atlas.height).toBe(8);
|
|
102
|
+
|
|
103
|
+
// Row 0 (v1/Original): frame 1 at (0,0), red pixel at (0,0)
|
|
104
|
+
expect(atlas.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
105
|
+
// Row 0, frame 2 at (4,0), red pixel at (4,0)
|
|
106
|
+
expect(atlas.getPixel(4, 0)).toEqual([255, 0, 0, 255]);
|
|
107
|
+
|
|
108
|
+
// Row 1 (v2/Blue): frame 1 at (0,4), should be blue
|
|
109
|
+
expect(atlas.getPixel(0, 4)).toEqual([0, 0, 255, 255]);
|
|
110
|
+
// Row 1, frame 2 at (4,4), should be blue
|
|
111
|
+
expect(atlas.getPixel(4, 4)).toEqual([0, 0, 255, 255]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bisection Export -- exports individual layer groups as spritesheets or variant atlases.
|
|
3
|
+
*
|
|
4
|
+
* "Bisection" refers to isolating a group from the rest of the layer tree,
|
|
5
|
+
* exporting only its pixels on a transparent background.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
9
|
+
import { composite } from '../layers/compositor.js';
|
|
10
|
+
import type { Layer } from '../layers/layer-types.js';
|
|
11
|
+
import type { Frame } from '../animation/frame-model.svelte.js';
|
|
12
|
+
import { applyPaletteSwap } from './palette-swap.js';
|
|
13
|
+
import type { VariantPreset } from './variant-state.svelte.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Find a layer by ID in the tree (depth-first search).
|
|
17
|
+
*/
|
|
18
|
+
function findLayer(id: string, tree: Layer[]): Layer | undefined {
|
|
19
|
+
for (const layer of tree) {
|
|
20
|
+
if (layer.id === id) return layer;
|
|
21
|
+
if (layer.type === 'group' && layer.children) {
|
|
22
|
+
const found = findLayer(id, layer.children);
|
|
23
|
+
if (found) return found;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Recursively collect IDs of all pixel layers within a layer.
|
|
31
|
+
*/
|
|
32
|
+
function collectPixelLayerIds(layer: Layer): string[] {
|
|
33
|
+
if (layer.type === 'pixel') return [layer.id];
|
|
34
|
+
if (layer.children) {
|
|
35
|
+
return layer.children.flatMap(collectPixelLayerIds);
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a minimal layer tree containing only the target group,
|
|
42
|
+
* preserving its internal structure for correct compositing.
|
|
43
|
+
*/
|
|
44
|
+
function isolateGroupTree(group: Layer): Layer[] {
|
|
45
|
+
return [group];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Composite a group's layers for a single frame, optionally applying a variant.
|
|
50
|
+
*/
|
|
51
|
+
function compositeGroupFrame(
|
|
52
|
+
group: Layer,
|
|
53
|
+
frame: Frame,
|
|
54
|
+
width: number,
|
|
55
|
+
height: number,
|
|
56
|
+
variant?: VariantPreset,
|
|
57
|
+
): PixelBuffer {
|
|
58
|
+
const pixelLayerIds = collectPixelLayerIds(group);
|
|
59
|
+
const colorMap = variant?.groupOverrides.get(group.id);
|
|
60
|
+
|
|
61
|
+
// Build pixel data map containing only this group's layers
|
|
62
|
+
const pixelData = new Map<string, PixelBuffer>();
|
|
63
|
+
for (const layerId of pixelLayerIds) {
|
|
64
|
+
const buffer = frame.pixelData.get(layerId);
|
|
65
|
+
if (!buffer) continue;
|
|
66
|
+
if (colorMap && colorMap.size > 0) {
|
|
67
|
+
pixelData.set(layerId, applyPaletteSwap(buffer, colorMap));
|
|
68
|
+
} else {
|
|
69
|
+
pixelData.set(layerId, buffer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Composite using the group's own layer tree (preserves blend modes and opacity)
|
|
74
|
+
return composite(isolateGroupTree(group), pixelData, width, height);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Export a layer group as a separate spritesheet.
|
|
79
|
+
* Only includes pixels from layers within the group, on transparent background.
|
|
80
|
+
*
|
|
81
|
+
* @param groupId - ID of the group layer to export
|
|
82
|
+
* @param layerTree - the full layer tree
|
|
83
|
+
* @param frames - all animation frames
|
|
84
|
+
* @param width - canvas width in pixels
|
|
85
|
+
* @param height - canvas height in pixels
|
|
86
|
+
* @param options - optionally apply a variant preset
|
|
87
|
+
* @returns one PixelBuffer per frame
|
|
88
|
+
*/
|
|
89
|
+
export function exportGroupSpritesheet(
|
|
90
|
+
groupId: string,
|
|
91
|
+
layerTree: Layer[],
|
|
92
|
+
frames: Frame[],
|
|
93
|
+
width: number,
|
|
94
|
+
height: number,
|
|
95
|
+
options?: { variant?: VariantPreset },
|
|
96
|
+
): PixelBuffer[] {
|
|
97
|
+
const group = findLayer(groupId, layerTree);
|
|
98
|
+
if (!group || group.type !== 'group') {
|
|
99
|
+
throw new Error(`Group "${groupId}" not found or is not a group.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return frames.map((frame) =>
|
|
103
|
+
compositeGroupFrame(group, frame, width, height, options?.variant),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Export all variants of a group as a variant atlas.
|
|
109
|
+
* Layout: rows = variants, columns = frames.
|
|
110
|
+
*
|
|
111
|
+
* @param groupId - ID of the group layer
|
|
112
|
+
* @param layerTree - the full layer tree
|
|
113
|
+
* @param frames - all animation frames
|
|
114
|
+
* @param variants - array of variant presets to render
|
|
115
|
+
* @param width - per-frame width in pixels
|
|
116
|
+
* @param height - per-frame height in pixels
|
|
117
|
+
* @returns a single PixelBuffer atlas image
|
|
118
|
+
*/
|
|
119
|
+
export function exportVariantAtlas(
|
|
120
|
+
groupId: string,
|
|
121
|
+
layerTree: Layer[],
|
|
122
|
+
frames: Frame[],
|
|
123
|
+
variants: VariantPreset[],
|
|
124
|
+
width: number,
|
|
125
|
+
height: number,
|
|
126
|
+
): PixelBuffer {
|
|
127
|
+
const group = findLayer(groupId, layerTree);
|
|
128
|
+
if (!group || group.type !== 'group') {
|
|
129
|
+
throw new Error(`Group "${groupId}" not found or is not a group.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const atlasWidth = frames.length * width;
|
|
133
|
+
const atlasHeight = variants.length * height;
|
|
134
|
+
const atlas = new PixelBuffer(atlasWidth, atlasHeight);
|
|
135
|
+
|
|
136
|
+
for (let row = 0; row < variants.length; row++) {
|
|
137
|
+
const variant = variants[row];
|
|
138
|
+
if (!variant) continue;
|
|
139
|
+
for (let col = 0; col < frames.length; col++) {
|
|
140
|
+
const frame = frames[col];
|
|
141
|
+
if (!frame) continue;
|
|
142
|
+
const frameBuffer = compositeGroupFrame(group, frame, width, height, variant);
|
|
143
|
+
atlas.paste(frameBuffer, col * width, row * height);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return atlas;
|
|
148
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
3
|
+
import { extractBufferPalette, extractGroupPalette } from './palette-extraction.js';
|
|
4
|
+
import type { Layer } from '../layers/layer-types.js';
|
|
5
|
+
import type { Frame } from '../animation/frame-model.svelte.js';
|
|
6
|
+
|
|
7
|
+
describe('extractBufferPalette', () => {
|
|
8
|
+
it('should extract unique colors from a buffer', () => {
|
|
9
|
+
const buf = new PixelBuffer(3, 1);
|
|
10
|
+
buf.setPixel(0, 0, 255, 0, 0, 255); // red
|
|
11
|
+
buf.setPixel(1, 0, 0, 255, 0, 255); // green
|
|
12
|
+
buf.setPixel(2, 0, 0, 0, 255, 255); // blue
|
|
13
|
+
|
|
14
|
+
const palette = extractBufferPalette(buf);
|
|
15
|
+
expect(palette).toHaveLength(3);
|
|
16
|
+
expect(palette).toContain('#FF0000');
|
|
17
|
+
expect(palette).toContain('#00FF00');
|
|
18
|
+
expect(palette).toContain('#0000FF');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should deduplicate identical colors', () => {
|
|
22
|
+
const buf = new PixelBuffer(3, 1);
|
|
23
|
+
buf.setPixel(0, 0, 255, 0, 0, 255);
|
|
24
|
+
buf.setPixel(1, 0, 255, 0, 0, 255);
|
|
25
|
+
buf.setPixel(2, 0, 255, 0, 0, 255);
|
|
26
|
+
|
|
27
|
+
const palette = extractBufferPalette(buf);
|
|
28
|
+
expect(palette).toHaveLength(1);
|
|
29
|
+
expect(palette[0]).toBe('#FF0000');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should skip transparent pixels', () => {
|
|
33
|
+
const buf = new PixelBuffer(2, 1);
|
|
34
|
+
buf.setPixel(0, 0, 255, 0, 0, 255);
|
|
35
|
+
// pixel (1,0) is transparent (default)
|
|
36
|
+
|
|
37
|
+
const palette = extractBufferPalette(buf);
|
|
38
|
+
expect(palette).toHaveLength(1);
|
|
39
|
+
expect(palette[0]).toBe('#FF0000');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return empty array for fully transparent buffer', () => {
|
|
43
|
+
const buf = new PixelBuffer(4, 4);
|
|
44
|
+
expect(extractBufferPalette(buf)).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should include semi-transparent pixels (alpha > 0)', () => {
|
|
48
|
+
const buf = new PixelBuffer(1, 1);
|
|
49
|
+
buf.setPixel(0, 0, 128, 64, 32, 1); // alpha=1, nearly transparent but not zero
|
|
50
|
+
|
|
51
|
+
const palette = extractBufferPalette(buf);
|
|
52
|
+
expect(palette).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('extractGroupPalette', () => {
|
|
57
|
+
it('should extract palette across multiple layers in a group', () => {
|
|
58
|
+
const layer1: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
59
|
+
const layer2: Layer = { id: 'l2', name: 'L2', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
60
|
+
const group: Layer = {
|
|
61
|
+
id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
|
|
62
|
+
children: [layer1, layer2], expanded: true,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const buf1 = new PixelBuffer(2, 1);
|
|
66
|
+
buf1.setPixel(0, 0, 255, 0, 0, 255);
|
|
67
|
+
buf1.setPixel(1, 0, 0, 255, 0, 255);
|
|
68
|
+
|
|
69
|
+
const buf2 = new PixelBuffer(2, 1);
|
|
70
|
+
buf2.setPixel(0, 0, 0, 0, 255, 255);
|
|
71
|
+
buf2.setPixel(1, 0, 255, 0, 0, 255); // duplicate of buf1's red
|
|
72
|
+
|
|
73
|
+
const frame: Frame = {
|
|
74
|
+
id: 'f1', index: 0, durationMs: null,
|
|
75
|
+
pixelData: new Map([['l1', buf1], ['l2', buf2]]),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const palette = extractGroupPalette('g1', [group], [frame]);
|
|
79
|
+
// Should have 3 unique colors: red, green, blue
|
|
80
|
+
expect(palette).toHaveLength(3);
|
|
81
|
+
expect(palette).toContain('#FF0000');
|
|
82
|
+
expect(palette).toContain('#00FF00');
|
|
83
|
+
expect(palette).toContain('#0000FF');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return empty array for non-existent group', () => {
|
|
87
|
+
const palette = extractGroupPalette('nonexistent', [], []);
|
|
88
|
+
expect(palette).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should extract across multiple frames', () => {
|
|
92
|
+
const layer: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
|
|
93
|
+
const group: Layer = {
|
|
94
|
+
id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
|
|
95
|
+
children: [layer], expanded: true,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const buf1 = new PixelBuffer(1, 1);
|
|
99
|
+
buf1.setPixel(0, 0, 255, 0, 0, 255);
|
|
100
|
+
const frame1: Frame = { id: 'f1', index: 0, durationMs: null, pixelData: new Map([['l1', buf1]]) };
|
|
101
|
+
|
|
102
|
+
const buf2 = new PixelBuffer(1, 1);
|
|
103
|
+
buf2.setPixel(0, 0, 0, 0, 255, 255);
|
|
104
|
+
const frame2: Frame = { id: 'f2', index: 1, durationMs: null, pixelData: new Map([['l1', buf2]]) };
|
|
105
|
+
|
|
106
|
+
const palette = extractGroupPalette('g1', [group], [frame1, frame2]);
|
|
107
|
+
expect(palette).toHaveLength(2);
|
|
108
|
+
expect(palette).toContain('#FF0000');
|
|
109
|
+
expect(palette).toContain('#0000FF');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette Extraction -- extracts unique colors from pixel layers and groups.
|
|
3
|
+
*
|
|
4
|
+
* Scans PixelBuffer data to collect all distinct opaque colors as uppercase
|
|
5
|
+
* 6-digit hex strings. Transparent pixels (alpha === 0) are excluded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
9
|
+
import { rgbToHex } from '../color/color-utils.js';
|
|
10
|
+
import type { Layer } from '../layers/layer-types.js';
|
|
11
|
+
import type { Frame } from '../animation/frame-model.svelte.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract unique colors from a single PixelBuffer.
|
|
15
|
+
* Skips fully transparent pixels (alpha === 0).
|
|
16
|
+
* Returns uppercase 6-digit hex strings, deduplicated.
|
|
17
|
+
*/
|
|
18
|
+
export function extractBufferPalette(buffer: PixelBuffer): string[] {
|
|
19
|
+
const colors = new Set<string>();
|
|
20
|
+
const { data } = buffer;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
23
|
+
if (data[i + 3] === 0) continue; // skip transparent pixels
|
|
24
|
+
colors.add(rgbToHex(data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return Array.from(colors);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recursively collect IDs of all pixel layers within a group.
|
|
32
|
+
*/
|
|
33
|
+
function collectPixelLayerIds(layer: Layer): string[] {
|
|
34
|
+
if (layer.type === 'pixel') return [layer.id];
|
|
35
|
+
if (layer.children) {
|
|
36
|
+
return layer.children.flatMap(collectPixelLayerIds);
|
|
37
|
+
}
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find a layer by ID in the tree (depth-first search).
|
|
43
|
+
*/
|
|
44
|
+
function findLayer(id: string, tree: Layer[]): Layer | undefined {
|
|
45
|
+
for (const layer of tree) {
|
|
46
|
+
if (layer.id === id) return layer;
|
|
47
|
+
if (layer.type === 'group' && layer.children) {
|
|
48
|
+
const found = findLayer(id, layer.children);
|
|
49
|
+
if (found) return found;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract unique colors from all pixel layers within a layer group,
|
|
57
|
+
* across all frames.
|
|
58
|
+
*
|
|
59
|
+
* Returns uppercase 6-digit hex strings, deduplicated and sorted for
|
|
60
|
+
* deterministic output.
|
|
61
|
+
*/
|
|
62
|
+
export function extractGroupPalette(
|
|
63
|
+
groupId: string,
|
|
64
|
+
layerTree: Layer[],
|
|
65
|
+
frames: Frame[],
|
|
66
|
+
): string[] {
|
|
67
|
+
const group = findLayer(groupId, layerTree);
|
|
68
|
+
if (!group || group.type !== 'group') return [];
|
|
69
|
+
|
|
70
|
+
const pixelLayerIds = collectPixelLayerIds(group);
|
|
71
|
+
const colors = new Set<string>();
|
|
72
|
+
|
|
73
|
+
for (const frame of frames) {
|
|
74
|
+
for (const layerId of pixelLayerIds) {
|
|
75
|
+
const buffer = frame.pixelData.get(layerId);
|
|
76
|
+
if (!buffer) continue;
|
|
77
|
+
for (const hex of extractBufferPalette(buffer)) {
|
|
78
|
+
colors.add(hex);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Array.from(colors).sort();
|
|
84
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { interpolatePresets, generateInterpolatedSeries } from './palette-interpolation.js';
|
|
3
|
+
import type { VariantPreset } from './variant-state.svelte.js';
|
|
4
|
+
|
|
5
|
+
/** Helper to create a preset with a single group's color overrides. */
|
|
6
|
+
function makePreset(
|
|
7
|
+
name: string,
|
|
8
|
+
groupId: string,
|
|
9
|
+
overrides: Record<string, string>,
|
|
10
|
+
): VariantPreset {
|
|
11
|
+
return {
|
|
12
|
+
id: `test-${name}`,
|
|
13
|
+
name,
|
|
14
|
+
groupOverrides: new Map([[groupId, new Map(Object.entries(overrides))]]),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('interpolatePresets', () => {
|
|
19
|
+
it('should return preset A colors at t=0', () => {
|
|
20
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
|
|
21
|
+
const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
|
|
22
|
+
|
|
23
|
+
const result = interpolatePresets(a, b, 0);
|
|
24
|
+
const color = result.groupOverrides.get('g1')?.get('#FF0000');
|
|
25
|
+
expect(color).toBe('#00FF00');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return preset B colors at t=1', () => {
|
|
29
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
|
|
30
|
+
const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
|
|
31
|
+
|
|
32
|
+
const result = interpolatePresets(a, b, 1);
|
|
33
|
+
const color = result.groupOverrides.get('g1')?.get('#FF0000');
|
|
34
|
+
expect(color).toBe('#0000FF');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return an intermediate color at t=0.5', () => {
|
|
38
|
+
const a = makePreset('A', 'g1', { '#000000': '#000000' });
|
|
39
|
+
const b = makePreset('B', 'g1', { '#000000': '#FFFFFF' });
|
|
40
|
+
|
|
41
|
+
const result = interpolatePresets(a, b, 0.5);
|
|
42
|
+
const color = result.groupOverrides.get('g1')?.get('#000000');
|
|
43
|
+
// OKLab midpoint of black and white is a medium gray; exact value depends on OKLab
|
|
44
|
+
// but it should not be black or white
|
|
45
|
+
expect(color).not.toBe('#000000');
|
|
46
|
+
expect(color).not.toBe('#FFFFFF');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle overrides present in only one preset', () => {
|
|
50
|
+
// Preset A overrides red, preset B does not
|
|
51
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
|
|
52
|
+
const b: VariantPreset = { id: 'b', name: 'B', groupOverrides: new Map() };
|
|
53
|
+
|
|
54
|
+
// At t=0, should be fully A's color
|
|
55
|
+
const r0 = interpolatePresets(a, b, 0);
|
|
56
|
+
expect(r0.groupOverrides.get('g1')?.get('#FF0000')).toBe('#00FF00');
|
|
57
|
+
|
|
58
|
+
// At t=1, B has no override so endpoint is the original color (#FF0000)
|
|
59
|
+
const r1 = interpolatePresets(a, b, 1);
|
|
60
|
+
expect(r1.groupOverrides.get('g1')?.get('#FF0000')).toBe('#FF0000');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should clamp t to [0, 1]', () => {
|
|
64
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
|
|
65
|
+
const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
|
|
66
|
+
|
|
67
|
+
const rNeg = interpolatePresets(a, b, -0.5);
|
|
68
|
+
const r0 = interpolatePresets(a, b, 0);
|
|
69
|
+
expect(rNeg.groupOverrides.get('g1')?.get('#FF0000')).toBe(
|
|
70
|
+
r0.groupOverrides.get('g1')?.get('#FF0000'),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const rOver = interpolatePresets(a, b, 1.5);
|
|
74
|
+
const r1 = interpolatePresets(a, b, 1);
|
|
75
|
+
expect(rOver.groupOverrides.get('g1')?.get('#FF0000')).toBe(
|
|
76
|
+
r1.groupOverrides.get('g1')?.get('#FF0000'),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('generateInterpolatedSeries', () => {
|
|
82
|
+
it('should produce the correct number of presets', () => {
|
|
83
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#000000' });
|
|
84
|
+
const b = makePreset('B', 'g1', { '#FF0000': '#FFFFFF' });
|
|
85
|
+
|
|
86
|
+
expect(generateInterpolatedSeries(a, b, 5)).toHaveLength(5);
|
|
87
|
+
expect(generateInterpolatedSeries(a, b, 1)).toHaveLength(1);
|
|
88
|
+
expect(generateInterpolatedSeries(a, b, 0)).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should have first = A and last = B for series of N >= 2', () => {
|
|
92
|
+
const a = makePreset('A', 'g1', { '#FF0000': '#000000' });
|
|
93
|
+
const b = makePreset('B', 'g1', { '#FF0000': '#FFFFFF' });
|
|
94
|
+
|
|
95
|
+
const series = generateInterpolatedSeries(a, b, 3);
|
|
96
|
+
const firstColor = series[0]?.groupOverrides.get('g1')?.get('#FF0000');
|
|
97
|
+
const lastColor = series[2]?.groupOverrides.get('g1')?.get('#FF0000');
|
|
98
|
+
expect(firstColor).toBe('#000000');
|
|
99
|
+
expect(lastColor).toBe('#FFFFFF');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should produce evenly spaced results', () => {
|
|
103
|
+
const a = makePreset('A', 'g1', { '#000000': '#000000' });
|
|
104
|
+
const b = makePreset('B', 'g1', { '#000000': '#FFFFFF' });
|
|
105
|
+
|
|
106
|
+
const series = generateInterpolatedSeries(a, b, 3);
|
|
107
|
+
// Middle value should be the same as interpolation at t=0.5
|
|
108
|
+
const mid = interpolatePresets(a, b, 0.5);
|
|
109
|
+
const midColor = mid.groupOverrides.get('g1')?.get('#000000');
|
|
110
|
+
const seriesMidColor = series[1]?.groupOverrides.get('g1')?.get('#000000');
|
|
111
|
+
expect(seriesMidColor).toBe(midColor);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette Interpolation -- blends between variant presets using OKLab color space.
|
|
3
|
+
*
|
|
4
|
+
* Uses lerpColor from color-utils.ts for perceptually uniform interpolation.
|
|
5
|
+
* Missing overrides in one preset are treated as identity (original color).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { lerpColor } from '../color/color-utils.js';
|
|
9
|
+
import type { VariantPreset } from './variant-state.svelte.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Interpolate between two variant presets.
|
|
13
|
+
* Uses OKLab color space (via lerpColor) for perceptual uniformity.
|
|
14
|
+
*
|
|
15
|
+
* When a color override exists in only one preset, the other endpoint
|
|
16
|
+
* is treated as the original color (the key itself), so interpolation
|
|
17
|
+
* smoothly transitions from/to the unmodified palette.
|
|
18
|
+
*
|
|
19
|
+
* @param presetA - first preset (t=0)
|
|
20
|
+
* @param presetB - second preset (t=1)
|
|
21
|
+
* @param t - interpolation factor: 0.0 = preset A, 1.0 = preset B
|
|
22
|
+
* @returns a new preset with interpolated colors
|
|
23
|
+
*/
|
|
24
|
+
export function interpolatePresets(
|
|
25
|
+
presetA: VariantPreset,
|
|
26
|
+
presetB: VariantPreset,
|
|
27
|
+
t: number,
|
|
28
|
+
): VariantPreset {
|
|
29
|
+
const clampedT = Math.max(0, Math.min(1, t));
|
|
30
|
+
const groupOverrides = new Map<string, Map<string, string>>();
|
|
31
|
+
|
|
32
|
+
// Collect all group IDs from both presets
|
|
33
|
+
const allGroupIds = new Set<string>();
|
|
34
|
+
for (const groupId of presetA.groupOverrides.keys()) allGroupIds.add(groupId);
|
|
35
|
+
for (const groupId of presetB.groupOverrides.keys()) allGroupIds.add(groupId);
|
|
36
|
+
|
|
37
|
+
for (const groupId of allGroupIds) {
|
|
38
|
+
const mapA = presetA.groupOverrides.get(groupId);
|
|
39
|
+
const mapB = presetB.groupOverrides.get(groupId);
|
|
40
|
+
|
|
41
|
+
// Collect all original colors from both maps
|
|
42
|
+
const allOriginals = new Set<string>();
|
|
43
|
+
if (mapA) for (const key of mapA.keys()) allOriginals.add(key);
|
|
44
|
+
if (mapB) for (const key of mapB.keys()) allOriginals.add(key);
|
|
45
|
+
|
|
46
|
+
const interpolatedMap = new Map<string, string>();
|
|
47
|
+
|
|
48
|
+
for (const originalColor of allOriginals) {
|
|
49
|
+
// If a preset doesn't override this color, use the original as the endpoint
|
|
50
|
+
const colorA = mapA?.get(originalColor) ?? originalColor;
|
|
51
|
+
const colorB = mapB?.get(originalColor) ?? originalColor;
|
|
52
|
+
interpolatedMap.set(originalColor, lerpColor(colorA, colorB, clampedT));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (interpolatedMap.size > 0) {
|
|
56
|
+
groupOverrides.set(groupId, interpolatedMap);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
name: `Interpolated (t=${clampedT.toFixed(2)})`,
|
|
63
|
+
groupOverrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate N evenly-spaced interpolated presets between A and B.
|
|
69
|
+
*
|
|
70
|
+
* For count=3, produces presets at t = 0.0, 0.5, 1.0.
|
|
71
|
+
* For count=1, produces a preset at t = 0.5.
|
|
72
|
+
*/
|
|
73
|
+
export function generateInterpolatedSeries(
|
|
74
|
+
presetA: VariantPreset,
|
|
75
|
+
presetB: VariantPreset,
|
|
76
|
+
count: number,
|
|
77
|
+
): VariantPreset[] {
|
|
78
|
+
if (count <= 0) return [];
|
|
79
|
+
if (count === 1) return [interpolatePresets(presetA, presetB, 0.5)];
|
|
80
|
+
|
|
81
|
+
const results: VariantPreset[] = [];
|
|
82
|
+
for (let i = 0; i < count; i++) {
|
|
83
|
+
const t = i / (count - 1);
|
|
84
|
+
results.push(interpolatePresets(presetA, presetB, t));
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|