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.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. 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
+ }