Projects on disk
A project is a directory you can read, copy and back up. Nothing about it needs the app to be meaningful.
projects/
sensor-rail/
project.json name, format, current version, the version table
draft.json constraints edited but not yet built; not a version
v001/
design.json the model's design
board.json everything the viewer draws
inspector.html the standalone viewer
meshes.json vendor 3D models for the viewer
models/C7593.wrl … the same models for KiCad
ne555-led-blinker.kicad_pcb
ne555-led-blinker-F_Cu.gtl … -Edge_Cuts.gm1, .drl
ne555-led-blinker-BOM.csv, -CPL.csv
ne555-led-blinker.dsn
board.svg
v002/ …
.work/ the router's scratch files (.dsn/.ses); not part of a version
.trash/
old-board-1788254009/ a deleted project, until the trash is emptiedprojects/ lives next to the code by default; set SOLDERMASK_HOME or SOLDERMASK_PROJECTS to move it. It is data, not code, and is not in git.
project.json
{
"format": 2,
"name": "Sensor rail",
"description": "",
"tags": [],
"archived": false,
"created": 1788102305.5,
"touched": 1788102889.6,
"current": "v004",
"versions": [
{"id": "v001", "created": 1788102305.5, "summary": "…", "prompt": "…",
"parts": 8, "erc_errors": 1, "gaps": 4, "cost": 3.41,
"parent": "LDO breakout/v002", "app_version": "0.1.0", "label": "", "notes": ""}
],
"chat": [
{"t": 1788102310.2, "you": "a 3.3V LDO breakout", "bot": "Built it around an AMS1117-3.3 …", "version": "v001"},
{"t": 1788102400.7, "you": "what is C1 for?", "bot": "C1 is the input bulk capacitor …", "version": ""}
]
}chat is the conversation: one entry per turn with what was said, what was answered, and the version the turn made ("" for a question). An entry may also carry kind: "note" — a turn the app started rather than the person: a rebuild, a route-again, a file read in — and error: true for a turn that failed before it answered, bot being the reason. The last 200 turns are kept. GET /api/projects/<slug> merges it with the version table into chat, oldest first, so the thread reads back whole; a project from before replies were kept reads back from its versions alone, and a note from before the tag is recognised by its wording (rebuilt with…, routed again from…, imported…). A turn whose build was revised made two versions and is remembered once, against the revision; the merge folds the first into that entry as first.
Versions are appended, never edited or removed; current is the only field that moves when you restore. A version id is handed out once: it counts the versions on disk, under the project's lock. So if a directory is already standing where the next version goes, it is what a build that died before committing left behind, and it is deleted before the new version is written — a version directory holds that version's files or nothing, and never the remains of a build that failed. parent is the version an edit was built from, or <project>/<version> for a fork's v001. app_version records which soldermask built the version, so an old board is recognisable as an old pipeline's output.
Writes are atomic (a temporary file replaced in place) and every change re-reads the file under a per-project lock before writing, so two devices — or the app and the command line — cannot lose each other's updates.
The format number
format is the on-disk layout's version, separate from the app's version. The app reads projects up to its own FORMAT (currently 2); a project from an older format is migrated the first time it is read, with the original kept beside it as project.json.pre-format1; a project from a newer app is refused with a message rather than guessed at, and is left out of the list. Migrations are one function per step in soldermask/projects.py.
| Format | What changed |
|---|---|
| 1 | the original layout: name, created, current, versions |
| 2 | description, tags, archived on the project; app_version, label, notes on each version |
Files in a version
| Kind | Files |
|---|---|
app | design.json, board.json, inspector.html — the app's own |
draft.json
The one mutable file in a project, and the reason a canvas gesture does not cost a version.
{ "base": "v004", "constraints": { "pinned": { "J1": { "x": 4, "y": 22.5, "rot": 90, "by": "human" } } },
"touched": 1788451932.35 }Everything else here is an immutable version, which is what makes the history worth keeping — and exactly why a drag cannot be written to one. A gesture is saved about a second after it happens; a version costs the anneal and the router. So the two cadences get two places: an autosave writes here and stops, and the next rebuild bakes the block into that version's design.json and deletes this file.
base is the version the edits amend, and it is what makes switching versions behave: a pin dragged onto v004 does not follow you back to v002, because v002 answers with the constraints it was built with. Forking copies a version directory, so a fork inherits the built constraints and none of the unbuilt ones.
| gerber | *.gtl .gbl .gts .gbs .gto .gtp .gm1 .drl |
| assembly | *-BOM.csv, *-CPL.csv |
| kicad | *.kicad_pcb |
| router | *.dsn |
| preview | board.svg |
| other | meshes.json |
gerbers.zip contains the gerber and assembly kinds only.
Bundles
export.zip (Project.export_bundle) is this layout zipped as it lies: project.json, draft.json when there is one, and every version directory the table names. Left out: .work/, proposal.json and .proposal/, *.tmp, and project.json.pre-format*. import_bundle reads the whole zip before writing — every member must be one of those names or under a vNNN/ the table knows, every table version must have its design.json and board.json in the zip and both must parse, current must be in the table, and a draft.json must amend a version that is there — then unpacks to projects/.import-* and renames into place, so a refused zip leaves nothing. A zip made by hand of the directory itself (one wrapper folder over everything, the desktop's __MACOSX beside it) is accepted and the wrapper stripped. The imported project.json gains imported: {at, from, app_version}.
Trash
Delete moves the whole directory to projects/.trash/<slug>-<timestamp>. Restore moves it back under its slug (with a suffix if the slug is taken again). Emptying the trash is the only destructive operation in the app.