Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
72 changes: 72 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ <h2>Actions</h2>
</div>
<button id="copyBtn">Copy Lua</button>

<h2>Project</h2>
<div class="btn-row">
<button id="exportJsonBtn">Export JSON</button>
<button id="importJsonBtn">Import JSON</button>
</div>
<input type="file" id="importJsonInput" accept="application/json,.json" style="display:none">

<h2>Vertices</h2>
<div class="vertex-list" id="vertexList"></div>
</div>
Expand Down Expand Up @@ -668,6 +675,71 @@ <h2>Lua Output</h2>
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(() => {
Expand Down
81 changes: 80 additions & 1 deletion tests.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)}`);
Expand Down
34 changes: 34 additions & 0 deletions use-cases/UC-14-save-and-load-project.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 40 additions & 1 deletion vector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => [
Expand All @@ -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 };
}