diff --git a/CLAUDE.md b/CLAUDE.md index 3a8895c..6aa4801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,8 @@ All tests must pass before considering work complete. Run tests after every chan - `generateLuaOutput(vertices, scaleFactor)` — generate Lua flat array string - `translateVertices(vertices, dx, dy, gridSize)` — shift all vertices by delta, clamped to grid bounds - `clampRotation(value)` — parse and clamp rotation input to integer 0–360 +- `generateProjectJson(state)` — serialize full editor state (vertices, closed, mirrors, gridSize, scaleFactor) to JSON +- `parseProjectJson(text)` — parse a project JSON string, returns null on invalid input ## Conventions diff --git a/README.md b/README.md index ac8f2ac..d8e53d4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A browser-based tool for creating vector polygon graphics for games like Asteroi - **Editable Lua output** — edit the Lua code directly and see changes reflected on the canvas - **Scale factor** — multiply output coordinates by an integer scale (1–10) - **Copy to clipboard** — one-click copy of the Lua table +- **Save/load projects** — export the full editor state to a `.json` file and import it later to resume work ![Vector Graphics Editor screenshot](screenshot.png) @@ -71,7 +72,7 @@ CLAUDE.md — AI assistant project instructions ## Use Cases -The `use-cases/` directory contains 13 Alistair Cockburn fully-dressed use cases that document every feature's expected behavior. These are maintained alongside the code — any new feature or bug fix includes a corresponding use case review or update. +The `use-cases/` directory contains 14 Alistair Cockburn fully-dressed use cases that document every feature's expected behavior. These are maintained alongside the code — any new feature or bug fix includes a corresponding use case review or update. ## Built With diff --git a/index.html b/index.html index cc9c431..d88e74e 100644 --- a/index.html +++ b/index.html @@ -233,6 +233,13 @@

Actions

+

Project

+
+ + +
+ +

Vertices

@@ -668,6 +675,71 @@

Lua Output

