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,126 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
3
|
+
import { checkSeams, suggestSeamFixes } from './seam-checker.js';
|
|
4
|
+
|
|
5
|
+
describe('seam-checker', () => {
|
|
6
|
+
describe('checkSeams', () => {
|
|
7
|
+
it('should return no issues for a uniformly colored tile', () => {
|
|
8
|
+
const buf = new PixelBuffer(4, 4);
|
|
9
|
+
buf.fill(100, 200, 50, 255);
|
|
10
|
+
const issues = checkSeams(buf);
|
|
11
|
+
expect(issues).toHaveLength(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return no issues when opposite edges match (non-uniform)', () => {
|
|
15
|
+
// Top and bottom rows match, left and right columns match,
|
|
16
|
+
// but interior is different
|
|
17
|
+
const buf = new PixelBuffer(4, 4);
|
|
18
|
+
buf.fill(0, 0, 0, 255);
|
|
19
|
+
|
|
20
|
+
// Set top row and bottom row to the same distinct color
|
|
21
|
+
for (let x = 0; x < 4; x++) {
|
|
22
|
+
buf.setPixel(x, 0, 255, 0, 0, 255);
|
|
23
|
+
buf.setPixel(x, 3, 255, 0, 0, 255);
|
|
24
|
+
}
|
|
25
|
+
// Set left and right columns to the same distinct color
|
|
26
|
+
for (let y = 0; y < 4; y++) {
|
|
27
|
+
buf.setPixel(0, y, 0, 255, 0, 255);
|
|
28
|
+
buf.setPixel(3, y, 0, 255, 0, 255);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const issues = checkSeams(buf);
|
|
32
|
+
expect(issues).toHaveLength(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should detect top-bottom mismatch', () => {
|
|
36
|
+
const buf = new PixelBuffer(4, 4);
|
|
37
|
+
buf.fill(100, 100, 100, 255);
|
|
38
|
+
// Make bottom row different from top row
|
|
39
|
+
buf.setPixel(2, 3, 200, 200, 200, 255);
|
|
40
|
+
|
|
41
|
+
const issues = checkSeams(buf);
|
|
42
|
+
const topIssues = issues.filter((i) => i.edge === 'top');
|
|
43
|
+
expect(topIssues.length).toBeGreaterThan(0);
|
|
44
|
+
// Specifically at position 2
|
|
45
|
+
const issue = topIssues.find((i) => i.position === 2);
|
|
46
|
+
expect(issue).toBeDefined();
|
|
47
|
+
expect(issue?.color1).not.toBe(issue?.color2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should detect left-right mismatch', () => {
|
|
51
|
+
const buf = new PixelBuffer(4, 4);
|
|
52
|
+
buf.fill(50, 50, 50, 255);
|
|
53
|
+
// Make right column pixel different from left column
|
|
54
|
+
buf.setPixel(3, 1, 250, 250, 250, 255);
|
|
55
|
+
|
|
56
|
+
const issues = checkSeams(buf);
|
|
57
|
+
const leftIssues = issues.filter((i) => i.edge === 'left');
|
|
58
|
+
expect(leftIssues.length).toBeGreaterThan(0);
|
|
59
|
+
const issue = leftIssues.find((i) => i.position === 1);
|
|
60
|
+
expect(issue).toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should report all mismatched positions', () => {
|
|
64
|
+
const buf = new PixelBuffer(3, 3);
|
|
65
|
+
buf.fill(0, 0, 0, 255);
|
|
66
|
+
|
|
67
|
+
// Make top and bottom rows differ at every position
|
|
68
|
+
for (let x = 0; x < 3; x++) {
|
|
69
|
+
buf.setPixel(x, 0, 100, 0, 0, 255);
|
|
70
|
+
buf.setPixel(x, 2, 200, 0, 0, 255);
|
|
71
|
+
}
|
|
72
|
+
// Make left and right columns differ at every position
|
|
73
|
+
for (let y = 0; y < 3; y++) {
|
|
74
|
+
buf.setPixel(0, y, buf.getPixel(0, y)[0], 100, 0, 255);
|
|
75
|
+
buf.setPixel(2, y, buf.getPixel(2, y)[0], 200, 0, 255);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const issues = checkSeams(buf);
|
|
79
|
+
const topIssues = issues.filter((i) => i.edge === 'top');
|
|
80
|
+
const leftIssues = issues.filter((i) => i.edge === 'left');
|
|
81
|
+
// All 3 top-bottom and all 3 left-right should mismatch
|
|
82
|
+
expect(topIssues.length).toBe(3);
|
|
83
|
+
expect(leftIssues.length).toBe(3);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('suggestSeamFixes', () => {
|
|
88
|
+
it('should suggest averaged colors for mismatched edges', () => {
|
|
89
|
+
const buf = new PixelBuffer(2, 2);
|
|
90
|
+
// Top row: (100, 100, 100, 255)
|
|
91
|
+
buf.setPixel(0, 0, 100, 100, 100, 255);
|
|
92
|
+
buf.setPixel(1, 0, 100, 100, 100, 255);
|
|
93
|
+
// Bottom row: (200, 200, 200, 255)
|
|
94
|
+
buf.setPixel(0, 1, 200, 200, 200, 255);
|
|
95
|
+
buf.setPixel(1, 1, 200, 200, 200, 255);
|
|
96
|
+
|
|
97
|
+
const issues = checkSeams(buf);
|
|
98
|
+
const topIssues = issues.filter((i) => i.edge === 'top');
|
|
99
|
+
expect(topIssues.length).toBeGreaterThan(0);
|
|
100
|
+
|
|
101
|
+
const fixes = suggestSeamFixes(topIssues);
|
|
102
|
+
expect(fixes).toHaveLength(topIssues.length);
|
|
103
|
+
|
|
104
|
+
for (const fix of fixes) {
|
|
105
|
+
// Average of 100 and 200 = 150; average of 255 and 255 = 255
|
|
106
|
+
expect(fix.suggestedColor).toBe('#969696FF');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return empty array when there are no issues', () => {
|
|
111
|
+
const fixes = suggestSeamFixes([]);
|
|
112
|
+
expect(fixes).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should preserve edge and position from the original issue', () => {
|
|
116
|
+
const issues = [
|
|
117
|
+
{ edge: 'top' as const, position: 3, color1: '#ff0000ff', color2: '#00ff00ff' },
|
|
118
|
+
];
|
|
119
|
+
const fixes = suggestSeamFixes(issues);
|
|
120
|
+
expect(fixes[0]?.edge).toBe('top');
|
|
121
|
+
expect(fixes[0]?.position).toBe(3);
|
|
122
|
+
// Average of (255,0,0,255) and (0,255,0,255) = (128,128,0,255)
|
|
123
|
+
expect(fixes[0]?.suggestedColor).toBe('#808000FF');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seam Checker -- detects and suggests fixes for tile edge mismatches.
|
|
3
|
+
*
|
|
4
|
+
* For a tile to tessellate seamlessly, opposite edges must have matching pixels:
|
|
5
|
+
* - top row must match bottom row
|
|
6
|
+
* - left column must match right column
|
|
7
|
+
*
|
|
8
|
+
* This module checks those constraints and suggests color-averaged fixes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
12
|
+
import { rgbaToHex, hexToRgba } from '../color/color-utils.js';
|
|
13
|
+
|
|
14
|
+
/** A single pixel-level seam mismatch between opposite edges. */
|
|
15
|
+
export interface SeamIssue {
|
|
16
|
+
edge: 'top' | 'right' | 'bottom' | 'left';
|
|
17
|
+
position: number; // pixel index along the edge
|
|
18
|
+
color1: string; // hex color on this edge
|
|
19
|
+
color2: string; // hex color on the opposite edge
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A suggested fix: the averaged color to apply at the given edge position. */
|
|
23
|
+
export interface SeamFix {
|
|
24
|
+
edge: string;
|
|
25
|
+
position: number;
|
|
26
|
+
suggestedColor: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a tile's edges are seamless for tiling.
|
|
31
|
+
*
|
|
32
|
+
* Compares top<->bottom and left<->right pixel by pixel.
|
|
33
|
+
* Returns an array of SeamIssue for every mismatched pixel.
|
|
34
|
+
* An empty array means the tile is fully seamless.
|
|
35
|
+
*/
|
|
36
|
+
export function checkSeams(buffer: PixelBuffer): SeamIssue[] {
|
|
37
|
+
const issues: SeamIssue[] = [];
|
|
38
|
+
const { width, height } = buffer;
|
|
39
|
+
|
|
40
|
+
// Top row vs bottom row
|
|
41
|
+
for (let x = 0; x < width; x++) {
|
|
42
|
+
const [tr, tg, tb, ta] = buffer.getPixel(x, 0);
|
|
43
|
+
const [br, bg, bb, ba] = buffer.getPixel(x, height - 1);
|
|
44
|
+
if (tr !== br || tg !== bg || tb !== bb || ta !== ba) {
|
|
45
|
+
issues.push({
|
|
46
|
+
edge: 'top',
|
|
47
|
+
position: x,
|
|
48
|
+
color1: rgbaToHex(tr, tg, tb, ta),
|
|
49
|
+
color2: rgbaToHex(br, bg, bb, ba),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Left column vs right column
|
|
55
|
+
for (let y = 0; y < height; y++) {
|
|
56
|
+
const [lr, lg, lb, la] = buffer.getPixel(0, y);
|
|
57
|
+
const [rr, rg, rb, ra] = buffer.getPixel(width - 1, y);
|
|
58
|
+
if (lr !== rr || lg !== rg || lb !== rb || la !== ra) {
|
|
59
|
+
issues.push({
|
|
60
|
+
edge: 'left',
|
|
61
|
+
position: y,
|
|
62
|
+
color1: rgbaToHex(lr, lg, lb, la),
|
|
63
|
+
color2: rgbaToHex(rr, rg, rb, ra),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return issues;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Suggest fixes for seam issues by averaging the two mismatched colors.
|
|
73
|
+
*
|
|
74
|
+
* For each issue, produces a suggested color that is the component-wise
|
|
75
|
+
* average of color1 and color2.
|
|
76
|
+
*/
|
|
77
|
+
export function suggestSeamFixes(issues: SeamIssue[]): SeamFix[] {
|
|
78
|
+
return issues.map((issue) => {
|
|
79
|
+
const c1 = hexToRgba(issue.color1);
|
|
80
|
+
const c2 = hexToRgba(issue.color2);
|
|
81
|
+
const avg = {
|
|
82
|
+
r: Math.round((c1.r + c2.r) / 2),
|
|
83
|
+
g: Math.round((c1.g + c2.g) / 2),
|
|
84
|
+
b: Math.round((c1.b + c2.b) / 2),
|
|
85
|
+
a: Math.round((c1.a + c2.a) / 2),
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
edge: issue.edge,
|
|
89
|
+
position: issue.position,
|
|
90
|
+
suggestedColor: rgbaToHex(avg.r, avg.g, avg.b, avg.a),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tile Tessellation -- generates isometric grids and renders PixelBuffer tiles.
|
|
3
|
+
*
|
|
4
|
+
* Uses iso-math for coordinate conversion and draws tiles onto a 2D canvas context.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
8
|
+
import { isoToScreen } from './iso-math.js';
|
|
9
|
+
|
|
10
|
+
/** A positioned tile in an isometric tessellation. */
|
|
11
|
+
export interface TessellationCell {
|
|
12
|
+
col: number;
|
|
13
|
+
row: number;
|
|
14
|
+
screenX: number;
|
|
15
|
+
screenY: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate screen positions for an NxM isometric tile grid.
|
|
20
|
+
*
|
|
21
|
+
* Tiles are ordered row-by-row, top-to-bottom (painter's order for flat grids).
|
|
22
|
+
* The returned screenX/screenY is the top-center anchor of each tile diamond.
|
|
23
|
+
*/
|
|
24
|
+
export function generateTessellation(
|
|
25
|
+
cols: number,
|
|
26
|
+
rows: number,
|
|
27
|
+
tileWidth: number,
|
|
28
|
+
tileHeight: number,
|
|
29
|
+
): TessellationCell[] {
|
|
30
|
+
const cells: TessellationCell[] = [];
|
|
31
|
+
for (let row = 0; row < rows; row++) {
|
|
32
|
+
for (let col = 0; col < cols; col++) {
|
|
33
|
+
const { x, y } = isoToScreen(col, row, tileWidth, tileHeight);
|
|
34
|
+
cells.push({ col, row, screenX: x, screenY: y });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return cells;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render tessellated tiles onto a canvas using a PixelBuffer as the tile source.
|
|
42
|
+
*
|
|
43
|
+
* Each tile is drawn by converting the PixelBuffer to ImageData and painting it
|
|
44
|
+
* at the correct screen position, adjusted by the given camera offsets.
|
|
45
|
+
* The tile is drawn so that its top-center aligns with the isometric anchor.
|
|
46
|
+
*/
|
|
47
|
+
export function renderTessellation(
|
|
48
|
+
ctx: CanvasRenderingContext2D,
|
|
49
|
+
tile: PixelBuffer,
|
|
50
|
+
cols: number,
|
|
51
|
+
rows: number,
|
|
52
|
+
tileWidth: number,
|
|
53
|
+
tileHeight: number,
|
|
54
|
+
offsetX: number,
|
|
55
|
+
offsetY: number,
|
|
56
|
+
): void {
|
|
57
|
+
const imageData = tile.toImageData();
|
|
58
|
+
const cells = generateTessellation(cols, rows, tileWidth, tileHeight);
|
|
59
|
+
|
|
60
|
+
for (const cell of cells) {
|
|
61
|
+
// Anchor the tile so its top-center diamond point matches screenX/screenY.
|
|
62
|
+
// The tile image's (tileWidth/2, 0) should land at (screenX, screenY).
|
|
63
|
+
const drawX = cell.screenX - tileWidth / 2 + offsetX;
|
|
64
|
+
const drawY = cell.screenY + offsetY;
|
|
65
|
+
ctx.putImageData(imageData, drawX, drawY);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { composite } from './compositor.js';
|
|
3
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
4
|
+
import type { Layer } from './layer-types.js';
|
|
5
|
+
import { createPixelLayer, createGroupLayer } from './layer-types.js';
|
|
6
|
+
|
|
7
|
+
// --- Helpers ---
|
|
8
|
+
|
|
9
|
+
/** Create a solid-color pixel buffer. */
|
|
10
|
+
function solidBuffer(w: number, h: number, r: number, g: number, b: number, a: number): PixelBuffer {
|
|
11
|
+
const buf = new PixelBuffer(w, h);
|
|
12
|
+
buf.fill(r, g, b, a);
|
|
13
|
+
return buf;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Tests ---
|
|
17
|
+
|
|
18
|
+
describe('composite', () => {
|
|
19
|
+
it('should return a transparent buffer when there are no visible layers', () => {
|
|
20
|
+
const result = composite([], new Map(), 4, 4);
|
|
21
|
+
expect(result.width).toBe(4);
|
|
22
|
+
expect(result.height).toBe(4);
|
|
23
|
+
// All pixels should be transparent (0,0,0,0)
|
|
24
|
+
for (let i = 0; i < result.data.length; i++) {
|
|
25
|
+
expect(result.data[i]).toBe(0);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should output a single visible layer as-is', () => {
|
|
30
|
+
const layer = createPixelLayer('Layer');
|
|
31
|
+
const data = new Map<string, PixelBuffer>();
|
|
32
|
+
data.set(layer.id, solidBuffer(2, 2, 255, 0, 0, 255));
|
|
33
|
+
|
|
34
|
+
const result = composite([layer], data, 2, 2);
|
|
35
|
+
const pixel = result.getPixel(0, 0);
|
|
36
|
+
expect(pixel).toEqual([255, 0, 0, 255]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should not include hidden layers', () => {
|
|
40
|
+
const visible = createPixelLayer('Visible');
|
|
41
|
+
const hidden = createPixelLayer('Hidden');
|
|
42
|
+
hidden.visible = false;
|
|
43
|
+
|
|
44
|
+
const data = new Map<string, PixelBuffer>();
|
|
45
|
+
data.set(visible.id, solidBuffer(2, 2, 255, 0, 0, 255));
|
|
46
|
+
data.set(hidden.id, solidBuffer(2, 2, 0, 255, 0, 255));
|
|
47
|
+
|
|
48
|
+
const result = composite([visible, hidden], data, 2, 2);
|
|
49
|
+
// Only red should be visible (hidden green is skipped)
|
|
50
|
+
const pixel = result.getPixel(0, 0);
|
|
51
|
+
expect(pixel).toEqual([255, 0, 0, 255]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should composite two layers with normal blend (top covers bottom)', () => {
|
|
55
|
+
const bottom = createPixelLayer('Bottom');
|
|
56
|
+
const top = createPixelLayer('Top');
|
|
57
|
+
|
|
58
|
+
const data = new Map<string, PixelBuffer>();
|
|
59
|
+
data.set(bottom.id, solidBuffer(2, 2, 255, 0, 0, 255)); // red
|
|
60
|
+
data.set(top.id, solidBuffer(2, 2, 0, 0, 255, 255)); // blue, fully opaque
|
|
61
|
+
|
|
62
|
+
// Bottom is first in array (drawn first), top is second (drawn on top)
|
|
63
|
+
const result = composite([bottom, top], data, 2, 2);
|
|
64
|
+
const pixel = result.getPixel(0, 0);
|
|
65
|
+
// Blue fully opaque on top should cover red completely
|
|
66
|
+
expect(pixel).toEqual([0, 0, 255, 255]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should blend a layer with 50% opacity correctly', () => {
|
|
70
|
+
const bottom = createPixelLayer('Bottom');
|
|
71
|
+
const top = createPixelLayer('Top');
|
|
72
|
+
top.opacity = 50;
|
|
73
|
+
|
|
74
|
+
const data = new Map<string, PixelBuffer>();
|
|
75
|
+
data.set(bottom.id, solidBuffer(2, 2, 0, 0, 0, 255)); // black
|
|
76
|
+
data.set(top.id, solidBuffer(2, 2, 255, 255, 255, 255)); // white at 50% opacity
|
|
77
|
+
|
|
78
|
+
const result = composite([bottom, top], data, 2, 2);
|
|
79
|
+
const pixel = result.getPixel(0, 0);
|
|
80
|
+
// White at 50% over black should give ~128 gray
|
|
81
|
+
// srcA = 255 * 0.5 = 128 -> sA = 128/255 ~ 0.502
|
|
82
|
+
// outA = 0.502 + 1 * (1 - 0.502) = 1.0
|
|
83
|
+
// outR = (255 * 0.502 + 0 * 1 * 0.498) / 1.0 = 128
|
|
84
|
+
expect(pixel[0]).toBeGreaterThanOrEqual(127);
|
|
85
|
+
expect(pixel[0]).toBeLessThanOrEqual(129);
|
|
86
|
+
expect(pixel[3]).toBe(255);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should apply group opacity to all children', () => {
|
|
90
|
+
const child = createPixelLayer('Child');
|
|
91
|
+
const group: Layer = {
|
|
92
|
+
...createGroupLayer('Group'),
|
|
93
|
+
children: [child],
|
|
94
|
+
opacity: 50,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const data = new Map<string, PixelBuffer>();
|
|
98
|
+
data.set(child.id, solidBuffer(2, 2, 255, 255, 255, 255)); // white
|
|
99
|
+
|
|
100
|
+
// Bottom: black background
|
|
101
|
+
const bg = createPixelLayer('BG');
|
|
102
|
+
data.set(bg.id, solidBuffer(2, 2, 0, 0, 0, 255));
|
|
103
|
+
|
|
104
|
+
const result = composite([bg, group], data, 2, 2);
|
|
105
|
+
const pixel = result.getPixel(0, 0);
|
|
106
|
+
// Group at 50% with white child over black BG -> ~128 gray
|
|
107
|
+
expect(pixel[0]).toBeGreaterThanOrEqual(127);
|
|
108
|
+
expect(pixel[0]).toBeLessThanOrEqual(129);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should skip children of hidden groups', () => {
|
|
112
|
+
const child = createPixelLayer('Child');
|
|
113
|
+
const group: Layer = {
|
|
114
|
+
...createGroupLayer('Group'),
|
|
115
|
+
children: [child],
|
|
116
|
+
visible: false,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const data = new Map<string, PixelBuffer>();
|
|
120
|
+
data.set(child.id, solidBuffer(2, 2, 255, 0, 0, 255));
|
|
121
|
+
|
|
122
|
+
const result = composite([group], data, 2, 2);
|
|
123
|
+
// Nothing should be rendered
|
|
124
|
+
const pixel = result.getPixel(0, 0);
|
|
125
|
+
expect(pixel).toEqual([0, 0, 0, 0]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should apply multiply blend mode', () => {
|
|
129
|
+
const bottom = createPixelLayer('Bottom');
|
|
130
|
+
const top = createPixelLayer('Top');
|
|
131
|
+
top.blendMode = 'multiply';
|
|
132
|
+
|
|
133
|
+
const data = new Map<string, PixelBuffer>();
|
|
134
|
+
data.set(bottom.id, solidBuffer(1, 1, 200, 200, 200, 255));
|
|
135
|
+
data.set(top.id, solidBuffer(1, 1, 128, 128, 128, 255));
|
|
136
|
+
|
|
137
|
+
const result = composite([bottom, top], data, 1, 1);
|
|
138
|
+
const pixel = result.getPixel(0, 0);
|
|
139
|
+
// multiply: base * blend / 255 = 200 * 128 / 255 ~ 100
|
|
140
|
+
// With rounding: (200 * 128 + 127) / 255 = 25727 / 255 = 100
|
|
141
|
+
expect(pixel[0]).toBeGreaterThanOrEqual(99);
|
|
142
|
+
expect(pixel[0]).toBeLessThanOrEqual(101);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should apply screen blend mode', () => {
|
|
146
|
+
const bottom = createPixelLayer('Bottom');
|
|
147
|
+
const top = createPixelLayer('Top');
|
|
148
|
+
top.blendMode = 'screen';
|
|
149
|
+
|
|
150
|
+
const data = new Map<string, PixelBuffer>();
|
|
151
|
+
data.set(bottom.id, solidBuffer(1, 1, 100, 100, 100, 255));
|
|
152
|
+
data.set(top.id, solidBuffer(1, 1, 100, 100, 100, 255));
|
|
153
|
+
|
|
154
|
+
const result = composite([bottom, top], data, 1, 1);
|
|
155
|
+
const pixel = result.getPixel(0, 0);
|
|
156
|
+
// screen: 255 - (255-100)*(255-100)/255 = 255 - 155*155/255 = 255 - 94 = 161
|
|
157
|
+
// With rounding: 255 - (155*155+127)/255 = 255 - 24152/255 = 255 - 94 = 161
|
|
158
|
+
expect(pixel[0]).toBeGreaterThanOrEqual(160);
|
|
159
|
+
expect(pixel[0]).toBeLessThanOrEqual(162);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should apply overlay blend mode', () => {
|
|
163
|
+
const bottom = createPixelLayer('Bottom');
|
|
164
|
+
const top = createPixelLayer('Top');
|
|
165
|
+
top.blendMode = 'overlay';
|
|
166
|
+
|
|
167
|
+
const data = new Map<string, PixelBuffer>();
|
|
168
|
+
// base < 128: uses multiply formula
|
|
169
|
+
data.set(bottom.id, solidBuffer(1, 1, 64, 64, 64, 255));
|
|
170
|
+
data.set(top.id, solidBuffer(1, 1, 128, 128, 128, 255));
|
|
171
|
+
|
|
172
|
+
const result = composite([bottom, top], data, 1, 1);
|
|
173
|
+
const pixel = result.getPixel(0, 0);
|
|
174
|
+
// overlay with base=64 (<128): 2*64*128/255 = 16384/255 ~ 64
|
|
175
|
+
// With rounding: (2*64*128+127)/255 = 16511/255 = 64
|
|
176
|
+
expect(pixel[0]).toBeGreaterThanOrEqual(63);
|
|
177
|
+
expect(pixel[0]).toBeLessThanOrEqual(65);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should handle semi-transparent layer over transparent background', () => {
|
|
181
|
+
const layer = createPixelLayer('Semi');
|
|
182
|
+
|
|
183
|
+
const data = new Map<string, PixelBuffer>();
|
|
184
|
+
data.set(layer.id, solidBuffer(1, 1, 255, 0, 0, 128));
|
|
185
|
+
|
|
186
|
+
const result = composite([layer], data, 1, 1);
|
|
187
|
+
const pixel = result.getPixel(0, 0);
|
|
188
|
+
expect(pixel[0]).toBe(255);
|
|
189
|
+
expect(pixel[1]).toBe(0);
|
|
190
|
+
expect(pixel[2]).toBe(0);
|
|
191
|
+
expect(pixel[3]).toBe(128);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer Compositor -- flattens the layer tree into a single output PixelBuffer.
|
|
3
|
+
*
|
|
4
|
+
* Walks the tree bottom-to-top, compositing each visible pixel layer onto
|
|
5
|
+
* the output. Groups are recursively composited into an intermediate buffer,
|
|
6
|
+
* then blended onto the output with the group's opacity and blend mode.
|
|
7
|
+
*
|
|
8
|
+
* All math is integer-based (pixel values 0-255).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
12
|
+
import type { Layer, BlendMode } from './layer-types.js';
|
|
13
|
+
|
|
14
|
+
// --- Blend mode implementations ---
|
|
15
|
+
// Each takes a base channel value and a blend channel value (both 0-255)
|
|
16
|
+
// and returns the blended result (0-255).
|
|
17
|
+
|
|
18
|
+
function blendNormal(_base: number, blend: number): number {
|
|
19
|
+
return blend;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function blendMultiply(base: number, blend: number): number {
|
|
23
|
+
return (base * blend + 127) / 255 | 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function blendScreen(base: number, blend: number): number {
|
|
27
|
+
return 255 - ((255 - base) * (255 - blend) + 127) / 255 | 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function blendOverlay(base: number, blend: number): number {
|
|
31
|
+
// If base < 128: multiply mode (2 * base * blend / 255)
|
|
32
|
+
// If base >= 128: screen mode (255 - 2 * (255-base) * (255-blend) / 255)
|
|
33
|
+
if (base < 128) {
|
|
34
|
+
return (2 * base * blend + 127) / 255 | 0;
|
|
35
|
+
}
|
|
36
|
+
return 255 - ((2 * (255 - base) * (255 - blend) + 127) / 255 | 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get the blend function for a given blend mode. */
|
|
40
|
+
function getBlendFn(mode: BlendMode): (base: number, blend: number) => number {
|
|
41
|
+
switch (mode) {
|
|
42
|
+
case 'normal':
|
|
43
|
+
return blendNormal;
|
|
44
|
+
case 'multiply':
|
|
45
|
+
return blendMultiply;
|
|
46
|
+
case 'screen':
|
|
47
|
+
return blendScreen;
|
|
48
|
+
case 'overlay':
|
|
49
|
+
return blendOverlay;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Blend a source pixel onto a destination pixel using source-over alpha compositing
|
|
55
|
+
* with a given blend mode. Modifies dst in place.
|
|
56
|
+
*
|
|
57
|
+
* @param dst - destination buffer
|
|
58
|
+
* @param di - byte offset into dst
|
|
59
|
+
* @param srcR, srcG, srcB, srcA - source pixel (srcA is 0-255, pre-opacity-adjusted)
|
|
60
|
+
* @param blendFn - the blend mode function for RGB channels
|
|
61
|
+
*/
|
|
62
|
+
function compositePixel(
|
|
63
|
+
dst: Uint8ClampedArray,
|
|
64
|
+
di: number,
|
|
65
|
+
srcR: number,
|
|
66
|
+
srcG: number,
|
|
67
|
+
srcB: number,
|
|
68
|
+
srcA: number,
|
|
69
|
+
blendFn: (base: number, blend: number) => number,
|
|
70
|
+
): void {
|
|
71
|
+
if (srcA === 0) return;
|
|
72
|
+
|
|
73
|
+
// Uint8ClampedArray indexing is `number | undefined` under
|
|
74
|
+
// noUncheckedIndexedAccess; `?? 0` fallbacks never trigger for in-bounds i.
|
|
75
|
+
const dstR = dst[di] ?? 0;
|
|
76
|
+
const dstG = dst[di + 1] ?? 0;
|
|
77
|
+
const dstB = dst[di + 2] ?? 0;
|
|
78
|
+
const dstA = dst[di + 3] ?? 0;
|
|
79
|
+
|
|
80
|
+
// Normalize alpha to 0-1 range for compositing math
|
|
81
|
+
const sA = srcA / 255;
|
|
82
|
+
const dA = dstA / 255;
|
|
83
|
+
|
|
84
|
+
const outA = sA + dA * (1 - sA);
|
|
85
|
+
if (outA === 0) return;
|
|
86
|
+
|
|
87
|
+
// Apply blend mode to RGB channels
|
|
88
|
+
const blendedR = blendFn(dstR, srcR);
|
|
89
|
+
const blendedG = blendFn(dstG, srcG);
|
|
90
|
+
const blendedB = blendFn(dstB, srcB);
|
|
91
|
+
|
|
92
|
+
// Source-over compositing
|
|
93
|
+
dst[di] = Math.round((blendedR * sA + dstR * dA * (1 - sA)) / outA);
|
|
94
|
+
dst[di + 1] = Math.round((blendedG * sA + dstG * dA * (1 - sA)) / outA);
|
|
95
|
+
dst[di + 2] = Math.round((blendedB * sA + dstB * dA * (1 - sA)) / outA);
|
|
96
|
+
dst[di + 3] = Math.round(outA * 255);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Composite a single layer's pixel data onto a destination buffer.
|
|
101
|
+
*/
|
|
102
|
+
function compositeLayer(
|
|
103
|
+
dst: PixelBuffer,
|
|
104
|
+
src: PixelBuffer,
|
|
105
|
+
opacity: number,
|
|
106
|
+
blendMode: BlendMode,
|
|
107
|
+
): void {
|
|
108
|
+
const blendFn = getBlendFn(blendMode);
|
|
109
|
+
const opacityFactor = opacity / 100;
|
|
110
|
+
const size = dst.width * dst.height * 4;
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < size; i += 4) {
|
|
113
|
+
const srcA = Math.round((src.data[i + 3] ?? 0) * opacityFactor);
|
|
114
|
+
if (srcA === 0) continue;
|
|
115
|
+
|
|
116
|
+
compositePixel(
|
|
117
|
+
dst.data,
|
|
118
|
+
i,
|
|
119
|
+
src.data[i] ?? 0,
|
|
120
|
+
src.data[i + 1] ?? 0,
|
|
121
|
+
src.data[i + 2] ?? 0,
|
|
122
|
+
srcA,
|
|
123
|
+
blendFn,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Recursively composite a list of layers into a buffer.
|
|
130
|
+
* Groups are composited into an intermediate buffer first, then blended
|
|
131
|
+
* onto the result with the group's opacity and blend mode.
|
|
132
|
+
*/
|
|
133
|
+
function compositeTree(
|
|
134
|
+
tree: Layer[],
|
|
135
|
+
pixelData: Map<string, PixelBuffer>,
|
|
136
|
+
width: number,
|
|
137
|
+
height: number,
|
|
138
|
+
): PixelBuffer {
|
|
139
|
+
const result = new PixelBuffer(width, height);
|
|
140
|
+
|
|
141
|
+
for (const layer of tree) {
|
|
142
|
+
if (!layer.visible) continue;
|
|
143
|
+
|
|
144
|
+
if (layer.type === 'pixel') {
|
|
145
|
+
const data = pixelData.get(layer.id);
|
|
146
|
+
if (!data) continue;
|
|
147
|
+
compositeLayer(result, data, layer.opacity, layer.blendMode);
|
|
148
|
+
} else if (layer.children) {
|
|
149
|
+
// Recursively composite the group's children into an intermediate buffer
|
|
150
|
+
const groupBuffer = compositeTree(layer.children, pixelData, width, height);
|
|
151
|
+
// Then blend the group result onto the output with the group's opacity and blend mode
|
|
152
|
+
compositeLayer(result, groupBuffer, layer.opacity, layer.blendMode);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Composite all visible layers into a single PixelBuffer.
|
|
161
|
+
*
|
|
162
|
+
* @param tree - the root-level layer list (bottom-to-top order)
|
|
163
|
+
* @param pixelData - pixel data for each layer, keyed by layer ID
|
|
164
|
+
* @param width - canvas width in pixels
|
|
165
|
+
* @param height - canvas height in pixels
|
|
166
|
+
* @returns a new PixelBuffer with all visible layers composited
|
|
167
|
+
*/
|
|
168
|
+
export function composite(
|
|
169
|
+
tree: Layer[],
|
|
170
|
+
pixelData: Map<string, PixelBuffer>,
|
|
171
|
+
width: number,
|
|
172
|
+
height: number,
|
|
173
|
+
): PixelBuffer {
|
|
174
|
+
return compositeTree(tree, pixelData, width, height);
|
|
175
|
+
}
|