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

@@ -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
Copy Lua
+ Project
+
+ Export JSON
+ Import JSON
+
+
+
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 };
}