refresh(); }); +document.getElementById('exportJsonBtn').addEventListener('click', () => { + const json = generateProjectJson({ + gridSize, scaleFactor, closed, mirrorX, mirrorY, vertices + }); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'vector-project.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + const btn = document.getElementById('exportJsonBtn'); + const orig = btn.textContent; + btn.textContent = 'Exported!'; + setTimeout(() => btn.textContent = orig, 1500); +}); + +document.getElementById('importJsonBtn').addEventListener('click', () => { + document.getElementById('importJsonInput').click(); +}); + +document.getElementById('importJsonInput').addEventListener('change', (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const parsed = parseProjectJson(reader.result); + const btn = document.getElementById('importJsonBtn'); + if (!parsed) { + const orig = btn.textContent; + btn.textContent = 'Invalid file'; + setTimeout(() => btn.textContent = orig, 1500); + return; + } + if (parsed.gridSize) { + gridSize = parsed.gridSize; + document.getElementById('gridSize').value = gridSize; + } + if (parsed.scaleFactor) { + scaleFactor = parsed.scaleFactor; + document.getElementById('scaleFactor').value = scaleFactor; + } + vertices = parsed.vertices; + mirrorX = parsed.mirrorX; + mirrorY = parsed.mirrorY; + closed = parsed.closed && vertices.length >= 3; + document.getElementById('mirrorX').classList.toggle('on', mirrorX); + document.getElementById('mirrorY').classList.toggle('on', mirrorY); + document.getElementById('closeBtn').textContent = closed ? 'Open Polygon' : 'Close Polygon'; + document.getElementById('closeBtn').classList.toggle('active', closed); + rotation = 0; + document.getElementById('rotation').value = 0; + document.getElementById('rotationInput').value = 0; + resize(); + refresh(); + const orig = btn.textContent; + btn.textContent = 'Imported!'; + setTimeout(() => btn.textContent = orig, 1500); + }; + reader.readAsText(file); + e.target.value = ''; +}); + document.getElementById('copyBtn').addEventListener('click', () => { const text = document.getElementById('luaOutput').value; navigator.clipboard.writeText(text).then(() => { diff --git a/tests.js b/tests.js index 5fa0229..7afb427 100644 --- a/tests.js +++ b/tests.js @@ -1,4 +1,4 @@ -const { gridToCanvas, canvasToGrid, rotatePoint, getEffectiveVertices, parseLuaTable, generateLuaOutput, translateVertices, clampRotation } = require('./vector.js'); +const { gridToCanvas, canvasToGrid, rotatePoint, getEffectiveVertices, parseLuaTable, generateLuaOutput, translateVertices, clampRotation, generateProjectJson, parseProjectJson } = require('./vector.js'); let passed = 0; let failed = 0; @@ -283,6 +283,85 @@ suite('clampRotation — truncates decimals', () => { assert(clampRotation('90.9') === 90, 'string decimal truncated'); }); +// ── generateProjectJson / parseProjectJson ── + +suite('generateProjectJson — full state', () => { + const json = generateProjectJson({ + gridSize: 32, + scaleFactor: 2, + closed: true, + mirrorX: true, + mirrorY: false, + vertices: [[0, -4], [3, 2]] + }); + const obj = JSON.parse(json); + assert(obj.version === 1, 'has version'); + assert(obj.gridSize === 32, 'gridSize preserved'); + assert(obj.scaleFactor === 2, 'scaleFactor preserved'); + assert(obj.closed === true, 'closed preserved'); + assert(obj.mirrorX === true && obj.mirrorY === false, 'mirrors preserved'); + assert(obj.vertices.length === 2 && obj.vertices[0][0] === 0 && obj.vertices[0][1] === -4, 'vertices preserved'); +}); + +suite('parseProjectJson — valid', () => { + const text = JSON.stringify({ + version: 1, + gridSize: 16, + scaleFactor: 3, + closed: true, + mirrorX: false, + mirrorY: true, + vertices: [[1, 2], [-3, 4]] + }); + const r = parseProjectJson(text); + assert(r !== null, 'returns object'); + assert(r.gridSize === 16, 'gridSize parsed'); + assert(r.scaleFactor === 3, 'scaleFactor parsed'); + assert(r.closed === true, 'closed parsed'); + assert(r.mirrorX === false && r.mirrorY === true, 'mirrors parsed'); + assert(r.vertices.length === 2 && r.vertices[1][0] === -3 && r.vertices[1][1] === 4, 'vertices parsed'); +}); + +suite('parseProjectJson — invalid inputs', () => { + assert(parseProjectJson('') === null, 'empty string'); + assert(parseProjectJson('not json') === null, 'malformed json'); + assert(parseProjectJson('{}') === null, 'missing vertices'); + assert(parseProjectJson('{"vertices":"nope"}') === null, 'vertices not array'); + assert(parseProjectJson('{"vertices":[[1]]}') === null, 'pair too short'); + assert(parseProjectJson('{"vertices":[["a","b"]]}') === null, 'non-numeric values'); +}); + +suite('parseProjectJson — missing optional fields default safely', () => { + const r = parseProjectJson('{"vertices":[[0,0]]}'); + assert(r !== null && r.vertices.length === 1, 'parses minimal'); + assert(r.gridSize === null, 'gridSize null when missing'); + assert(r.scaleFactor === null, 'scaleFactor null when missing'); + assert(r.closed === false, 'closed defaults false'); + assert(r.mirrorX === false && r.mirrorY === false, 'mirrors default false'); +}); + +suite('generateProjectJson -> parseProjectJson roundtrip', () => { + const state = { + gridSize: 32, + scaleFactor: 1, + closed: true, + mirrorX: true, + mirrorY: true, + vertices: [[0, -4], [3, 2], [-3, 2]] + }; + const json = generateProjectJson(state); + const r = parseProjectJson(json); + assert(r.gridSize === state.gridSize, 'gridSize roundtrip'); + assert(r.scaleFactor === state.scaleFactor, 'scaleFactor roundtrip'); + assert(r.closed === state.closed, 'closed roundtrip'); + assert(r.mirrorX === state.mirrorX && r.mirrorY === state.mirrorY, 'mirrors roundtrip'); + assert(r.vertices.length === state.vertices.length, 'vertex count roundtrip'); + for (let i = 0; i < state.vertices.length; i++) { + assert(r.vertices[i][0] === state.vertices[i][0] && r.vertices[i][1] === state.vertices[i][1], + `vertex ${i} roundtrip`); + } +}); + // ── Summary ── console.log(`\n${'='.repeat(40)}`); diff --git a/use-cases/UC-14-save-and-load-project.md b/use-cases/UC-14-save-and-load-project.md new file mode 100644 index 0000000..6117d34 --- /dev/null +++ b/use-cases/UC-14-save-and-load-project.md @@ -0,0 +1,34 @@ +# UC-14: Save and Load Project as JSON + +| Field | Value | +|---|---| +| **Use Case** | UC-14: Save and Load Project as JSON | +| **Scope** | Vector Graphics Editor | +| **Level** | User goal | +| **Primary Actor** | Game Artist | +| **Precondition** | Editor is loaded | +| **Postcondition (Success)** | Export: a `.json` file is downloaded with full editor state. Import: editor state is replaced with the loaded file's state | +| **Postcondition (Failure)** | Import: editor state is unchanged; "Invalid file" feedback is shown briefly on the button | +| **Trigger** | Actor clicks "Export JSON" or "Import JSON" | + +## Main Success Scenario — Export + +1. Actor clicks "Export JSON." +2. System serializes current state via `generateProjectJson` — capturing `gridSize`, `scaleFactor`, `closed`, `mirrorX`, `mirrorY`, and the user-placed `vertices` array (raw grid coordinates, unscaled). +3. System triggers a browser download named `vector-project.json`. +4. Button text changes to "Exported!" for 1.5 seconds, then reverts. + +## Main Success Scenario — Import + +1. Actor clicks "Import JSON." +2. System opens a native file picker filtered to `.json` files. +3. Actor selects a previously exported project file. +4. System reads the file and parses it via `parseProjectJson`. +5. Parse succeeds: system replaces `vertices`, restores `closed`, `mirrorX`, `mirrorY`, and (when present) `gridSize` and `scaleFactor`. The canvas is re-sized and re-drawn. Rotation preview resets to 0. +6. Button text changes to "Imported!" for 1.5 seconds, then reverts. + +## Extensions + +- **4a.** Parse fails (malformed JSON, missing/invalid `vertices`): button text changes to "Invalid file" for 1.5 seconds; editor state is unchanged. +- **5a.** Imported `vertices` has fewer than 3 points: `closed` is forced to `false` regardless of the file's `closed` flag. +- **Format:** JSON is the canonical project file format; the Lua textarea (UC-10) and copy action (UC-11) remain the way to extract render-ready output for use in a game. diff --git a/vector.js b/vector.js index 76ef62a..2e06990 100644 --- a/vector.js +++ b/vector.js @@ -102,6 +102,45 @@ function generateLuaOutput(vertices, scaleFactor) { return lua; } +function generateProjectJson(state) { + const data = { + version: 1, + gridSize: state.gridSize, + scaleFactor: state.scaleFactor, + closed: !!state.closed, + mirrorX: !!state.mirrorX, + mirrorY: !!state.mirrorY, + vertices: state.vertices.map(([x, y]) => [x, y]) + }; + return JSON.stringify(data, null, 2); +} + +function parseProjectJson(text) { + let obj; + try { + obj = JSON.parse(text); + } catch (e) { + return null; + } + if (!obj || !Array.isArray(obj.vertices)) return null; + const pts = []; + for (const p of obj.vertices) { + if (!Array.isArray(p) || p.length < 2) return null; + const x = Number(p[0]); + const y = Number(p[1]); + if (!isFinite(x) || !isFinite(y)) return null; + pts.push([Math.round(x), Math.round(y)]); + } + return { + vertices: pts, + gridSize: Number.isFinite(obj.gridSize) ? obj.gridSize : null, + scaleFactor: Number.isFinite(obj.scaleFactor) ? obj.scaleFactor : null, + closed: !!obj.closed, + mirrorX: !!obj.mirrorX, + mirrorY: !!obj.mirrorY + }; +} + function translateVertices(vertices, dx, dy, gridSize) { const half = gridSize / 2; return vertices.map(([x, y]) => [ @@ -117,5 +156,5 @@ function clampRotation(value) { } if (typeof module !== 'undefined' && module.exports) { - module.exports = { gridToCanvas, canvasToGrid, rotatePoint, getEffectiveVertices, parseLuaTable, generateLuaOutput, translateVertices, clampRotation }; + module.exports = { gridToCanvas, canvasToGrid, rotatePoint, getEffectiveVertices, parseLuaTable, generateLuaOutput, translateVertices, clampRotation, generateProjectJson, parseProjectJson }; }