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,267 @@
1
+ /**
2
+ * Line Tool Plugin -- draws straight lines between two points.
3
+ *
4
+ * Registers:
5
+ * - Command: `draw_line` (tier: 'frame')
6
+ * - Tool: `line` (standard Bresenham)
7
+ * - Tool: `line-nodoubles` (no-doubles Bresenham variant)
8
+ *
9
+ * Both tools share the `draw_line` command. The `algorithm` param determines
10
+ * which Bresenham variant is used. Click sets the start point, drag previews,
11
+ * release draws the final line.
12
+ */
13
+
14
+ import type { PluginModule } from '../../src/lib/core/plugin-loader.js';
15
+ import {
16
+ bresenhamLine,
17
+ bresenhamLineNoDoubles,
18
+ snapshotPixels,
19
+ applyPixels,
20
+ hexToRgba,
21
+ makeSnapshotUndo,
22
+ } from './drawing-utils.js';
23
+ import { setShapePreview, clearShapePreview } from '../../src/lib/canvas/shape-preview-state.svelte.js';
24
+ import LineIcon from '~icons/lucide/pen-line';
25
+
26
+ /**
27
+ * Snap a point to the nearest of 8 directions (horizontal, vertical, 4 diagonals)
28
+ * from a given origin. Used for Shift+drag line constraint.
29
+ * (Same algorithm as make-stroke-tool.ts -- duplicated to avoid exporting internals.)
30
+ */
31
+ function snapTo8Direction(
32
+ originX: number,
33
+ originY: number,
34
+ x: number,
35
+ y: number,
36
+ ): { x: number; y: number } {
37
+ const dx = x - originX;
38
+ const dy = y - originY;
39
+ if (dx === 0 && dy === 0) return { x: originX, y: originY };
40
+ const angle = Math.atan2(dy, dx);
41
+ const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
42
+ const dist = dx * Math.cos(snapped) + dy * Math.sin(snapped);
43
+ return {
44
+ x: originX + Math.round(dist * Math.cos(snapped)),
45
+ y: originY + Math.round(dist * Math.sin(snapped)),
46
+ };
47
+ }
48
+
49
+ export const lineToolPlugin: PluginModule = {
50
+ name: 'builtin/line',
51
+ version: '1.0.0',
52
+ dependencies: [],
53
+ register(api) {
54
+ api.addCommand('draw_line', {
55
+ tier: 'frame',
56
+ icon: LineIcon,
57
+
58
+ execute(params, ctx) {
59
+ const buffer = ctx.getActiveBuffer?.();
60
+ if (!buffer) return;
61
+
62
+ const algorithm = params["algorithm"];
63
+ const x0 = params["x0"];
64
+ const y0 = params["y0"];
65
+ const x1 = params["x1"];
66
+ const y1 = params["y1"];
67
+ const { r, g, b, a } = hexToRgba(params["color"]);
68
+
69
+ const lineFn = algorithm === 'nodoubles' ? bresenhamLineNoDoubles : bresenhamLine;
70
+ const points = lineFn(x0, y0, x1, y1);
71
+
72
+ const snapshot = snapshotPixels(buffer, points);
73
+ const pixels = points.map((p) => ({ ...p, r, g, b, a }));
74
+ applyPixels(buffer, pixels);
75
+ return snapshot;
76
+ },
77
+
78
+ undo: makeSnapshotUndo(),
79
+
80
+ describe(params) {
81
+ return `Drew line from (${String(params["x0"])},${String(params["y0"])}) to (${String(params["x1"])},${String(params["y1"])})`;
82
+ },
83
+ });
84
+
85
+ api.addToolbarItem('toolbar:drawing-tools:line', {
86
+ toolbarId: 'drawing-tools',
87
+ kind: 'tool',
88
+ targetId: 'line',
89
+ group: 'draw',
90
+ order: 40,
91
+ });
92
+
93
+ // --- Standard Bresenham tool ---
94
+ let lineDrawing = false;
95
+ let lineStartX = 0;
96
+ let lineStartY = 0;
97
+ let lineColor = '';
98
+
99
+ api.addTool('line', {
100
+ icon: LineIcon,
101
+ cursor: 'crosshair',
102
+
103
+ onPointerDown(_e, ctx) {
104
+ lineDrawing = true;
105
+ lineStartX = ctx.canvasX;
106
+ lineStartY = ctx.canvasY;
107
+ lineColor = ctx.color;
108
+ },
109
+
110
+ onPointerMove(e, ctx) {
111
+ if (!lineDrawing) return;
112
+
113
+ // Shift constrains to nearest 45-degree angle
114
+ let endX = ctx.canvasX;
115
+ let endY = ctx.canvasY;
116
+ if (e.shiftKey) {
117
+ const snapped = snapTo8Direction(lineStartX, lineStartY, endX, endY);
118
+ endX = snapped.x;
119
+ endY = snapped.y;
120
+ }
121
+
122
+ // Ctrl draws from midpoint: start becomes midpoint, mirror the start
123
+ let previewStartX = lineStartX;
124
+ let previewStartY = lineStartY;
125
+ if (e.ctrlKey) {
126
+ previewStartX = 2 * lineStartX - endX;
127
+ previewStartY = 2 * lineStartY - endY;
128
+ }
129
+
130
+ setShapePreview({
131
+ type: 'line',
132
+ startX: previewStartX,
133
+ startY: previewStartY,
134
+ endX,
135
+ endY,
136
+ color: lineColor,
137
+ filled: false,
138
+ });
139
+ },
140
+
141
+ onPointerUp(e, ctx) {
142
+ if (!lineDrawing) return;
143
+ lineDrawing = false;
144
+ clearShapePreview();
145
+
146
+ // Shift constrains to nearest 45-degree angle
147
+ let endX = ctx.canvasX;
148
+ let endY = ctx.canvasY;
149
+ if (e.shiftKey) {
150
+ const snapped = snapTo8Direction(lineStartX, lineStartY, endX, endY);
151
+ endX = snapped.x;
152
+ endY = snapped.y;
153
+ }
154
+
155
+ // Ctrl draws from midpoint: start becomes midpoint, mirror the start
156
+ let finalStartX = lineStartX;
157
+ let finalStartY = lineStartY;
158
+ if (e.ctrlKey) {
159
+ finalStartX = 2 * lineStartX - endX;
160
+ finalStartY = 2 * lineStartY - endY;
161
+ }
162
+
163
+ ctx.api.dispatch({
164
+ type: 'draw_line',
165
+ plugin: 'builtin/line',
166
+ version: '1.0.0',
167
+ params: {
168
+ x0: finalStartX,
169
+ y0: finalStartY,
170
+ x1: endX,
171
+ y1: endY,
172
+ color: lineColor,
173
+ algorithm: 'standard',
174
+ layerId: '',
175
+ },
176
+ });
177
+ },
178
+ });
179
+
180
+ // --- No-doubles Bresenham tool ---
181
+ let ndDrawing = false;
182
+ let ndStartX = 0;
183
+ let ndStartY = 0;
184
+ let ndColor = '';
185
+
186
+ api.addTool('line-nodoubles', {
187
+ icon: LineIcon,
188
+ cursor: 'crosshair',
189
+
190
+ onPointerDown(_e, ctx) {
191
+ ndDrawing = true;
192
+ ndStartX = ctx.canvasX;
193
+ ndStartY = ctx.canvasY;
194
+ ndColor = ctx.color;
195
+ },
196
+
197
+ onPointerMove(e, ctx) {
198
+ if (!ndDrawing) return;
199
+
200
+ // Shift constrains to nearest 45-degree angle
201
+ let endX = ctx.canvasX;
202
+ let endY = ctx.canvasY;
203
+ if (e.shiftKey) {
204
+ const snapped = snapTo8Direction(ndStartX, ndStartY, endX, endY);
205
+ endX = snapped.x;
206
+ endY = snapped.y;
207
+ }
208
+
209
+ // Ctrl draws from midpoint: start becomes midpoint, mirror the start
210
+ let previewStartX = ndStartX;
211
+ let previewStartY = ndStartY;
212
+ if (e.ctrlKey) {
213
+ previewStartX = 2 * ndStartX - endX;
214
+ previewStartY = 2 * ndStartY - endY;
215
+ }
216
+
217
+ setShapePreview({
218
+ type: 'line',
219
+ startX: previewStartX,
220
+ startY: previewStartY,
221
+ endX,
222
+ endY,
223
+ color: ndColor,
224
+ filled: false,
225
+ });
226
+ },
227
+
228
+ onPointerUp(e, ctx) {
229
+ if (!ndDrawing) return;
230
+ ndDrawing = false;
231
+ clearShapePreview();
232
+
233
+ // Shift constrains to nearest 45-degree angle
234
+ let endX = ctx.canvasX;
235
+ let endY = ctx.canvasY;
236
+ if (e.shiftKey) {
237
+ const snapped = snapTo8Direction(ndStartX, ndStartY, endX, endY);
238
+ endX = snapped.x;
239
+ endY = snapped.y;
240
+ }
241
+
242
+ // Ctrl draws from midpoint: start becomes midpoint, mirror the start
243
+ let finalStartX = ndStartX;
244
+ let finalStartY = ndStartY;
245
+ if (e.ctrlKey) {
246
+ finalStartX = 2 * ndStartX - endX;
247
+ finalStartY = 2 * ndStartY - endY;
248
+ }
249
+
250
+ ctx.api.dispatch({
251
+ type: 'draw_line',
252
+ plugin: 'builtin/line',
253
+ version: '1.0.0',
254
+ params: {
255
+ x0: finalStartX,
256
+ y0: finalStartY,
257
+ x1: endX,
258
+ y1: endY,
259
+ color: ndColor,
260
+ algorithm: 'nodoubles',
261
+ layerId: '',
262
+ },
263
+ });
264
+ },
265
+ });
266
+ },
267
+ };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Stroke Tool Factory -- eliminates duplication between pencil and eraser tools.
3
+ *
4
+ * Both tools share identical structure: a command (execute/undo/describe),
5
+ * stroke state with Bresenham interpolation, pointer handlers, and toolbar
6
+ * registration. They differ only in configuration: command name, color
7
+ * resolution, icon, toolbar order, and describe verb.
8
+ *
9
+ * Usage:
10
+ * export const myToolPlugin = makeStrokeTool({ ... config ... });
11
+ */
12
+
13
+ import type { PluginModule } from '../../src/lib/core/plugin-loader.js';
14
+ import type { CommandType } from '../../src/lib/core/command-params.js';
15
+ import type { ToolContext, ToolOption } from '../../src/lib/core/plugin-types.js';
16
+ import type { PixelData } from './drawing-utils.js';
17
+ import { bresenhamLine, snapshotPixels, applyPixels, makeSnapshotUndo, hexToRgba } from './drawing-utils.js';
18
+ import { getToolOptionValue } from '../../src/lib/core/tool-options-state.svelte.js';
19
+ import { getForegroundColor, getBackgroundColor } from '../../src/lib/color/color-state.svelte.js';
20
+
21
+ /**
22
+ * Snap a point to the nearest of 8 directions (horizontal, vertical, 4 diagonals)
23
+ * from a given origin. Used for Shift+drag line constraint.
24
+ *
25
+ * Calculates the angle from origin to the point, rounds to the nearest 45-degree
26
+ * increment, then projects the point onto that axis-aligned or diagonal line.
27
+ */
28
+ function snapTo8Direction(
29
+ originX: number,
30
+ originY: number,
31
+ x: number,
32
+ y: number,
33
+ ): { x: number; y: number } {
34
+ const dx = x - originX;
35
+ const dy = y - originY;
36
+
37
+ // No movement -- nothing to snap
38
+ if (dx === 0 && dy === 0) return { x: originX, y: originY };
39
+
40
+ // atan2 gives angle in radians; convert to 0..360 range
41
+ const angle = Math.atan2(dy, dx);
42
+ // Round to nearest 45 degrees (PI/4 radians)
43
+ const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
44
+
45
+ // Distance from origin to the current point, projected onto the snapped direction
46
+ const dist = dx * Math.cos(snapped) + dy * Math.sin(snapped);
47
+
48
+ return {
49
+ x: originX + Math.round(dist * Math.cos(snapped)),
50
+ y: originY + Math.round(dist * Math.sin(snapped)),
51
+ };
52
+ }
53
+
54
+ /** RGBA color value returned by resolveColor. */
55
+ export interface RGBA {
56
+ r: number;
57
+ g: number;
58
+ b: number;
59
+ a: number;
60
+ }
61
+
62
+ /** Configuration for a stroke-based drawing tool. */
63
+ export interface StrokeToolConfig<T extends string = string> {
64
+ /** Plugin name, e.g. 'builtin/pencil' */
65
+ pluginName: string;
66
+ /** Command name, e.g. 'draw_pixels' -- typed as a literal when T extends CommandType */
67
+ commandName: T;
68
+ /** Tool ID, e.g. 'pencil' */
69
+ toolId: string;
70
+ /** Svelte icon component */
71
+ icon: string | import('svelte').Component;
72
+ /** Toolbar sort order within the group */
73
+ toolbarOrder: number;
74
+ /** Toolbar group, e.g. 'draw' */
75
+ toolbarGroup: string;
76
+ /** Verb for describe(), e.g. 'Drew' or 'Erased' */
77
+ describeVerb: string;
78
+ /** Resolve the RGBA color for a stroke. Called once at pointer-down and cached. */
79
+ resolveColor: (ctx: ToolContext) => RGBA;
80
+ }
81
+
82
+ /**
83
+ * Create a PluginModule for a stroke-based drawing tool.
84
+ *
85
+ * The returned plugin registers a command (with undo support via snapshotPixels)
86
+ * and a tool with Bresenham-interpolated pointer handlers.
87
+ *
88
+ * When T extends CommandType (a known command in CommandParamsMap), the typed
89
+ * addCommand overload is used and params are fully typed. The string fallback
90
+ * supports future tools whose command isn't yet in the map.
91
+ */
92
+ export function makeStrokeTool<T extends CommandType>(config: StrokeToolConfig<T>): PluginModule;
93
+ export function makeStrokeTool(config: StrokeToolConfig<string>): PluginModule;
94
+ export function makeStrokeTool(config: StrokeToolConfig<string>): PluginModule {
95
+ return {
96
+ name: config.pluginName,
97
+ version: '1.0.0',
98
+ dependencies: [],
99
+ register(api) {
100
+ api.addCommand(config.commandName, {
101
+ tier: 'frame',
102
+
103
+ execute(params, ctx) {
104
+ const buffer = ctx.getActiveBuffer?.();
105
+ if (!buffer) return;
106
+
107
+ const pixels = params["pixels"] as PixelData[];
108
+ const points = pixels.map(p => ({ x: p.x, y: p.y }));
109
+ const snapshot = snapshotPixels(buffer, points);
110
+ applyPixels(buffer, pixels);
111
+ return snapshot;
112
+ },
113
+
114
+ undo: makeSnapshotUndo(),
115
+
116
+ describe(params) {
117
+ const pixels = params["pixels"] as PixelData[];
118
+ return `${config.describeVerb} ${String(pixels.length)} pixel(s)`;
119
+ },
120
+ });
121
+
122
+ // -- Stroke state for pointer handlers --
123
+ let drawing = false;
124
+ let lastX = 0;
125
+ let lastY = 0;
126
+ // Start point of the stroke, used for Shift+drag 8-direction constraint
127
+ let startX = 0;
128
+ let startY = 0;
129
+ let strokePixels: Map<string, PixelData> = new Map();
130
+ let strokeColor: RGBA = { r: 0, g: 0, b: 0, a: 0 };
131
+ let strokeLayerId = '';
132
+ // Ctrl+drag swaps the color: pencil uses bg instead of fg,
133
+ // eraser uses fg instead of transparent. Latched at stroke start.
134
+ let ctrlSwap = false;
135
+
136
+ api.addToolbarItem(`toolbar:drawing-tools:${config.toolId}`, {
137
+ toolbarId: 'drawing-tools',
138
+ kind: 'tool',
139
+ targetId: config.toolId,
140
+ group: config.toolbarGroup,
141
+ order: config.toolbarOrder,
142
+ });
143
+
144
+ /** Brush size preset choices for stroke tools. */
145
+ const brushSizeOption: ToolOption = {
146
+ id: 'size',
147
+ label: 'Size',
148
+ type: 'select',
149
+ choices: [
150
+ { value: 1, label: '1' },
151
+ { value: 2, label: '2' },
152
+ { value: 4, label: '4' },
153
+ { value: 8, label: '8' },
154
+ ],
155
+ defaultValue: 1,
156
+ };
157
+
158
+ /**
159
+ * Stamp an NxN square of pixels centered on (cx, cy) into the stroke map.
160
+ * For size 1, this is just the single pixel. For larger sizes, the square
161
+ * extends floor((size-1)/2) pixels in each direction from center.
162
+ *
163
+ * Alpha is modulated by pointer pressure (0-1). A pressure of 0 means
164
+ * "no pressure support" (e.g. mouse) and is treated as full opacity.
165
+ */
166
+ function stampSquare(
167
+ cx: number,
168
+ cy: number,
169
+ size: number,
170
+ color: RGBA,
171
+ pressure: number,
172
+ target: Map<string, PixelData>,
173
+ ): void {
174
+ const half = Math.floor((size - 1) / 2);
175
+ const { r, g, b, a } = color;
176
+ // Mice and trackpads report pressure 0; treat as full opacity.
177
+ const effectivePressure = pressure > 0 ? pressure : 1.0;
178
+ const modulatedAlpha = Math.round(a * effectivePressure);
179
+ for (let dy = -half; dy <= -half + size - 1; dy++) {
180
+ for (let dx = -half; dx <= -half + size - 1; dx++) {
181
+ const px = cx + dx;
182
+ const py = cy + dy;
183
+ const key = `${String(px)},${String(py)}`;
184
+ if (!target.has(key)) {
185
+ target.set(key, { x: px, y: py, r, g, b, a: modulatedAlpha });
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ api.addTool(config.toolId, {
192
+ icon: config.icon,
193
+ cursor: 'crosshair',
194
+ options: [brushSizeOption],
195
+
196
+ onPointerDown(_e, ctx) {
197
+ drawing = true;
198
+ lastX = ctx.canvasX;
199
+ lastY = ctx.canvasY;
200
+ startX = ctx.canvasX;
201
+ startY = ctx.canvasY;
202
+ ctrlSwap = _e.ctrlKey;
203
+
204
+ if (ctrlSwap) {
205
+ // Ctrl held: swap the color. If the tool normally paints transparent
206
+ // (eraser), paint with foreground instead. Otherwise (pencil), paint
207
+ // with the background color.
208
+ const normal = config.resolveColor(ctx);
209
+ const isTransparent = normal.r === 0 && normal.g === 0 && normal.b === 0 && normal.a === 0;
210
+ strokeColor = isTransparent
211
+ ? hexToRgba(getForegroundColor())
212
+ : hexToRgba(getBackgroundColor());
213
+ } else {
214
+ strokeColor = config.resolveColor(ctx);
215
+ }
216
+
217
+ strokeLayerId = '';
218
+ strokePixels = new Map();
219
+
220
+ // Stamp the first point at the current brush size
221
+ const size = (getToolOptionValue(config.toolId, 'size') as number) || 1;
222
+ stampSquare(ctx.canvasX, ctx.canvasY, size, strokeColor, ctx.pressure, strokePixels);
223
+ },
224
+
225
+ onPointerMove(e, ctx) {
226
+ if (!drawing) return;
227
+
228
+ // Shift+drag: constrain to nearest 8-direction line from stroke start
229
+ let targetX = ctx.canvasX;
230
+ let targetY = ctx.canvasY;
231
+ if (e.shiftKey) {
232
+ const snapped = snapTo8Direction(startX, startY, ctx.canvasX, ctx.canvasY);
233
+ targetX = snapped.x;
234
+ targetY = snapped.y;
235
+ }
236
+
237
+ const size = (getToolOptionValue(config.toolId, 'size') as number) || 1;
238
+ // Interpolate between last position and current to fill gaps
239
+ const line = bresenhamLine(lastX, lastY, targetX, targetY);
240
+ for (const p of line) {
241
+ stampSquare(p.x, p.y, size, strokeColor, ctx.pressure, strokePixels);
242
+ }
243
+
244
+ lastX = targetX;
245
+ lastY = targetY;
246
+ },
247
+
248
+ onPointerUp(_e, ctx) {
249
+ if (!drawing) return;
250
+ drawing = false;
251
+ ctrlSwap = false;
252
+
253
+ const pixels = Array.from(strokePixels.values());
254
+ if (pixels.length === 0) return;
255
+
256
+ ctx.api.dispatch({
257
+ type: config.commandName,
258
+ plugin: config.pluginName,
259
+ version: '1.0.0',
260
+ params: {
261
+ pixels,
262
+ layerId: strokeLayerId,
263
+ },
264
+ });
265
+
266
+ strokePixels = new Map();
267
+ },
268
+ });
269
+ },
270
+ };
271
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Tests for noise and dither tools.
3
+ *
4
+ * Covers noise density, seeded reproducibility, and dither patterns.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { PixelBuffer } from '../../src/lib/canvas/pixel-buffer.js';
9
+ import { commandRegistry } from '../../src/lib/core/registries.svelte.js';
10
+ import { dispatch, undoLast, _resetForTesting as resetDispatcher, setContext } from '../../src/lib/core/dispatcher.js';
11
+ import type { Command } from '../../src/lib/core/commands.js';
12
+ import type { CommandType, ParamsOf } from '../../src/lib/core/command-params.js';
13
+ import { createPluginAPI } from '../../src/lib/core/plugin-api.js';
14
+
15
+ import { noiseToolPlugin, computeNoise, mulberry32 } from './noise-tool.js';
16
+ import { ditherToolPlugin, computeDither } from './dither-tool.js';
17
+
18
+ // --- Helpers ---
19
+
20
+ // Typed overload: validates params when type is a known command literal
21
+ function makeCommand<T extends CommandType>(type: T, params: ParamsOf<T>): Command;
22
+ // String fallback: for dynamic/unknown command types in tests
23
+ function makeCommand(type: string, params?: Record<string, unknown>): Command;
24
+ function makeCommand(type: string, params: Record<string, unknown> = {}): Command {
25
+ return { type, plugin: 'test', version: '1.0.0', params, timestamp: Date.now(), id: crypto.randomUUID() };
26
+ }
27
+
28
+ function setupBuffer(width = 16, height = 16): PixelBuffer {
29
+ const buffer = new PixelBuffer(width, height);
30
+ setContext({ getActiveBuffer: () => buffer });
31
+ return buffer;
32
+ }
33
+
34
+ // --- Noise Tests ---
35
+
36
+ describe('Noise Tool', () => {
37
+ beforeEach(() => {
38
+ resetDispatcher();
39
+ const api = createPluginAPI('builtin/noise');
40
+ noiseToolPlugin.register(api);
41
+ });
42
+
43
+ it('should register the draw_noise command', () => {
44
+ expect(commandRegistry.get('draw_noise')).toBeDefined();
45
+ });
46
+
47
+ it('noise at density 0.5 fills roughly half the pixels', () => {
48
+ const totalPixels = 32 * 32;
49
+ const pixels = computeNoise(0, 0, 32, 32, ['#FF0000'], 0.5, 42);
50
+
51
+ // Allow +/- 15% tolerance for statistical variation
52
+ const ratio = pixels.length / totalPixels;
53
+ expect(ratio).toBeGreaterThan(0.35);
54
+ expect(ratio).toBeLessThan(0.65);
55
+ });
56
+
57
+ it('seeded noise is reproducible', () => {
58
+ const seed = 12345;
59
+ const a = computeNoise(0, 0, 16, 16, ['#FF0000', '#00FF00'], 0.5, seed);
60
+ const b = computeNoise(0, 0, 16, 16, ['#FF0000', '#00FF00'], 0.5, seed);
61
+
62
+ expect(a.length).toBe(b.length);
63
+ for (let i = 0; i < a.length; i++) {
64
+ expect(a[i]?.x).toBe(b[i]?.x);
65
+ expect(a[i]?.y).toBe(b[i]?.y);
66
+ expect(a[i]?.r).toBe(b[i]?.r);
67
+ expect(a[i]?.g).toBe(b[i]?.g);
68
+ expect(a[i]?.b).toBe(b[i]?.b);
69
+ }
70
+ });
71
+
72
+ it('noise at density 0 produces no pixels', () => {
73
+ const pixels = computeNoise(0, 0, 10, 10, ['#FF0000'], 0, 42);
74
+ expect(pixels.length).toBe(0);
75
+ });
76
+
77
+ it('mulberry32 PRNG produces values in [0, 1)', () => {
78
+ const rng = mulberry32(42);
79
+ for (let i = 0; i < 100; i++) {
80
+ const v = rng();
81
+ expect(v).toBeGreaterThanOrEqual(0);
82
+ expect(v).toBeLessThan(1);
83
+ }
84
+ });
85
+ });
86
+
87
+ // --- Dither Tests ---
88
+
89
+ describe('Dither Tool', () => {
90
+ beforeEach(() => {
91
+ resetDispatcher();
92
+ const api = createPluginAPI('builtin/dither');
93
+ ditherToolPlugin.register(api);
94
+ });
95
+
96
+ it('should register the draw_dither command', () => {
97
+ expect(commandRegistry.get('draw_dither')).toBeDefined();
98
+ });
99
+
100
+ it('dither 2x2 produces expected pattern', () => {
101
+ // 2x2 region with 2x2 matrix
102
+ const pixels = computeDither(0, 0, 2, 2, '#FF0000', '#0000FF', 2);
103
+
104
+ // Bayer 2x2: [[0, 2], [3, 1]]
105
+ // threshold = value / 4: [[0.0, 0.5], [0.75, 0.25]]
106
+ // With t=0.5 (the blend level), pixels with threshold < 0.5 get color1 (blue)
107
+ // (0,0): threshold 0.0 < 0.5 -> blue
108
+ // (1,0): threshold 0.5 NOT < 0.5 -> red
109
+ // (0,1): threshold 0.75 NOT < 0.5 -> red
110
+ // (1,1): threshold 0.25 < 0.5 -> blue
111
+ const p00 = pixels.find((p) => p.x === 0 && p.y === 0);
112
+ const p10 = pixels.find((p) => p.x === 1 && p.y === 0);
113
+ const p01 = pixels.find((p) => p.x === 0 && p.y === 1);
114
+ const p11 = pixels.find((p) => p.x === 1 && p.y === 1);
115
+ if (!p00 || !p10 || !p01 || !p11) throw new Error('missing dither pixel');
116
+
117
+ expect(p00.b).toBe(255); // blue
118
+ expect(p10.r).toBe(255); // red
119
+ expect(p01.r).toBe(255); // red
120
+ expect(p11.b).toBe(255); // blue
121
+ });
122
+
123
+ it('dither 4x4 produces a non-uniform pattern', () => {
124
+ const pixels = computeDither(0, 0, 4, 4, '#FFFFFF', '#000000', 4);
125
+ const colors = new Set<string>();
126
+ for (const p of pixels) {
127
+ colors.add(`${String(p.r)},${String(p.g)},${String(p.b)}`);
128
+ }
129
+ expect(colors.size).toBe(2);
130
+ });
131
+
132
+ it('dither command undoes correctly', () => {
133
+ const buffer = setupBuffer(4, 4);
134
+
135
+ dispatch(makeCommand('draw_dither', {
136
+ x: 0, y: 0, w: 4, h: 4,
137
+ color0: '#FF0000', color1: '#0000FF',
138
+ matrixSize: 2,
139
+ layerId: '',
140
+ }));
141
+
142
+ // Buffer should have dither pattern
143
+ const [r] = buffer.getPixel(0, 0);
144
+ expect(r === 255 || r === 0).toBe(true);
145
+
146
+ undoLast();
147
+
148
+ // Should be back to transparent
149
+ expect(buffer.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
150
+ });
151
+ });