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,266 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MapPropertiesPanel -- dockable right-sidebar panel for editing
4
+ * level editor map properties: grid dimensions (cols/rows),
5
+ * tile size (width/height in px), and grid projection mode (iso/ortho).
6
+ *
7
+ * Reads from and writes to mapState reactive properties.
8
+ */
9
+
10
+ import { mapState, type GridMode } from './map-state.svelte.js';
11
+
12
+ // --- Clamping constants ---
13
+
14
+ const MIN_GRID = 1;
15
+ const MAX_GRID = 256;
16
+ const MIN_TILE = 4;
17
+ const MAX_TILE = 256;
18
+
19
+ // --- Clamping helpers ---
20
+
21
+ /** Clamp a parsed integer to [min, max], falling back to `fallback` on NaN. */
22
+ function clampInt(raw: string, min: number, max: number, fallback: number): number {
23
+ const v = parseInt(raw, 10);
24
+ return isNaN(v) ? fallback : Math.max(min, Math.min(max, v));
25
+ }
26
+
27
+ function handleCols(e: Event) {
28
+ mapState.gridCols = clampInt((e.target as HTMLInputElement).value, MIN_GRID, MAX_GRID, mapState.gridCols);
29
+ }
30
+
31
+ function handleRows(e: Event) {
32
+ mapState.gridRows = clampInt((e.target as HTMLInputElement).value, MIN_GRID, MAX_GRID, mapState.gridRows);
33
+ }
34
+
35
+ function handleTileWidth(e: Event) {
36
+ mapState.tileWidth = clampInt((e.target as HTMLInputElement).value, MIN_TILE, MAX_TILE, mapState.tileWidth);
37
+ }
38
+
39
+ function handleTileHeight(e: Event) {
40
+ mapState.tileHeight = clampInt((e.target as HTMLInputElement).value, MIN_TILE, MAX_TILE, mapState.tileHeight);
41
+ }
42
+
43
+ function setGridMode(mode: GridMode) {
44
+ mapState.gridMode = mode;
45
+ }
46
+ </script>
47
+
48
+ <div class="map-props">
49
+ <!-- Header -->
50
+ <div class="map-props__header">
51
+ <span class="map-props__title">Map Properties</span>
52
+ </div>
53
+
54
+ <div class="map-props__body">
55
+ <!-- Grid Dimensions -->
56
+ <section class="map-props__section">
57
+ <h3 class="map-props__section-title">Grid Dimensions</h3>
58
+
59
+ <div class="map-props__field">
60
+ <label class="map-props__label" for="mp-cols">Columns</label>
61
+ <input
62
+ id="mp-cols"
63
+ class="map-props__input"
64
+ type="number"
65
+ min={MIN_GRID}
66
+ max={MAX_GRID}
67
+ step="1"
68
+ value={mapState.gridCols}
69
+ onchange={handleCols}
70
+ />
71
+ </div>
72
+
73
+ <div class="map-props__field">
74
+ <label class="map-props__label" for="mp-rows">Rows</label>
75
+ <input
76
+ id="mp-rows"
77
+ class="map-props__input"
78
+ type="number"
79
+ min={MIN_GRID}
80
+ max={MAX_GRID}
81
+ step="1"
82
+ value={mapState.gridRows}
83
+ onchange={handleRows}
84
+ />
85
+ </div>
86
+ </section>
87
+
88
+ <!-- Tile Size -->
89
+ <section class="map-props__section">
90
+ <h3 class="map-props__section-title">Tile Size</h3>
91
+
92
+ <div class="map-props__field">
93
+ <label class="map-props__label" for="mp-tw">Width</label>
94
+ <input
95
+ id="mp-tw"
96
+ class="map-props__input"
97
+ type="number"
98
+ min={MIN_TILE}
99
+ max={MAX_TILE}
100
+ step="1"
101
+ value={mapState.tileWidth}
102
+ onchange={handleTileWidth}
103
+ />
104
+ <span class="map-props__unit">px</span>
105
+ </div>
106
+
107
+ <div class="map-props__field">
108
+ <label class="map-props__label" for="mp-th">Height</label>
109
+ <input
110
+ id="mp-th"
111
+ class="map-props__input"
112
+ type="number"
113
+ min={MIN_TILE}
114
+ max={MAX_TILE}
115
+ step="1"
116
+ value={mapState.tileHeight}
117
+ onchange={handleTileHeight}
118
+ />
119
+ <span class="map-props__unit">px</span>
120
+ </div>
121
+ </section>
122
+
123
+ <!-- Grid Mode -->
124
+ <section class="map-props__section">
125
+ <h3 class="map-props__section-title">Grid Mode</h3>
126
+
127
+ <div class="map-props__toggle-group">
128
+ <button
129
+ class="map-props__toggle"
130
+ class:map-props__toggle--active={mapState.gridMode === 'ortho'}
131
+ onclick={() => setGridMode('ortho')}
132
+ >Orthogonal</button>
133
+ <button
134
+ class="map-props__toggle"
135
+ class:map-props__toggle--active={mapState.gridMode === 'iso'}
136
+ onclick={() => setGridMode('iso')}
137
+ >Isometric</button>
138
+ </div>
139
+ </section>
140
+ </div>
141
+ </div>
142
+
143
+ <style>
144
+ .map-props {
145
+ display: flex;
146
+ flex-direction: column;
147
+ width: 100%;
148
+ height: 100%;
149
+ background: var(--bg-panel);
150
+ color: var(--text-primary);
151
+ font-size: var(--text-base);
152
+ user-select: none;
153
+ }
154
+
155
+ .map-props__header {
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: space-between;
159
+ padding: 6px var(--space-3);
160
+ border-bottom: 1px solid var(--border);
161
+ background: var(--bg-toolbar);
162
+ flex-shrink: 0;
163
+ }
164
+
165
+ .map-props__title {
166
+ font-weight: 600;
167
+ font-size: var(--text-sm);
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.5px;
170
+ color: var(--text-secondary);
171
+ }
172
+
173
+ .map-props__body {
174
+ flex: 1;
175
+ overflow-y: auto;
176
+ overflow-x: hidden;
177
+ padding: var(--space-2) var(--space-3);
178
+ }
179
+
180
+ .map-props__section {
181
+ margin-bottom: var(--space-3);
182
+ }
183
+
184
+ .map-props__section-title {
185
+ margin: 0 0 var(--space-2) 0;
186
+ font-size: var(--text-sm);
187
+ font-weight: 600;
188
+ color: var(--text-secondary);
189
+ text-transform: uppercase;
190
+ letter-spacing: 0.3px;
191
+ }
192
+
193
+ .map-props__field {
194
+ display: flex;
195
+ align-items: center;
196
+ gap: var(--space-2);
197
+ margin-bottom: var(--space-2);
198
+ }
199
+
200
+ .map-props__label {
201
+ width: 64px;
202
+ flex-shrink: 0;
203
+ font-size: var(--text-sm);
204
+ color: var(--text-secondary);
205
+ }
206
+
207
+ .map-props__input {
208
+ flex: 1;
209
+ max-width: 80px;
210
+ background: var(--bg-toolbar);
211
+ border: 1px solid var(--border);
212
+ border-radius: var(--radius-sm);
213
+ color: var(--text-primary);
214
+ font-size: var(--text-sm);
215
+ padding: 4px 6px;
216
+ }
217
+
218
+ .map-props__input:focus-visible {
219
+ border-color: var(--accent);
220
+ outline: none;
221
+ }
222
+
223
+ .map-props__unit {
224
+ font-size: var(--text-xs);
225
+ color: var(--text-muted);
226
+ }
227
+
228
+ /* Segmented toggle for grid mode */
229
+ .map-props__toggle-group {
230
+ display: flex;
231
+ gap: 0;
232
+ border: 1px solid var(--border);
233
+ border-radius: var(--radius-sm);
234
+ overflow: hidden;
235
+ }
236
+
237
+ .map-props__toggle {
238
+ flex: 1;
239
+ background: var(--bg-toolbar);
240
+ border: none;
241
+ color: var(--text-secondary);
242
+ font-size: var(--text-sm);
243
+ padding: 5px 8px;
244
+ cursor: pointer;
245
+ transition: background 150ms ease, color 150ms ease;
246
+ }
247
+
248
+ .map-props__toggle:not(:last-child) {
249
+ border-right: 1px solid var(--border);
250
+ }
251
+
252
+ .map-props__toggle:hover {
253
+ background: var(--bg-primary);
254
+ color: var(--text-primary);
255
+ }
256
+
257
+ .map-props__toggle--active {
258
+ background: var(--accent);
259
+ color: #ffffff;
260
+ }
261
+
262
+ .map-props__toggle--active:hover {
263
+ background: var(--accent-hover);
264
+ color: #ffffff;
265
+ }
266
+ </style>
@@ -0,0 +1,324 @@
1
+ <!--
2
+ TilePickerPanel -- right-sidebar dock panel that displays all tile sources
3
+ as a scrollable grid of thumbnails.
4
+
5
+ Auto-registers every canvas frame from the current project as a tile source
6
+ in the tile-source-registry. Clicking a thumbnail sets mapState.activeTile
7
+ so the level editor viewport paints with it.
8
+ -->
9
+ <script lang="ts">
10
+ import { getFrames } from '../animation/frame-model.svelte.js';
11
+ import { getLayers } from '../layers/layer-tree.svelte.js';
12
+ import { composite } from '../layers/compositor.js';
13
+ import { canvasState } from '../canvas/canvas-state.svelte.js';
14
+ import { tileSourceRegistry, tileRefId } from './tile-source-registry.svelte.js';
15
+ import { mapState, type TilePlacement } from './map-state.svelte.js';
16
+ import { placementToTileRefId } from './tile-source-registry.svelte.js';
17
+
18
+ // Thumbnail canvas size in CSS pixels.
19
+ const THUMB_SIZE = 48;
20
+
21
+ // --- Auto-registration of canvas frames as tile sources ---
22
+
23
+ /**
24
+ * Reactively register every frame in the current project as a tile source.
25
+ * Each frame at index i is registered under id "default:canvas:{i}".
26
+ * The getter composites all visible layers for that frame, so edits to the
27
+ * sprite propagate live to the level editor.
28
+ *
29
+ * Also cleans up stale registrations when frames are removed.
30
+ */
31
+ $effect(() => {
32
+ const frames = getFrames();
33
+ const w = canvasState.canvasWidth;
34
+ const h = canvasState.canvasHeight;
35
+
36
+ // Register (or re-register) each frame.
37
+ for (let i = 0; i < frames.length; i++) {
38
+ const frame = frames[i];
39
+ if (!frame) continue;
40
+ const id = tileRefId({ projectRef: 'default', canvasRef: 'canvas', frameIndex: i });
41
+
42
+ // The getter captures the frame reference and reads layers at call time,
43
+ // so it always returns the freshest composite.
44
+ tileSourceRegistry.register(id, () => {
45
+ return composite(getLayers(), frame.pixelData, w, h);
46
+ });
47
+ }
48
+
49
+ // Unregister tile sources for frames that no longer exist (e.g. if the
50
+ // user deleted a frame, the old higher-index entries become stale).
51
+ for (const existingId of tileSourceRegistry.list()) {
52
+ if (!existingId.startsWith('default:canvas:')) continue;
53
+ const part = existingId.split(':')[2];
54
+ if (part === undefined) continue;
55
+ const idx = parseInt(part, 10);
56
+ if (idx >= frames.length) {
57
+ tileSourceRegistry.unregister(existingId);
58
+ }
59
+ }
60
+ });
61
+
62
+ // --- Thumbnail rendering ---
63
+
64
+ /**
65
+ * Map of tile ref id -> canvas element for thumbnail rendering.
66
+ * Populated via the Svelte action below.
67
+ */
68
+ let thumbCanvasEls = $state<Map<string, HTMLCanvasElement>>(new Map());
69
+
70
+ /**
71
+ * Svelte action that registers a canvas element in the thumbCanvasEls map
72
+ * on mount and removes it on destroy.
73
+ */
74
+ function trackThumbCanvas(node: HTMLCanvasElement, tileId: string) {
75
+ thumbCanvasEls.set(tileId, node);
76
+ return {
77
+ destroy() {
78
+ thumbCanvasEls.delete(tileId);
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Render a tile thumbnail onto a canvas element using nearest-neighbor
85
+ * scaling to preserve pixel art crispness.
86
+ */
87
+ function renderThumb(tileId: string, canvasEl: HTMLCanvasElement): void {
88
+ canvasEl.width = THUMB_SIZE;
89
+ canvasEl.height = THUMB_SIZE;
90
+ const ctx = canvasEl.getContext('2d');
91
+ if (!ctx) return;
92
+ ctx.imageSmoothingEnabled = false;
93
+ ctx.clearRect(0, 0, THUMB_SIZE, THUMB_SIZE);
94
+
95
+ const buffer = tileSourceRegistry.get(tileId);
96
+ if (!buffer) return;
97
+
98
+ const imgData = buffer.toImageData();
99
+
100
+ // Draw at native size onto an offscreen canvas, then scale down.
101
+ const offscreen = new OffscreenCanvas(buffer.width, buffer.height);
102
+ const offCtx = offscreen.getContext('2d');
103
+ if (!offCtx) return;
104
+ offCtx.putImageData(imgData, 0, 0);
105
+
106
+ // Scale proportionally to fit within THUMB_SIZE, centered.
107
+ const scale = Math.min(THUMB_SIZE / buffer.width, THUMB_SIZE / buffer.height);
108
+ const dw = Math.round(buffer.width * scale);
109
+ const dh = Math.round(buffer.height * scale);
110
+ const dx = Math.round((THUMB_SIZE - dw) / 2);
111
+ const dy = Math.round((THUMB_SIZE - dh) / 2);
112
+
113
+ ctx.drawImage(offscreen, 0, 0, buffer.width, buffer.height, dx, dy, dw, dh);
114
+ }
115
+
116
+ /**
117
+ * Re-render all thumbnails reactively. Triggers when frames, layers,
118
+ * canvas dimensions, or the registry contents change.
119
+ */
120
+ $effect(() => {
121
+ // Read reactive dependencies to trigger re-runs.
122
+ const _frames = getFrames();
123
+ const _layers = getLayers();
124
+ const _w = canvasState.canvasWidth;
125
+ const _h = canvasState.canvasHeight;
126
+ // Touch the registry to react to registration changes.
127
+ const _all = tileSourceRegistry.getAll();
128
+
129
+ for (const [tileId, el] of thumbCanvasEls) {
130
+ renderThumb(tileId, el);
131
+ }
132
+ });
133
+
134
+ // --- Derived state ---
135
+
136
+ /** List of tile source ids that belong to the current project's canvases. */
137
+ let canvasTileIds = $derived.by(() => {
138
+ // Touch reactive dependencies so the derivation re-runs.
139
+ const _all = tileSourceRegistry.getAll();
140
+ return tileSourceRegistry
141
+ .list()
142
+ .filter((id) => id.startsWith('default:canvas:'))
143
+ .sort((a, b) => {
144
+ const ai = parseInt(a.split(':')[2] ?? '0', 10);
145
+ const bi = parseInt(b.split(':')[2] ?? '0', 10);
146
+ return ai - bi;
147
+ });
148
+ });
149
+
150
+ /** The ref id of the currently active tile (or null). */
151
+ let activeTileRefId = $derived(
152
+ mapState.activeTile ? placementToTileRefId(mapState.activeTile) : null,
153
+ );
154
+
155
+ // --- Selection ---
156
+
157
+ /**
158
+ * Select a tile by frame index, setting mapState.activeTile to the
159
+ * corresponding TilePlacement.
160
+ */
161
+ function selectTile(tileId: string): void {
162
+ // Parse the frame index from the tile id ("default:canvas:N").
163
+ const parts = tileId.split(':');
164
+ const frameIndex = parseInt(parts[2] ?? '0', 10);
165
+ const placement: TilePlacement = {
166
+ projectRef: 'default',
167
+ canvasRef: 'canvas',
168
+ frameIndex,
169
+ height: 0,
170
+ };
171
+ mapState.activeTile = placement;
172
+ }
173
+
174
+ /**
175
+ * Extract a human-readable label from a tile id.
176
+ * "default:canvas:0" -> "Frame 0", etc.
177
+ */
178
+ function tileLabel(tileId: string): string {
179
+ const idx = parseInt(tileId.split(':')[2] ?? '0', 10);
180
+ return `Frame ${String(idx)}`;
181
+ }
182
+ </script>
183
+
184
+ <div class="tile-picker">
185
+ <div class="tile-picker__header">
186
+ <span class="tile-picker__title">Tiles</span>
187
+ </div>
188
+
189
+ {#if canvasTileIds.length === 0}
190
+ <div class="tile-picker__empty">
191
+ <p class="tile-picker__empty-text">Draw sprites to use as tiles</p>
192
+ </div>
193
+ {:else}
194
+ <div class="tile-picker__grid">
195
+ {#each canvasTileIds as tileId (tileId)}
196
+ <button
197
+ class="tile-picker__item"
198
+ class:tile-picker__item--active={activeTileRefId === tileId}
199
+ title={tileLabel(tileId)}
200
+ onclick={() => { selectTile(tileId); }}
201
+ >
202
+ <canvas
203
+ class="tile-picker__thumb"
204
+ width={THUMB_SIZE}
205
+ height={THUMB_SIZE}
206
+ use:trackThumbCanvas={tileId}
207
+ ></canvas>
208
+ <span class="tile-picker__label">{tileLabel(tileId)}</span>
209
+ </button>
210
+ {/each}
211
+ </div>
212
+ {/if}
213
+ </div>
214
+
215
+ <style>
216
+ .tile-picker {
217
+ display: flex;
218
+ flex-direction: column;
219
+ width: 100%;
220
+ height: 100%;
221
+ background: var(--bg-panel);
222
+ color: var(--text-primary);
223
+ font-size: var(--text-base);
224
+ user-select: none;
225
+ }
226
+
227
+ .tile-picker__header {
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: space-between;
231
+ padding: 6px var(--space-3);
232
+ border-bottom: 1px solid var(--border);
233
+ background: var(--bg-toolbar);
234
+ flex-shrink: 0;
235
+ }
236
+
237
+ .tile-picker__title {
238
+ font-weight: 600;
239
+ font-size: var(--text-sm);
240
+ text-transform: uppercase;
241
+ letter-spacing: 0.5px;
242
+ color: var(--text-secondary);
243
+ }
244
+
245
+ .tile-picker__empty {
246
+ flex: 1;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: center;
250
+ padding: var(--space-4);
251
+ text-align: center;
252
+ }
253
+
254
+ .tile-picker__empty-text {
255
+ margin: 0;
256
+ font-size: var(--text-sm);
257
+ color: var(--text-muted);
258
+ line-height: 1.4;
259
+ }
260
+
261
+ .tile-picker__grid {
262
+ flex: 1;
263
+ overflow-y: auto;
264
+ overflow-x: hidden;
265
+ padding: var(--space-2);
266
+ display: grid;
267
+ grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
268
+ gap: var(--space-2);
269
+ align-content: start;
270
+ }
271
+
272
+ .tile-picker__item {
273
+ display: flex;
274
+ flex-direction: column;
275
+ align-items: center;
276
+ gap: 2px;
277
+ padding: 3px;
278
+ background: none;
279
+ border: 2px solid transparent;
280
+ border-radius: var(--radius-sm);
281
+ cursor: pointer;
282
+ transition: border-color 150ms ease, background 150ms ease;
283
+ }
284
+
285
+ .tile-picker__item:hover {
286
+ background: rgba(255, 255, 255, 0.06);
287
+ border-color: var(--border);
288
+ }
289
+
290
+ :global([data-theme="light"]) .tile-picker__item:hover {
291
+ background: rgba(0, 0, 0, 0.06);
292
+ }
293
+
294
+ .tile-picker__item--active {
295
+ border-color: var(--accent);
296
+ background: rgba(74, 158, 255, 0.1);
297
+ }
298
+
299
+ .tile-picker__item--active:hover {
300
+ border-color: var(--accent-hover);
301
+ }
302
+
303
+ .tile-picker__thumb {
304
+ width: 48px;
305
+ height: 48px;
306
+ image-rendering: pixelated;
307
+ border: 1px solid var(--border);
308
+ border-radius: var(--radius-sm);
309
+ background: var(--bg-canvas);
310
+ }
311
+
312
+ .tile-picker__label {
313
+ font-size: var(--text-xs);
314
+ color: var(--text-muted);
315
+ white-space: nowrap;
316
+ overflow: hidden;
317
+ text-overflow: ellipsis;
318
+ max-width: 100%;
319
+ }
320
+
321
+ .tile-picker__item--active .tile-picker__label {
322
+ color: var(--accent);
323
+ }
324
+ </style>
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { depthSort, type RenderItem } from './depth-sort.js';
3
+
4
+ describe('depthSort', () => {
5
+ it('should sort items by isometric depth (col+row sum)', () => {
6
+ const items: RenderItem[] = [
7
+ { type: 'tile', col: 3, row: 3, height: 0, layerIndex: 0, data: {} as never },
8
+ { type: 'tile', col: 0, row: 0, height: 0, layerIndex: 0, data: {} as never },
9
+ { type: 'tile', col: 1, row: 1, height: 0, layerIndex: 0, data: {} as never },
10
+ ];
11
+
12
+ const sorted = depthSort(items);
13
+ // (0,0) sum=0, (1,1) sum=2, (3,3) sum=6
14
+ expect(sorted[0]?.col).toBe(0);
15
+ expect(sorted[1]?.col).toBe(1);
16
+ expect(sorted[2]?.col).toBe(3);
17
+ });
18
+
19
+ it('should sort by height when col+row sums are equal', () => {
20
+ const items: RenderItem[] = [
21
+ { type: 'tile', col: 2, row: 2, height: 3, layerIndex: 0, data: {} as never },
22
+ { type: 'tile', col: 2, row: 2, height: 0, layerIndex: 0, data: {} as never },
23
+ { type: 'tile', col: 2, row: 2, height: 1, layerIndex: 0, data: {} as never },
24
+ ];
25
+
26
+ const sorted = depthSort(items);
27
+ expect(sorted[0]?.height).toBe(0);
28
+ expect(sorted[1]?.height).toBe(1);
29
+ expect(sorted[2]?.height).toBe(3);
30
+ });
31
+
32
+ it('should sort by layer index when depth and height are equal', () => {
33
+ const items: RenderItem[] = [
34
+ { type: 'tile', col: 1, row: 1, height: 0, layerIndex: 2, data: {} as never },
35
+ { type: 'tile', col: 1, row: 1, height: 0, layerIndex: 0, data: {} as never },
36
+ { type: 'tile', col: 1, row: 1, height: 0, layerIndex: 1, data: {} as never },
37
+ ];
38
+
39
+ const sorted = depthSort(items);
40
+ expect(sorted[0]?.layerIndex).toBe(0);
41
+ expect(sorted[1]?.layerIndex).toBe(1);
42
+ expect(sorted[2]?.layerIndex).toBe(2);
43
+ });
44
+
45
+ it('should not mutate the input array', () => {
46
+ const items: RenderItem[] = [
47
+ { type: 'tile', col: 5, row: 5, height: 0, layerIndex: 0, data: {} as never },
48
+ { type: 'tile', col: 0, row: 0, height: 0, layerIndex: 0, data: {} as never },
49
+ ];
50
+ const original = [...items];
51
+ depthSort(items);
52
+ expect(items[0]).toBe(original[0]);
53
+ expect(items[1]).toBe(original[1]);
54
+ });
55
+
56
+ it('should handle mixed tile and entity items', () => {
57
+ const items: RenderItem[] = [
58
+ { type: 'entity', col: 3, row: 0, height: 0, layerIndex: 1, data: {} as never },
59
+ { type: 'tile', col: 0, row: 3, height: 0, layerIndex: 0, data: {} as never },
60
+ { type: 'tile', col: 0, row: 0, height: 0, layerIndex: 0, data: {} as never },
61
+ ];
62
+
63
+ const sorted = depthSort(items);
64
+ // (0,0) sum=0 first, then (3,0) and (0,3) sum=3 -- sorted by layerIndex
65
+ expect(sorted[0]?.col).toBe(0);
66
+ expect(sorted[0]?.row).toBe(0);
67
+ expect(sorted[1]?.type).toBe('tile'); // layerIndex 0
68
+ expect(sorted[2]?.type).toBe('entity'); // layerIndex 1
69
+ });
70
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Depth Sort -- sorts isometric render items for correct painter's-order rendering.
3
+ *
4
+ * Combines tile and entity items from visible map layers and sorts them so that
5
+ * farther objects (smaller col + row sum) render first, with layer index and
6
+ * height as tiebreakers.
7
+ */
8
+
9
+ import type { TilePlacement, EntityPlacement } from './map-state.svelte.js';
10
+ import { isoDepthCompare } from '../iso/iso-math.js';
11
+
12
+ /** A single renderable item ready for depth sorting. */
13
+ export interface RenderItem {
14
+ type: 'tile' | 'entity';
15
+ col: number;
16
+ row: number;
17
+ height: number;
18
+ layerIndex: number; // map layer index for secondary sort
19
+ data: TilePlacement | EntityPlacement;
20
+ }
21
+
22
+ /**
23
+ * Sort all visible items for correct isometric rendering.
24
+ *
25
+ * Primary sort: isometric depth (col + row sum, then height via isoDepthCompare).
26
+ * Secondary sort: layer index (lower layers render first).
27
+ *
28
+ * Returns a new sorted array; does not mutate the input.
29
+ */
30
+ export function depthSort(items: RenderItem[]): RenderItem[] {
31
+ return [...items].sort((a, b) => {
32
+ // Primary: isometric depth (farther from camera renders first)
33
+ const depthCmp = isoDepthCompare(a, b);
34
+ if (depthCmp !== 0) return depthCmp;
35
+
36
+ // Secondary: layer ordering (lower index = rendered first = behind)
37
+ return a.layerIndex - b.layerIndex;
38
+ });
39
+ }