Jelajahi Sumber

Add Gerber Viewer

Toby Chui 1 hari lalu
induk
melakukan
5a35fa0af2

+ 135 - 0
src/web/Gerber Viewer/README.md

@@ -0,0 +1,135 @@
+# Gerber Viewer
+
+A desktop-style viewer for PCB fabrication data: Gerber RS-274X / X2 layers and
+Excellon drill files, shown as coloured CAM layers, as a realistic board
+preview (solder mask, surface finish, silkscreen, drilled holes) or as a 3D
+board. Everything is drawn in the browser; the only server-side code is the
+AGI scripts in `backend/` (no Go endpoints were added).
+
+## Opening data
+
+| Source | How | Who unpacks |
+|---|---|---|
+| ArozOS | File > Open from ArozOS (zip / 7z / tar / tar.gz or any set of files), Open Folder, drag from File Manager, file association (`SupportedExt` in `init.agi`) | `backend/project.agi` (ziplib) |
+| This device | File > Open from This Device / Open Folder, drag and drop (files, folders, zips) | `js/zip.js` in the browser (zip only) |
+
+Opening a single layer through a file association offers "Load all" when the
+same folder holds more CAM files (`project.agi` `siblings`). `.zip` is
+deliberately not in `SupportedExt`, so the Zip File Manager stays the default
+for zips.
+
+## File map
+
+| File | Purpose |
+|---|---|
+| `init.agi` | Registers the module |
+| `backend/project.agi` | `open` (archives, folders, files), `read` (reload one layer), `siblings` |
+| `backend/prefs.agi` | Settings and recent projects in the `Gerber Viewer` DB table (per user) |
+| `backend/export.agi` | Saves exports (`writeText`, `writeBinary` as hex) to a vpath; JSON body |
+| `js/geom.js` | Item format, arcs, orientation, hit testing, Path2D / SVG path sinks |
+| `js/gerber.js` | RS-274X / X2 parser |
+| `js/excellon.js` | Excellon parser (tools, LZ / TZ, slots, routing) |
+| `js/layers.js` | Layer type detection (X2 FileFunction, EasyEDA, KiCad, Altium, Eagle, ...) |
+| `js/board.js` | Outline loops (board edge + cut-outs), de-duplicated holes and slots |
+| `js/render.js` | Canvas renderer (layer and realistic modes) and the pan / zoom viewer |
+| `js/view3d.js` | 3D board (ES module, loaded on demand), three.js in `lib/three` |
+| `js/zip.js` | Zip reader / writer using the browser's (De)CompressionStream |
+| `js/ui.js` | Menu bar, dropdowns, context menus, dialogs, toasts, popovers |
+| `js/store.js` | AGI calls, settings / recents (localStorage fallback), IndexedDB cache |
+| `js/exporter.js` | SVG export, download / save to ArozOS, printing, drill CSV |
+| `js/app.js` | Application state, loading, viewer interaction, panels, menus, keys |
+| `js/dialogs.js` | Preferences, export, print, go to, drill / aperture tables, reports, help |
+| `js/mfg.js` | Manufacturing Capabilities Preview panel (3D view) |
+| `locale/gerberviewer.json` | UI text in en-us, zh-tw, zh-hk, zh-cn, ja-jp, ko-kr |
+
+## How it works
+
+**Parsing.** Every layer becomes a list of polarity groups. Each group holds
+items in board millimetres (Y up): discs, round-capped strokes, arc strokes and
+filled contours (`geom.js` documents the shapes). Region arcs and non-circular
+apertures are flattened to contours with outer loops counter clockwise and
+holes clockwise, so a single nonzero fill is correct. Aperture macros are
+evaluated per flash (variables and arithmetic included); exposure-off
+primitives become a clipped compound item so they only cut the aperture
+itself. Step and repeat and block apertures are expanded at parse time.
+
+**Rendering.** Each layer's groups are turned once into cached `Path2D`s (one
+fill path, one stroke path per line width). A layer is drawn on its own scratch
+canvas (clear polarity uses `destination-out` there, so it never erases other
+layers) and composited with its opacity. The realistic mode composites
+substrate, copper, the solder mask with the mask layer's openings cut out,
+surface finish on exposed copper, silkscreen clipped from the openings, then
+trims to the board outline and punches the holes. The same scene function
+draws the view, the minimap, exports, prints, thumbnails and the 3D textures.
+
+**3D.** The outline is extruded to the board thickness with cut-outs, holes
+and slots removed; top and bottom faces are textured with the realistic
+render, plated holes get instanced copper barrels.
+
+**Persistence.** Settings and the recent list (with a thumbnail and the
+project's layer colours, order, visibility and view) are stored through
+`prefs.agi`. Outside ArozOS they fall back to localStorage. Projects opened
+from the device are cached in IndexedDB (up to 40 MB each) so they reopen from
+Recents in the same browser.
+
+## Manufacturing Capabilities Preview
+
+Tools > Manufacturing Capabilities Preview (or the Manufacturing button in
+the 3D view) opens a panel modelled on a PCB fab's order form. The choices
+are saved in `settings.mfg` and, while Preview is ticked, the 3D board is
+rebuilt from them:
+
+- **Board material** (FPC or FR-4) sets the laminate colour of the edge and
+  which layer counts and thicknesses can be picked (`MATERIALS` in
+  `mfg.js`). Switching material resets the thickness to the material's
+  usual value (1.6 mm for FR-4). FPC uses an amber coverlay instead of a
+  coloured mask.
+- **Layers and thickness** drive the extrusion depth and the stack-up
+  stripes drawn on the board edge (`stackTexture` in `view3d.js`; outer
+  copper is drawn as 1 oz).
+- **Solder mask, silkscreen, surface finish** recolour the face textures.
+- **Via covering** (tented, untented, plugged, resin filled + capped) changes
+  how vias are drawn (`look.via` in `render.js`) and whether they are
+  drilled through the 3D board. Vias come from the via drill file, or are
+  plated holes of 0.45 mm and below when there is none (`board.js`).
+- **Delivery format** "Panelization" lays the board out in columns and rows
+  with V-cut grooves or stamp-hole tabs and optional edge rails with
+  tooling holes and fiducials (`buildPanel` in `view3d.js`). The summary
+  gives the panel size and boards per panel.
+
+Changing an option keeps the scroll position of the panel and the 3D camera
+(`keepView` in `setBoard`); the camera only resets for a new project.
+
+## Localisation
+
+All UI text goes through `T(key, englishFallback, vars)` (`GV.ui.t`), which
+reads `locale/gerberviewer.json` through `script/applocale.js`; static markup
+uses `locale="key"`, and tooltips are translated through the file's `titles`
+map (keyed by the English title). Parser warnings, skipped-file reasons and
+zip / AGI errors carry codes (`warn/*`, `skip/*`, `error/*`), so they are
+translated as well. Keys whose name is built at run time: `type/*`,
+`kind/*`, `tip/<n>`, `mask/*`, `finish/*`, `mfin/*`, `mat/*`, `via/*`,
+`bg/*`, plus labels passed to the `row()`, `label()` and `check()` helpers. When adding a string, add the same key to every language in the
+JSON file; the drill table CSV keeps English column titles on purpose.
+
+## Limits and known gaps
+
+- Server-side reads: 32 MB per file, 96 MB per request, 300 files; archives
+  may expand to 512 MB of CAM data, tar archives up to 64 MB.
+- Block apertures ignore `LM` / `LR` / `LS`; deprecated `SF`, `MI`, `IR` are
+  ignored with a warning (`OF`, `IP`, `G70/71`, `G90/91` are honoured).
+- An exposure-off macro primitive that only partly overlaps earlier exposure-on
+  primitives is approximated.
+- Zips opened from the device must not be ZIP64 or encrypted.
+- Layer view hides solder mask, paste and documentation layers by default; the
+  realistic preview always applies the file's solder mask (pick "None" under
+  Preferences > Board for a bare board).
+
+## Testing
+
+The parsers have no DOM dependency: `geom.js`, `gerber.js`, `excellon.js`,
+`layers.js` and `board.js` attach to `global.GV` under Node, so a small script
+can `require` them and parse real fabrication files (Node 20 needs a
+`DecompressionStream('deflate-raw')` shim for `zip.js`; browsers have it). The
+AGI scripts need a running ArozOS: call them with
+`POST /system/ajgi/interface?script=Gerber%20Viewer/backend/<script>.agi`.

+ 77 - 0
src/web/Gerber Viewer/backend/export.agi

@@ -0,0 +1,77 @@
+/*
+    Gerber Viewer - save exported files (images, SVG, reports, project zips)
+    into the ArozOS file system
+
+    Sent as a JSON body, because a hex encoded image can exceed the size
+    limit of an url-encoded form.
+
+    POST opr:
+        writeText    path=vpath, content=text
+        writeBinary  path=vpath, hex=lowercase hex string
+*/
+
+requirelib("filelib");
+
+var MAX_TEXT = 48 * 1024 * 1024;
+var MAX_HEX = 96 * 1024 * 1024;     //48 MB of binary data
+var ALLOWED = ["png", "jpg", "jpeg", "svg", "csv", "txt", "zip", "json", "gbr", "drl"];
+
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null) {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+function reply(obj) {
+    sendJSONResp(JSON.stringify(obj));
+}
+
+function extOf(p) {
+    var b = String(p).split("/").pop().toLowerCase();
+    var i = b.lastIndexOf(".");
+    return i < 0 ? "" : b.substring(i + 1);
+}
+
+function parentOf(p) {
+    var i = p.lastIndexOf("/");
+    return i < 0 ? "" : p.substring(0, i);
+}
+
+var opr = param("opr", "");
+var path = String(param("path", ""));
+
+if (path === "" || path.indexOf(":/") < 0 || /(^|\/)\.\.(\/|$)/.test(path)) {
+    reply({ error: "Invalid destination path", code: "badpath" });
+} else if (ALLOWED.indexOf(extOf(path)) < 0) {
+    reply({ error: "This file type cannot be saved by Gerber Viewer", code: "badtype" });
+} else if (!pathCanWrite(path)) {
+    reply({ error: "You do not have permission to write to this location", code: "denied" });
+} else if (parentOf(path) !== "" && !filelib.isDir(parentOf(path))) {
+    reply({ error: "The destination folder does not exist", code: "nofolder" });
+} else if (opr === "writeText") {
+    var content = String(param("content", ""));
+    if (content.length > MAX_TEXT) {
+        reply({ error: "File too large", code: "toolarge" });
+    } else if (filelib.writeFile(path, content)) {
+        reply({ ok: true, path: path });
+    } else {
+        reply({ error: "Unable to write the file", code: "writefail" });
+    }
+} else if (opr === "writeBinary") {
+    var hex = String(param("hex", ""));
+    if (hex.length > MAX_HEX || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex.substring(0, 64))) {
+        reply({ error: "Invalid or oversized file data", code: "baddata" });
+    } else if (filelib.writeBinaryFile(path, hex)) {
+        reply({ ok: true, path: path });
+    } else {
+        reply({ error: "Unable to write the file", code: "writefail" });
+    }
+} else {
+    reply({ error: "Unknown operation", code: "badop" });
+}

+ 190 - 0
src/web/Gerber Viewer/backend/prefs.agi

@@ -0,0 +1,190 @@
+/*
+    Gerber Viewer - settings and recent projects
+
+    Stored per user in the "Gerber Viewer" table of the system database.
+
+    POST opr:
+        boot                         -> {settings, recent}
+        saveSettings  settings=JSON  -> {ok}
+        recentAdd     entry=JSON     add or refresh an entry (matched by key)
+        recentUpdate  key=, patch=JSON   merge layer state / thumbnail into an entry
+        recentRemove  key=
+        recentClear
+*/
+
+var TABLE = "Gerber Viewer";
+var MAX_RECENT = 16;
+var MAX_THUMB = 120 * 1024;     //characters of a thumbnail data URL
+var MAX_STATE = 64 * 1024;      //characters of a saved layer state
+
+newDBTableIfNotExists(TABLE);
+
+//POST fields arrive as globals. The odd argument names keep eval() from
+//resolving a field named like an argument
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null) {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+function readJSON(key, def) {
+    var raw = readDBItem(TABLE, USERNAME + "/" + key);
+    if (!raw) {
+        return def;
+    }
+    try {
+        return JSON.parse(raw);
+    } catch (e) {
+        return def;
+    }
+}
+
+function writeJSON(key, value) {
+    writeDBItem(TABLE, USERNAME + "/" + key, JSON.stringify(value));
+}
+
+function parseField(name, def) {
+    var raw = param(name, null);
+    if (raw === null) {
+        return def;
+    }
+    if (typeof raw !== "string") {
+        return raw;
+    }
+    try {
+        return JSON.parse(raw);
+    } catch (e) {
+        return def;
+    }
+}
+
+function str(v, max) {
+    v = v === undefined || v === null ? "" : String(v);
+    return v.length > max ? v.substring(0, max) : v;
+}
+
+//Only keep the fields the front end knows about, with bounded sizes
+function cleanEntry(e) {
+    if (!e || typeof e !== "object") {
+        return null;
+    }
+    var out = {
+        key: str(e.key, 2048),
+        name: str(e.name, 200),
+        source: e.source === "local" ? "local" : "aroz",
+        kind: str(e.kind, 20),
+        paths: [],
+        cacheId: str(e.cacheId, 80),
+        time: Number(e.time) || Date.now(),
+        layers: Number(e.layers) || 0,
+        size: str(e.size, 60),
+        thumb: "",
+        state: null
+    };
+    if (out.key === "") {
+        return null;
+    }
+    if (e.paths && e.paths.length) {
+        for (var i = 0; i < e.paths.length && i < 200; i++) {
+            out.paths.push(str(e.paths[i], 2048));
+        }
+    }
+    if (typeof e.thumb === "string" && e.thumb.indexOf("data:image/") === 0 && e.thumb.length <= MAX_THUMB) {
+        out.thumb = e.thumb;
+    }
+    if (e.state && typeof e.state === "object" && JSON.stringify(e.state).length <= MAX_STATE) {
+        out.state = e.state;
+    }
+    return out;
+}
+
+function getRecent() {
+    var list = readJSON("recent", []);
+    return list && list.length !== undefined ? list : [];
+}
+
+var opr = param("opr", "");
+
+if (opr === "boot") {
+    sendJSONResp(JSON.stringify({
+        settings: readJSON("settings", {}),
+        recent: getRecent()
+    }));
+} else if (opr === "saveSettings") {
+    var s = parseField("settings", null);
+    if (!s || typeof s !== "object" || JSON.stringify(s).length > 64 * 1024) {
+        sendJSONResp(JSON.stringify({ error: "Invalid settings", code: "badsettings" }));
+    } else {
+        writeJSON("settings", s);
+        sendJSONResp(JSON.stringify({ ok: true }));
+    }
+} else if (opr === "recentAdd") {
+    var entry = cleanEntry(parseField("entry", null));
+    if (!entry) {
+        sendJSONResp(JSON.stringify({ error: "Invalid entry", code: "badentry" }));
+    } else {
+        var list = getRecent();
+        var kept = [];
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].key === entry.key) {
+                //Keep what the user customised last time unless replaced
+                if (!entry.state && list[i].state) {
+                    entry.state = list[i].state;
+                }
+                if (!entry.thumb && list[i].thumb) {
+                    entry.thumb = list[i].thumb;
+                }
+                continue;
+            }
+            kept.push(list[i]);
+        }
+        kept.unshift(entry);
+        if (kept.length > MAX_RECENT) {
+            kept = kept.slice(0, MAX_RECENT);
+        }
+        writeJSON("recent", kept);
+        sendJSONResp(JSON.stringify({ ok: true, recent: kept }));
+    }
+} else if (opr === "recentUpdate") {
+    var key = str(param("key", ""), 2048);
+    var patch = parseField("patch", {});
+    var list2 = getRecent();
+    var found = false;
+    for (var j = 0; j < list2.length; j++) {
+        if (list2[j].key === key) {
+            var merged = cleanEntry({
+                key: list2[j].key, name: list2[j].name, source: list2[j].source, kind: list2[j].kind,
+                paths: list2[j].paths, cacheId: list2[j].cacheId, time: list2[j].time,
+                layers: patch.layers !== undefined ? patch.layers : list2[j].layers,
+                size: patch.size !== undefined ? patch.size : list2[j].size,
+                thumb: patch.thumb !== undefined ? patch.thumb : list2[j].thumb,
+                state: patch.state !== undefined ? patch.state : list2[j].state
+            });
+            if (merged) {
+                list2[j] = merged;
+                found = true;
+            }
+            break;
+        }
+    }
+    if (found) {
+        writeJSON("recent", list2);
+    }
+    sendJSONResp(JSON.stringify({ ok: found }));
+} else if (opr === "recentRemove") {
+    var rkey = str(param("key", ""), 2048);
+    var list3 = getRecent().filter(function (e) { return e.key !== rkey; });
+    writeJSON("recent", list3);
+    sendJSONResp(JSON.stringify({ ok: true, recent: list3 }));
+} else if (opr === "recentClear") {
+    writeJSON("recent", []);
+    sendJSONResp(JSON.stringify({ ok: true, recent: [] }));
+} else {
+    sendJSONResp(JSON.stringify({ error: "Unknown operation", code: "badop" }));
+}

+ 368 - 0
src/web/Gerber Viewer/backend/project.agi

@@ -0,0 +1,368 @@
+/*
+    Gerber Viewer - read Gerber / Excellon data from the ArozOS file system
+
+    Archives are unpacked here on the server with ziplib, so the browser only
+    ever receives the text of the CAM files it needs.
+
+    POST opr:
+        open      paths=JSON array of vpaths (archives, folders or single files)
+                  -> {name, files:[{name, path, size, mtime, content}], skipped:[{name, reason}]}
+        read      path=vpath   -> one file, used to reload a single layer
+        siblings  path=vpath   -> other CAM files in the same folder as path
+*/
+
+requirelib("filelib");
+requirelib("ziplib");
+
+var MAX_FILE = 32 * 1024 * 1024;        //one CAM file
+var MAX_TOTAL = 96 * 1024 * 1024;       //everything returned by one request
+var MAX_FILES = 300;
+var MAX_EXPANDED = 512 * 1024 * 1024;   //uncompressed size of the CAM entries of an archive
+var MAX_TAR = 64 * 1024 * 1024;         //tar archives cannot be listed before extraction
+
+var ARCHIVES = ["zip", "7z", "tar", "gz", "tgz"];
+
+//File types that are never CAM data, skipped without being read
+var NOT_CAM = ["pdf", "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "zip", "7z", "rar", "gz", "tgz", "tar",
+    "step", "stp", "wrl", "igs", "iges", "stl", "xls", "xlsx", "csv", "doc", "docx", "htm", "html", "json", "xml",
+    "sch", "brd", "pcbdoc", "schdoc", "prjpcb", "lib", "ipc", "rep", "apr", "drr", "log", "ini", "md", "exe", "dll",
+    "kicad_pcb", "kicad_sch", "kicad_pro", "kicad_prl", "mp4", "mp3", "db"];
+
+//POST fields arrive as globals. The odd argument names keep eval() from
+//resolving a field named like an argument
+function param(__field, __fallback) {
+    try {
+        var __value = eval(__field);
+        if (__value === undefined || __value === null) {
+            return __fallback;
+        }
+        return __value;
+    } catch (e) {
+        return __fallback;
+    }
+}
+
+//code lets the front end show the message in the user's language
+function fail(msg, code) {
+    sendJSONResp(JSON.stringify({ error: msg, code: code || "" }));
+}
+
+function skip(out, name, code, reason) {
+    out.skipped.push({ name: name, code: code, reason: reason });
+}
+
+function baseName(p) {
+    p = String(p).replace(/\/+$/, "");
+    var i = p.lastIndexOf("/");
+    return i < 0 ? p : p.substring(i + 1);
+}
+
+function extOf(p) {
+    var b = baseName(p).toLowerCase();
+    var i = b.lastIndexOf(".");
+    return i < 0 ? "" : b.substring(i + 1);
+}
+
+function isArchive(p) {
+    var lower = String(p).toLowerCase();
+    if (/\.tar\.gz$/.test(lower)) {
+        return true;
+    }
+    return ARCHIVES.indexOf(extOf(p)) >= 0;
+}
+
+function isCandidate(name) {
+    var b = baseName(name);
+    if (b === "" || b.charAt(0) === "." || /(^|\/)__MACOSX\//i.test(name)) {
+        return false;
+    }
+    return NOT_CAM.indexOf(extOf(b)) < 0;
+}
+
+//Same sniffing rules as js/layers.js, kept loose: the client parses properly
+function looksLikeCAM(text) {
+    var head = String(text).substring(0, 4096);
+    if (/%FS[LTD]?[AI]?/.test(head) || /%MO(MM|IN)/.test(head) || /%ADD\d+/.test(head)) {
+        return true;
+    }
+    if (/^\s*M48\b/m.test(head)) {
+        return true;
+    }
+    if (/^T\d+C[\d.]+/m.test(head)) {
+        return true;
+    }
+    return /G0?4[^*]*\*/.test(head) && /D0?[123]\*/.test(String(text).substring(0, 65536));
+}
+
+function newResult(name) {
+    return { name: name, files: [], skipped: [], total: 0 };
+}
+
+//source: the archive a temporary file came from, reported instead of vpath
+function readCandidate(vpath, displayName, out, source) {
+    if (out.files.length >= MAX_FILES) {
+        skip(out, displayName, "toomany", "Too many files");
+        return;
+    }
+    var size = filelib.filesize(vpath);
+    if (size > MAX_FILE) {
+        skip(out, displayName, "toolarge", "File is larger than 32 MB");
+        return;
+    }
+    if (out.total + size > MAX_TOTAL) {
+        skip(out, displayName, "projecttoolarge", "Project is larger than 96 MB");
+        return;
+    }
+    var content = filelib.readFile(vpath);
+    if (content === null || content === false || !looksLikeCAM(content)) {
+        skip(out, displayName, "notcam", "Not a Gerber or drill file");
+        return;
+    }
+    out.total += size;
+    out.files.push({
+        name: displayName,
+        path: source ? source + "#" + displayName : vpath,
+        size: size,
+        mtime: filelib.mtime(source || vpath, true),
+        content: content
+    });
+}
+
+function readFolder(vpath, out, prefix) {
+    var entries = filelib.readdir(vpath, "default");
+    for (var i = 0; i < entries.length; i++) {
+        var e = entries[i];
+        if (e.IsDir) {
+            continue;
+        }
+        if (!isCandidate(e.Filename)) {
+            if (!isArchive(e.Filename)) {
+                skip(out, prefix + e.Filename, "notcam", "Not a Gerber or drill file");
+            }
+            continue;
+        }
+        readCandidate(e.Filepath, prefix + e.Filename, out);
+    }
+}
+
+//Flatten the JSON tree from listZipFileContents into [{path, size}]
+function flattenTree(node, prefix, list) {
+    if (!node || !node.children) {
+        return;
+    }
+    for (var key in node.children) {
+        var child = node.children[key];
+        var p = prefix === "" ? child.name : prefix + "/" + child.name;
+        if (child.isDir) {
+            flattenTree(child, p, list);
+        } else {
+            list.push({ path: p, size: child.size || 0 });
+        }
+    }
+}
+
+function dirOf(p) {
+    var i = p.lastIndexOf("/");
+    return i < 0 ? "" : p.substring(0, i);
+}
+
+//Remove a temporary extraction folder (deleteFile does not recurse)
+function removeTree(dir) {
+    try {
+        var files = filelib.walk(dir, "file");
+        for (var i = 0; i < files.length; i++) {
+            filelib.deleteFile(files[i]);
+        }
+        var folders = filelib.walk(dir, "folder");
+        folders.sort(function (a, b) { return b.length - a.length; });
+        for (var j = 0; j < folders.length; j++) {
+            if (filelib.fileExists(folders[j])) {
+                filelib.deleteFile(folders[j]);
+            }
+        }
+        if (filelib.fileExists(dir)) {
+            filelib.deleteFile(dir);
+        }
+    } catch (e) {
+        //Leftovers in tmp:/ are cleared by the system's tmp cleanup
+    }
+}
+
+function readArchive(vpath, out) {
+    var type = ziplib.getCompressFileType(vpath);
+    var tmpRoot = "tmp:/GerberViewer";
+    if (!filelib.fileExists(tmpRoot)) {
+        filelib.mkdir(tmpRoot);
+    }
+    var tmp = tmpRoot + "/" + Date.now() + "_" + Math.floor(Math.random() * 1000000);
+    filelib.mkdir(tmp);
+    try {
+        if (type === "zip" || type === "7z") {
+            var list = [];
+            flattenTree(JSON.parse(ziplib.listZipFileContents(vpath)), "", list);
+            //Group the wanted entries per folder: a partial extract flattens to
+            //base names, so each folder goes to its own temporary sub folder
+            var byDir = {};
+            var expanded = 0;
+            for (var i = 0; i < list.length; i++) {
+                var entry = list[i];
+                if (!isCandidate(entry.path)) {
+                    if (baseName(entry.path).charAt(0) !== "." && !/__MACOSX/i.test(entry.path)) {
+                        skip(out, entry.path, "notcam", "Not a Gerber or drill file");
+                    }
+                    continue;
+                }
+                if (entry.size > MAX_FILE) {
+                    skip(out, entry.path, "toolarge", "File is larger than 32 MB");
+                    continue;
+                }
+                expanded += entry.size;
+                if (expanded > MAX_EXPANDED) {
+                    throw { code: "expanded", msg: "The archive expands to more than 512 MB of CAM data" };
+                }
+                var d = dirOf(entry.path);
+                if (!byDir[d]) {
+                    byDir[d] = [];
+                }
+                byDir[d].push(entry.path);
+            }
+            var n = 0;
+            for (var dir in byDir) {
+                var dest = tmp + "/" + n;
+                filelib.mkdir(dest);
+                if (type === "zip") {
+                    ziplib.extractPartialZip(vpath, byDir[dir], dest);
+                } else {
+                    ziplib.extractPartial7z(vpath, byDir[dir], dest);
+                }
+                for (var k = 0; k < byDir[dir].length; k++) {
+                    var inner = byDir[dir][k];
+                    var extracted = dest + "/" + baseName(inner);
+                    if (filelib.fileExists(extracted)) {
+                        readCandidate(extracted, inner, out, vpath);
+                    }
+                }
+                n++;
+            }
+        } else if (type === "tar" || type === "tar.gz" || type === "gz") {
+            if (filelib.filesize(vpath) > MAX_TAR) {
+                throw { code: "tartoolarge", msg: "Tar archives larger than 64 MB are not supported" };
+            }
+            ziplib.extractAnyFile(vpath, tmp + "/");
+            var files = filelib.walk(tmp, "file");
+            for (var f = 0; f < files.length; f++) {
+                var rel = files[f].substring(tmp.length + 1);
+                if (isCandidate(rel)) {
+                    readCandidate(files[f], rel, out, vpath);
+                } else {
+                    skip(out, rel, "notcam", "Not a Gerber or drill file");
+                }
+            }
+        } else {
+            throw { code: "archivetype", msg: "Unsupported archive type" };
+        }
+    } finally {
+        removeTree(tmp);
+    }
+}
+
+function projectName(paths) {
+    var first = paths[0];
+    var b = baseName(first);
+    if (paths.length > 1 && !filelib.isDir(first)) {
+        b = baseName(dirOf(first));
+    }
+    return b.replace(/\.(zip|7z|tar\.gz|tgz|tar|gz)$/i, "");
+}
+
+function openPaths() {
+    var raw = param("paths", "[]");
+    var paths;
+    try {
+        paths = typeof raw === "string" ? JSON.parse(raw) : raw;
+    } catch (e) {
+        fail("Invalid path list", "badpaths");
+        return;
+    }
+    if (!paths || paths.length === 0) {
+        fail("Nothing to open", "nothing");
+        return;
+    }
+    var out = newResult(projectName(paths));
+    for (var i = 0; i < paths.length; i++) {
+        var p = String(paths[i]);
+        if (!filelib.fileExists(p)) {
+            skip(out, baseName(p), "notfound", "File not found");
+            continue;
+        }
+        try {
+            if (filelib.isDir(p)) {
+                readFolder(p, out, paths.length > 1 ? baseName(p) + "/" : "");
+            } else if (isArchive(p)) {
+                readArchive(p, out);
+            } else {
+                readCandidate(p, baseName(p), out);
+            }
+        } catch (e) {
+            if (e && e.code) {
+                skip(out, baseName(p), e.code, e.msg);
+            } else {
+                skip(out, baseName(p), "error", String(e));
+            }
+        }
+    }
+    delete out.total;
+    sendJSONResp(JSON.stringify(out));
+}
+
+function readOne() {
+    var p = String(param("path", ""));
+    if (p === "" || !filelib.fileExists(p) || filelib.isDir(p)) {
+        fail("File not found", "notfound");
+        return;
+    }
+    var out = newResult(baseName(p));
+    readCandidate(p, baseName(p), out);
+    if (out.files.length === 0) {
+        fail(out.skipped.length ? out.skipped[0].reason : "Unable to read file", out.skipped.length ? out.skipped[0].code : "readfail");
+        return;
+    }
+    sendJSONResp(JSON.stringify(out.files[0]));
+}
+
+function siblings() {
+    var p = String(param("path", ""));
+    var dir = dirOf(p);
+    if (dir === "" || !filelib.isDir(dir)) {
+        sendJSONResp(JSON.stringify([]));
+        return;
+    }
+    var found = [];
+    var entries = filelib.readdir(dir, "default");
+    for (var i = 0; i < entries.length; i++) {
+        var e = entries[i];
+        if (e.IsDir || e.Filepath === p || !isCandidate(e.Filename) || e.Filesize > MAX_FILE) {
+            continue;
+        }
+        //Only sniff the head of each file
+        var content = filelib.readFile(e.Filepath);
+        if (content && looksLikeCAM(content)) {
+            found.push({ name: e.Filename, path: e.Filepath, size: e.Filesize });
+        }
+        if (found.length >= 60) {
+            break;
+        }
+    }
+    sendJSONResp(JSON.stringify(found));
+}
+
+var opr = param("opr", "");
+if (opr === "open") {
+    openPaths();
+} else if (opr === "read") {
+    readOne();
+} else if (opr === "siblings") {
+    siblings();
+} else {
+    fail("Unknown operation", "badop");
+}

+ 1844 - 0
src/web/Gerber Viewer/css/style.css

@@ -0,0 +1,1844 @@
+/*
+    Gerber Viewer - styles
+    Light theme by default, body.dark switches every token.
+*/
+:root {
+    --bg: #f4f5f7;
+    --panel: #ffffff;
+    --panel-2: #fafafb;
+    --border: #e5e6ea;
+    --border-strong: #d4d6dc;
+    --text: #1f2328;
+    --muted: #6b7280;
+    --faint: #9ca3af;
+    --accent: #e5383b;
+    --accent-hover: #d42d30;
+    --accent-soft: #fdecec;
+    --accent-text: #c62a2d;
+    --hover: #f1f2f4;
+    --active: #e9eaee;
+    --menu: #ffffff;
+    --shadow: 0 10px 30px rgba(15, 18, 25, 0.14), 0 2px 6px rgba(15, 18, 25, 0.06);
+    --stage: #1d1e22;
+    --float: rgba(28, 29, 33, 0.88);
+    --float-border: rgba(255, 255, 255, 0.08);
+    --float-text: #e7e7ea;
+    --tip: #fdeeee;
+    --tip-border: #f8d5d5;
+    --input: #ffffff;
+    --radius: 10px;
+    --font: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, "PingFang TC", "Microsoft JhengHei", sans-serif;
+    --mono: ui-monospace, "Cascadia Mono", Consolas, "SFMono-Regular", Menlo, monospace;
+}
+
+body.dark {
+    --bg: #17181b;
+    --panel: #222327;
+    --panel-2: #26272c;
+    --border: #313338;
+    --border-strong: #3d4046;
+    --text: #e6e6e9;
+    --muted: #9ea1a8;
+    --faint: #6d7078;
+    --accent: #ef4b4f;
+    --accent-hover: #f26165;
+    --accent-soft: rgba(239, 75, 79, 0.16);
+    --accent-text: #ff8a8d;
+    --hover: #2b2c31;
+    --active: #33353b;
+    --menu: #26272c;
+    --shadow: 0 12px 34px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
+    --tip: rgba(239, 75, 79, 0.1);
+    --tip-border: rgba(239, 75, 79, 0.25);
+    --input: #1c1d20;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+html,
+body {
+    margin: 0;
+    height: 100%;
+}
+
+body {
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    background: var(--bg);
+    color: var(--text);
+    font-family: var(--font);
+    font-size: 13px;
+    -webkit-font-smoothing: antialiased;
+    user-select: none;
+}
+
+#iconsprite {
+    position: absolute;
+    width: 0;
+    height: 0;
+    overflow: hidden;
+}
+
+svg {
+    width: 18px;
+    height: 18px;
+    fill: none;
+    stroke: currentColor;
+    stroke-width: 1.8;
+    stroke-linecap: round;
+    stroke-linejoin: round;
+    flex: 0 0 auto;
+}
+
+button {
+    font: inherit;
+    color: inherit;
+}
+
+[hidden] {
+    display: none !important;
+}
+
+.muted {
+    color: var(--muted);
+}
+
+.small {
+    font-size: 12px;
+}
+
+.pad {
+    padding: 10px 14px 14px;
+}
+
+.mono {
+    font-family: var(--mono);
+}
+
+/* ===================== Menu bar ===================== */
+#menubar {
+    display: flex;
+    align-items: center;
+    height: 32px;
+    padding: 0 8px;
+    background: var(--panel);
+    border-bottom: 1px solid var(--border);
+    flex: 0 0 auto;
+    position: relative;
+    z-index: 30;
+}
+
+.mb-logo {
+    display: flex;
+    align-items: center;
+    padding: 0 8px 0 2px;
+}
+
+.mb-logo img {
+    width: 18px;
+    height: 18px;
+}
+
+#menubarItems {
+    display: flex;
+    align-items: center;
+    gap: 1px;
+}
+
+.mb-item {
+    border: 0;
+    background: transparent;
+    padding: 4px 10px;
+    border-radius: 5px;
+    cursor: default;
+    line-height: 18px;
+}
+
+.mb-item:hover,
+.mb-item.open {
+    background: var(--hover);
+}
+
+.mb-item.open {
+    background: var(--active);
+}
+
+.mb-spacer {
+    flex: 1;
+}
+
+.mb-status {
+    color: var(--muted);
+    font-size: 12px;
+    padding-right: 6px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 40%;
+}
+
+/* ===================== Menus (dropdown + context) ===================== */
+.menu {
+    position: fixed;
+    z-index: 1000;
+    min-width: 250px;
+    max-width: 380px;
+    padding: 5px;
+    background: var(--menu);
+    border: 1px solid var(--border);
+    border-radius: 9px;
+    box-shadow: var(--shadow);
+    max-height: calc(100vh - 16px);
+    overflow-y: auto;
+}
+
+.menu-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    width: 100%;
+    border: 0;
+    background: transparent;
+    text-align: left;
+    padding: 6px 10px 6px 8px;
+    border-radius: 6px;
+    cursor: default;
+    min-height: 30px;
+}
+
+.menu-item .mi-icon {
+    width: 18px;
+    height: 18px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--muted);
+}
+
+.menu-item .mi-icon svg {
+    width: 16px;
+    height: 16px;
+}
+
+.menu-item .mi-label {
+    flex: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.menu-item .mi-key {
+    padding-left: 16px;
+    color: var(--faint);
+    font-size: 12px;
+    white-space: nowrap;
+}
+
+.menu-item .mi-sub svg {
+    width: 14px;
+    height: 14px;
+    color: var(--faint);
+}
+
+.menu-item.focus,
+.menu-item:hover:not(.disabled) {
+    background: var(--accent);
+    color: #fff;
+}
+
+.menu-item.focus .mi-icon,
+.menu-item:hover:not(.disabled) .mi-icon,
+.menu-item.focus .mi-key,
+.menu-item:hover:not(.disabled) .mi-key,
+.menu-item.focus .mi-sub svg,
+.menu-item:hover:not(.disabled) .mi-sub svg {
+    color: #fff;
+}
+
+.menu-item.disabled {
+    opacity: 0.45;
+}
+
+.menu-sep {
+    height: 1px;
+    margin: 4px 6px;
+    background: var(--border);
+}
+
+.menu-header {
+    padding: 6px 10px 4px;
+    font-size: 11px;
+    font-weight: 600;
+    letter-spacing: 0.04em;
+    text-transform: uppercase;
+    color: var(--faint);
+}
+
+.menu-empty {
+    padding: 8px 10px;
+    color: var(--faint);
+}
+
+/* ===================== Layout ===================== */
+#main {
+    flex: 1;
+    display: flex;
+    min-height: 0;
+    gap: 14px;
+    padding: 12px 14px 14px;
+}
+
+#workspace {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+#toolbar {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) auto minmax(0, 0.25fr);
+    align-items: center;
+    gap: 12px;
+    min-height: 36px;
+}
+
+.tb-title {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    min-width: 0;
+}
+
+#projectName {
+    font-size: 17px;
+    font-weight: 700;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.badge {
+    flex: 0 0 auto;
+    padding: 2px 9px;
+    border-radius: 999px;
+    background: var(--accent-soft);
+    color: var(--accent-text);
+    font-size: 11.5px;
+    font-weight: 600;
+    white-space: nowrap;
+}
+
+.tb-center {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.seg,
+.tbgroup {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+}
+
+.seg button,
+.tbgroup button {
+    height: 32px;
+    min-width: 34px;
+    border: 1px solid var(--border);
+    background: var(--panel);
+    border-radius: 7px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    padding: 0 8px;
+    transition: background 0.12s, color 0.12s, border-color 0.12s;
+}
+
+.seg button {
+    font-weight: 600;
+    min-width: 36px;
+}
+
+.seg button:hover,
+.tbgroup button:hover {
+    background: var(--hover);
+}
+
+.seg button.active,
+.tbgroup button.active {
+    background: var(--accent);
+    border-color: var(--accent);
+    color: #fff;
+}
+
+.tbgroup button:disabled,
+.seg button:disabled {
+    opacity: 0.4;
+    cursor: default;
+}
+
+.tbgroup {
+    padding-left: 10px;
+    border-left: 1px solid var(--border);
+}
+
+/* ===================== Stage ===================== */
+#stage {
+    position: relative;
+    flex: 1;
+    min-height: 0;
+    border-radius: var(--radius);
+    overflow: hidden;
+    background: var(--stage);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+}
+
+#cvBase,
+#cvOverlay,
+#view3d {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+}
+
+#cvOverlay {
+    touch-action: none;
+    cursor: default;
+}
+
+#cvOverlay.tool-pan,
+#cvOverlay.panning {
+    cursor: grab;
+}
+
+#cvOverlay.panning {
+    cursor: grabbing;
+}
+
+#cvOverlay.tool-zoom {
+    cursor: zoom-in;
+}
+
+#cvOverlay.tool-measure,
+#cvOverlay.tool-origin,
+#cvOverlay.crosshair {
+    cursor: crosshair;
+}
+
+#view3d canvas {
+    display: block;
+    outline: none;
+}
+
+.floatbar {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    gap: 2px;
+    padding: 4px;
+    background: var(--float);
+    border: 1px solid var(--float-border);
+    border-radius: 9px;
+    color: var(--float-text);
+    backdrop-filter: blur(6px);
+    z-index: 5;
+}
+
+.floatbar button {
+    border: 0;
+    background: transparent;
+    color: inherit;
+    height: 28px;
+    min-width: 28px;
+    padding: 0 8px;
+    border-radius: 6px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    cursor: pointer;
+}
+
+.floatbar button:hover {
+    background: rgba(255, 255, 255, 0.1);
+}
+
+.floatbar button.active {
+    background: var(--accent);
+    color: #fff;
+}
+
+.floatbar svg {
+    width: 16px;
+    height: 16px;
+}
+
+#coordBar {
+    left: 12px;
+    bottom: 12px;
+}
+
+#coordBar .coord {
+    padding: 0 8px;
+    font-variant-numeric: tabular-nums;
+    min-width: 92px;
+    white-space: nowrap;
+    border-left: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+#coordBar .coord b {
+    font-weight: 500;
+    color: #a8aab2;
+}
+
+#coordBar .zoomsel {
+    border-left: 1px solid rgba(255, 255, 255, 0.1);
+    border-radius: 0 6px 6px 0;
+    font-variant-numeric: tabular-nums;
+    min-width: 70px;
+}
+
+#coordBar .zoomsel svg {
+    width: 13px;
+    height: 13px;
+}
+
+#view3dBar {
+    left: 50%;
+    top: 12px;
+    transform: translateX(-50%);
+}
+
+#minimap {
+    position: absolute;
+    right: 12px;
+    bottom: 12px;
+    width: 168px;
+    height: 108px;
+    border-radius: 9px;
+    background: var(--float);
+    border: 1px solid var(--float-border);
+    overflow: hidden;
+    z-index: 5;
+    cursor: pointer;
+}
+
+#cvMini {
+    width: 100%;
+    height: 100%;
+    display: block;
+}
+
+.chip {
+    position: absolute;
+    top: 12px;
+    left: 12px;
+    z-index: 5;
+    padding: 4px 10px;
+    border-radius: 999px;
+    background: var(--float);
+    border: 1px solid var(--float-border);
+    color: var(--float-text);
+    font-size: 12px;
+}
+
+.floatbox {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    z-index: 6;
+    min-width: 210px;
+    padding: 10px 12px;
+    border-radius: 10px;
+    background: var(--float);
+    border: 1px solid var(--float-border);
+    color: var(--float-text);
+    font-variant-numeric: tabular-nums;
+}
+
+.floatbox .fb-h {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-weight: 600;
+    margin-bottom: 6px;
+}
+
+.floatbox .fb-h button {
+    margin-left: auto;
+    border: 0;
+    background: transparent;
+    color: inherit;
+    cursor: pointer;
+    padding: 2px;
+    border-radius: 5px;
+    display: flex;
+}
+
+.floatbox .fb-h button:hover {
+    background: rgba(255, 255, 255, 0.1);
+}
+
+.floatbox .fb-big {
+    font-size: 20px;
+    font-weight: 600;
+    margin-bottom: 4px;
+}
+
+.floatbox .fb-row {
+    display: flex;
+    justify-content: space-between;
+    gap: 14px;
+    color: #c9cbd1;
+    font-size: 12px;
+    line-height: 19px;
+}
+
+.floatbox svg {
+    width: 15px;
+    height: 15px;
+}
+
+/* ===================== Empty state ===================== */
+#emptyState {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: auto;
+    padding: 24px;
+    z-index: 8;
+    color: #e7e7ea;
+    background: radial-gradient(ellipse at 50% 30%, rgba(255, 255, 255, 0.05), transparent 65%), var(--stage);
+}
+
+.es-inner {
+    width: min(760px, 100%);
+    text-align: center;
+    margin: auto;
+}
+
+.es-hero img {
+    width: 64px;
+    height: 64px;
+    margin-bottom: 8px;
+}
+
+.es-hero h1 {
+    margin: 0 0 6px;
+    font-size: 22px;
+    font-weight: 700;
+}
+
+.es-hero p {
+    margin: 0 0 20px;
+    color: #a8aab2;
+}
+
+.es-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+    justify-content: center;
+}
+
+.btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    height: 36px;
+    padding: 0 14px;
+    border-radius: 8px;
+    border: 1px solid var(--border-strong);
+    background: var(--panel);
+    color: var(--text);
+    cursor: pointer;
+    font-weight: 500;
+    white-space: nowrap;
+}
+
+.btn:hover {
+    background: var(--hover);
+}
+
+.btn.primary {
+    background: var(--accent);
+    border-color: var(--accent);
+    color: #fff;
+}
+
+.btn.primary:hover {
+    background: var(--accent-hover);
+}
+
+.btn.small {
+    height: 30px;
+    padding: 0 10px;
+    font-size: 12.5px;
+}
+
+.btn:disabled {
+    opacity: 0.5;
+    cursor: default;
+}
+
+#emptyState .btn:not(.primary) {
+    background: rgba(255, 255, 255, 0.06);
+    border-color: rgba(255, 255, 255, 0.14);
+    color: #e7e7ea;
+}
+
+#emptyState .btn:not(.primary):hover {
+    background: rgba(255, 255, 255, 0.12);
+}
+
+.es-drop {
+    margin-top: 14px;
+    color: #8d9098;
+    font-size: 12px;
+}
+
+.es-recent {
+    margin-top: 34px;
+    text-align: left;
+}
+
+.es-recent-h {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-weight: 600;
+    color: #c9cbd1;
+    margin-bottom: 10px;
+}
+
+.es-recent-h svg {
+    width: 16px;
+    height: 16px;
+}
+
+.es-recent-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
+    gap: 10px;
+}
+
+.rcard {
+    position: relative;
+    border-radius: 10px;
+    background: rgba(255, 255, 255, 0.05);
+    border: 1px solid rgba(255, 255, 255, 0.08);
+    overflow: hidden;
+    cursor: pointer;
+    transition: border-color 0.12s, transform 0.12s;
+    text-align: left;
+}
+
+.rcard:hover {
+    border-color: var(--accent);
+}
+
+.rcard .rthumb {
+    height: 92px;
+    background: #141518 center / contain no-repeat;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #55585f;
+}
+
+.rcard .rthumb svg {
+    width: 34px;
+    height: 34px;
+}
+
+.rcard .rbody {
+    padding: 7px 10px 9px;
+}
+
+.rcard .rname {
+    font-weight: 600;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.rcard .rmeta {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    color: #8d9098;
+    font-size: 11.5px;
+    margin-top: 2px;
+    white-space: nowrap;
+    overflow: hidden;
+}
+
+.rcard .rmeta svg {
+    width: 13px;
+    height: 13px;
+}
+
+.rcard .rdel {
+    position: absolute;
+    top: 6px;
+    right: 6px;
+    width: 24px;
+    height: 24px;
+    border-radius: 6px;
+    border: 0;
+    background: rgba(0, 0, 0, 0.55);
+    color: #fff;
+    display: none;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+}
+
+.rcard:hover .rdel {
+    display: flex;
+}
+
+.rcard .rdel svg {
+    width: 14px;
+    height: 14px;
+}
+
+#dropHint {
+    position: absolute;
+    inset: 10px;
+    border: 2px dashed var(--accent);
+    border-radius: 10px;
+    background: rgba(229, 56, 59, 0.12);
+    z-index: 20;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: none;
+    color: #fff;
+    font-size: 16px;
+    font-weight: 600;
+}
+
+#dropHint div {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px 18px;
+    border-radius: 10px;
+    background: rgba(0, 0, 0, 0.55);
+}
+
+#loading {
+    position: absolute;
+    inset: 0;
+    z-index: 25;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 14px;
+    background: rgba(20, 21, 24, 0.6);
+    color: #e7e7ea;
+}
+
+.spinner {
+    width: 34px;
+    height: 34px;
+    border-radius: 50%;
+    border: 3px solid rgba(255, 255, 255, 0.18);
+    border-top-color: var(--accent);
+    animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+    to {
+        transform: rotate(360deg);
+    }
+}
+
+/* ===================== Side panel ===================== */
+#sidepanel {
+    width: 300px;
+    flex: 0 0 300px;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+}
+
+.tabs {
+    display: flex;
+    gap: 2px;
+    border-bottom: 1px solid var(--border);
+    margin-bottom: 12px;
+    flex: 0 0 auto;
+}
+
+.tabs button {
+    flex: 1;
+    border: 0;
+    background: transparent;
+    padding: 9px 0 10px;
+    color: var(--muted);
+    font-weight: 600;
+    cursor: pointer;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -1px;
+}
+
+.tabs button:hover {
+    color: var(--text);
+}
+
+.tabs button.active {
+    color: var(--accent);
+    border-bottom-color: var(--accent);
+}
+
+.tabpane {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding-bottom: 2px;
+}
+
+.card {
+    background: var(--panel);
+    border: 1px solid var(--border);
+    border-radius: var(--radius);
+    flex: 0 0 auto;
+}
+
+.card-h {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 12px 12px 8px 14px;
+    font-weight: 600;
+}
+
+.card-h svg {
+    width: 17px;
+    height: 17px;
+    color: var(--text);
+}
+
+.card-actions {
+    margin-left: auto;
+    display: flex;
+    gap: 2px;
+}
+
+.iconbtn {
+    border: 0;
+    background: transparent;
+    width: 26px;
+    height: 26px;
+    border-radius: 6px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--muted);
+    cursor: pointer;
+}
+
+.iconbtn:hover {
+    background: var(--hover);
+    color: var(--text);
+}
+
+.iconbtn svg {
+    width: 16px;
+    height: 16px;
+}
+
+/* Layer list */
+.layerlist {
+    list-style: none;
+    margin: 0;
+    padding: 0 6px 8px;
+}
+
+.layer {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 5px 6px 5px 2px;
+    border-radius: 7px;
+    cursor: pointer;
+    position: relative;
+}
+
+.layer:hover {
+    background: var(--hover);
+}
+
+.layer.selected {
+    background: var(--accent-soft);
+}
+
+.layer.dragging {
+    opacity: 0.4;
+}
+
+.layer.drop-before::before,
+.layer.drop-after::after {
+    content: "";
+    position: absolute;
+    left: 6px;
+    right: 6px;
+    height: 2px;
+    background: var(--accent);
+    border-radius: 2px;
+}
+
+.layer.drop-before::before {
+    top: -1px;
+}
+
+.layer.drop-after::after {
+    bottom: -1px;
+}
+
+.layer .grip {
+    color: var(--faint);
+    width: 14px;
+    display: flex;
+    justify-content: center;
+    opacity: 0;
+    cursor: grab;
+}
+
+.layer:hover .grip {
+    opacity: 1;
+}
+
+.layer .grip svg {
+    width: 14px;
+    height: 14px;
+}
+
+.layer .swatch {
+    width: 22px;
+    height: 22px;
+    border-radius: 5px;
+    border: 1px solid rgba(0, 0, 0, 0.12);
+    background: var(--c);
+    cursor: pointer;
+    flex: 0 0 auto;
+    padding: 0;
+}
+
+body.dark .layer .swatch {
+    border-color: rgba(255, 255, 255, 0.15);
+}
+
+.layer .lname {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    line-height: 16px;
+}
+
+.layer .lname span {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.layer .lname small {
+    color: var(--faint);
+    font-size: 11px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.layer.hiddenlayer .lname span {
+    color: var(--muted);
+}
+
+.layer .eye {
+    color: var(--text);
+}
+
+.layer.hiddenlayer .eye {
+    color: var(--faint);
+}
+
+.layer .warnmark {
+    color: #d97706;
+    display: flex;
+}
+
+.layer .warnmark svg {
+    width: 14px;
+    height: 14px;
+}
+
+/* key / value lists */
+.kv {
+    display: grid;
+    grid-template-columns: minmax(84px, auto) 1fr;
+    gap: 9px 12px;
+    margin: 0;
+    padding: 4px 14px 14px;
+}
+
+.kv dt {
+    color: var(--muted);
+    white-space: nowrap;
+}
+
+.kv dd {
+    margin: 0;
+    min-width: 0;
+    overflow-wrap: anywhere;
+    user-select: text;
+}
+
+.kv .sub {
+    color: var(--faint);
+    font-size: 11.5px;
+}
+
+.card.tip {
+    background: var(--tip);
+    border-color: var(--tip-border);
+}
+
+.card.tip .card-h,
+.card.tip .card-h svg {
+    color: var(--accent-text);
+}
+
+.card.tip p {
+    margin: 0;
+    padding: 0 14px 14px;
+    color: var(--muted);
+    line-height: 1.5;
+}
+
+.props-h {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 12px 14px 4px;
+}
+
+.props-h .swatch-lg {
+    width: 26px;
+    height: 26px;
+    border-radius: 6px;
+    border: 1px solid rgba(0, 0, 0, 0.12);
+    background: var(--c);
+}
+
+.props-h .ph-title {
+    font-weight: 700;
+    font-size: 14px;
+}
+
+.props-h .ph-sub {
+    color: var(--muted);
+    font-size: 12px;
+}
+
+.props-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    padding: 0 14px 14px;
+}
+
+.field {
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    padding: 4px 14px 10px;
+}
+
+.field label {
+    color: var(--muted);
+    font-size: 12px;
+}
+
+.field-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+select,
+input[type=text],
+input[type=number] {
+    height: 32px;
+    border: 1px solid var(--border-strong);
+    border-radius: 7px;
+    background: var(--input);
+    color: var(--text);
+    padding: 0 8px;
+    font: inherit;
+    min-width: 0;
+}
+
+select:focus,
+input:focus {
+    outline: 2px solid var(--accent-soft);
+    border-color: var(--accent);
+}
+
+input[type=range] {
+    accent-color: var(--accent);
+    flex: 1;
+}
+
+input[type=checkbox] {
+    accent-color: var(--accent);
+    width: 16px;
+    height: 16px;
+}
+
+input[type=color] {
+    width: 34px;
+    height: 30px;
+    border: 1px solid var(--border-strong);
+    border-radius: 7px;
+    background: var(--input);
+    padding: 2px;
+    cursor: pointer;
+}
+
+/* tables */
+.tbl {
+    width: 100%;
+    border-collapse: collapse;
+    font-variant-numeric: tabular-nums;
+    user-select: text;
+}
+
+.tbl th,
+.tbl td {
+    text-align: left;
+    padding: 6px 10px;
+    border-bottom: 1px solid var(--border);
+    white-space: nowrap;
+}
+
+.tbl th {
+    position: sticky;
+    top: 0;
+    background: var(--panel-2);
+    color: var(--muted);
+    font-weight: 600;
+    font-size: 12px;
+}
+
+.tbl td.num,
+.tbl th.num {
+    text-align: right;
+}
+
+.tbl tfoot td {
+    font-weight: 600;
+}
+
+.tbl-wrap {
+    max-height: 52vh;
+    overflow: auto;
+    border: 1px solid var(--border);
+    border-radius: 8px;
+}
+
+/* ===================== Dialogs ===================== */
+.modal-back {
+    position: fixed;
+    inset: 0;
+    z-index: 900;
+    background: rgba(10, 12, 16, 0.42);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 16px;
+    animation: fade 0.12s ease-out;
+}
+
+@keyframes fade {
+    from {
+        opacity: 0;
+    }
+}
+
+.modal {
+    width: min(var(--w, 520px), 100%);
+    max-height: calc(100vh - 32px);
+    display: flex;
+    flex-direction: column;
+    background: var(--panel);
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    box-shadow: var(--shadow);
+    animation: pop 0.14s ease-out;
+}
+
+@keyframes pop {
+    from {
+        transform: scale(0.97);
+        opacity: 0;
+    }
+}
+
+.modal-h {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 14px 14px 10px 18px;
+    font-weight: 700;
+    font-size: 15px;
+}
+
+.modal-h svg {
+    color: var(--accent);
+}
+
+.modal-h .iconbtn {
+    margin-left: auto;
+}
+
+.modal-b {
+    padding: 4px 18px 16px;
+    overflow: auto;
+    min-height: 0;
+    line-height: 1.5;
+}
+
+.modal-f {
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    gap: 8px;
+    padding: 12px 18px 16px;
+    border-top: 1px solid var(--border);
+}
+
+.modal-f .left {
+    margin-right: auto;
+}
+
+.form-grid {
+    display: grid;
+    grid-template-columns: minmax(130px, 38%) 1fr;
+    gap: 10px 14px;
+    align-items: center;
+}
+
+.form-grid>label {
+    color: var(--muted);
+}
+
+.form-section {
+    font-weight: 700;
+    margin: 18px 0 10px;
+    padding-bottom: 6px;
+    border-bottom: 1px solid var(--border);
+}
+
+.form-section:first-child {
+    margin-top: 4px;
+}
+
+.settings-tabs {
+    display: flex;
+    gap: 4px;
+    margin-bottom: 14px;
+    border-bottom: 1px solid var(--border);
+}
+
+.settings-tabs button {
+    border: 0;
+    background: transparent;
+    padding: 8px 12px;
+    cursor: pointer;
+    color: var(--muted);
+    font-weight: 600;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -1px;
+}
+
+.settings-tabs button.active {
+    color: var(--accent);
+    border-bottom-color: var(--accent);
+}
+
+.swatches {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+}
+
+.swatches button {
+    width: 26px;
+    height: 26px;
+    border-radius: 6px;
+    border: 2px solid transparent;
+    background: var(--c);
+    cursor: pointer;
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
+    padding: 0;
+}
+
+.swatches button.active {
+    border-color: var(--text);
+}
+
+.chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+}
+
+.chips button {
+    border: 1px solid var(--border-strong);
+    background: var(--panel);
+    border-radius: 999px;
+    padding: 4px 10px;
+    cursor: pointer;
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.chips button .dot {
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: var(--c);
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
+}
+
+.chips button.active {
+    border-color: var(--accent);
+    background: var(--accent-soft);
+    color: var(--accent-text);
+}
+
+.source-view {
+    margin: 0;
+    padding: 10px 12px;
+    background: var(--panel-2);
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    font-family: var(--mono);
+    font-size: 12px;
+    line-height: 1.45;
+    max-height: 56vh;
+    overflow: auto;
+    white-space: pre;
+    user-select: text;
+}
+
+.keys {
+    display: grid;
+    grid-template-columns: 1fr auto;
+    gap: 6px 18px;
+}
+
+kbd {
+    display: inline-block;
+    min-width: 22px;
+    padding: 1px 6px;
+    border: 1px solid var(--border-strong);
+    border-bottom-width: 2px;
+    border-radius: 5px;
+    background: var(--panel-2);
+    font-family: var(--font);
+    font-size: 11.5px;
+    text-align: center;
+    color: var(--text);
+}
+
+/* Popover (colour picker) */
+.popover {
+    position: fixed;
+    z-index: 950;
+    width: 248px;
+    padding: 12px;
+    background: var(--menu);
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    box-shadow: var(--shadow);
+}
+
+.popover .pop-title {
+    font-weight: 600;
+    margin-bottom: 10px;
+}
+
+.popover .pop-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-top: 10px;
+}
+
+.popover .pop-row span {
+    color: var(--muted);
+    font-size: 12px;
+    min-width: 50px;
+}
+
+/* Toasts */
+#toasts {
+    position: fixed;
+    left: 50%;
+    bottom: 22px;
+    transform: translateX(-50%);
+    z-index: 1100;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    align-items: center;
+    pointer-events: none;
+}
+
+.toast {
+    pointer-events: auto;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    max-width: min(560px, calc(100vw - 32px));
+    padding: 9px 12px 9px 14px;
+    border-radius: 9px;
+    background: #24262b;
+    color: #f0f0f2;
+    box-shadow: var(--shadow);
+    animation: pop 0.14s ease-out;
+}
+
+.toast.error {
+    background: #8f1d20;
+}
+
+.toast svg {
+    width: 16px;
+    height: 16px;
+}
+
+.toast button {
+    border: 0;
+    background: rgba(255, 255, 255, 0.14);
+    color: #fff;
+    border-radius: 6px;
+    padding: 4px 10px;
+    cursor: pointer;
+    white-space: nowrap;
+}
+
+.toast button:hover {
+    background: rgba(255, 255, 255, 0.24);
+}
+
+.list-plain {
+    margin: 6px 0 0;
+    padding-left: 18px;
+}
+
+.warn-list li {
+    margin-bottom: 4px;
+}
+
+/* ===================== Focus mode & narrow layouts ===================== */
+body.zen #menubar,
+body.zen #sidepanel,
+body.zen .tb-title {
+    display: none;
+}
+
+body.zen #main {
+    padding: 8px;
+}
+
+body.panel-hidden #sidepanel {
+    display: none;
+}
+
+@media (max-width: 980px) {
+    #toolbar {
+        grid-template-columns: minmax(0, 1fr) auto;
+    }
+
+    .tb-right {
+        display: none;
+    }
+
+    .tbgroup {
+        padding-left: 6px;
+    }
+}
+
+@media (max-width: 820px) {
+    #main {
+        padding: 8px;
+        gap: 0;
+    }
+
+    #sidepanel {
+        position: fixed;
+        top: 32px;
+        right: 0;
+        bottom: 0;
+        width: min(320px, 92vw);
+        z-index: 40;
+        background: var(--bg);
+        padding: 10px;
+        box-shadow: var(--shadow);
+        border-left: 1px solid var(--border);
+    }
+
+    body:not(.panel-open) #sidepanel {
+        display: none;
+    }
+
+    #toolbar {
+        grid-template-columns: 1fr;
+        gap: 6px;
+    }
+
+    .tb-center {
+        overflow-x: auto;
+        gap: 6px;
+        padding-bottom: 2px;
+    }
+
+    .seg button,
+    .tbgroup button {
+        height: 30px;
+        min-width: 30px;
+        padding: 0 6px;
+    }
+
+    #minimap {
+        width: 120px;
+        height: 78px;
+    }
+
+    #coordBar .coord {
+        min-width: 70px;
+        padding: 0 5px;
+    }
+
+    .mb-status {
+        display: none;
+    }
+
+    .form-grid {
+        grid-template-columns: 1fr;
+        gap: 6px;
+    }
+}
+
+@media (max-width: 520px) {
+    #minimap {
+        display: none;
+    }
+
+    .mb-item {
+        padding: 4px 7px;
+    }
+}
+
+/* ===================== Manufacturing preview ===================== */
+#view3dBar .mfg-btn {
+    border-left: 1px solid rgba(255, 255, 255, 0.12);
+    border-radius: 0 6px 6px 0;
+    margin-left: 2px;
+    padding: 0 10px;
+}
+
+.mfgpanel {
+    position: absolute;
+    top: 12px;
+    right: 12px;
+    bottom: 12px;
+    width: min(400px, calc(100% - 24px));
+    z-index: 7;
+    display: flex;
+    flex-direction: column;
+    background: var(--panel);
+    color: var(--text);
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    box-shadow: var(--shadow);
+    overflow: hidden;
+}
+
+.mfg-h {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 10px 10px 14px;
+    font-weight: 700;
+    border-bottom: 1px solid var(--border);
+}
+
+.mfg-h svg {
+    color: var(--accent);
+}
+
+.mfg-switch {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-weight: 500;
+    color: var(--muted);
+}
+
+.mfg-body {
+    flex: 1;
+    overflow-y: auto;
+    padding: 6px 14px 14px;
+}
+
+.mfg-body.off .mfg-row,
+.mfg-body.off .mfg-summary {
+    opacity: 0.55;
+}
+
+.mfg-row {
+    padding: 8px 0 2px;
+}
+
+.mfg-label {
+    font-size: 12px;
+    font-weight: 600;
+    color: var(--muted);
+    margin-bottom: 6px;
+}
+
+.mfg-chips button {
+    border-radius: 6px;
+    padding: 4px 10px;
+    min-width: 44px;
+    justify-content: center;
+}
+
+.mfg-chips button:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+}
+
+.mfg-value {
+    font-variant-numeric: tabular-nums;
+}
+
+.mfg-row input[type=number] {
+    width: 120px;
+}
+
+.mfg-sep {
+    height: 1px;
+    background: var(--border);
+    margin: 10px 0 4px;
+}
+
+.mfg-grid {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.mfg-x {
+    color: var(--muted);
+}
+
+.mfg-stepper {
+    display: inline-flex;
+    align-items: center;
+    border: 1px solid var(--border-strong);
+    border-radius: 7px;
+    overflow: hidden;
+}
+
+.mfg-stepper button {
+    border: 0;
+    background: var(--panel-2);
+    width: 28px;
+    height: 28px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+}
+
+.mfg-stepper button:disabled {
+    opacity: 0.35;
+    cursor: default;
+}
+
+.mfg-stepper svg {
+    width: 14px;
+    height: 14px;
+}
+
+.mfg-stepper span {
+    min-width: 30px;
+    text-align: center;
+    font-variant-numeric: tabular-nums;
+}
+
+.mfg-summary {
+    margin-top: 4px;
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    background: var(--panel-2);
+}
+
+.mfg-summary-h {
+    font-weight: 600;
+    padding: 10px 14px 4px;
+}
+
+.mfg-notes {
+    margin: 0;
+    padding: 0 14px 12px 30px;
+    color: var(--muted);
+    font-size: 12px;
+}
+
+.mfg-notes li {
+    margin-bottom: 4px;
+}
+
+.mfg-actions {
+    padding-top: 12px;
+}
+
+#stage.mfg-open #view3d {
+    width: auto;
+    right: min(424px, 60%);
+}
+
+#stage.mfg-open #view3dBar {
+    left: calc((100% - min(424px, 60%)) / 2);
+}
+
+@media (max-width: 820px) {
+    #stage.mfg-open #view3d {
+        right: 0;
+        width: 100%;
+    }
+}

+ 30 - 0
src/web/Gerber Viewer/img/module_icon.svg

@@ -0,0 +1,30 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+    <defs>
+        <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+            <stop offset="0" stop-color="#f05a5d"/>
+            <stop offset="1" stop-color="#c4262a"/>
+        </linearGradient>
+        <linearGradient id="pcb" x1="0" y1="0" x2="0.4" y2="1">
+            <stop offset="0" stop-color="#1c8a4a"/>
+            <stop offset="1" stop-color="#0e5c30"/>
+        </linearGradient>
+    </defs>
+    <rect x="2" y="2" width="60" height="60" rx="13" fill="url(#bg)"/>
+    <!-- board -->
+    <rect x="11" y="14" width="42" height="36" rx="4" fill="url(#pcb)"/>
+    <rect x="11" y="14" width="42" height="36" rx="4" fill="none" stroke="#ffffff" stroke-opacity="0.35" stroke-width="1"/>
+    <!-- traces -->
+    <path d="M18 22h9l5 5v9M18 42h12l4-4M46 24v8l-5 5" fill="none" stroke="#e9c46a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+    <!-- pads and vias -->
+    <circle cx="18" cy="22" r="2.6" fill="#e9c46a"/>
+    <circle cx="18" cy="42" r="2.6" fill="#e9c46a"/>
+    <circle cx="46" cy="24" r="2.6" fill="#e9c46a"/>
+    <circle cx="32" cy="36" r="2.2" fill="#e9c46a"/>
+    <circle cx="41" cy="37" r="2.2" fill="#e9c46a"/>
+    <circle cx="32" cy="36" r="0.9" fill="#0e3b20"/>
+    <circle cx="41" cy="37" r="0.9" fill="#0e3b20"/>
+    <!-- chip -->
+    <rect x="37" y="40" width="10" height="6" rx="1" fill="#1d1f24"/>
+    <!-- silkscreen -->
+    <rect x="14.5" y="18.5" width="7" height="7" rx="1" fill="none" stroke="#ffffff" stroke-width="0.9" stroke-opacity="0.85"/>
+</svg>

+ 265 - 0
src/web/Gerber Viewer/index.html

@@ -0,0 +1,265 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
+    <meta name="theme-color" content="#f4f5f7">
+    <title>Gerber Viewer</title>
+    <link rel="icon" href="img/module_icon.svg">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <script src="../script/applocale.js"></script>
+    <link rel="stylesheet" href="css/style.css">
+    <script type="importmap">
+    {
+        "imports": {
+            "three": "./lib/three/three.module.min.js",
+            "three/addons/": "./lib/three/addons/"
+        }
+    }
+    </script>
+</head>
+
+<body>
+    <!-- Icon sprite: hand drawn inline SVG, no emoji (contribution rule 6) -->
+    <svg id="iconsprite" aria-hidden="true" focusable="false">
+        <defs>
+            <symbol id="ic-pointer" viewBox="0 0 24 24"><path d="M6 3.5l12.5 7.1-5.7 1.6-2.5 5.6z" /></symbol>
+            <symbol id="ic-hand" viewBox="0 0 24 24">
+                <path d="M9 11V5.5a1.5 1.5 0 0 1 3 0V11" /><path d="M12 11V4.5a1.5 1.5 0 0 1 3 0V11" />
+                <path d="M15 11V6.5a1.5 1.5 0 0 1 3 0V13" />
+                <path d="M9 11V9.5a1.5 1.5 0 0 0-3 0V15c0 3.3 2.2 6 5.5 6h1.5c3 0 5-2.4 5-5.5V13" />
+            </symbol>
+            <symbol id="ic-zoom" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5l5 5M8 10.5h5M10.5 8v5" /></symbol>
+            <symbol id="ic-zoom-out" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5l5 5M8 10.5h5" /></symbol>
+            <symbol id="ic-fit" viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" /><rect x="8.5" y="8.5" width="7" height="7" rx="1" /></symbol>
+            <symbol id="ic-rotate" viewBox="0 0 24 24"><path d="M20 12a8 8 0 1 1-2.6-5.9" /><path d="M20 4v4h-4" /></symbol>
+            <symbol id="ic-ruler" viewBox="0 0 24 24">
+                <path d="M3.5 15.5L15.5 3.5l5 5-12 12z" /><path d="M7 12l1.8 1.8M10 9l2.6 2.6M13 6l1.8 1.8" />
+            </symbol>
+            <symbol id="ic-flip" viewBox="0 0 24 24"><path d="M12 3v18" stroke-dasharray="2 2.2" /><path d="M9 7L4 12l5 5z" /><path d="M15 7l5 5-5 5z" /></symbol>
+            <symbol id="ic-layers" viewBox="0 0 24 24"><path d="M12 3.5l8.5 4.5-8.5 4.5L3.5 8z" /><path d="M3.5 12.2l8.5 4.5 8.5-4.5" /><path d="M3.5 16.2l8.5 4.5 8.5-4.5" /></symbol>
+            <symbol id="ic-expand" viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" /></symbol>
+            <symbol id="ic-panel" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" /></symbol>
+            <symbol id="ic-eye" viewBox="0 0 24 24"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z" /><circle cx="12" cy="12" r="2.8" /></symbol>
+            <symbol id="ic-eye-off" viewBox="0 0 24 24">
+                <path d="M9.9 5.7A10 10 0 0 1 12 5.5c6.4 0 10 6.5 10 6.5a17 17 0 0 1-2.6 3.4M6.4 6.9C3.6 8.6 2 12 2 12s3.6 6.5 10 6.5a9.7 9.7 0 0 0 4.9-1.3" />
+                <path d="M9.9 10a2.8 2.8 0 0 0 4 4M3 3l18 18" />
+            </symbol>
+            <symbol id="ic-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 11v5.5" /><circle cx="12" cy="7.8" r="0.6" fill="currentColor" /></symbol>
+            <symbol id="ic-bulb" viewBox="0 0 24 24"><path d="M9 17.5h6M10 21h4" /><path d="M12 3a6 6 0 0 0-3.6 10.8c.7.6 1.1 1.4 1.1 2.2v1.5h5V16c0-.8.4-1.6 1.1-2.2A6 6 0 0 0 12 3z" /></symbol>
+            <symbol id="ic-folder" viewBox="0 0 24 24"><path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 9.7v8.8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5z" /></symbol>
+            <symbol id="ic-drive" viewBox="0 0 24 24">
+                <rect x="3" y="4" width="18" height="7" rx="1.8" /><rect x="3" y="13" width="18" height="7" rx="1.8" />
+                <circle cx="7" cy="7.5" r="0.7" fill="currentColor" /><circle cx="7" cy="16.5" r="0.7" fill="currentColor" /><path d="M11 7.5h6M11 16.5h6" />
+            </symbol>
+            <symbol id="ic-laptop" viewBox="0 0 24 24"><rect x="4.5" y="5" width="15" height="10" rx="1.4" /><path d="M2.5 18.5h19l-1.5-3.5H4z" /></symbol>
+            <symbol id="ic-file" viewBox="0 0 24 24"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z" /><path d="M14 3v5h5" /></symbol>
+            <symbol id="ic-file-plus" viewBox="0 0 24 24"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z" /><path d="M14 3v5h5M12 11.5v6M9 14.5h6" /></symbol>
+            <symbol id="ic-zip" viewBox="0 0 24 24"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z" /><path d="M11 4v2M11 8v2M11 12v2" /><rect x="9.5" y="14.5" width="3" height="3.5" rx="0.8" /></symbol>
+            <symbol id="ic-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></symbol>
+            <symbol id="ic-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" /></symbol>
+            <symbol id="ic-chevron-right" viewBox="0 0 24 24"><path d="M9.5 6l6 6-6 6" /></symbol>
+            <symbol id="ic-chevron-down" viewBox="0 0 24 24"><path d="M6 9.5l6 6 6-6" /></symbol>
+            <symbol id="ic-grip" viewBox="0 0 24 24">
+                <circle cx="9" cy="6.5" r="1.2" fill="currentColor" stroke="none" /><circle cx="15" cy="6.5" r="1.2" fill="currentColor" stroke="none" />
+                <circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" /><circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
+                <circle cx="9" cy="17.5" r="1.2" fill="currentColor" stroke="none" /><circle cx="15" cy="17.5" r="1.2" fill="currentColor" stroke="none" />
+            </symbol>
+            <symbol id="ic-crosshair" viewBox="0 0 24 24"><circle cx="12" cy="12" r="7" /><path d="M12 2.5v5M12 16.5v5M2.5 12h5M16.5 12h5" /></symbol>
+            <symbol id="ic-gear" viewBox="0 0 24 24">
+                <circle cx="12" cy="12" r="3" />
+                <path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
+            </symbol>
+            <symbol id="ic-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M5.5 7l1 12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2l1-12M9 7V4.5h6V7" /></symbol>
+            <symbol id="ic-refresh" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.3-4.5L4 8.5M4 13a8 8 0 0 0 14.3 4.5l1.7-2" /><path d="M4 4v4.5h4.5M20 20v-4.5h-4.5" /></symbol>
+            <symbol id="ic-download" viewBox="0 0 24 24"><path d="M12 4v11M7.5 10.5L12 15l4.5-4.5M4.5 19.5h15" /></symbol>
+            <symbol id="ic-upload" viewBox="0 0 24 24"><path d="M12 15V4M7.5 8.5L12 4l4.5 4.5M4.5 19.5h15" /></symbol>
+            <symbol id="ic-print" viewBox="0 0 24 24"><path d="M7 8V3.5h10V8" /><rect x="3.5" y="8" width="17" height="8.5" rx="1.6" /><path d="M7 14h10v6.5H7z" /></symbol>
+            <symbol id="ic-image" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="2.2" /><circle cx="8.5" cy="10" r="1.6" /><path d="M4 17l5-4.6 4.2 3.8 3-2.6L20 17.6" /></symbol>
+            <symbol id="ic-cube" viewBox="0 0 24 24"><path d="M12 2.6l8 4.2v9.4l-8 4.2-8-4.2V6.8z" /><path d="M4 6.8l8 4.2 8-4.2M12 11v9.4" /></symbol>
+            <symbol id="ic-table" viewBox="0 0 24 24"><rect x="3" y="4.5" width="18" height="15" rx="1.8" /><path d="M3 9.5h18M3 14.5h18M9.5 4.5v15" /></symbol>
+            <symbol id="ic-keyboard" viewBox="0 0 24 24"><rect x="2.5" y="6" width="19" height="12" rx="2" /><path d="M6 9.5h.01M9 9.5h.01M12 9.5h.01M15 9.5h.01M18 9.5h.01M7.5 14.5h9" /></symbol>
+            <symbol id="ic-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8" /><circle cx="12" cy="17" r="0.6" fill="currentColor" /></symbol>
+            <symbol id="ic-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></symbol>
+            <symbol id="ic-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3.5" /><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4" /></symbol>
+            <symbol id="ic-code" viewBox="0 0 24 24"><path d="M8.5 7L3.5 12l5 5M15.5 7l5 5-5 5M13.5 4.5l-3 15" /></symbol>
+            <symbol id="ic-drill" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" /></symbol>
+            <symbol id="ic-chip" viewBox="0 0 24 24">
+                <rect x="6" y="6" width="12" height="12" rx="1.8" /><rect x="9.5" y="9.5" width="5" height="5" rx="0.6" />
+                <path d="M9 3v3M12 3v3M15 3v3M9 18v3M12 18v3M15 18v3M3 9h3M3 12h3M3 15h3M18 9h3M18 12h3M18 15h3" />
+            </symbol>
+            <symbol id="ic-board" viewBox="0 0 24 24">
+                <rect x="3" y="5" width="18" height="14" rx="1.8" /><circle cx="6.5" cy="8.5" r="1" /><circle cx="17.5" cy="15.5" r="1" />
+                <path d="M9 9h3.5l2 2v4.5M6.5 15.5H11" /><rect x="13" y="7" width="4" height="2.5" rx="0.5" />
+            </symbol>
+            <symbol id="ic-palette" viewBox="0 0 24 24">
+                <path d="M12 3a9 9 0 1 0 0 18c1.2 0 2-1 2-2s-.8-1.5-.8-2.5 1-1.8 2.2-1.8H18a3 3 0 0 0 3-3c0-4.7-4-8.7-9-8.7z" />
+                <circle cx="7.5" cy="11" r="1.1" fill="currentColor" /><circle cx="10" cy="7" r="1.1" fill="currentColor" /><circle cx="14.5" cy="7" r="1.1" fill="currentColor" />
+            </symbol>
+            <symbol id="ic-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /><path d="M12 2v2.4M12 19.6V22M2 12h2.4M19.6 12H22M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M19.1 4.9l-1.7 1.7M6.6 17.4l-1.7 1.7" /></symbol>
+            <symbol id="ic-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></symbol>
+            <symbol id="ic-minus" viewBox="0 0 24 24"><path d="M5 12h14" /></symbol>
+            <symbol id="ic-more" viewBox="0 0 24 24"><circle cx="6" cy="12" r="1.3" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" /><circle cx="18" cy="12" r="1.3" fill="currentColor" stroke="none" /></symbol>
+            <symbol id="ic-warning" viewBox="0 0 24 24"><path d="M12 3.5l9.5 16.5h-19z" /><path d="M12 10v4.5" /><circle cx="12" cy="17.3" r="0.6" fill="currentColor" /></symbol>
+            <symbol id="ic-factory" viewBox="0 0 24 24"><path d="M3 20.5V9.5l5 3v-3l5 3v-3l5 3V4h3v16.5z" /><path d="M7 16.5h2M12 16.5h2M17 16.5h2" /></symbol>
+            <symbol id="ic-show-all" viewBox="0 0 24 24"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z" /><circle cx="12" cy="12" r="2.8" fill="currentColor" /></symbol>
+        </defs>
+    </svg>
+
+    <!-- ===================== Menu bar ===================== -->
+    <nav id="menubar" role="menubar">
+        <div class="mb-logo" title="Gerber Viewer"><img src="img/module_icon.svg" alt=""></div>
+        <div id="menubarItems"></div>
+        <div class="mb-spacer"></div>
+        <div id="mbStatus" class="mb-status"></div>
+    </nav>
+
+    <div id="main">
+        <!-- ===================== Workspace ===================== -->
+        <section id="workspace">
+            <header id="toolbar">
+                <div class="tb-title">
+                    <span id="projectName" locale="app/title">Gerber Viewer</span>
+                    <span id="projectBadge" class="badge" locale="badge/none">No Project</span>
+                </div>
+                <div class="tb-center">
+                    <div class="seg" id="segDim">
+                        <button data-dim="2d" class="active" title="2D view">2D</button>
+                        <button data-dim="3d" title="3D view (3)">3D</button>
+                    </div>
+                    <div class="tbgroup" id="toolGroup">
+                        <button data-tool="select" class="active" title="Select / inspect (V)"><svg><use href="#ic-pointer" /></svg></button>
+                        <button data-tool="pan" title="Pan (H)"><svg><use href="#ic-hand" /></svg></button>
+                        <button data-tool="zoom" title="Zoom to area (Z)"><svg><use href="#ic-zoom" /></svg></button>
+                        <button data-cmd="fit" title="Zoom to fit (F)"><svg><use href="#ic-fit" /></svg></button>
+                        <button data-cmd="rotate" title="Rotate 90 degrees (R)"><svg><use href="#ic-rotate" /></svg></button>
+                    </div>
+                    <div class="tbgroup">
+                        <button data-tool="measure" title="Measure (M)"><svg><use href="#ic-ruler" /></svg></button>
+                        <button data-cmd="flip" id="btnFlip" title="View bottom side (B)"><svg><use href="#ic-flip" /></svg></button>
+                        <button data-cmd="renderMenu" id="btnRender" title="Render mode"><svg><use href="#ic-layers" /></svg></button>
+                    </div>
+                    <div class="tbgroup">
+                        <button data-cmd="togglePanel" id="btnPanel" title="Show / hide side panel"><svg><use href="#ic-panel" /></svg></button>
+                        <button data-cmd="fullscreen" id="btnFullscreen" title="Focus mode"><svg><use href="#ic-expand" /></svg></button>
+                    </div>
+                </div>
+                <div class="tb-right"></div>
+            </header>
+
+            <div id="stage">
+                <canvas id="cvBase"></canvas>
+                <canvas id="cvOverlay"></canvas>
+                <div id="view3d" hidden></div>
+
+                <div id="modeChip" class="chip" hidden></div>
+                <div id="measureBox" class="floatbox" hidden></div>
+
+                <div id="view3dBar" class="floatbar" hidden>
+                    <button data-view3d="iso" locale="view3d/iso">Iso</button>
+                    <button data-view3d="top" locale="view3d/top">Top</button>
+                    <button data-view3d="bottom" locale="view3d/bottom">Bottom</button>
+                    <button data-view3d="front" locale="view3d/front">Front</button>
+                    <button data-view3d-mfg data-cmd="mfgToggle" class="mfg-btn"><svg><use href="#ic-factory" /></svg><span locale="view3d/mfg">Manufacturing</span></button>
+                </div>
+
+                <div id="mfgPanel" class="mfgpanel" hidden></div>
+
+                <div id="coordBar" class="floatbar">
+                    <button id="btnCrosshair" title="Crosshair cursor"><svg><use href="#ic-crosshair" /></svg></button>
+                    <span class="coord"><b>X:</b> <span id="coordX">-</span></span>
+                    <span class="coord"><b>Y:</b> <span id="coordY">-</span></span>
+                    <button id="btnZoom" class="zoomsel" title="Zoom">1:1 <svg><use href="#ic-chevron-down" /></svg></button>
+                </div>
+
+                <div id="minimap" title="Click or drag to move the view">
+                    <canvas id="cvMini"></canvas>
+                </div>
+
+                <div id="emptyState">
+                    <div class="es-inner">
+                        <div class="es-hero">
+                            <img src="img/module_icon.svg" alt="">
+                            <h1 locale="app/title">Gerber Viewer</h1>
+                            <p locale="empty/intro">Open a PCB fabrication package (zip) or individual Gerber and drill files.</p>
+                        </div>
+                        <div class="es-actions">
+                            <button class="btn primary" data-cmd="openAroz"><svg><use href="#ic-drive" /></svg><span locale="empty/openAroz">Open from ArozOS</span></button>
+                            <button class="btn" data-cmd="openLocal"><svg><use href="#ic-laptop" /></svg><span locale="empty/openLocal">Open from This Device</span></button>
+                            <button class="btn" data-cmd="openLocalFolder"><svg><use href="#ic-folder" /></svg><span locale="empty/openFolder">Open Folder</span></button>
+                        </div>
+                        <div class="es-drop" locale="empty/drop">or drop a .zip, a folder or Gerber files anywhere in this window</div>
+                        <div class="es-recent" id="esRecent" hidden>
+                            <div class="es-recent-h"><svg><use href="#ic-clock" /></svg><span locale="empty/recent">Recent projects</span></div>
+                            <div class="es-recent-grid" id="esRecentGrid"></div>
+                        </div>
+                    </div>
+                </div>
+
+                <div id="dropHint" hidden>
+                    <div><svg><use href="#ic-upload" /></svg><span locale="drop/hint">Drop to open</span></div>
+                </div>
+
+                <div id="loading" hidden>
+                    <div class="spinner"></div>
+                    <div id="loadingText" locale="loading/default">Loading...</div>
+                </div>
+            </div>
+        </section>
+
+        <!-- ===================== Side panel ===================== -->
+        <aside id="sidepanel">
+            <div class="tabs" role="tablist">
+                <button data-tab="layers" class="active" role="tab" locale="tab/layers">Layers</button>
+                <button data-tab="props" role="tab" locale="tab/props">Properties</button>
+            </div>
+            <div class="tabpane" id="tabLayers">
+                <div class="card">
+                    <div class="card-h">
+                        <svg><use href="#ic-eye" /></svg><span locale="card/visibility">Layer Visibility</span>
+                        <div class="card-actions">
+                            <button class="iconbtn" data-cmd="showAll" title="Show all layers"><svg><use href="#ic-eye" /></svg></button>
+                            <button class="iconbtn" data-cmd="hideAll" title="Hide all layers"><svg><use href="#ic-eye-off" /></svg></button>
+                            <button class="iconbtn" data-cmd="layerMenu" title="More"><svg><use href="#ic-more" /></svg></button>
+                        </div>
+                    </div>
+                    <ul id="layerList" class="layerlist"></ul>
+                    <div id="layerEmpty" class="muted small pad" locale="layers/empty">No layers loaded.</div>
+                </div>
+                <div class="card">
+                    <div class="card-h"><svg><use href="#ic-info" /></svg><span locale="card/fileInfo">File Information</span></div>
+                    <dl id="fileInfo" class="kv"></dl>
+                </div>
+                <div class="card tip" id="tipCard">
+                    <div class="card-h"><svg><use href="#ic-bulb" /></svg><span locale="card/tip">Tip</span>
+                        <div class="card-actions"><button class="iconbtn" id="tipNext" title="Next tip"><svg><use href="#ic-chevron-right" /></svg></button></div>
+                    </div>
+                    <p id="tipText"></p>
+                </div>
+            </div>
+            <div class="tabpane" id="tabProps" hidden></div>
+        </aside>
+    </div>
+
+    <div id="toasts"></div>
+    <input type="file" id="fileInput" multiple hidden>
+    <input type="file" id="folderInput" webkitdirectory multiple hidden>
+
+    <script src="js/geom.js"></script>
+    <script src="js/gerber.js"></script>
+    <script src="js/excellon.js"></script>
+    <script src="js/layers.js"></script>
+    <script src="js/zip.js"></script>
+    <script src="js/board.js"></script>
+    <script src="js/render.js"></script>
+    <script src="js/ui.js"></script>
+    <script src="js/store.js"></script>
+    <script src="js/exporter.js"></script>
+    <script src="js/app.js"></script>
+    <script src="js/dialogs.js"></script>
+    <script src="js/mfg.js"></script>
+</body>
+
+</html>

+ 48 - 0
src/web/Gerber Viewer/init.agi

@@ -0,0 +1,48 @@
+/*
+    Gerber Viewer
+
+    View PCB fabrication data: Gerber RS-274X / X2 layers and Excellon drill
+    files, loaded as a zip, a folder or individual files from ArozOS or from
+    the local device. Layer view, realistic board preview and a 3D board.
+
+    Backend work (archive extraction, settings, recent projects, saving
+    exports) is done by the AGI scripts in ./backend.
+*/
+
+var moduleLaunchInfo = {
+    Name: "Gerber Viewer",
+    Desc: "View Gerber and Excellon PCB fabrication files in 2D and 3D",
+    Group: "Engineering",
+    IconPath: "Gerber Viewer/img/module_icon.svg",
+    Version: "1.0",
+    StartDir: "Gerber Viewer/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Gerber Viewer/index.html",
+    InitFWSize: [1220, 780],
+    SupportEmb: true,
+    LaunchEmb: "Gerber Viewer/index.html",
+    InitEmbSize: [1220, 780],
+    SupportedExt: [
+        ".gbr",
+        ".ger",
+        ".gtl",
+        ".gbl",
+        ".gto",
+        ".gbo",
+        ".gts",
+        ".gbs",
+        ".gtp",
+        ".gbp",
+        ".gko",
+        ".gm1",
+        ".gml",
+        ".gdl",
+        ".pho",
+        ".art",
+        ".drl",
+        ".xln",
+        ".exc"
+    ]
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 3416 - 0
src/web/Gerber Viewer/js/app.js

@@ -0,0 +1,3416 @@
+/*
+    Gerber Viewer - application controller
+
+    Owns the project / settings state, loading (ArozOS through AGI, the local
+    device, drag and drop), the 2D viewer interaction, the side panel and the
+    menu bar. Dialogs live in dialogs.js and register their commands on
+    GV.app.cmd.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom, UI = GV.ui, R = GV.render, L = GV.layers, S = GV.store;
+    var icon = UI.icon, esc = UI.esc, T = UI.t;
+    function $(id) { return document.getElementById(id); }
+
+    /* ==================================================================
+        Settings and look presets
+    ================================================================== */
+    var DEFAULTS = {
+        units: 'mm',            //mm | in | mil
+        decimals: 3,
+        theme: 'system',        //system | light | dark
+        canvasBg: '#1d1e22',
+        grid: false,
+        minimap: true,
+        crosshair: false,
+        snap: true,
+        restoreLast: false,
+        tips: true,
+        defaultOpacity: 0.9,
+        mask: 'green',
+        silk: 'auto',
+        finish: 'enig',
+        thickness: 1.6,
+        layerColors: {},
+        serverArchives: true
+    };
+
+    var MASKS = {
+        green: { label: 'Green', color: '#0e6b34', alpha: 0.86, silk: '#f2f2f2' },
+        red: { label: 'Red', color: '#a3121c', alpha: 0.88, silk: '#f2f2f2' },
+        blue: { label: 'Blue', color: '#143c94', alpha: 0.88, silk: '#f2f2f2' },
+        black: { label: 'Black', color: '#141414', alpha: 0.94, silk: '#f2f2f2' },
+        matte: { label: 'Matte black', color: '#232323', alpha: 0.97, silk: '#e6e6e6' },
+        white: { label: 'White', color: '#ececec', alpha: 0.95, silk: '#141414' },
+        purple: { label: 'Purple', color: '#4d1f78', alpha: 0.9, silk: '#f2f2f2' },
+        yellow: { label: 'Yellow', color: '#c9a300', alpha: 0.9, silk: '#141414' },
+        none: { label: 'None (bare board)', color: null, alpha: 0, silk: '#f2f2f2' }
+    };
+    var SILKS = { auto: 'Automatic', white: '#f2f2f2', black: '#141414', yellow: '#f2d21b' };
+    var FINISHES = {
+        enig: { label: 'ENIG (gold)', color: '#d9b45c' },
+        hasl: { label: 'HASL (silver)', color: '#c8ccd1' },
+        osp: { label: 'OSP / bare copper', color: '#c98048' }
+    };
+    var BACKGROUNDS = [
+        { label: 'Graphite', color: '#1d1e22' },
+        { label: 'Black', color: '#000000' },
+        { label: 'Midnight', color: '#0f1726' },
+        { label: 'Slate', color: '#2a2d33' },
+        { label: 'Paper', color: '#f3f3f1' }
+    ];
+    var PALETTE = ['#e5484d', '#f97316', '#f2b84b', '#eab308', '#84cc16', '#3fae6a', '#14b8a6', '#22d3ee',
+        '#3b74e8', '#6366f1', '#9b5de5', '#ec4899', '#f4f4f5', '#a1a1aa', '#c98048', '#d9b45c'];
+
+    var TIPS = [
+        'Drag layers in the list to change the drawing order. The top of the list is drawn on top.',
+        'Click a colour swatch to change a layer\'s colour and opacity.',
+        'Press M to measure. Measurements snap to pads, vias and line end points.',
+        'Press P for the realistic board preview and B to look at the bottom side.',
+        'Right-click a layer for more options, such as showing only that layer.',
+        'Tools > Drill Table lists every drill size with its hole count and plating.',
+        'Hold Space and drag to pan with any tool. Double-click empty space to zoom to fit.',
+        'Press 3 to see the board in 3D. Board thickness and colours are in Preferences.',
+        'Recent projects remember their layer colours, order and visibility.',
+        'Click a pad or trace to see its aperture, size and position under Properties.'
+    ];
+
+    /* ==================================================================
+        State
+    ================================================================== */
+    var state = {
+        settings: Object.assign({}, DEFAULTS),
+        project: null,
+        selectedLayer: null,    //layer id shown in Properties
+        selection: null,        //{layer, item}
+        tab: 'layers',
+        tool: 'select',
+        dim: '2d',
+        mode: 'layers',         //layers | realistic
+        origin: [0, 0],
+        measure: null,          //{a, b, cur}
+        cursor: null,           //board coordinates under the mouse
+        snapPt: null,
+        zoomRect: null,
+        marker: null,
+        tipIndex: Math.floor(Math.random() * TIPS.length),
+        recentKey: null
+    };
+
+    var viewer = null;
+    var view3d = null;
+    var view3dLoading = null;
+    var nextLayerId = 1;
+    var cmd = {};
+
+    /* ==================================================================
+        Formatting helpers
+    ================================================================== */
+    function unitInfo() {
+        var u = state.settings.units;
+        if (u === 'in') {
+            return { f: 1 / 25.4, label: 'in', dec: Math.max(3, state.settings.decimals + 1) };
+        }
+        if (u === 'mil') {
+            return { f: 1 / 0.0254, label: 'mil', dec: Math.max(0, state.settings.decimals - 2) };
+        }
+        return { f: 1, label: 'mm', dec: state.settings.decimals };
+    }
+
+    function fmtLen(mm, noUnit) {
+        var u = unitInfo();
+        var v = (mm * u.f).toFixed(u.dec);
+        return noUnit ? v : v + ' ' + u.label;
+    }
+
+    function fmtXY(x, y) {
+        return fmtLen(x - state.origin[0], true) + ', ' + fmtLen(y - state.origin[1]);
+    }
+
+    function fmtSize(w, h) {
+        var u = unitInfo();
+        return (w * u.f).toFixed(Math.min(u.dec, 2)) + ' × ' + (h * u.f).toFixed(Math.min(u.dec, 2)) + ' ' + u.label;
+    }
+
+    function fmtBytes(b) {
+        if (b < 1024) {
+            return b + ' B';
+        }
+        if (b < 1024 * 1024) {
+            return (b / 1024).toFixed(1) + ' KB';
+        }
+        return (b / 1024 / 1024).toFixed(2) + ' MB';
+    }
+
+    function fmtDate(s) {
+        if (!s) {
+            return '';
+        }
+        if (typeof s === 'number') {
+            var d = new Date(s < 1e12 ? s * 1000 : s);
+            if (isNaN(d)) {
+                return '';
+            }
+            var p = function (v) { return (v < 10 ? '0' : '') + v; };
+            return d.getFullYear() + '/' + p(d.getMonth() + 1) + '/' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+        }
+        var m = /(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})(?:\D+(\d{1,2}):(\d{2}))?/.exec(String(s));
+        if (!m) {
+            return String(s);
+        }
+        var pad = function (v) { return (String(v).length < 2 ? '0' : '') + v; };
+        return m[1] + '/' + pad(m[2]) + '/' + pad(m[3]) + (m[4] ? ' ' + pad(m[4]) + ':' + m[5] : '');
+    }
+
+    function relTime(t) {
+        var d = (Date.now() - t) / 1000;
+        if (d < 60) {
+            return T('time/now', 'just now');
+        }
+        if (d < 3600) {
+            return T('time/min', '{n} min ago', { n: Math.floor(d / 60) });
+        }
+        if (d < 86400) {
+            return T('time/hour', '{n} h ago', { n: Math.floor(d / 3600) });
+        }
+        if (d < 86400 * 30) {
+            return T('time/day', '{n} d ago', { n: Math.floor(d / 86400) });
+        }
+        return fmtDate(t).slice(0, 10);
+    }
+
+    function nextTick() {
+        return new Promise(function (r) { setTimeout(r, 0); });
+    }
+
+    function hashStr(s) {
+        var h1 = 0x811c9dc5;
+        for (var i = 0; i < s.length; i++) {
+            h1 ^= s.charCodeAt(i);
+            h1 = Math.imul(h1, 0x01000193);
+        }
+        return (h1 >>> 0).toString(36);
+    }
+
+    /* ==================================================================
+        Look (realistic colours) and theme
+    ================================================================== */
+    function look() {
+        var s = state.settings;
+        var mask = MASKS[s.mask] || MASKS.green;
+        var silk = s.silk === 'auto' || !SILKS[s.silk] ? mask.silk : SILKS[s.silk];
+        return {
+            substrate: '#3c3923',
+            copper: '#b8743a',
+            mask: mask.color,
+            maskAlpha: mask.alpha,
+            silk: silk,
+            finish: (FINISHES[s.finish] || FINISHES.enig).color,
+            edge: '#cbb989'
+        };
+    }
+
+    function isDarkColor(c) {
+        var m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(c || '');
+        if (!m) {
+            return true;
+        }
+        var l = 0.299 * parseInt(m[1], 16) + 0.587 * parseInt(m[2], 16) + 0.114 * parseInt(m[3], 16);
+        return l < 140;
+    }
+
+    var systemTheme = 'light';
+    function applyTheme() {
+        var t = state.settings.theme === 'system' ? systemTheme : state.settings.theme;
+        document.body.classList.toggle('dark', t === 'dark');
+        document.querySelector('meta[name=theme-color]').setAttribute('content', t === 'dark' ? '#17181b' : '#f4f5f7');
+        try {
+            ao_module_setWindowTheme(t === 'dark' ? 'dark' : 'white');
+        } catch (e) {
+            //Not running inside the ArozOS desktop
+        }
+        document.documentElement.style.setProperty('--stage', state.settings.canvasBg);
+    }
+
+    function initTheme() {
+        try {
+            ao_module_getSystemThemeColor(function (pref) {
+                //The preference is stored as "darkTheme" / "whiteTheme"
+                systemTheme = (pref === 'darkTheme' || pref === 'dark') ? 'dark' : 'light';
+                applyTheme();
+            });
+            ao_module_onThemeChanged(function (theme) {
+                systemTheme = theme === 'dark' ? 'dark' : 'light';
+                applyTheme();
+            });
+        } catch (e) {
+            //Standalone page
+        }
+        if (window.matchMedia && !(typeof ao_module_virtualDesktop !== 'undefined' && ao_module_virtualDesktop)) {
+            var mq = window.matchMedia('(prefers-color-scheme: dark)');
+            if (mq.matches) {
+                systemTheme = 'dark';
+            }
+        }
+        applyTheme();
+    }
+
+    /* ==================================================================
+        Layers
+    ================================================================== */
+    function defaultColor(type) {
+        var c = state.settings.layerColors && state.settings.layerColors[type];
+        return c || L.info(type).color;
+    }
+
+    function defaultVisible(type) {
+        return /copper|top-silk|outline|drill/.test(type);
+    }
+
+    function defaultOpacity(type) {
+        var o = state.settings.defaultOpacity;
+        if (/mask/.test(type)) {
+            return Math.min(o, 0.55);
+        }
+        if (/paste/.test(type)) {
+            return Math.min(o, 0.7);
+        }
+        if (/silk|outline|drill/.test(type)) {
+            return 1;
+        }
+        return o;
+    }
+
+    function sideOf(layer) {
+        return L.info(layer.type).side;
+    }
+
+    function stackOrder(layer) {
+        var o = L.info(layer.type).order;
+        if (layer.type === 'inner-copper') {
+            o -= (layer.index || 0) * 0.01;
+        }
+        return o;
+    }
+
+    function sortDefault(layers) {
+        return layers.slice().sort(function (a, b) {
+            return stackOrder(b) - stackOrder(a) || a.name.localeCompare(b.name);
+        });
+    }
+
+    function makeLayer(f, image, info) {
+        var layer = {
+            id: nextLayerId++,
+            name: f.name,
+            path: f.path || '',
+            text: f.text,
+            size: f.size || f.text.length,
+            mtime: f.mtime || 0,
+            image: image,
+            type: info.type,
+            index: info.index,
+            plated: info.plated,
+            via: info.via,
+            label: '',
+            color: '',
+            opacity: 1,
+            visible: true
+        };
+        applyType(layer, info.type, true);
+        return layer;
+    }
+
+    //Localised layer name; the English names live in js/layers.js
+    function layerLabel(info) {
+        if (info.type === 'inner-copper' && info.index) {
+            return T('type/inner-n', 'Inner Copper {n}', { n: info.index });
+        }
+        if (info.type === 'drill') {
+            if (info.via) {
+                return T('type/drill-via', 'Drill (Vias)');
+            }
+            if (info.plated === true) {
+                return T('type/drill-pth', 'Drill (PTH)');
+            }
+            if (info.plated === false) {
+                return T('type/drill-npth', 'Drill (NPTH)');
+            }
+        }
+        return T('type/' + info.type, L.info(info.type).label);
+    }
+
+    //Parser warnings and skip reasons carry codes so they can be translated
+    function warningTexts(image) {
+        var notes = image.notes || [];
+        return image.warnings.map(function (w, i) {
+            var n = notes[i];
+            return n ? T('warn/' + n.code, w, n.vars) : w;
+        });
+    }
+
+    function reasonText(s) {
+        //"error" reasons are already a message (an exception text)
+        return s.code && s.code !== 'error' ? T('skip/' + s.code, s.reason, s.vars) : s.reason;
+    }
+
+    function errText(e) {
+        if (!e) {
+            return '';
+        }
+        return e.code && e.vars ? T('error/' + e.code, e.message, e.vars) : (e.message || String(e));
+    }
+
+    function applyType(layer, type, initial) {
+        layer.type = type;
+        layer.label = layerLabel({ type: type, index: layer.index, plated: layer.plated, via: layer.via });
+        layer.color = defaultColor(type);
+        if (initial) {
+            layer.visible = defaultVisible(type);
+            layer.opacity = defaultOpacity(type);
+        }
+    }
+
+    function layerById(id) {
+        if (!state.project) {
+            return null;
+        }
+        for (var i = 0; i < state.project.layers.length; i++) {
+            if (state.project.layers[i].id === id) {
+                return state.project.layers[i];
+            }
+        }
+        return null;
+    }
+
+    //Draw order, bottom first. Viewing the bottom side reverses the stack of
+    //side specific layers while outline / drill / document keep their place.
+    function drawOrder() {
+        if (!state.project) {
+            return [];
+        }
+        var list = state.project.layers.slice().reverse();
+        if (!viewer.view.flip) {
+            return list;
+        }
+        var idx = [], sided = [];
+        list.forEach(function (l, i) {
+            if (sideOf(l) !== 'all') {
+                idx.push(i);
+                sided.push(l);
+            }
+        });
+        sided.reverse();
+        idx.forEach(function (p, k) { list[p] = sided[k]; });
+        return list;
+    }
+
+    function projectBounds(visibleOnly) {
+        var p = state.project;
+        if (!p) {
+            return null;
+        }
+        if (p.board && p.board.source === 'outline') {
+            var bb = p.board.bounds.slice();
+            var pad = Math.max(bb[2] - bb[0], bb[3] - bb[1]) * 0.02;
+            return [bb[0] - pad, bb[1] - pad, bb[2] + pad, bb[3] + pad];
+        }
+        var b = G.emptyBounds();
+        p.layers.forEach(function (l) {
+            if ((!visibleOnly || l.visible) && l.image && G.boundsValid(l.image.bounds)) {
+                G.unionBounds(b, l.image.bounds);
+            }
+        });
+        return G.boundsValid(b) ? b : (p.board && G.boundsValid(p.board.bounds) ? p.board.bounds : null);
+    }
+
+    function layersChanged(opts) {
+        opts = opts || {};
+        if (opts.rebuildBoard && state.project) {
+            state.project.board = GV.board.build(state.project.layers);
+            delete state.project.board._paths;
+        }
+        state.miniCache = null;
+        viewer.requestRender();
+        scheduleMinimap();
+        renderLayerList();
+        renderFileInfo();
+        if (state.tab === 'props') {
+            renderProps();
+        }
+        if (state.dim === '3d') {
+            schedule3D();
+        }
+        saveStateSoon();
+    }
+
+    /* ==================================================================
+        Loading
+    ================================================================== */
+    function showLoading(text) {
+        $('loading').hidden = false;
+        $('loadingText').textContent = text || T('loading/default', 'Loading...');
+    }
+
+    function hideLoading() {
+        $('loading').hidden = true;
+    }
+
+    function platedHint(name) {
+        var n = name.toLowerCase();
+        if (/npth|non[_ -]?plated|unplated/.test(n)) {
+            return false;
+        }
+        if (/pth|plated|via/.test(n)) {
+            return true;
+        }
+        return null;
+    }
+
+    //"Gerber_<project>_PCB_<board>_<date>.zip" (EasyEDA) -> "<board>"
+    function cleanName(name) {
+        var n = String(name || '').replace(/\.(zip|7z|tar\.gz|tgz|tar|gz)$/i, '');
+        n = n.replace(/^gerbers?[_ -]+/i, '').replace(/[_ -]+\d{4}-\d{2}-\d{2}([_ -]\d{2}-?\d{2}(-?\d{2})?)?$/, '');
+        n = n.replace(/[_ -]+(gerbers?|fab(rication)?|cam|output)$/i, '');
+        var pcb = /^.+?_PCB_(.+)$/i.exec(n);
+        if (pcb) {
+            n = pcb[1];
+        }
+        return n || String(name || T('project/untitled', 'Untitled project'));
+    }
+
+    //Parse files into layers. files: [{name, text, path, size, mtime}]
+    async function parseFiles(files, skipped) {
+        var layers = [];
+        for (var i = 0; i < files.length; i++) {
+            var f = files[i];
+            showLoading(T('loading/reading', 'Reading {name} ({i} of {n})', { name: L.base(f.name), i: i + 1, n: files.length }));
+            await nextTick();
+            var kind = L.sniff(f.text);
+            if (!kind) {
+                skipped.push({ name: f.name, code: 'notcam', reason: 'Not a Gerber or drill file' });
+                continue;
+            }
+            var image;
+            try {
+                image = kind === 'drill' ? GV.excellon.parse(f.text, { plated: platedHint(f.name) }) : GV.gerber.parse(f.text);
+            } catch (e) {
+                console.error(e);
+                skipped.push({ name: f.name, code: 'parse', reason: 'Could not be parsed: ' + e.message, vars: { error: e.message } });
+                continue;
+            }
+            var info = L.identify(f.name, f.text, image);
+            layers.push(makeLayer(f, image, info));
+        }
+        return layers;
+    }
+
+    /*
+        meta: {name, source: 'aroz'|'local', kind, paths, key, cacheId, skipped, add}
+    */
+    async function openFiles(files, meta) {
+        var skipped = (meta.skipped || []).slice();
+        var layers;
+        try {
+            layers = await parseFiles(files, skipped);
+        } finally {
+            hideLoading();
+        }
+        if (!layers.length) {
+            reportSkipped(skipped, T('skip/none', 'No Gerber or drill files were found.'));
+            return false;
+        }
+        if (meta.add && state.project) {
+            var p = state.project;
+            var existing = new Set(p.layers.map(function (l) { return l.name; }));
+            layers.forEach(function (l) {
+                if (existing.has(l.name)) {
+                    l.name = l.name + ' (' + l.id + ')';
+                }
+            });
+            p.layers = sortDefault(p.layers.concat(layers));
+            p.fileCount += files.length;
+            p.skipped = p.skipped.concat(skipped);
+            if (meta.paths) {
+                p.paths = (p.paths || []).concat(meta.paths);
+            }
+            p.added = true;
+            layersChanged({ rebuildBoard: true });
+            UI.toast(layers.length === 1 ? T('toast/addedOne', 'Added 1 layer') : T('toast/added', 'Added {n} layers', { n: layers.length }), { icon: 'check' });
+            if (skipped.length) {
+                reportSkipped(skipped);
+            }
+            updateRecentAfterAdd();
+            return true;
+        }
+
+        var project = {
+            name: meta.name || T('project/untitled', 'Untitled project'),
+            source: meta.source,
+            kind: meta.kind || 'files',
+            paths: meta.paths || [],
+            key: meta.key,
+            cacheId: meta.cacheId || '',
+            layers: sortDefault(layers),
+            skipped: skipped,
+            fileCount: files.length + (meta.skipped || []).length,
+            loadedAt: Date.now()
+        };
+        var created = layers.map(function (l) { return l.image.created; }).filter(Boolean);
+        project.created = created.length ? created[0] : (files[0] && files[0].mtime ? files[0].mtime : '');
+        var gens = layers.map(function (l) { return l.image.generator; }).filter(Boolean);
+        project.generator = gens.length ? gens[0] : '';
+        project.board = GV.board.build(project.layers);
+
+        var saved = S.recent.filter(function (e) { return e.key === project.key; })[0];
+        setProject(project, saved && saved.state);
+        if (skipped.length && skipped.some(function (s) { return s.code ? s.code !== 'notcam' : !/Not a Gerber/.test(s.reason); })) {
+            reportSkipped(skipped);
+        }
+        addRecent(project);
+        return true;
+    }
+
+    function reportSkipped(skipped, title) {
+        if (!skipped.length) {
+            if (title) {
+                UI.toast(title, { type: 'error' });
+            }
+            return;
+        }
+        UI.dialog({
+            title: title || T('skip/title', 'Some files were skipped'),
+            icon: 'warning',
+            width: 520,
+            body: '<ul class="list-plain warn-list">' + skipped.map(function (s) {
+                return '<li><b>' + esc(s.name) + '</b> <span class="muted">' + esc(reasonText(s)) + '</span></li>';
+            }).join('') + '</ul>',
+            buttons: [{ label: T('common/ok', 'OK'), primary: true }]
+        });
+    }
+
+    /* ---------------- From ArozOS (AGI) ---------------- */
+    function arozAvailable() {
+        return S.online;
+    }
+
+    async function openFromAroz(paths, opts) {
+        opts = opts || {};
+        if (!paths || !paths.length) {
+            return;
+        }
+        showLoading(T('loading/aroz', 'Reading from ArozOS...'));
+        var res;
+        try {
+            res = await S.agi('project.agi', { opr: 'open', paths: JSON.stringify(paths) });
+        } catch (e) {
+            hideLoading();
+            //Fall back to fetching the raw files and unpacking them here
+            try {
+                res = await fetchViaMedia(paths);
+            } catch (e2) {
+                UI.toast(T('toast/openFailed', 'Unable to open: {error}', { error: errText(e) }), { type: 'error' });
+                return;
+            }
+        }
+        var files = (res.files || []).map(function (f) {
+            return { name: f.name, text: f.content, path: f.path, size: f.size, mtime: f.mtime };
+        });
+        if (!files.length) {
+            hideLoading();
+            reportSkipped(res.skipped || [], T('skip/none', 'No Gerber or drill files were found.'));
+            return;
+        }
+        var kind = paths.length === 1 ? (/\.(zip|7z|tar|tgz|gz)$/i.test(paths[0]) ? 'archive' : (files.length > 1 ? 'folder' : 'file')) : 'files';
+        var ok = await openFiles(files, {
+            name: kind === 'file' ? L.base(paths[0]) : cleanName(res.name || L.base(paths[0])),
+            source: 'aroz',
+            kind: kind,
+            paths: paths,
+            key: 'aroz:' + paths.slice().sort().join('|'),
+            skipped: res.skipped || [],
+            add: opts.add
+        });
+        if (ok && !opts.add && paths.length === 1 && kind === 'file') {
+            offerSiblings(paths[0]);
+        }
+    }
+
+    //Without the AGI backend, read the files through the media endpoint
+    async function fetchViaMedia(paths) {
+        var out = { name: L.base(paths[0]), files: [], skipped: [] };
+        for (var i = 0; i < paths.length; i++) {
+            var p = paths[i];
+            var r = await fetch(ao_root + 'media?file=' + encodeURIComponent(p));
+            if (!r.ok) {
+                throw new Error(T('error/read', 'Cannot read {name}', { name: L.base(p) }));
+            }
+            var buf = new Uint8Array(await r.arrayBuffer());
+            if (GV.zip.isZip(buf)) {
+                var got = await unzipToFiles(buf, out.skipped);
+                got.forEach(function (f) { f.path = p + '#' + f.name; });
+                out.files = out.files.concat(got.map(function (f) { return { name: f.name, content: f.text, path: f.path, size: f.size }; }));
+            } else {
+                out.files.push({ name: L.base(p), content: new TextDecoder().decode(buf), path: p, size: buf.length });
+            }
+        }
+        return out;
+    }
+
+    async function offerSiblings(path) {
+        try {
+            var list = await S.agi('project.agi', { opr: 'siblings', path: path });
+            if (list && list.length) {
+                UI.toast(list.length === 1 ? T('toast/sibling', '1 more Gerber / drill file is in this folder.') :
+                    T('toast/siblings', '{n} more Gerber / drill files are in this folder.', { n: list.length }), {
+                    icon: 'layers',
+                    action: {
+                        label: T('toast/loadAll', 'Load all'),
+                        fn: function () {
+                            //Open the folder itself so the project is named after it
+                            openFromAroz([path.slice(0, path.lastIndexOf('/'))]);
+                        }
+                    }
+                });
+            }
+        } catch (e) {
+            //Not important
+        }
+    }
+
+    cmd.openAroz = function () {
+        if (!arozAvailable()) {
+            UI.toast(T('toast/offline', 'ArozOS is not reachable. Use Open from This Device instead.'), { type: 'error' });
+            return;
+        }
+        ao_module_openFileSelector(function (files) {
+            if (files && files.length) {
+                openFromAroz(files.map(function (f) { return f.filepath; }));
+            }
+        }, 'user:/', 'file', true, { path_memory_key: 'open' });
+    };
+
+    cmd.openArozFolder = function () {
+        if (!arozAvailable()) {
+            return;
+        }
+        ao_module_openFileSelector(function (files) {
+            if (files && files.length) {
+                openFromAroz([files[0].filepath]);
+            }
+        }, 'user:/', 'folder', false, { path_memory_key: 'open' });
+    };
+
+    cmd.addAroz = function () {
+        if (!arozAvailable() || !state.project) {
+            return;
+        }
+        ao_module_openFileSelector(function (files) {
+            if (files && files.length) {
+                openFromAroz(files.map(function (f) { return f.filepath; }), { add: true });
+            }
+        }, 'user:/', 'file', true, { path_memory_key: 'add' });
+    };
+
+    /* ---------------- From the local device ---------------- */
+    async function unzipToFiles(buf, skipped) {
+        var zip = GV.zip.list(buf);
+        var out = [];
+        for (var i = 0; i < zip.entries.length; i++) {
+            var e = zip.entries[i];
+            if (e.dir) {
+                continue;
+            }
+            if (!L.isCandidate(e.name)) {
+                if (!/(^|\/)(\.|__MACOSX)/.test(e.name)) {
+                    skipped.push({ name: e.name, code: 'notcam', reason: 'Not a Gerber or drill file' });
+                }
+                continue;
+            }
+            if (e.size > 32 * 1024 * 1024) {
+                skipped.push({ name: e.name, code: 'toolarge', reason: 'File is larger than 32 MB' });
+                continue;
+            }
+            showLoading(T('loading/unpack', 'Unpacking {name}', { name: L.base(e.name) }));
+            try {
+                var data = await GV.zip.extract(zip, e);
+                out.push({ name: e.name, text: new TextDecoder().decode(data), size: data.length });
+            } catch (err) {
+                skipped.push({ name: e.name, code: 'error', reason: errText(err), vars: { error: errText(err) } });
+            }
+        }
+        return out;
+    }
+
+    //files: [{file: File, rel: 'folder/name'}]
+    async function openLocal(entries, opts) {
+        opts = opts || {};
+        if (!entries.length) {
+            return;
+        }
+        showLoading(T('loading/files', 'Reading files...'));
+        var files = [], skipped = [];
+        var zips = 0;
+        try {
+            for (var i = 0; i < entries.length; i++) {
+                var f = entries[i].file;
+                var rel = entries[i].rel || f.name;
+                var lower = f.name.toLowerCase();
+                if (/\.(7z|rar|tar|tgz|gz)$/.test(lower)) {
+                    skipped.push({ name: rel, code: 'ziponly', reason: 'Only zip archives can be opened from this device' });
+                    continue;
+                }
+                if (/\.zip$/.test(lower)) {
+                    zips++;
+                    var got = await unzipToFiles(await f.arrayBuffer(), skipped);
+                    got.forEach(function (g) {
+                        g.mtime = f.lastModified;
+                        files.push(g);
+                    });
+                    continue;
+                }
+                if (!L.isCandidate(f.name)) {
+                    skipped.push({ name: rel, code: 'notcam', reason: 'Not a Gerber or drill file' });
+                    continue;
+                }
+                if (f.size > 32 * 1024 * 1024) {
+                    skipped.push({ name: rel, code: 'toolarge', reason: 'File is larger than 32 MB' });
+                    continue;
+                }
+                files.push({ name: rel, text: await f.text(), size: f.size, mtime: f.lastModified });
+            }
+        } catch (e) {
+            hideLoading();
+            UI.toast(T('toast/readFailed', 'Unable to read the files: {error}', { error: errText(e) }), { type: 'error' });
+            return;
+        }
+        var first = entries[0];
+        var name;
+        if (entries.length === 1 && zips === 1) {
+            name = first.file.name;
+        } else if (first.rel && first.rel.indexOf('/') > 0) {
+            name = first.rel.split('/')[0];
+        } else {
+            name = commonPrefix(entries.map(function (e) { return e.file.name; })) || first.file.name;
+        }
+        name = cleanName(name);
+        var key = 'local:' + hashStr(entries.map(function (e) { return (e.rel || e.file.name) + ':' + e.file.size; }).sort().join('|'));
+        var total = files.reduce(function (s, f) { return s + f.text.length; }, 0);
+        var cacheId = '';
+        if (!opts.add && files.length && total <= 40 * 1024 * 1024) {
+            cacheId = 'p_' + key.slice(6);
+            S.cachePut(cacheId, { name: name, files: files, skipped: skipped, time: Date.now() });
+        }
+        await openFiles(files, {
+            name: name,
+            source: 'local',
+            kind: zips === 1 && entries.length === 1 ? 'archive' : (first.rel && first.rel.indexOf('/') > 0 ? 'folder' : 'files'),
+            key: key,
+            cacheId: cacheId,
+            skipped: skipped,
+            add: opts.add
+        });
+    }
+
+    function commonPrefix(names) {
+        if (!names.length) {
+            return '';
+        }
+        var p = names[0];
+        names.forEach(function (n) {
+            while (n.indexOf(p) !== 0) {
+                p = p.slice(0, -1);
+            }
+        });
+        p = p.replace(/[-_. ]+$/, '');
+        return p.length >= 3 ? p : '';
+    }
+
+    var localAddMode = false;
+    cmd.openLocal = function () {
+        localAddMode = false;
+        $('fileInput').value = '';
+        $('fileInput').click();
+    };
+    cmd.openLocalFolder = function () {
+        localAddMode = false;
+        $('folderInput').value = '';
+        $('folderInput').click();
+    };
+    cmd.addLocal = function () {
+        if (!state.project) {
+            return;
+        }
+        localAddMode = true;
+        $('fileInput').value = '';
+        $('fileInput').click();
+    };
+
+    function bindFileInputs() {
+        $('fileInput').addEventListener('change', function () {
+            var list = Array.prototype.map.call(this.files, function (f) { return { file: f, rel: f.name }; });
+            openLocal(list, { add: localAddMode });
+        });
+        $('folderInput').addEventListener('change', function () {
+            var list = Array.prototype.map.call(this.files, function (f) { return { file: f, rel: f.webkitRelativePath || f.name }; });
+            openLocal(list, { add: false });
+        });
+    }
+
+    /* ---------------- Drag and drop ---------------- */
+    function readEntry(entry, prefix, out) {
+        return new Promise(function (resolve) {
+            if (entry.isFile) {
+                entry.file(function (f) {
+                    out.push({ file: f, rel: prefix + f.name });
+                    resolve();
+                }, function () { resolve(); });
+            } else if (entry.isDirectory) {
+                var reader = entry.createReader();
+                var all = [];
+                (function more() {
+                    reader.readEntries(function (batch) {
+                        if (!batch.length) {
+                            Promise.all(all.map(function (e) {
+                                return readEntry(e, prefix + entry.name + '/', out);
+                            })).then(resolve);
+                            return;
+                        }
+                        all = all.concat(Array.prototype.slice.call(batch));
+                        more();
+                    }, function () { resolve(); });
+                })();
+            } else {
+                resolve();
+            }
+        });
+    }
+
+    function bindDrop() {
+        var depth = 0;
+        function hasPayload(e) {
+            var t = e.dataTransfer && e.dataTransfer.types;
+            return t && (Array.prototype.indexOf.call(t, 'Files') >= 0 || Array.prototype.indexOf.call(t, 'filedata') >= 0);
+        }
+        document.addEventListener('dragenter', function (e) {
+            if (!hasPayload(e)) {
+                return;
+            }
+            depth++;
+            $('dropHint').hidden = false;
+        });
+        document.addEventListener('dragleave', function () {
+            depth = Math.max(0, depth - 1);
+            if (!depth) {
+                $('dropHint').hidden = true;
+            }
+        });
+        document.addEventListener('dragover', function (e) {
+            if (hasPayload(e)) {
+                e.preventDefault();
+            }
+        });
+        document.addEventListener('drop', function (e) {
+            if (!hasPayload(e)) {
+                return;
+            }
+            e.preventDefault();
+            depth = 0;
+            $('dropHint').hidden = true;
+            var add = !!state.project && e.shiftKey;
+            //From the ArozOS file manager: only paths are carried
+            var aroz = null;
+            try {
+                aroz = ao_module_utils.getDropFileInfo(e);
+            } catch (err) {
+                aroz = null;
+            }
+            if (aroz && aroz.length) {
+                openFromAroz(aroz.map(function (f) { return f.filepath; }), { add: add });
+                return;
+            }
+            var items = e.dataTransfer.items;
+            var entries = [];
+            if (items && items.length && items[0].webkitGetAsEntry) {
+                var roots = [];
+                for (var i = 0; i < items.length; i++) {
+                    var en = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
+                    if (en) {
+                        roots.push(en);
+                    }
+                }
+                if (roots.length) {
+                    Promise.all(roots.map(function (r) { return readEntry(r, '', entries); })).then(function () {
+                        openLocal(entries, { add: add });
+                    });
+                    return;
+                }
+            }
+            Array.prototype.forEach.call(e.dataTransfer.files, function (f) { entries.push({ file: f, rel: f.name }); });
+            openLocal(entries, { add: add });
+        });
+    }
+
+    /* ==================================================================
+        Project lifecycle
+    ================================================================== */
+    function setProject(p, saved) {
+        state.project = p;
+        state.selection = null;
+        state.measure = null;
+        state.marker = null;
+        state.origin = [0, 0];
+        state.selectedLayer = null;
+        state.miniCache = null;
+        viewer.view.rot = 0;
+        viewer.view.flip = false;
+        state.mode = 'layers';
+        if (saved) {
+            applySavedState(saved);
+        }
+        $('emptyState').hidden = true;
+        updateTitle();
+        renderLayerList();
+        renderFileInfo();
+        renderProps();
+        updateToolbar();
+        nextTip();
+        viewer.resize();
+        cmd.fit();
+        renderMinimap();
+        if (state.dim === '3d') {
+            schedule3D();
+        }
+    }
+
+    cmd.close = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project = null;
+        state.selection = null;
+        state.measure = null;
+        state.miniCache = null;
+        if (state.dim === '3d') {
+            setDim('2d');
+        }
+        $('emptyState').hidden = false;
+        renderRecentGrid();
+        updateTitle();
+        renderLayerList();
+        renderFileInfo();
+        renderProps();
+        updateToolbar();
+        viewer.requestRender();
+        renderMinimap();
+    };
+
+    cmd.reload = async function () {
+        var p = state.project;
+        if (!p) {
+            return;
+        }
+        if (p.source === 'aroz' && p.paths.length) {
+            await openFromAroz(p.paths);
+        } else if (p.cacheId) {
+            await openRecent({ key: p.key, source: 'local', cacheId: p.cacheId, name: p.name });
+        } else {
+            UI.toast(T('toast/noReload', 'This project cannot be reloaded. Open the files again.'), { type: 'error' });
+        }
+    };
+
+    function updateTitle() {
+        var p = state.project;
+        var appName = T('app/title', 'Gerber Viewer');
+        $('projectName').textContent = p ? p.name : appName;
+        $('projectBadge').textContent = p ? T('badge/project', 'Gerber Project') : T('badge/none', 'No Project');
+        $('projectName').title = p ? p.name : '';
+        try {
+            ao_module_setWindowTitle(p ? appName + ' - ' + p.name : appName);
+        } catch (e) {
+            document.title = p ? p.name + ' - ' + appName : appName;
+        }
+        var st = '';
+        if (p) {
+            st = T('status/layers', '{n} layers', { n: p.layers.length });
+            if (p.board && p.board.width) {
+                st += ' · ' + fmtSize(p.board.width, p.board.height);
+            }
+        }
+        $('mbStatus').textContent = st;
+    }
+
+    /* ---------------- Saved layer state ---------------- */
+    function collectState() {
+        var p = state.project;
+        var layers = {};
+        p.layers.forEach(function (l) {
+            layers[l.name] = { c: l.color, o: Math.round(l.opacity * 100) / 100, v: l.visible ? 1 : 0, t: l.type };
+        });
+        return {
+            order: p.layers.map(function (l) { return l.name; }),
+            layers: layers,
+            mode: state.mode,
+            flip: viewer.view.flip ? 1 : 0,
+            rot: viewer.view.rot
+        };
+    }
+
+    function applySavedState(st) {
+        var p = state.project;
+        if (!st || !st.layers) {
+            return;
+        }
+        p.layers.forEach(function (l) {
+            var s = st.layers[l.name];
+            if (!s) {
+                return;
+            }
+            if (s.t && s.t !== l.type && L.TYPES[s.t]) {
+                applyType(l, s.t, false);
+            }
+            if (s.c) {
+                l.color = s.c;
+            }
+            if (typeof s.o === 'number') {
+                l.opacity = Math.max(0.05, Math.min(1, s.o));
+            }
+            if (s.v !== undefined) {
+                l.visible = !!s.v;
+            }
+        });
+        if (Array.isArray(st.order)) {
+            var pos = {};
+            st.order.forEach(function (n, i) { pos[n] = i; });
+            p.layers.sort(function (a, b) {
+                var pa = pos[a.name] !== undefined ? pos[a.name] : 1000 - stackOrder(a);
+                var pb = pos[b.name] !== undefined ? pos[b.name] : 1000 - stackOrder(b);
+                return pa - pb;
+            });
+        }
+        p.board = GV.board.build(p.layers);
+        state.mode = st.mode === 'realistic' ? 'realistic' : 'layers';
+        viewer.view.flip = !!st.flip;
+        viewer.view.rot = [0, 90, 180, 270].indexOf(st.rot) >= 0 ? st.rot : 0;
+    }
+
+    var saveTimer = 0;
+    function saveStateSoon() {
+        if (!state.project || !state.project.key) {
+            return;
+        }
+        clearTimeout(saveTimer);
+        var key = state.project.key;
+        saveTimer = setTimeout(function () {
+            if (state.project && state.project.key === key) {
+                S.recentUpdate(key, { state: collectState() });
+            }
+        }, 1200);
+    }
+
+    /* ---------------- Recents ---------------- */
+    function thumbnail(p) {
+        try {
+            var b = projectBounds(false);
+            if (!b) {
+                return '';
+            }
+            var W = 240, H = 140;
+            var scale = Math.min((W - 12) / (b[2] - b[0]), (H - 12) / (b[3] - b[1]));
+            var realistic = p.board && p.board.source !== 'none' && p.layers.some(function (l) { return /copper/.test(l.type); });
+            var cv = R.renderToCanvas({
+                mode: realistic ? 'realistic' : 'layers',
+                side: 'top',
+                layers: p.layers.slice().reverse(),
+                all: p.layers,
+                board: p.board,
+                background: '#141518',
+                look: look()
+            }, { cx: (b[0] + b[2]) / 2, cy: (b[1] + b[3]) / 2, scale: scale, rot: 0, flip: false }, W, H);
+            return cv.toDataURL('image/jpeg', 0.82);
+        } catch (e) {
+            return '';
+        }
+    }
+
+    function recentEntry(p) {
+        return {
+            key: p.key,
+            name: p.name,
+            source: p.source,
+            kind: p.kind,
+            paths: p.paths || [],
+            cacheId: p.cacheId || '',
+            time: Date.now(),
+            layers: p.layers.length,
+            size: p.board && p.board.width ? fmtSize(p.board.width, p.board.height) : '',
+            thumb: thumbnail(p),
+            state: collectState()
+        };
+    }
+
+    function addRecent(p) {
+        if (!p.key) {
+            return;
+        }
+        if (p.source === 'local' && !p.cacheId) {
+            return;
+        }
+        S.recentAdd(recentEntry(p)).then(function () {
+            S.cachePrune();
+            renderRecentGrid();
+        }).catch(function () { });
+    }
+
+    function updateRecentAfterAdd() {
+        var p = state.project;
+        if (p && p.key && p.source === 'aroz') {
+            //The project now spans more paths; store it under the new path set
+            var oldKey = p.key;
+            p.key = 'aroz:' + p.paths.slice().sort().join('|');
+            if (oldKey !== p.key) {
+                addRecent(p);
+            }
+        }
+    }
+
+    async function openRecent(entry) {
+        if (entry.source === 'aroz') {
+            if (!arozAvailable()) {
+                UI.toast(T('toast/offlineShort', 'ArozOS is not reachable.'), { type: 'error' });
+                return;
+            }
+            await openFromAroz(entry.paths);
+            return;
+        }
+        showLoading(T('loading/opening', 'Opening {name}...', { name: entry.name }));
+        var cached = entry.cacheId ? await S.cacheGet(entry.cacheId) : null;
+        if (!cached) {
+            hideLoading();
+            UI.toast(T('toast/notCached', '"{name}" was opened from a device and is not cached in this browser. Open it again.', { name: entry.name }), { type: 'error', timeout: 6000 });
+            return;
+        }
+        await openFiles(cached.files, {
+            name: entry.name,
+            source: 'local',
+            kind: entry.kind,
+            key: entry.key,
+            cacheId: entry.cacheId,
+            skipped: cached.skipped || []
+        });
+    }
+
+    function recentMenu() {
+        var items = S.recent.slice(0, 12).map(function (e) {
+            return {
+                label: e.name,
+                icon: e.source === 'aroz' ? 'drive' : 'laptop',
+                title: e.source === 'aroz' ? e.paths.join('\n') : T('recent/device', 'Opened from this device'),
+                action: function () { openRecent(e); }
+            };
+        });
+        if (!items.length) {
+            items.push({ empty: T('recent/none', 'No recent projects') });
+        }
+        items.push({ sep: true });
+        items.push({ label: T('menu/clearRecent', 'Clear Recent Projects'), icon: 'trash', enabled: S.recent.length > 0, action: cmd.clearRecent });
+        return items;
+    }
+
+    cmd.clearRecent = function () {
+        UI.confirm(T('confirm/clearRecent', 'Clear recent projects'), T('confirm/clearRecentText', 'Remove every project from the recent list?'), T('common/clear', 'Clear')).then(function (ok) {
+            if (ok) {
+                S.recentClear().then(renderRecentGrid);
+            }
+        });
+    };
+
+    function renderRecentGrid() {
+        var grid = $('esRecentGrid');
+        var list = S.recent.slice(0, 12);
+        $('esRecent').hidden = list.length === 0;
+        grid.innerHTML = '';
+        list.forEach(function (e) {
+            var card = UI.h('div', { class: 'rcard', title: e.source === 'aroz' ? e.paths.join('\n') : T('recent/device', 'Opened from this device') });
+            card.innerHTML =
+                '<div class="rthumb"' + (e.thumb ? ' style="background-image:url(' + e.thumb + ')"' : '') + '>' + (e.thumb ? '' : icon('board')) + '</div>' +
+                '<div class="rbody"><div class="rname">' + esc(e.name) + '</div>' +
+                '<div class="rmeta">' + icon(e.source === 'aroz' ? 'drive' : 'laptop') + '<span>' +
+                esc((e.layers ? T('status/layers', '{n} layers', { n: e.layers }) + ' · ' : '') + relTime(e.time)) + '</span></div></div>' +
+                '<button class="rdel" title="' + esc(T('recent/remove', 'Remove from list')) + '">' + icon('close') + '</button>';
+            card.addEventListener('click', function (ev) {
+                if (ev.target.closest('.rdel')) {
+                    ev.stopPropagation();
+                    S.recentRemove(e.key).then(renderRecentGrid);
+                    return;
+                }
+                openRecent(e);
+            });
+            grid.appendChild(card);
+        });
+    }
+
+    /* ==================================================================
+        Side panel: layer list
+    ================================================================== */
+    var dragId = null;
+
+    function renderLayerList() {
+        var ul = $('layerList');
+        ul.innerHTML = '';
+        var p = state.project;
+        $('layerEmpty').hidden = !!(p && p.layers.length);
+        if (!p) {
+            return;
+        }
+        p.layers.forEach(function (l) {
+            var li = UI.h('li', {
+                class: 'layer' + (l.visible ? '' : ' hiddenlayer') + (state.selectedLayer === l.id ? ' selected' : ''),
+                draggable: 'true',
+                title: l.name
+            });
+            li.dataset.id = l.id;
+            var warn = l.image.warnings.length ? '<span class="warnmark" title="' + esc(warningTexts(l.image).join('\n')) + '">' + icon('warning') + '</span>' : '';
+            li.innerHTML = '<span class="grip">' + icon('grip') + '</span>' +
+                '<button class="swatch" style="--c:' + esc(l.color) + ';opacity:' + Math.max(0.35, l.opacity) + '" title="' + esc(T('layer/colourTitle', 'Colour and opacity')) + '"></button>' +
+                '<span class="lname"><span>' + esc(l.label) + '</span><small>' + esc(L.base(l.name)) + '</small></span>' + warn +
+                '<button class="iconbtn eye" title="' + esc(l.visible ? T('layer/hide', 'Hide layer') : T('layer/show', 'Show layer')) + '">' + icon(l.visible ? 'eye' : 'eye-off') + '</button>';
+            li.querySelector('.eye').addEventListener('click', function (e) {
+                e.stopPropagation();
+                l.visible = !l.visible;
+                layersChanged();
+            });
+            li.querySelector('.swatch').addEventListener('click', function (e) {
+                e.stopPropagation();
+                colorPopover(this, l);
+            });
+            li.addEventListener('click', function () {
+                selectLayer(l.id, true);
+            });
+            li.addEventListener('dblclick', function () {
+                selectLayer(l.id, true);
+                setTab('props');
+            });
+            li.addEventListener('contextmenu', function (e) {
+                e.preventDefault();
+                selectLayer(l.id, false);
+                UI.contextMenu(layerContextItems(l), e.clientX, e.clientY);
+            });
+            li.addEventListener('dragstart', function (e) {
+                dragId = l.id;
+                li.classList.add('dragging');
+                e.dataTransfer.effectAllowed = 'move';
+                e.dataTransfer.setData('text/plain', 'layer');
+            });
+            li.addEventListener('dragend', function () {
+                dragId = null;
+                li.classList.remove('dragging');
+                clearDropMarks();
+            });
+            li.addEventListener('dragover', function (e) {
+                if (dragId === null) {
+                    return;
+                }
+                e.preventDefault();
+                e.stopPropagation();
+                var r = li.getBoundingClientRect();
+                var before = e.clientY < r.top + r.height / 2;
+                clearDropMarks();
+                li.classList.add(before ? 'drop-before' : 'drop-after');
+            });
+            li.addEventListener('drop', function (e) {
+                if (dragId === null) {
+                    return;
+                }
+                e.preventDefault();
+                e.stopPropagation();
+                var before = li.classList.contains('drop-before');
+                clearDropMarks();
+                moveLayer(dragId, l.id, before);
+            });
+            ul.appendChild(li);
+        });
+    }
+
+    function clearDropMarks() {
+        Array.prototype.forEach.call(document.querySelectorAll('.drop-before,.drop-after'), function (n) {
+            n.classList.remove('drop-before', 'drop-after');
+        });
+    }
+
+    function moveLayer(id, targetId, before) {
+        var list = state.project.layers;
+        var from = list.findIndex(function (l) { return l.id === id; });
+        if (from < 0 || id === targetId) {
+            return;
+        }
+        var moving = list.splice(from, 1)[0];
+        var to = list.findIndex(function (l) { return l.id === targetId; });
+        list.splice(before ? to : to + 1, 0, moving);
+        layersChanged();
+    }
+
+    function moveBy(layer, d) {
+        var list = state.project.layers;
+        var i = list.indexOf(layer);
+        var j = i + d;
+        if (j < 0 || j >= list.length) {
+            return;
+        }
+        list.splice(i, 1);
+        list.splice(j, 0, layer);
+        layersChanged();
+    }
+
+    function selectLayer(id, toggle) {
+        state.selectedLayer = toggle && state.selectedLayer === id ? null : id;
+        if (state.selectedLayer !== null) {
+            state.selection = null;
+            viewer.renderOverlay();
+        }
+        renderLayerList();
+        renderProps();
+    }
+
+    function layerContextItems(l) {
+        return [
+            { header: l.label },
+            { label: T('menu/showOnly', 'Show Only This Layer'), icon: 'eye', action: function () { solo(l); } },
+            { label: l.visible ? T('menu/hideLayer', 'Hide Layer') : T('menu/showLayer', 'Show Layer'), icon: l.visible ? 'eye-off' : 'eye', action: function () { l.visible = !l.visible; layersChanged(); } },
+            { label: T('menu/colour', 'Colour and Opacity...'), icon: 'palette', action: function () { colorPopover(document.querySelector('.layer[data-id="' + l.id + '"] .swatch') || $('layerList'), l); } },
+            { label: T('menu/layerType', 'Layer Type'), icon: 'layers', submenu: function () { return typeMenu(l); } },
+            { sep: true },
+            { label: T('menu/moveUp', 'Move Up'), enabled: state.project.layers.indexOf(l) > 0, action: function () { moveBy(l, -1); } },
+            { label: T('menu/moveDown', 'Move Down'), enabled: state.project.layers.indexOf(l) < state.project.layers.length - 1, action: function () { moveBy(l, 1); } },
+            { label: T('menu/zoomLayer', 'Zoom to Layer'), icon: 'fit', action: function () { zoomToBounds(l.image.bounds); } },
+            { sep: true },
+            { label: T('menu/properties', 'Properties'), icon: 'info', action: function () { selectLayer(l.id, false); setTab('props'); } },
+            { label: T('menu/viewSource', 'View Source...'), icon: 'code', action: function () { cmd.viewSource(l); } },
+            { label: T('menu/apertureTable', 'Aperture Table...'), icon: 'table', enabled: l.image.kind === 'gerber', action: function () { cmd.apertureTable(l); } },
+            { label: T('menu/reloadLayer', 'Reload Layer'), icon: 'refresh', enabled: !!l.path && state.project.source === 'aroz' && l.path.indexOf('#') < 0, action: function () { reloadLayer(l); } },
+            { label: T('menu/removeLayer', 'Remove Layer'), icon: 'trash', action: function () { removeLayer(l); } }
+        ];
+    }
+
+    function typeMenu(l) {
+        return L.TYPE_LIST.map(function (t) {
+            return {
+                label: layerLabel({ type: t, index: l.index, plated: l.plated, via: l.via }),
+                swatch: defaultColor(t),
+                checked: l.type === t,
+                action: function () { setLayerType(l, t); }
+            };
+        });
+    }
+
+    function setLayerType(l, t) {
+        if (l.type === t) {
+            return;
+        }
+        if (t === 'drill' && l.image.kind !== 'drill') {
+            //A Gerber drill map can still be shown as the drill layer
+        }
+        applyType(l, t, false);
+        layersChanged({ rebuildBoard: true });
+    }
+
+    function solo(l) {
+        state.project.layers.forEach(function (x) { x.visible = x === l; });
+        layersChanged();
+    }
+
+    function removeLayer(l) {
+        var p = state.project;
+        p.layers.splice(p.layers.indexOf(l), 1);
+        if (state.selectedLayer === l.id) {
+            state.selectedLayer = null;
+        }
+        if (state.selection && state.selection.layer === l) {
+            state.selection = null;
+        }
+        if (!p.layers.length) {
+            cmd.close();
+            return;
+        }
+        layersChanged({ rebuildBoard: true });
+        updateTitle();
+    }
+
+    async function reloadLayer(l) {
+        try {
+            showLoading(T('loading/reloading', 'Reloading {name}', { name: L.base(l.name) }));
+            var f = await S.agi('project.agi', { opr: 'read', path: l.path });
+            var kind = L.sniff(f.content);
+            l.image = kind === 'drill' ? GV.excellon.parse(f.content, { plated: platedHint(l.name) }) : GV.gerber.parse(f.content);
+            l.text = f.content;
+            l.size = f.size;
+            l.mtime = f.mtime;
+            delete l._paths;
+            delete l._snap;
+            hideLoading();
+            layersChanged({ rebuildBoard: true });
+            UI.toast(T('toast/reloaded', 'Reloaded {name}', { name: l.label }), { icon: 'check' });
+        } catch (e) {
+            hideLoading();
+            UI.toast(T('toast/reloadFailed', 'Unable to reload: {error}', { error: errText(e) }), { type: 'error' });
+        }
+    }
+
+    function colorPopover(anchor, l) {
+        var box = UI.h('div');
+        box.innerHTML = '<div class="pop-title">' + esc(l.label) + '</div>' +
+            '<div class="swatches">' + PALETTE.map(function (c) {
+                return '<button style="--c:' + c + '" data-c="' + c + '" class="' + (c.toLowerCase() === l.color.toLowerCase() ? 'active' : '') + '"></button>';
+            }).join('') + '</div>' +
+            '<div class="pop-row"><span>' + esc(T('colour/custom', 'Custom')) + '</span><input type="color" value="' + esc(toHex6(l.color)) + '">' +
+            '<button class="btn small" data-reset>' + esc(T('common/reset', 'Reset')) + '</button></div>' +
+            '<div class="pop-row"><span>' + esc(T('colour/opacity', 'Opacity')) + '</span><input type="range" min="5" max="100" value="' + Math.round(l.opacity * 100) + '"><b class="op">' + Math.round(l.opacity * 100) + '%</b></div>';
+        box.querySelectorAll('.swatches button').forEach(function (b) {
+            b.addEventListener('click', function () {
+                l.color = b.dataset.c;
+                box.querySelectorAll('.swatches button').forEach(function (x) { x.classList.toggle('active', x === b); });
+                box.querySelector('input[type=color]').value = l.color;
+                layersChanged();
+            });
+        });
+        box.querySelector('input[type=color]').addEventListener('input', function () {
+            l.color = this.value;
+            layersChanged();
+        });
+        box.querySelector('[data-reset]').addEventListener('click', function () {
+            l.color = defaultColor(l.type);
+            l.opacity = defaultOpacity(l.type);
+            box.querySelector('input[type=color]').value = toHex6(l.color);
+            box.querySelector('input[type=range]').value = Math.round(l.opacity * 100);
+            box.querySelector('.op').textContent = Math.round(l.opacity * 100) + '%';
+            layersChanged();
+        });
+        box.querySelector('input[type=range]').addEventListener('input', function () {
+            l.opacity = this.value / 100;
+            box.querySelector('.op').textContent = this.value + '%';
+            layersChanged();
+        });
+        UI.popover(anchor, box);
+    }
+
+    function toHex6(c) {
+        if (/^#[0-9a-f]{6}$/i.test(c)) {
+            return c;
+        }
+        var cv = document.createElement('canvas').getContext('2d');
+        cv.fillStyle = c;
+        return cv.fillStyle;
+    }
+
+    /* ==================================================================
+        Side panel: file information, properties, tips
+    ================================================================== */
+    function kv(rows) {
+        return rows.filter(function (r) { return r && r[1] !== undefined && r[1] !== null && r[1] !== ''; }).map(function (r) {
+            return '<dt>' + esc(r[0]) + '</dt><dd' + (r[2] ? ' class="mono"' : '') + '>' + (r[3] ? r[1] : esc(r[1])) + '</dd>';
+        }).join('');
+    }
+
+    function formatLabel(p) {
+        var x2 = p.layers.some(function (l) { return l.image.kind === 'gerber' && l.image.x2; });
+        var drill = p.layers.some(function (l) { return l.image.kind === 'drill'; });
+        var gerber = p.layers.some(function (l) { return l.image.kind === 'gerber'; });
+        var parts = [];
+        if (gerber) {
+            parts.push(x2 ? 'Gerber X2' : 'Gerber RS-274X');
+        }
+        if (drill) {
+            parts.push('Excellon');
+        }
+        return parts.join(' + ');
+    }
+
+    function renderFileInfo() {
+        var p = state.project;
+        var dl = $('fileInfo');
+        if (!p) {
+            dl.innerHTML = '<dt>' + esc(T('info/project', 'Project')) + '</dt><dd class="muted">' + esc(T('info/none', 'None')) + '</dd>';
+            return;
+        }
+        var src = p.source === 'aroz' ? (p.paths.length === 1 ? p.paths[0] : T('info/itemsOnAroz', '{n} items on ArozOS', { n: p.paths.length })) : T('info/device', 'This device');
+        var copper = p.layers.filter(function (l) { return /copper/.test(l.type); }).length;
+        var size = p.board && p.board.width ? fmtSize(p.board.width, p.board.height) : '';
+        var files = p.fileCount === 1 ? T('info/file1', '1 file') : T('info/files', '{n} files', { n: p.fileCount });
+        dl.innerHTML = kv([
+            [T('info/name', 'Project Name'), p.name],
+            [T('info/format', 'Format'), formatLabel(p)],
+            [T('info/layers', 'Layers'), copper ? T('info/layersCopper', '{n} ({c} copper)', { n: p.layers.length, c: copper }) : String(p.layers.length)],
+            [T('info/size', 'Size'), size && p.board.source === 'extent' ? T('info/noOutline', '{size} (no outline)', { size: size }) : size],
+            [T('info/created', 'Created'), fmtDate(p.created)],
+            [T('info/generator', 'Generator'), p.generator],
+            [T('info/filesLabel', 'Files'), p.skipped.length ? T('info/filesIgnored', '{files} ({n} ignored)', { files: files, n: p.skipped.length }) : files],
+            [T('info/source', 'Source'), src]
+        ]);
+    }
+
+    function setTab(t) {
+        state.tab = t;
+        document.querySelectorAll('.tabs button').forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
+        $('tabLayers').hidden = t !== 'layers';
+        $('tabProps').hidden = t !== 'props';
+        if (t === 'props') {
+            renderProps();
+        }
+    }
+
+    function nextTip() {
+        state.tipIndex = (state.tipIndex + 1) % TIPS.length;
+        $('tipText').textContent = T('tip/' + state.tipIndex, TIPS[state.tipIndex]);
+        $('tipCard').hidden = !state.settings.tips;
+    }
+
+    function apertureOf(layer, num) {
+        var list = layer.image.apertures || [];
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].num === num) {
+                return list[i];
+            }
+        }
+        return null;
+    }
+
+    var KIND_LABEL = {
+        flash: 'Pad (flash)', trace: 'Trace', arc: 'Arc', region: 'Region / pour',
+        hole: 'Drill hole', slot: 'Slot', image: 'Image fill'
+    };
+
+    function kindLabel(k) {
+        return KIND_LABEL[k] ? T('kind/' + k, KIND_LABEL[k]) : T('kind/object', 'Object');
+    }
+
+    function yesNo(v) {
+        return v === null || v === undefined ? T('common/unknown', 'Unknown') : (v ? T('common/yes', 'Yes') : T('common/no', 'No'));
+    }
+
+    function describeItem(layer, it) {
+        var o = it.o || {};
+        var rows = [[T('prop/layer', 'Layer'), layer.label], [T('prop/object', 'Object'), kindLabel(o.k)]];
+        if (o.d !== undefined) {
+            var ap = apertureOf(layer, o.d);
+            rows.push([T('prop/aperture', 'Aperture'), 'D' + o.d + (ap ? ' – ' + apertureDesc(ap) : '')]);
+            if (ap && ap.attrs && ap.attrs.AperFunction) {
+                rows.push([T('prop/function', 'Function'), ap.attrs.AperFunction.replace(/,/g, ', ')]);
+            }
+        }
+        if (o.at) {
+            if (o.at.N !== undefined) {
+                rows.push([T('prop/net', 'Net'), o.at.N || T('prop/noNet', '(no net)')]);
+            }
+            if (o.at.C) {
+                rows.push([T('prop/component', 'Component'), o.at.C]);
+            }
+            if (o.at.P) {
+                var pin = o.at.P.split(',');
+                rows.push([T('prop/pin', 'Pin'), T('prop/pinValue', '{ref} pin {pin}', { ref: pin[0], pin: pin.slice(1).join(' ') })]);
+            }
+        }
+        if (o.k === 'hole' || o.k === 'slot') {
+            rows.push([T('prop/tool', 'Tool'), o.tool]);
+            rows.push([T('prop/diameter', 'Diameter'), fmtLen(o.dia)]);
+            rows.push([T('prop/plated', 'Plated'), yesNo(o.plated)]);
+        }
+        if (it.t === 'c') {
+            rows.push([T('prop/center', 'Center'), fmtXY(it.x, it.y)]);
+            if (o.k !== 'hole') {
+                rows.push([T('prop/diameter', 'Diameter'), fmtLen(it.r * 2)]);
+            }
+        } else if (it.t === 'l') {
+            rows.push([T('prop/start', 'Start'), fmtXY(it.x1, it.y1)]);
+            rows.push([T('prop/end', 'End'), fmtXY(it.x2, it.y2)]);
+            if (o.k !== 'slot') {
+                rows.push([T('prop/width', 'Width'), fmtLen(it.w)]);
+            }
+            rows.push([T('prop/length', 'Length'), fmtLen(Math.hypot(it.x2 - it.x1, it.y2 - it.y1))]);
+            rows.push([T('prop/angle', 'Angle'), (Math.atan2(it.y2 - it.y1, it.x2 - it.x1) * 180 / Math.PI).toFixed(2) + '°']);
+        } else if (it.t === 'a') {
+            var s = G.arcStart(it), e = G.arcEnd(it);
+            rows.push([T('prop/center', 'Center'), fmtXY(it.cx, it.cy)]);
+            rows.push([T('prop/radius', 'Radius'), fmtLen(it.r)]);
+            rows.push([T('prop/start', 'Start'), fmtXY(s[0], s[1])]);
+            rows.push([T('prop/end', 'End'), fmtXY(e[0], e[1])]);
+            rows.push([T('prop/sweep', 'Sweep'), (it.sw * 180 / Math.PI).toFixed(2) + '° ' + (it.sw > 0 ? T('prop/ccw', 'CCW') : T('prop/cw', 'CW'))]);
+            rows.push([T('prop/width', 'Width'), fmtLen(it.w)]);
+            rows.push([T('prop/length', 'Length'), fmtLen(Math.abs(it.sw) * it.r)]);
+        } else if (it.t === 'p') {
+            if (o.k === 'flash' && o.x !== undefined) {
+                rows.push([T('prop/position', 'Position'), fmtXY(o.x, o.y)]);
+            }
+            var u = unitInfo();
+            rows.push([T('prop/area', 'Area'), (G.polygonArea(it) * u.f * u.f).toFixed(Math.max(2, u.dec - 1)) + ' ' + u.label + '²']);
+            rows.push([T('prop/vertices', 'Vertices'), it.c.reduce(function (n, c) { return n + c.length / 2; }, 0)]);
+        }
+        rows.push([T('prop/extent', 'Extent'), fmtSize(it.b[2] - it.b[0], it.b[3] - it.b[1])]);
+        return rows;
+    }
+
+    //Aperture description built from its shape, so it can be translated
+    function apertureDesc(a) {
+        var p = a.params || [];
+        function f(v) {
+            return (Math.round(v * 10000) / 10000).toString();
+        }
+        switch (a.type) {
+            case 'C':
+                return p[1] ? T('ap/circleHole', 'Circle Ø{d}, hole {h}', { d: f(p[0]), h: f(p[1]) }) : T('ap/circle', 'Circle Ø{d}', { d: f(p[0]) });
+            case 'R':
+                return T('ap/rect', 'Rectangle {w} × {h}', { w: f(p[0]), h: f(p[1]) });
+            case 'O':
+                return T('ap/obround', 'Obround {w} × {h}', { w: f(p[0]), h: f(p[1]) });
+            case 'P':
+                return T('ap/polygon', 'Polygon Ø{d}, {n} sides', { d: f(p[0]), n: Math.round(p[1]) });
+            case 'block':
+                return T('ap/block', 'Block aperture');
+        }
+        var raw = a.rawParams && a.rawParams.length ? ' (' + a.rawParams.map(f).join(', ') + ')' : '';
+        return T('ap/macro', 'Macro {name}', { name: a.type }) + raw;
+    }
+
+    //Coordinate format of a layer in words
+    function formatText(im) {
+        var fi = im.formatInfo;
+        if (!fi) {
+            return im.format;
+        }
+        var mode = fi.abs ? T('fmt/absolute', 'absolute') : T('fmt/incremental', 'incremental');
+        if (im.kind === 'drill') {
+            var zero = fi.zeros === 'TZ' ? T('fmt/tz', 'Trailing zeros') : (fi.zeros === 'LZ' ? T('fmt/lz', 'Leading zeros') : T('fmt/decimal', 'Decimal / default'));
+            return zero + ', ' + fi.int + ':' + fi.dec + ', ' + mode;
+        }
+        var z = fi.zero === 'T' ? T('fmt/trailing', 'Trailing zero omission') : T('fmt/leading', 'Leading zero omission');
+        return z + ', ' + mode + ', X ' + fi.xi + '.' + fi.xd + ' / Y ' + fi.yi + '.' + fi.yd;
+    }
+
+    function renderProps() {
+        var pane = $('tabProps');
+        var p = state.project;
+        if (!p) {
+            pane.innerHTML = '<div class="card"><div class="pad muted">' + esc(T('props/noProject', 'Open a project to see its properties.')) + '</div></div>';
+            return;
+        }
+        var sel = state.selection;
+        if (sel) {
+            var o = sel.item.o || {};
+            pane.innerHTML = '<div class="card"><div class="props-h"><span class="swatch-lg" style="--c:' + esc(sel.layer.color) + '"></span>' +
+                '<div><div class="ph-title">' + esc(kindLabel(o.k)) + '</div><div class="ph-sub">' + esc(sel.layer.label) + '</div></div></div>' +
+                '<dl class="kv">' + kv(describeItem(sel.layer, sel.item)) + '</dl>' +
+                '<div class="props-actions">' +
+                '<button class="btn small" data-a="zoom">' + icon('fit') + esc(T('props/zoomTo', 'Zoom to')) + '</button>' +
+                '<button class="btn small" data-a="measure">' + icon('ruler') + esc(T('props/measureFrom', 'Measure from')) + '</button>' +
+                '<button class="btn small" data-a="clear">' + icon('close') + esc(T('common/clear', 'Clear')) + '</button></div></div>';
+            pane.querySelector('[data-a=zoom]').onclick = function () { zoomToBounds(sel.item.b, 4); };
+            pane.querySelector('[data-a=measure]').onclick = function () {
+                var c = itemCenter(sel.item);
+                setTool('measure');
+                state.measure = { a: c, b: null, cur: c };
+                renderMeasureBox();
+                viewer.renderOverlay();
+            };
+            pane.querySelector('[data-a=clear]').onclick = function () { setSelection(null); };
+            return;
+        }
+        var l = layerById(state.selectedLayer);
+        if (l) {
+            var im = l.image;
+            var st = im.stats;
+            var objects = im.kind === 'drill' ? T('props/holesN', '{n} holes', { n: st.holes }) + (st.slots ? ', ' + T('props/slotsN', '{n} slots', { n: st.slots }) : '') :
+                [T('props/flashesN', '{n} flashes', { n: st.flashes }), T('props/linesN', '{n} lines', { n: st.lines }),
+                    T('props/arcsN', '{n} arcs', { n: st.arcs }), T('props/regionsN', '{n} regions', { n: st.regions })].join(', ');
+            var b = im.bounds;
+            pane.innerHTML = '<div class="card"><div class="props-h"><span class="swatch-lg" style="--c:' + esc(l.color) + '"></span>' +
+                '<div><div class="ph-title">' + esc(l.label) + '</div><div class="ph-sub">' + esc(L.base(l.name)) + '</div></div></div>' +
+                '<div class="field"><label>' + esc(T('props/layerType', 'Layer type')) + '</label><select data-f="type">' + L.TYPE_LIST.map(function (t) {
+                    return '<option value="' + t + '"' + (t === l.type ? ' selected' : '') + '>' + esc(layerLabel({ type: t, index: l.index, plated: l.plated, via: l.via })) + '</option>';
+                }).join('') + '</select></div>' +
+                '<div class="field"><label>' + esc(T('props/colour', 'Colour and opacity')) + '</label><div class="field-row"><input type="color" data-f="color" value="' + esc(toHex6(l.color)) + '">' +
+                '<input type="range" data-f="opacity" min="5" max="100" value="' + Math.round(l.opacity * 100) + '"><span class="op">' + Math.round(l.opacity * 100) + '%</span>' +
+                '<label class="field-row" style="margin-left:6px"><input type="checkbox" data-f="visible"' + (l.visible ? ' checked' : '') + '>' + esc(T('props/visible', 'Visible')) + '</label></div></div>' +
+                '<dl class="kv">' + kv([
+                    [T('props/file', 'File'), l.name],
+                    [T('props/size', 'Size'), fmtBytes(l.size)],
+                    [T('props/format', 'Format'), im.kind === 'drill' ? T('props/excellon', 'Excellon drill') : (im.x2 ? 'Gerber X2' : 'Gerber RS-274X')],
+                    [T('props/units', 'Units'), im.units === 'in' ? T('unit/inch', 'Inch') : T('unit/mm', 'Millimetre')],
+                    [T('props/coordinates', 'Coordinates'), formatText(im)],
+                    [T('props/generator', 'Generator'), im.generator],
+                    [T('props/created', 'Created'), fmtDate(im.created)],
+                    [T('props/function', 'Function'), im.attrs.FileFunction ? im.attrs.FileFunction.replace(/,/g, ', ') : ''],
+                    [T('props/objects', 'Objects'), objects],
+                    [T('props/apertures', 'Apertures'), im.kind === 'gerber' ? String(im.apertures.length) : ''],
+                    [T('props/tools', 'Tools'), im.tools ? String(im.tools.length) : ''],
+                    [T('props/polarity', 'Polarity'), im.hasClear ? T('props/polarityBoth', 'Dark and clear') : T('props/polarityDark', 'Dark')],
+                    [T('props/extent', 'Extent'), G.boundsValid(b) ? fmtSize(b[2] - b[0], b[3] - b[1]) : T('props/empty', 'Empty')],
+                    [T('props/originOffset', 'Origin offset'), G.boundsValid(b) ? fmtXY(b[0], b[1]) : '']
+                ]) + '</dl>' +
+                (im.warnings.length ? '<div class="pad small" style="padding-top:0"><b>' + esc(T('props/warnings', 'Warnings')) + '</b><ul class="list-plain warn-list">' +
+                    warningTexts(im).map(function (w) { return '<li>' + esc(w) + '</li>'; }).join('') + '</ul></div>' : '') +
+                '<div class="props-actions">' +
+                '<button class="btn small" data-a="source">' + icon('code') + esc(T('props/source', 'Source')) + '</button>' +
+                (im.kind === 'gerber' ? '<button class="btn small" data-a="apertures">' + icon('table') + esc(T('props/aperturesBtn', 'Apertures')) + '</button>' :
+                    '<button class="btn small" data-a="drills">' + icon('table') + esc(T('props/drillTable', 'Drill table')) + '</button>') +
+                '<button class="btn small" data-a="zoom">' + icon('fit') + esc(T('props/zoomTo', 'Zoom to')) + '</button>' +
+                '<button class="btn small" data-a="remove">' + icon('trash') + esc(T('props/remove', 'Remove')) + '</button></div></div>';
+            pane.querySelector('[data-f=type]').onchange = function () { setLayerType(l, this.value); renderProps(); };
+            pane.querySelector('[data-f=color]').oninput = function () { l.color = this.value; layersChanged(); };
+            pane.querySelector('[data-f=opacity]').oninput = function () {
+                l.opacity = this.value / 100;
+                pane.querySelector('.op').textContent = this.value + '%';
+                state.miniCache = null;
+                viewer.requestRender();
+                scheduleMinimap();
+                renderLayerList();
+                saveStateSoon();
+            };
+            pane.querySelector('[data-f=visible]').onchange = function () { l.visible = this.checked; layersChanged(); };
+            pane.querySelector('[data-a=source]').onclick = function () { cmd.viewSource(l); };
+            var ab = pane.querySelector('[data-a=apertures]');
+            if (ab) {
+                ab.onclick = function () { cmd.apertureTable(l); };
+            }
+            var db = pane.querySelector('[data-a=drills]');
+            if (db) {
+                db.onclick = function () { cmd.drillTable(); };
+            }
+            pane.querySelector('[data-a=zoom]').onclick = function () { zoomToBounds(l.image.bounds); };
+            pane.querySelector('[data-a=remove]').onclick = function () { removeLayer(l); };
+            return;
+        }
+        //Board summary
+        var bd = p.board;
+        var sum = GV.app.boardSummary();
+        pane.innerHTML = '<div class="card"><div class="card-h">' + icon('board') + '<span>' + esc(T('props/board', 'Board')) + '</span></div><dl class="kv">' + kv([
+            [T('props/size', 'Size'), bd && bd.width ? fmtSize(bd.width, bd.height) : ''],
+            [T('props/area', 'Area'), bd && bd.area ? (bd.area / 100).toFixed(2) + ' cm²' : ''],
+            [T('props/outline', 'Outline'), outlineText(bd)],
+            [T('props/copperLayers', 'Copper layers'), String(sum.copper)],
+            [T('props/holesLabel', 'Holes'), sum.holes ? T('props/holesValue', '{n} ({p} plated, {np} non-plated)', { n: sum.holes, p: sum.pth, np: sum.npth }) : '0'],
+            [T('props/slotsLabel', 'Slots'), String(sum.slots)],
+            [T('props/minHole', 'Smallest hole'), sum.minHole ? fmtLen(sum.minHole) : ''],
+            [T('props/minTrace', 'Thinnest trace'), sum.minTrace ? fmtLen(sum.minTrace) : '']
+        ]) + '</dl><div class="pad muted small" style="padding-top:0">' + esc(T('props/hint', 'Select a layer or click an object on the board to see its details.')) + '</div></div>';
+    }
+
+    function outlineText(bd) {
+        if (!bd) {
+            return '';
+        }
+        if (bd.source !== 'outline') {
+            return T('props/noOutline', 'Not found, using the layer extent');
+        }
+        return bd.cutouts.length ? T('props/outlineCut', '{n} closed, {c} cut-outs', { n: bd.outer.length, c: bd.cutouts.length }) :
+            T('props/outlineClosed', '{n} closed', { n: bd.outer.length });
+    }
+
+    function itemCenter(it) {
+        if (it.o && it.o.x !== undefined) {
+            return [it.o.x, it.o.y];
+        }
+        if (it.t === 'c') {
+            return [it.x, it.y];
+        }
+        if (it.t === 'a') {
+            return [it.cx, it.cy];
+        }
+        return [(it.b[0] + it.b[2]) / 2, (it.b[1] + it.b[3]) / 2];
+    }
+
+    function setSelection(sel) {
+        state.selection = sel;
+        if (sel) {
+            state.selectedLayer = null;
+            renderLayerList();
+            if (state.tab !== 'props') {
+                setTab('props');
+            }
+        }
+        renderProps();
+        viewer.renderOverlay();
+    }
+
+    /* ==================================================================
+        Viewer: interaction
+    ================================================================== */
+    function hitTest(bx, by) {
+        var tol = 3 / viewer.view.scale;
+        var order = drawOrder().slice().reverse();
+        if (state.mode === 'realistic') {
+            var side = viewer.view.flip ? 'bottom' : 'top';
+            order = order.filter(function (l) {
+                return l.type === 'drill' || l.type === side + '-copper' || l.type === side + '-silk';
+            });
+        }
+        for (var li = 0; li < order.length; li++) {
+            var layer = order[li];
+            if (!layer.visible || !layer.image) {
+                continue;
+            }
+            var best = null, bestArea = Infinity;
+            var groups = layer.image.groups;
+            //A later clear group that covers the point erased what lies below it
+            var clearedFrom = groups.length;
+            for (var ci = groups.length - 1; ci >= 0; ci--) {
+                if (!groups[ci].dark && groups[ci].items.some(function (c) { return G.hitItem(c, bx, by, 0); })) {
+                    clearedFrom = ci;
+                    break;
+                }
+            }
+            for (var gi = groups.length - 1; gi >= 0; gi--) {
+                if (!groups[gi].dark || gi < clearedFrom && clearedFrom < groups.length) {
+                    continue;
+                }
+                var items = groups[gi].items;
+                for (var ii = items.length - 1; ii >= 0; ii--) {
+                    var it = items[ii];
+                    if (G.hitItem(it, bx, by, tol)) {
+                        var a = (it.b[2] - it.b[0]) * (it.b[3] - it.b[1]);
+                        if (a < bestArea) {
+                            best = it;
+                            bestArea = a;
+                        }
+                    }
+                }
+            }
+            if (best) {
+                return { layer: layer, item: best };
+            }
+        }
+        return null;
+    }
+
+    //Snap points: pad / hole centres, line and arc end points, arc centres
+    function snapIndex(layer) {
+        if (layer._snap) {
+            return layer._snap;
+        }
+        var cell = 2;
+        var map = new Map();
+        function add(x, y) {
+            var k = Math.floor(x / cell) + ',' + Math.floor(y / cell);
+            var a = map.get(k);
+            if (!a) {
+                a = [];
+                map.set(k, a);
+            }
+            a.push(x, y);
+        }
+        var seenFlash = new Set();
+        layer.image.groups.forEach(function (g) {
+            if (!g.dark) {
+                return;
+            }
+            g.items.forEach(function (it) {
+                var o = it.o || {};
+                if (o.x !== undefined) {
+                    if (!seenFlash.has(o)) {
+                        seenFlash.add(o);
+                        add(o.x, o.y);
+                    }
+                    return;
+                }
+                if (it.t === 'c') {
+                    add(it.x, it.y);
+                } else if (it.t === 'l') {
+                    add(it.x1, it.y1);
+                    add(it.x2, it.y2);
+                } else if (it.t === 'a') {
+                    var s = G.arcStart(it), e = G.arcEnd(it);
+                    add(s[0], s[1]);
+                    add(e[0], e[1]);
+                    add(it.cx, it.cy);
+                }
+            });
+        });
+        layer._snap = { cell: cell, map: map };
+        return layer._snap;
+    }
+
+    function snap(bx, by) {
+        if (!state.settings.snap || !state.project) {
+            return null;
+        }
+        var tol = 9 / viewer.view.scale;
+        var best = null, bestD = tol;
+        state.project.layers.forEach(function (l) {
+            if (!l.visible || !l.image) {
+                return;
+            }
+            var idx = snapIndex(l);
+            var c = idx.cell;
+            var r = Math.ceil(tol / c);
+            var cx = Math.floor(bx / c), cy = Math.floor(by / c);
+            for (var dx = -r; dx <= r; dx++) {
+                for (var dy = -r; dy <= r; dy++) {
+                    var a = idx.map.get((cx + dx) + ',' + (cy + dy));
+                    if (!a) {
+                        continue;
+                    }
+                    for (var i = 0; i < a.length; i += 2) {
+                        var d = Math.hypot(a[i] - bx, a[i + 1] - by);
+                        if (d < bestD) {
+                            bestD = d;
+                            best = [a[i], a[i + 1]];
+                        }
+                    }
+                }
+            }
+        });
+        return best;
+    }
+
+    var ptr = {
+        down: null,         //{x, y, button, moved, tool}
+        pointers: new Map(),
+        pinch: null,
+        space: false
+    };
+
+    function localXY(e) {
+        var r = $('cvOverlay').getBoundingClientRect();
+        return [e.clientX - r.left, e.clientY - r.top];
+    }
+
+    function effectiveTool() {
+        return ptr.space ? 'pan' : state.tool;
+    }
+
+    function bindViewer() {
+        var cv = $('cvOverlay');
+        cv.addEventListener('pointerdown', function (e) {
+            if (!state.project) {
+                return;
+            }
+            cv.setPointerCapture(e.pointerId);
+            var p = localXY(e);
+            ptr.pointers.set(e.pointerId, p);
+            if (ptr.pointers.size === 2) {
+                var pts = Array.from(ptr.pointers.values());
+                ptr.pinch = { d: Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]), c: [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2] };
+                ptr.down = null;
+                return;
+            }
+            ptr.down = { x: p[0], y: p[1], lx: p[0], ly: p[1], button: e.button, moved: false, tool: effectiveTool() };
+            if (ptr.down.tool === 'zoom' && e.button === 0) {
+                state.zoomRect = null;
+            }
+        });
+        cv.addEventListener('pointermove', function (e) {
+            var p = localXY(e);
+            if (ptr.pointers.has(e.pointerId)) {
+                ptr.pointers.set(e.pointerId, p);
+            }
+            if (ptr.pinch && ptr.pointers.size === 2) {
+                var pts = Array.from(ptr.pointers.values());
+                var d = Math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]);
+                var c = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2];
+                viewer.panBy(c[0] - ptr.pinch.c[0], c[1] - ptr.pinch.c[1]);
+                if (ptr.pinch.d > 0) {
+                    viewer.zoomAt(d / ptr.pinch.d, c[0], c[1]);
+                }
+                ptr.pinch = { d: d, c: c };
+                return;
+            }
+            if (!state.project) {
+                return;
+            }
+            var b = viewer.toBoard(p[0], p[1]);
+            state.cursor = b;
+            var tool = effectiveTool();
+            state.snapPt = tool === 'measure' || tool === 'origin' ? snap(b[0], b[1]) : null;
+            updateCoords();
+            var dn = ptr.down;
+            if (dn) {
+                if (!dn.moved && Math.hypot(p[0] - dn.x, p[1] - dn.y) > 4) {
+                    dn.moved = true;
+                }
+                var panning = dn.button === 1 || dn.button === 2 || dn.tool === 'pan' || (dn.tool === 'select' && dn.button === 0);
+                if (dn.moved && dn.tool === 'zoom' && dn.button === 0) {
+                    state.zoomRect = [dn.x, dn.y, p[0], p[1]];
+                } else if (dn.moved && panning) {
+                    cv.classList.add('panning');
+                    viewer.panBy(p[0] - dn.lx, p[1] - dn.ly);
+                }
+                dn.lx = p[0];
+                dn.ly = p[1];
+            }
+            if (state.measure && state.measure.a && !state.measure.b) {
+                state.measure.cur = state.snapPt || b;
+                renderMeasureBox();
+            }
+            viewer.requestOverlay();
+        });
+        function finish(e) {
+            var dn = ptr.down;
+            ptr.pointers.delete(e.pointerId);
+            if (ptr.pointers.size < 2) {
+                ptr.pinch = null;
+            }
+            if (!dn) {
+                return;
+            }
+            ptr.down = null;
+            cv.classList.remove('panning');
+            if (e.type === 'pointercancel') {
+                state.zoomRect = null;
+                viewer.renderOverlay();
+                return;
+            }
+            var p = localXY(e);
+            if (dn.button === 2 && !dn.moved) {
+                canvasContextMenu(e, p);
+                return;
+            }
+            if (dn.button !== 0) {
+                return;
+            }
+            if (dn.tool === 'zoom') {
+                if (dn.moved && state.zoomRect) {
+                    var r = state.zoomRect;
+                    state.zoomRect = null;
+                    var a = viewer.toBoard(Math.min(r[0], r[2]), Math.min(r[1], r[3]));
+                    var c = viewer.toBoard(Math.max(r[0], r[2]), Math.max(r[1], r[3]));
+                    zoomToBounds([Math.min(a[0], c[0]), Math.min(a[1], c[1]), Math.max(a[0], c[0]), Math.max(a[1], c[1])], 0);
+                } else {
+                    viewer.zoomAt(e.altKey ? 0.5 : 2, p[0], p[1]);
+                }
+                viewer.renderOverlay();
+                return;
+            }
+            if (dn.moved) {
+                return;
+            }
+            var b = viewer.toBoard(p[0], p[1]);
+            if (dn.tool === 'measure') {
+                var pt = snap(b[0], b[1]) || b;
+                if (!state.measure || state.measure.b) {
+                    state.measure = { a: pt, b: null, cur: pt };
+                } else {
+                    state.measure.b = pt;
+                    state.measure.cur = pt;
+                }
+                renderMeasureBox();
+                viewer.renderOverlay();
+            } else if (dn.tool === 'origin') {
+                state.origin = snap(b[0], b[1]) || b;
+                setTool('select');
+                UI.toast(T('toast/originSet', 'Origin set to the picked point'), { icon: 'target' });
+                updateCoords();
+                renderProps();
+                viewer.renderOverlay();
+            } else if (dn.tool === 'select') {
+                var hit = hitTest(b[0], b[1]);
+                setSelection(hit);
+            }
+        }
+        cv.addEventListener('pointerup', finish);
+        cv.addEventListener('pointercancel', finish);
+        cv.addEventListener('pointerleave', function () {
+            state.cursor = null;
+            state.snapPt = null;
+            updateCoords();
+            viewer.requestOverlay();
+        });
+        cv.addEventListener('contextmenu', function (e) { e.preventDefault(); });
+        cv.addEventListener('dblclick', function (e) {
+            if (!state.project || state.tool !== 'select') {
+                return;
+            }
+            var p = localXY(e);
+            var b = viewer.toBoard(p[0], p[1]);
+            var hit = hitTest(b[0], b[1]);
+            if (hit) {
+                zoomToBounds(hit.item.b, 6);
+            } else {
+                cmd.fit();
+            }
+        });
+        cv.addEventListener('wheel', function (e) {
+            if (!state.project) {
+                return;
+            }
+            e.preventDefault();
+            var p = localXY(e);
+            if (e.shiftKey && !e.ctrlKey) {
+                viewer.panBy(-e.deltaY, 0);
+                return;
+            }
+            var dy = e.deltaMode === 1 ? e.deltaY * 32 : e.deltaY;
+            var f = Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0018));
+            viewer.zoomAt(Math.max(0.5, Math.min(2, f)), p[0], p[1]);
+        }, { passive: false });
+    }
+
+    function canvasContextMenu(e, p) {
+        var b = viewer.toBoard(p[0], p[1]);
+        var hit = hitTest(b[0], b[1]);
+        UI.contextMenu([
+            hit ? { header: T('ctx/objectOn', '{object} on {layer}', { object: kindLabel(hit.item.o && hit.item.o.k), layer: hit.layer.label }) } : null,
+            hit ? { label: T('ctx/select', 'Select'), icon: 'pointer', action: function () { setSelection(hit); } } : null,
+            hit ? { label: T('ctx/zoomObject', 'Zoom to Object'), icon: 'fit', action: function () { zoomToBounds(hit.item.b, 6); } } : null,
+            hit ? { label: T('ctx/showOnly', 'Show Only {layer}', { layer: hit.layer.label }), icon: 'eye', action: function () { solo(hit.layer); } } : null,
+            hit ? { sep: true } : null,
+            { label: T('menu/zoomFit', 'Zoom to Fit'), icon: 'fit', key: 'F', action: cmd.fit },
+            { label: T('ctx/center', 'Center Here'), icon: 'crosshair', action: function () { viewer.centerOn(b[0], b[1]); } },
+            { label: T('ctx/measureFrom', 'Measure From Here'), icon: 'ruler', action: function () {
+                setTool('measure');
+                var pt = snap(b[0], b[1]) || b;
+                state.measure = { a: pt, b: null, cur: pt };
+                renderMeasureBox();
+            } },
+            { label: T('ctx/setOrigin', 'Set Origin Here'), icon: 'target', action: function () { state.origin = snap(b[0], b[1]) || b; updateCoords(); viewer.renderOverlay(); } },
+            { label: T('ctx/copyCoords', 'Copy Coordinates'), icon: 'file', action: function () { copyText(fmtXY(b[0], b[1])); } },
+            { sep: true },
+            { label: state.mode === 'realistic' ? T('menu/layerView', 'Layer View') : T('menu/realistic', 'Realistic Preview'), icon: 'layers', key: 'P', action: cmd.toggleRealistic },
+            { label: viewer.view.flip ? T('ctx/viewTop', 'View Top Side') : T('menu/viewBottom', 'View Bottom Side'), icon: 'flip', key: 'B', action: cmd.flip }
+        ], e.clientX, e.clientY);
+    }
+
+    function copyText(t) {
+        if (navigator.clipboard && navigator.clipboard.writeText) {
+            navigator.clipboard.writeText(t).then(function () {
+                UI.toast(T('toast/copied', 'Copied {text}', { text: t }), { icon: 'check' });
+            }, function () {
+                UI.toast(t);
+            });
+        } else {
+            UI.toast(t);
+        }
+    }
+
+    function zoomToBounds(b, padMM) {
+        if (!G.boundsValid(b)) {
+            return;
+        }
+        var pad = padMM === undefined ? 0 : padMM;
+        var w = b[2] - b[0], h = b[3] - b[1];
+        var minSize = 2;
+        var cx = (b[0] + b[2]) / 2, cy = (b[1] + b[3]) / 2;
+        w = Math.max(w + pad * 2, minSize);
+        h = Math.max(h + pad * 2, minSize);
+        viewer.fit([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], 30);
+    }
+
+    function updateCoords() {
+        var c = state.snapPt || state.cursor;
+        $('coordX').textContent = c ? fmtLen(c[0] - state.origin[0]) : '-';
+        $('coordY').textContent = c ? fmtLen(c[1] - state.origin[1]) : '-';
+    }
+
+    function updateZoomLabel() {
+        var r = viewer.view.scale / R.CSS_PX_PER_MM;
+        var t;
+        if (r >= 1) {
+            t = (r >= 10 ? Math.round(r) : Math.round(r * 10) / 10) + ':1';
+        } else {
+            var inv = 1 / r;
+            t = '1:' + (inv >= 10 ? Math.round(inv) : Math.round(inv * 10) / 10);
+        }
+        $('btnZoom').firstChild.nodeValue = t + ' ';
+    }
+
+    /* ---------------- Overlay drawing ---------------- */
+    function itemOutline(it) {
+        var p = new Path2D();
+        if (it.t === 'c') {
+            G.emitCircle(p, it.x, it.y, it.r, true);
+        } else if (it.t === 'p') {
+            G.emitFill(p, it);
+        } else if (it.w > 0) {
+            G.emitContour(p, GV.board.slotContour(it));
+        } else {
+            G.emitStroke(p, it);
+        }
+        return p;
+    }
+
+    function netItems(net) {
+        var out = [];
+        state.project.layers.forEach(function (l) {
+            if (!l.visible || !l.image || !/copper/.test(l.type)) {
+                return;
+            }
+            l.image.groups.forEach(function (g) {
+                if (!g.dark) {
+                    return;
+                }
+                g.items.forEach(function (it) {
+                    if (out.length < 6000 && it.o && it.o.at && it.o.at.N === net) {
+                        out.push(it);
+                    }
+                });
+            });
+        });
+        return out;
+    }
+
+    function drawOverlay(ctx, v) {
+        if (!state.project || state.dim === '3d') {
+            return;
+        }
+        var W = v.cssW, H = v.cssH;
+        var m = v.matrix(1);
+        var accent = getComputedStyle(document.body).getPropertyValue('--accent').trim() || '#e5383b';
+        var lightBg = !isDarkColor(state.settings.canvasBg);
+        var ink = lightBg ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.85)';
+
+        //Selection highlight (and the rest of its net when X2 nets exist)
+        if (state.selection) {
+            var it = state.selection.item;
+            ctx.save();
+            ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
+            var net = it.o && it.o.at && it.o.at.N;
+            if (net) {
+                var others = new Path2D();
+                netItems(net).forEach(function (x) { others.addPath(itemOutline(x)); });
+                ctx.fillStyle = 'rgba(255,214,10,0.35)';
+                ctx.fill(others);
+            }
+            var path = itemOutline(it);
+            ctx.fillStyle = 'rgba(255,255,255,0.28)';
+            ctx.fill(path);
+            ctx.lineWidth = 2 / v.view.scale;
+            ctx.strokeStyle = '#ffd60a';
+            ctx.stroke(path);
+            ctx.restore();
+        }
+
+        //Origin marker
+        if (state.origin[0] !== 0 || state.origin[1] !== 0 || state.tool === 'origin') {
+            var o = v.toScreen(state.origin[0], state.origin[1]);
+            ctx.strokeStyle = accent;
+            ctx.lineWidth = 1.5;
+            ctx.beginPath();
+            ctx.arc(o[0], o[1], 7, 0, Math.PI * 2);
+            ctx.moveTo(o[0] - 12, o[1]);
+            ctx.lineTo(o[0] + 12, o[1]);
+            ctx.moveTo(o[0], o[1] - 12);
+            ctx.lineTo(o[0], o[1] + 12);
+            ctx.stroke();
+        }
+
+        //Go-to marker
+        if (state.marker) {
+            var mk = v.toScreen(state.marker[0], state.marker[1]);
+            ctx.strokeStyle = '#ffd60a';
+            ctx.lineWidth = 2;
+            ctx.beginPath();
+            ctx.moveTo(mk[0] - 10, mk[1] - 10);
+            ctx.lineTo(mk[0] + 10, mk[1] + 10);
+            ctx.moveTo(mk[0] + 10, mk[1] - 10);
+            ctx.lineTo(mk[0] - 10, mk[1] + 10);
+            ctx.stroke();
+        }
+
+        //Crosshair cursor
+        if (state.settings.crosshair && state.cursor) {
+            var cp = v.toScreen((state.snapPt || state.cursor)[0], (state.snapPt || state.cursor)[1]);
+            ctx.strokeStyle = lightBg ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.35)';
+            ctx.lineWidth = 1;
+            ctx.beginPath();
+            ctx.moveTo(0, Math.round(cp[1]) + 0.5);
+            ctx.lineTo(W, Math.round(cp[1]) + 0.5);
+            ctx.moveTo(Math.round(cp[0]) + 0.5, 0);
+            ctx.lineTo(Math.round(cp[0]) + 0.5, H);
+            ctx.stroke();
+        }
+
+        //Measurement
+        var ms = state.measure;
+        if (ms && ms.a) {
+            var end = ms.b || ms.cur || ms.a;
+            var A = v.toScreen(ms.a[0], ms.a[1]), B = v.toScreen(end[0], end[1]);
+            var corner = v.toScreen(end[0], ms.a[1]);
+            ctx.save();
+            ctx.setLineDash([4, 4]);
+            ctx.strokeStyle = lightBg ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.45)';
+            ctx.lineWidth = 1;
+            ctx.beginPath();
+            ctx.moveTo(A[0], A[1]);
+            ctx.lineTo(corner[0], corner[1]);
+            ctx.lineTo(B[0], B[1]);
+            ctx.stroke();
+            ctx.restore();
+            ctx.strokeStyle = '#ffd60a';
+            ctx.lineWidth = 2;
+            ctx.beginPath();
+            ctx.moveTo(A[0], A[1]);
+            ctx.lineTo(B[0], B[1]);
+            ctx.stroke();
+            [A, B].forEach(function (q) {
+                ctx.fillStyle = '#ffd60a';
+                ctx.beginPath();
+                ctx.arc(q[0], q[1], 3.5, 0, Math.PI * 2);
+                ctx.fill();
+            });
+            var d = Math.hypot(end[0] - ms.a[0], end[1] - ms.a[1]);
+            if (d > 0) {
+                var label = fmtLen(d);
+                ctx.font = '600 12px ' + getComputedStyle(document.body).fontFamily;
+                var tw = ctx.measureText(label).width + 12;
+                var lx = (A[0] + B[0]) / 2 + 10, ly = (A[1] + B[1]) / 2 - 12;
+                ctx.fillStyle = 'rgba(20,21,24,0.88)';
+                roundRect(ctx, lx, ly - 11, tw, 22, 6);
+                ctx.fill();
+                ctx.fillStyle = '#ffd60a';
+                ctx.fillText(label, lx + 6, ly + 4);
+            }
+        }
+
+        //Snap indicator
+        if (state.snapPt) {
+            var sp = v.toScreen(state.snapPt[0], state.snapPt[1]);
+            ctx.strokeStyle = '#ffd60a';
+            ctx.lineWidth = 1.5;
+            ctx.strokeRect(sp[0] - 5, sp[1] - 5, 10, 10);
+        }
+
+        //Zoom rectangle
+        if (state.zoomRect) {
+            var zr = state.zoomRect;
+            ctx.save();
+            ctx.setLineDash([5, 4]);
+            ctx.strokeStyle = ink;
+            ctx.fillStyle = 'rgba(229,56,59,0.12)';
+            ctx.lineWidth = 1;
+            var rx = Math.min(zr[0], zr[2]), ry = Math.min(zr[1], zr[3]);
+            ctx.fillRect(rx, ry, Math.abs(zr[2] - zr[0]), Math.abs(zr[3] - zr[1]));
+            ctx.strokeRect(rx + 0.5, ry + 0.5, Math.abs(zr[2] - zr[0]), Math.abs(zr[3] - zr[1]));
+            ctx.restore();
+        }
+        renderMinimapViewport();
+    }
+
+    function roundRect(ctx, x, y, w, h, r) {
+        ctx.beginPath();
+        ctx.moveTo(x + r, y);
+        ctx.arcTo(x + w, y, x + w, y + h, r);
+        ctx.arcTo(x + w, y + h, x, y + h, r);
+        ctx.arcTo(x, y + h, x, y, r);
+        ctx.arcTo(x, y, x + w, y, r);
+        ctx.closePath();
+    }
+
+    function renderMeasureBox() {
+        var box = $('measureBox');
+        var ms = state.measure;
+        if (!ms || !ms.a || state.tool !== 'measure' && !ms.b) {
+            box.hidden = true;
+            return;
+        }
+        var end = ms.b || ms.cur || ms.a;
+        var dx = end[0] - ms.a[0], dy = end[1] - ms.a[1];
+        var d = Math.hypot(dx, dy);
+        box.hidden = false;
+        box.innerHTML = '<div class="fb-h">' + icon('ruler') + '<span>' + esc(ms.b ? T('measure/title', 'Measurement') : T('measure/live', 'Measuring...')) + '</span>' +
+            '<button title="' + esc(T('measure/clear', 'Clear measurement')) + '">' + icon('close') + '</button></div>' +
+            '<div class="fb-big">' + fmtLen(d) + '</div>' +
+            '<div class="fb-row"><span>ΔX</span><span>' + fmtLen(Math.abs(dx)) + '</span></div>' +
+            '<div class="fb-row"><span>ΔY</span><span>' + fmtLen(Math.abs(dy)) + '</span></div>' +
+            '<div class="fb-row"><span>' + esc(T('measure/angle', 'Angle')) + '</span><span>' + (Math.atan2(dy, dx) * 180 / Math.PI).toFixed(2) + '°</span></div>' +
+            '<div class="fb-row"><span>' + esc(T('measure/from', 'From')) + '</span><span>' + fmtXY(ms.a[0], ms.a[1]) + '</span></div>' +
+            '<div class="fb-row"><span>' + esc(T('measure/to', 'To')) + '</span><span>' + fmtXY(end[0], end[1]) + '</span></div>';
+        box.querySelector('button').onclick = function () { cmd.clearMeasure(); };
+    }
+
+    cmd.clearMeasure = function () {
+        state.measure = null;
+        renderMeasureBox();
+        viewer.renderOverlay();
+    };
+
+    /* ---------------- Minimap ---------------- */
+    var mini = { view: null, timer: 0 };
+
+    //Layer edits (opacity sliders, colour pickers) come in bursts
+    function scheduleMinimap() {
+        clearTimeout(mini.timer);
+        mini.timer = setTimeout(renderMinimap, 150);
+    }
+
+    function renderMinimap() {
+        var box = $('minimap');
+        var show = !!state.project && state.settings.minimap && state.dim === '2d';
+        box.hidden = !show;
+        if (!show) {
+            return;
+        }
+        var cv = $('cvMini');
+        var r = box.getBoundingClientRect();
+        var dpr = Math.min(window.devicePixelRatio || 1, 2);
+        var w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
+        var b = projectBounds(false);
+        if (!b) {
+            return;
+        }
+        var rot = (viewer.view.rot / 90) % 2 !== 0;
+        var bw = rot ? b[3] - b[1] : b[2] - b[0], bh = rot ? b[2] - b[0] : b[3] - b[1];
+        var scale = Math.min((r.width - 12) / bw, (r.height - 12) / bh);
+        mini.view = { cx: (b[0] + b[2]) / 2, cy: (b[1] + b[3]) / 2, scale: scale, rot: viewer.view.rot, flip: viewer.view.flip };
+        if (!state.miniCache || state.miniCache.width !== w || state.miniCache.height !== h) {
+            state.miniCache = R.renderToCanvas(scene(true), Object.assign({}, mini.view, { scale: scale * dpr }), w, h, mini.scratch || (mini.scratch = new R.Scratch()));
+        }
+        cv.width = w;
+        cv.height = h;
+        renderMinimapViewport();
+    }
+
+    function renderMinimapViewport() {
+        if (!state.miniCache || $('minimap').hidden || !mini.view) {
+            return;
+        }
+        var cv = $('cvMini');
+        var ctx = cv.getContext('2d');
+        var dpr = cv.width / Math.max(1, $('minimap').getBoundingClientRect().width);
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        ctx.clearRect(0, 0, cv.width, cv.height);
+        ctx.drawImage(state.miniCache, 0, 0);
+        var mm = R.viewMatrix(mini.view, cv.width / dpr, cv.height / dpr, 1);
+        var corners = [[0, 0], [viewer.cssW, 0], [viewer.cssW, viewer.cssH], [0, viewer.cssH]].map(function (c) {
+            var b = viewer.toBoard(c[0], c[1]);
+            return R.apply(mm, b[0], b[1]);
+        });
+        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+        ctx.strokeStyle = '#ff5a5f';
+        ctx.lineWidth = 1.5;
+        ctx.fillStyle = 'rgba(255,90,95,0.12)';
+        ctx.beginPath();
+        corners.forEach(function (c, i) {
+            if (i) {
+                ctx.lineTo(c[0], c[1]);
+            } else {
+                ctx.moveTo(c[0], c[1]);
+            }
+        });
+        ctx.closePath();
+        ctx.fill();
+        ctx.stroke();
+    }
+
+    function bindMinimap() {
+        var box = $('minimap');
+        var dragging = false;
+        function go(e) {
+            if (!mini.view) {
+                return;
+            }
+            var r = box.getBoundingClientRect();
+            var mm = R.viewMatrix(mini.view, r.width, r.height, 1);
+            var b = R.apply(R.invert(mm), e.clientX - r.left, e.clientY - r.top);
+            viewer.centerOn(b[0], b[1]);
+        }
+        box.addEventListener('pointerdown', function (e) {
+            dragging = true;
+            box.setPointerCapture(e.pointerId);
+            go(e);
+        });
+        box.addEventListener('pointermove', function (e) {
+            if (dragging) {
+                go(e);
+            }
+        });
+        box.addEventListener('pointerup', function () { dragging = false; });
+    }
+
+    /* ---------------- Scene ---------------- */
+    function gridOpts() {
+        if (!state.settings.grid) {
+            return null;
+        }
+        var light = !isDarkColor(state.settings.canvasBg);
+        return {
+            step: R.niceStep(viewer.view.scale, 16, state.settings.units !== 'mm'),
+            major: 5,
+            color: light ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.045)',
+            majorColor: light ? 'rgba(0,0,0,0.13)' : 'rgba(255,255,255,0.1)'
+        };
+    }
+
+    function scene(forMini) {
+        if (!state.project) {
+            return null;
+        }
+        return {
+            mode: state.mode,
+            side: viewer.view.flip ? 'bottom' : 'top',
+            layers: drawOrder(),
+            all: state.project.layers,
+            board: state.project.board,
+            background: forMini ? null : state.settings.canvasBg,
+            look: look(),
+            grid: forMini ? null : gridOpts(),
+            shadow: !forMini && state.mode === 'realistic'
+        };
+    }
+
+    /* ==================================================================
+        Tools, modes and view commands
+    ================================================================== */
+    function setTool(t) {
+        if (state.dim === '3d') {
+            return;
+        }
+        state.tool = t;
+        var cv = $('cvOverlay');
+        cv.className = 'tool-' + t + (state.settings.crosshair ? ' crosshair' : '');
+        document.querySelectorAll('[data-tool]').forEach(function (b) { b.classList.toggle('active', b.dataset.tool === t); });
+        if (t !== 'measure' && state.measure && !state.measure.b) {
+            state.measure = null;
+        }
+        state.snapPt = null;
+        renderMeasureBox();
+        viewer.renderOverlay();
+    }
+
+    function updateToolbar() {
+        var has = !!state.project;
+        var is3d = state.dim === '3d';
+        document.querySelectorAll('#toolbar [data-tool], #toolbar [data-cmd="fit"], #toolbar [data-cmd="rotate"]').forEach(function (b) {
+            b.disabled = !has;
+        });
+        document.querySelectorAll('#toolbar [data-tool]').forEach(function (b) {
+            b.disabled = !has || is3d;
+        });
+        $('btnFlip').disabled = !has;
+        $('btnRender').disabled = !has || is3d;
+        $('btnFlip').classList.toggle('active', has && viewer.view.flip);
+        $('btnRender').classList.toggle('active', has && state.mode === 'realistic');
+        $('btnCrosshair').classList.toggle('active', state.settings.crosshair);
+        document.querySelectorAll('#segDim button').forEach(function (b) {
+            b.classList.toggle('active', b.dataset.dim === state.dim);
+            b.disabled = !has && b.dataset.dim === '3d';
+        });
+        $('btnPanel').classList.toggle('active', !document.body.classList.contains('panel-hidden') && (window.innerWidth > 820 || document.body.classList.contains('panel-open')));
+        $('btnFullscreen').classList.toggle('active', document.body.classList.contains('zen'));
+        var chip = $('modeChip');
+        if (has && (state.mode === 'realistic' || viewer.view.flip) && !is3d) {
+            chip.hidden = false;
+            chip.textContent = (state.mode === 'realistic' ? T('chip/realistic', 'Realistic') : T('chip/layers', 'Layers')) + ' · ' +
+                (viewer.view.flip ? T('chip/bottom', 'Bottom side (mirrored)') : T('chip/top', 'Top side'));
+        } else {
+            chip.hidden = true;
+        }
+        $('coordBar').hidden = !has || is3d;
+        $('view3dBar').hidden = !is3d;
+        $('cvOverlay').className = 'tool-' + state.tool + (state.settings.crosshair ? ' crosshair' : '');
+    }
+
+    cmd.fit = function () {
+        if (!state.project) {
+            return;
+        }
+        if (state.dim === '3d' && view3d) {
+            view3d.setView('iso');
+            return;
+        }
+        var b = projectBounds(true);
+        if (b) {
+            viewer.fit(b, 36);
+        }
+    };
+
+    cmd.zoomIn = function () {
+        viewer.zoomAt(1.5, viewer.cssW / 2, viewer.cssH / 2);
+    };
+
+    cmd.zoomOut = function () {
+        viewer.zoomAt(1 / 1.5, viewer.cssW / 2, viewer.cssH / 2);
+    };
+
+    cmd.zoomTo = function (ratio) {
+        viewer.view.scale = R.CSS_PX_PER_MM * ratio;
+        viewer.changed();
+    };
+
+    cmd.rotate = function () {
+        if (!state.project) {
+            return;
+        }
+        viewer.view.rot = (viewer.view.rot + 90) % 360;
+        state.miniCache = null;
+        viewer.changed();
+        renderMinimap();
+        saveStateSoon();
+    };
+
+    cmd.flip = function () {
+        if (!state.project) {
+            return;
+        }
+        viewer.view.flip = !viewer.view.flip;
+        state.miniCache = null;
+        if (state.dim === '3d' && view3d) {
+            view3d.setView(viewer.view.flip ? 'bottom' : 'top');
+        }
+        viewer.changed();
+        renderMinimap();
+        updateToolbar();
+        saveStateSoon();
+    };
+
+    cmd.setMode = function (m) {
+        state.mode = m;
+        state.miniCache = null;
+        viewer.requestRender();
+        renderMinimap();
+        updateToolbar();
+        saveStateSoon();
+    };
+
+    cmd.toggleRealistic = function () {
+        if (!state.project) {
+            return;
+        }
+        cmd.setMode(state.mode === 'realistic' ? 'layers' : 'realistic');
+    };
+
+    cmd.viewSide = function (bottom) {
+        if (viewer.view.flip !== bottom) {
+            cmd.flip();
+        }
+    };
+
+    function renderModeItems() {
+        return [
+            { label: T('menu/layerView', 'Layer View'), icon: 'layers', checked: function () { return state.mode === 'layers'; }, action: function () { cmd.setMode('layers'); } },
+            { label: T('menu/realistic', 'Realistic Preview'), icon: 'board', key: 'P', checked: function () { return state.mode === 'realistic'; }, action: function () { cmd.setMode('realistic'); } },
+            { sep: true },
+            { label: T('menu/topSide', 'Top Side'), checked: function () { return !viewer.view.flip; }, action: function () { cmd.viewSide(false); } },
+            { label: T('menu/bottomSide', 'Bottom Side (Mirrored)'), key: 'B', checked: function () { return viewer.view.flip; }, action: function () { cmd.viewSide(true); } }
+        ];
+    }
+
+    cmd.renderMenu = function () {
+        var r = $('btnRender').getBoundingClientRect();
+        UI.contextMenu(renderModeItems(), r.left, r.bottom + 4);
+    };
+
+    cmd.togglePanel = function () {
+        if (window.innerWidth <= 820) {
+            document.body.classList.toggle('panel-open');
+        } else {
+            document.body.classList.toggle('panel-hidden');
+        }
+        setTimeout(onResize, 30);
+        updateToolbar();
+    };
+
+    cmd.fullscreen = function () {
+        document.body.classList.toggle('zen');
+        var standalone = !(typeof ao_module_virtualDesktop !== 'undefined' && ao_module_virtualDesktop);
+        if (standalone && document.fullscreenEnabled) {
+            if (document.body.classList.contains('zen') && !document.fullscreenElement) {
+                document.documentElement.requestFullscreen().catch(function () { });
+            } else if (document.fullscreenElement) {
+                document.exitFullscreen().catch(function () { });
+            }
+        }
+        setTimeout(onResize, 60);
+        updateToolbar();
+    };
+
+    cmd.toggleSetting = function (key) {
+        state.settings[key] = !state.settings[key];
+        S.saveSettings(state.settings);
+        if (key === 'minimap') {
+            renderMinimap();
+        }
+        if (key === 'tips') {
+            $('tipCard').hidden = !state.settings.tips;
+        }
+        updateToolbar();
+        viewer.requestRender();
+    };
+
+    cmd.showAll = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) { l.visible = true; });
+        layersChanged();
+    };
+
+    cmd.hideAll = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) { l.visible = false; });
+        layersChanged();
+    };
+
+    cmd.showSide = function (side) {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) {
+            var s = sideOf(l);
+            l.visible = s === side || l.type === 'outline' || l.type === 'drill';
+            if (/mask|paste|assembly/.test(l.type)) {
+                l.visible = false;
+            }
+        });
+        layersChanged();
+    };
+
+    cmd.copperOnly = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) { l.visible = /copper|outline|drill/.test(l.type); });
+        layersChanged();
+    };
+
+    cmd.resetColors = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) {
+            l.color = defaultColor(l.type);
+            l.opacity = defaultOpacity(l.type);
+        });
+        layersChanged();
+    };
+
+    cmd.resetOrder = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers = sortDefault(state.project.layers);
+        layersChanged();
+    };
+
+    cmd.resetLayers = function () {
+        if (!state.project) {
+            return;
+        }
+        state.project.layers.forEach(function (l) {
+            applyType(l, L.identify(l.name, l.text, l.image).type, true);
+        });
+        state.project.layers = sortDefault(state.project.layers);
+        layersChanged({ rebuildBoard: true });
+    };
+
+    cmd.layerMenu = function () {
+        var b = document.querySelector('[data-cmd=layerMenu]').getBoundingClientRect();
+        UI.contextMenu(layersMenuItems(), b.left - 180, b.bottom + 4);
+    };
+
+    cmd.cycleLayer = function (d) {
+        var p = state.project;
+        if (!p || !p.layers.length) {
+            return;
+        }
+        var i = p.layers.findIndex(function (l) { return l.id === state.selectedLayer; });
+        i = i < 0 ? 0 : (i + d + p.layers.length) % p.layers.length;
+        selectLayer(p.layers[i].id, false);
+    };
+
+    cmd.copyCursor = function () {
+        if (state.cursor) {
+            copyText(fmtXY(state.cursor[0], state.cursor[1]));
+        }
+    };
+
+    cmd.copyImage = function () {
+        if (!state.project) {
+            return;
+        }
+        var src = state.dim === '3d' && view3d ? null : $('cvBase');
+        var p = src ? GV.exporter.canvasBlob(src) : fetch(view3d.snapshot()).then(function (r) { return r.blob(); });
+        p.then(function (blob) {
+            if (!navigator.clipboard || !window.ClipboardItem) {
+                throw new Error(T('toast/noClipboard', 'Clipboard images are not supported here'));
+            }
+            return navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
+        }).then(function () {
+            UI.toast(T('toast/imageCopied', 'View copied to the clipboard'), { icon: 'check' });
+        }, function (e) {
+            UI.toast(T('toast/copyFailed', 'Unable to copy: {error}', { error: errText(e) }), { type: 'error' });
+        });
+    };
+
+    cmd.setOriginTool = function () {
+        setTool('origin');
+        UI.toast(T('toast/pickOrigin', 'Click a point to use as the origin. Esc cancels.'), { icon: 'target' });
+    };
+
+    cmd.resetOrigin = function () {
+        state.origin = [0, 0];
+        updateCoords();
+        renderProps();
+        viewer.renderOverlay();
+    };
+
+    cmd.originToBoard = function () {
+        var b = state.project && state.project.board;
+        if (b && G.boundsValid(b.bounds)) {
+            state.origin = [b.bounds[0], b.bounds[1]];
+            updateCoords();
+            renderProps();
+            viewer.renderOverlay();
+            UI.toast(T('toast/originCorner', 'Origin moved to the lower-left corner of the board'), { icon: 'target' });
+        }
+    };
+
+    /* ==================================================================
+        3D
+    ================================================================== */
+    var build3DTimer = 0;
+    var built3DFor = null;      //project last shown in 3D
+    function schedule3D() {
+        clearTimeout(build3DTimer);
+        build3DTimer = setTimeout(build3D, 250);
+    }
+
+    function boardTexture(side, lk) {
+        var p = state.project;
+        var b = p.board.bounds;
+        var w = b[2] - b[0], h = b[3] - b[1];
+        var maxTex = Math.min(view3d.maxTexture(), 4096);
+        var pxmm = Math.min(30, maxTex / Math.max(w, h));
+        return R.renderToCanvas({
+            mode: 'realistic',
+            side: side,
+            layers: [],
+            all: p.layers,
+            board: p.board,
+            background: null,
+            look: lk || look()
+        }, { cx: (b[0] + b[2]) / 2, cy: (b[1] + b[3]) / 2, scale: pxmm, rot: 0, flip: false }, w * pxmm, h * pxmm);
+    }
+
+    async function build3D() {
+        if (state.dim !== '3d' || !state.project) {
+            return;
+        }
+        var p = state.project;
+        if (!p.board || !p.board.outer.length) {
+            UI.toast(T('toast/no3d', 'The 3D view needs a board outline or copper layers.'), { type: 'error' });
+            return;
+        }
+        try {
+            if (!view3d) {
+                if (!view3dLoading) {
+                    showLoading(T('loading/3dStart', 'Starting 3D view...'));
+                    view3dLoading = import('./view3d.js').then(function (mod) {
+                        view3d = mod.createView($('view3d'));
+                        return view3d;
+                    });
+                }
+                await view3dLoading;
+                hideLoading();
+            }
+            showLoading(T('loading/3dBuild', 'Building 3D board...'));
+            await nextTick();
+            //The camera framing needs the real aspect ratio
+            view3d.resize();
+            //The manufacturing preview (mfg.js) swaps in the fab's options
+            var mf = GV.mfg && GV.mfg.active() ? GV.mfg.build() : null;
+            var lk = mf ? mf.look : look();
+            //Rebuilding the same project (option changes) keeps the camera
+            var keepView = built3DFor === p;
+            built3DFor = p;
+            view3d.setBoard({
+                board: p.board,
+                thickness: mf ? mf.thickness : state.settings.thickness,
+                top: boardTexture('top', lk),
+                bottom: boardTexture('bottom', lk),
+                edge: lk.edge,
+                barrel: lk.finish,
+                background: state.settings.canvasBg,
+                slotContour: GV.board.slotContour,
+                fillVias: !!(mf && mf.fillVias),
+                stack: mf ? mf.stack : null,
+                panel: mf ? mf.panel : null,
+                keepView: keepView
+            });
+            hideLoading();
+            view3d.resize();
+            view3d.start();
+        } catch (e) {
+            hideLoading();
+            console.error(e);
+            UI.toast(T('toast/3dFailed', 'Unable to start the 3D view: {error}', { error: errText(e) }), { type: 'error' });
+            setDim('2d');
+        }
+    }
+
+    function setDim(d) {
+        if (d === '3d' && !state.project) {
+            return;
+        }
+        state.dim = d;
+        $('view3d').hidden = d !== '3d';
+        $('cvBase').hidden = d === '3d';
+        $('cvOverlay').hidden = d === '3d';
+        $('measureBox').hidden = d === '3d' || !state.measure;
+        if (d === '3d') {
+            build3D();
+        } else if (view3d) {
+            view3d.stop();
+            viewer.requestRender();
+        }
+        renderMinimap();
+        updateToolbar();
+        if (GV.mfg) {
+            GV.mfg.dimChanged(d);
+        }
+    }
+
+    cmd.dim2d = function () { setDim('2d'); };
+    cmd.dim3d = function () { setDim('3d'); };
+    cmd.toggle3D = function () { setDim(state.dim === '3d' ? '2d' : '3d'); };
+
+    /* ==================================================================
+        Menus
+    ================================================================== */
+    function layersMenuItems() {
+        var has = function () { return !!state.project; };
+        return [
+            { label: T('menu/showAll', 'Show All Layers'), icon: 'eye', enabled: has, action: cmd.showAll },
+            { label: T('menu/hideAll', 'Hide All Layers'), icon: 'eye-off', enabled: has, action: cmd.hideAll },
+            { label: T('menu/topOnly', 'Top Side Only'), enabled: has, action: function () { cmd.showSide('top'); } },
+            { label: T('menu/bottomOnly', 'Bottom Side Only'), enabled: has, action: function () { cmd.showSide('bottom'); } },
+            { label: T('menu/copperOnly', 'Copper Only'), enabled: has, action: cmd.copperOnly },
+            { sep: true },
+            { label: T('menu/prevLayer', 'Previous Layer'), key: 'PgUp', enabled: has, action: function () { cmd.cycleLayer(-1); } },
+            { label: T('menu/nextLayer', 'Next Layer'), key: 'PgDn', enabled: has, action: function () { cmd.cycleLayer(1); } },
+            { sep: true },
+            { label: T('menu/resetColours', 'Reset Layer Colours'), icon: 'palette', enabled: has, action: cmd.resetColors },
+            { label: T('menu/resetOrder', 'Reset Layer Order'), enabled: has, action: cmd.resetOrder },
+            { label: T('menu/redetect', 'Re-detect Layer Types'), icon: 'refresh', enabled: has, action: cmd.resetLayers }
+        ];
+    }
+
+    function buildMenus() {
+        var has = function () { return !!state.project; };
+        var online = function () { return S.online; };
+        var is2d = function () { return !!state.project && state.dim === '2d'; };
+        return [
+            {
+                id: 'file', label: T('menu/file', 'File'), items: [
+                    { label: T('menu/openAroz', 'Open from ArozOS...'), icon: 'drive', key: 'Ctrl+O', enabled: online, action: cmd.openAroz },
+                    { label: T('menu/openArozFolder', 'Open Folder from ArozOS...'), icon: 'folder', enabled: online, action: cmd.openArozFolder },
+                    { label: T('menu/openLocal', 'Open from This Device...'), icon: 'laptop', key: 'Ctrl+Shift+O', action: cmd.openLocal },
+                    { label: T('menu/openLocalFolder', 'Open Folder from This Device...'), icon: 'folder', action: cmd.openLocalFolder },
+                    { label: T('menu/openRecent', 'Open Recent'), icon: 'clock', submenu: recentMenu },
+                    { sep: true },
+                    { label: T('menu/addAroz', 'Add Layers from ArozOS...'), icon: 'file-plus', enabled: function () { return has() && online(); }, action: cmd.addAroz },
+                    { label: T('menu/addLocal', 'Add Layers from This Device...'), icon: 'file-plus', enabled: has, action: cmd.addLocal },
+                    { sep: true },
+                    { label: T('menu/reload', 'Reload Project'), icon: 'refresh', key: 'Ctrl+Shift+R', enabled: has, action: cmd.reload },
+                    { label: T('menu/close', 'Close Project'), icon: 'close', enabled: has, action: cmd.close },
+                    { sep: true },
+                    { label: T('menu/export', 'Export Image...'), icon: 'image', key: 'Ctrl+E', enabled: has, action: function () { cmd.exportDialog(); } },
+                    { label: T('menu/saveZip', 'Save Project as ZIP...'), icon: 'zip', key: 'Ctrl+S', enabled: has, action: function () { cmd.saveZip(); } },
+                    { label: T('menu/exportCsv', 'Export Drill Table (CSV)...'), icon: 'table', enabled: function () { return has() && state.project.layers.some(function (l) { return l.type === 'drill'; }); }, action: function () { cmd.exportDrillCSV(); } },
+                    { label: T('menu/print', 'Print...'), icon: 'print', key: 'Ctrl+P', enabled: has, action: function () { cmd.printDialog(); } },
+                    { sep: true },
+                    { label: T('menu/exit', 'Exit'), icon: 'close', action: function () { try { ao_module_close(); } catch (e) { window.close(); } } }
+                ]
+            },
+            {
+                id: 'edit', label: T('menu/edit', 'Edit'), items: [
+                    { label: T('menu/copyCursor', 'Copy Cursor Coordinates'), icon: 'crosshair', key: 'Ctrl+Shift+C', enabled: function () { return !!state.cursor; }, action: cmd.copyCursor },
+                    { label: T('menu/copyImage', 'Copy View as Image'), icon: 'image', key: 'Ctrl+Shift+I', enabled: has, action: cmd.copyImage },
+                    { sep: true },
+                    { label: T('menu/clearSelection', 'Clear Selection'), icon: 'pointer', key: 'Esc', enabled: function () { return !!state.selection; }, action: function () { setSelection(null); } },
+                    { label: T('menu/clearMeasure', 'Clear Measurement'), icon: 'ruler', enabled: function () { return !!state.measure; }, action: cmd.clearMeasure },
+                    { sep: true },
+                    { label: T('menu/preferences', 'Preferences...'), icon: 'gear', key: 'Ctrl+,', action: function () { cmd.settingsDialog(); } }
+                ]
+            },
+            {
+                id: 'view', label: T('menu/view', 'View'), items: [
+                    { label: T('menu/2d', '2D View'), key: '2', checked: function () { return state.dim === '2d'; }, enabled: has, action: cmd.dim2d },
+                    { label: T('menu/3d', '3D View'), icon: 'cube', key: '3', checked: function () { return state.dim === '3d'; }, enabled: has, action: cmd.dim3d },
+                    { sep: true },
+                    { label: T('menu/layerView', 'Layer View'), icon: 'layers', checked: function () { return state.mode === 'layers'; }, enabled: is2d, action: function () { cmd.setMode('layers'); } },
+                    { label: T('menu/realistic', 'Realistic Preview'), icon: 'board', key: 'P', checked: function () { return state.mode === 'realistic'; }, enabled: is2d, action: function () { cmd.setMode('realistic'); } },
+                    { label: T('menu/viewBottom', 'View Bottom Side'), icon: 'flip', key: 'B', checked: function () { return viewer.view.flip; }, enabled: has, action: cmd.flip },
+                    { label: T('menu/rotate', 'Rotate 90°'), icon: 'rotate', key: 'R', enabled: is2d, action: cmd.rotate },
+                    { sep: true },
+                    { label: T('menu/zoomIn', 'Zoom In'), icon: 'zoom', key: '+', enabled: is2d, action: cmd.zoomIn },
+                    { label: T('menu/zoomOut', 'Zoom Out'), icon: 'zoom-out', key: '-', enabled: is2d, action: cmd.zoomOut },
+                    { label: T('menu/zoomFit', 'Zoom to Fit'), icon: 'fit', key: 'F', enabled: has, action: cmd.fit },
+                    { label: T('menu/actualSize', 'Actual Size (1:1)'), key: '1', enabled: is2d, action: function () { cmd.zoomTo(1); } },
+                    { label: T('menu/zoomSelection', 'Zoom to Selection'), enabled: function () { return !!state.selection; }, action: function () { zoomToBounds(state.selection.item.b, 6); } },
+                    { sep: true },
+                    { label: T('menu/grid', 'Grid'), key: 'G', checked: function () { return state.settings.grid; }, action: function () { cmd.toggleSetting('grid'); } },
+                    { label: T('menu/crosshair', 'Crosshair Cursor'), key: 'C', checked: function () { return state.settings.crosshair; }, action: function () { cmd.toggleSetting('crosshair'); } },
+                    { label: T('menu/minimap', 'Minimap'), checked: function () { return state.settings.minimap; }, action: function () { cmd.toggleSetting('minimap'); } },
+                    { label: T('menu/tips', 'Tips'), checked: function () { return state.settings.tips; }, action: function () { cmd.toggleSetting('tips'); } },
+                    { sep: true },
+                    { label: T('menu/sidePanel', 'Side Panel'), icon: 'panel', key: 'Ctrl+B', checked: function () { return !document.body.classList.contains('panel-hidden'); }, action: cmd.togglePanel },
+                    { label: T('menu/focus', 'Focus Mode'), icon: 'expand', key: 'Shift+F', checked: function () { return document.body.classList.contains('zen'); }, action: cmd.fullscreen },
+                    { label: T('menu/theme', 'Theme'), icon: 'sun', submenu: function () {
+                        return ['system', 'light', 'dark'].map(function (t) {
+                            return {
+                                label: t === 'system' ? T('theme/system', 'Follow ArozOS') : (t === 'light' ? T('theme/light', 'Light') : T('theme/dark', 'Dark')),
+                                checked: state.settings.theme === t,
+                                action: function () { state.settings.theme = t; applyTheme(); S.saveSettings(state.settings); }
+                            };
+                        });
+                    } },
+                    { label: T('menu/units', 'Units'), submenu: function () {
+                        return [['mm', T('unit/mmLong', 'Millimetres')], ['in', T('unit/inLong', 'Inches')], ['mil', T('unit/milLong', 'Mils')]].map(function (u) {
+                            return { label: u[1], checked: state.settings.units === u[0], action: function () { setUnits(u[0]); } };
+                        });
+                    } }
+                ]
+            },
+            { id: 'layers', label: T('menu/layers', 'Layers'), items: layersMenuItems },
+            {
+                id: 'tools', label: T('menu/tools', 'Tools'), items: [
+                    { label: T('menu/select', 'Select'), icon: 'pointer', key: 'V', checked: function () { return state.tool === 'select'; }, enabled: is2d, action: function () { setTool('select'); } },
+                    { label: T('menu/pan', 'Pan'), icon: 'hand', key: 'H', checked: function () { return state.tool === 'pan'; }, enabled: is2d, action: function () { setTool('pan'); } },
+                    { label: T('menu/zoomArea', 'Zoom to Area'), icon: 'zoom', key: 'Z', checked: function () { return state.tool === 'zoom'; }, enabled: is2d, action: function () { setTool('zoom'); } },
+                    { label: T('menu/measure', 'Measure'), icon: 'ruler', key: 'M', checked: function () { return state.tool === 'measure'; }, enabled: is2d, action: function () { setTool('measure'); } },
+                    { label: T('menu/snap', 'Snap to Features'), checked: function () { return state.settings.snap; }, action: function () { cmd.toggleSetting('snap'); } },
+                    { sep: true },
+                    { label: T('menu/setOrigin', 'Set Origin...'), icon: 'target', enabled: is2d, action: cmd.setOriginTool },
+                    { label: T('menu/originCorner', 'Origin at Board Corner'), enabled: has, action: cmd.originToBoard },
+                    { label: T('menu/resetOrigin', 'Reset Origin'), enabled: function () { return state.origin[0] !== 0 || state.origin[1] !== 0; }, action: cmd.resetOrigin },
+                    { label: T('menu/goto', 'Go to Coordinate...'), icon: 'crosshair', key: 'Ctrl+G', enabled: is2d, action: function () { cmd.gotoDialog(); } },
+                    { sep: true },
+                    { label: T('menu/drillTable', 'Drill Table...'), icon: 'drill', enabled: function () { return has() && state.project.layers.some(function (l) { return l.type === 'drill'; }); }, action: function () { cmd.drillTable(); } },
+                    { label: T('menu/apertureTable', 'Aperture Table...'), icon: 'table', enabled: has, action: function () { cmd.apertureTable(); } },
+                    { label: T('menu/boardReport', 'Board Report...'), icon: 'board', enabled: has, action: function () { cmd.boardReport(); } },
+                    { label: T('menu/viewLayerSource', 'View Layer Source...'), icon: 'code', enabled: has, action: function () { cmd.viewSource(); } },
+                    { sep: true },
+                    { label: T('menu/mfg', 'Manufacturing Capabilities Preview...'), icon: 'factory', enabled: has, action: function () { cmd.mfgPreview(); } }
+                ]
+            },
+            {
+                id: 'help', label: T('menu/help', 'Help'), items: [
+                    { label: T('menu/shortcuts', 'Keyboard Shortcuts'), icon: 'keyboard', key: '?', action: function () { cmd.shortcuts(); } },
+                    { label: T('menu/formats', 'Supported Formats'), icon: 'file', action: function () { cmd.formats(); } },
+                    { sep: true },
+                    { label: T('menu/about', 'About Gerber Viewer'), icon: 'info', action: function () { cmd.about(); } }
+                ]
+            }
+        ];
+    }
+
+    function setUnits(u) {
+        state.settings.units = u;
+        S.saveSettings(state.settings);
+        updateCoords();
+        renderFileInfo();
+        renderProps();
+        renderMeasureBox();
+        updateTitle();
+        viewer.requestRender();
+    }
+
+    /* ==================================================================
+        Keyboard
+    ================================================================== */
+    function bindKeys() {
+        document.addEventListener('keydown', function (e) {
+            if (UI.isMenuOpen() && UI.menuKey(e)) {
+                e.preventDefault();
+                return;
+            }
+            if (UI.hasDialog()) {
+                if (UI.dialogKey(e) && (e.key === 'Escape' || e.key === 'Enter')) {
+                    e.preventDefault();
+                }
+                return;
+            }
+            if (UI.hasPopover() && e.key === 'Escape') {
+                UI.closePopover();
+                return;
+            }
+            var t = e.target;
+            if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) {
+                return;
+            }
+            var k = e.key;
+            var ctrl = e.ctrlKey || e.metaKey;
+            var handled = true;
+            if (ctrl) {
+                var lk = k.toLowerCase();
+                if (lk === 'o' && e.shiftKey) {
+                    cmd.openLocal();
+                } else if (lk === 'o') {
+                    if (S.online) {
+                        cmd.openAroz();
+                    } else {
+                        cmd.openLocal();
+                    }
+                } else if (lk === 'e' && state.project) {
+                    cmd.exportDialog();
+                } else if (lk === 's' && state.project) {
+                    cmd.saveZip();
+                } else if (lk === 'p' && state.project) {
+                    cmd.printDialog();
+                } else if (lk === ',') {
+                    cmd.settingsDialog();
+                } else if (lk === 'g' && state.project) {
+                    cmd.gotoDialog();
+                } else if (lk === 'b') {
+                    cmd.togglePanel();
+                } else if (lk === 'c' && e.shiftKey) {
+                    cmd.copyCursor();
+                } else if (lk === 'i' && e.shiftKey) {
+                    cmd.copyImage();
+                } else if (lk === 'r' && e.shiftKey) {
+                    cmd.reload();
+                } else {
+                    handled = false;
+                }
+            } else if (k === 'Escape') {
+                if (state.zoomRect) {
+                    state.zoomRect = null;
+                } else if (state.measure) {
+                    cmd.clearMeasure();
+                } else if (state.selection) {
+                    setSelection(null);
+                } else if (state.tool !== 'select') {
+                    setTool('select');
+                } else if (document.body.classList.contains('zen')) {
+                    cmd.fullscreen();
+                }
+                viewer.renderOverlay();
+            } else if (k === ' ') {
+                if (!ptr.space) {
+                    ptr.space = true;
+                    $('cvOverlay').classList.add('tool-pan');
+                }
+            } else if (k === '?' || k === 'F1') {
+                cmd.shortcuts();
+            } else if (!state.project) {
+                handled = false;
+            } else if (k === 'F' && e.shiftKey) {
+                cmd.fullscreen();
+            } else if (e.altKey) {
+                handled = false;
+            } else {
+                switch (k.toLowerCase()) {
+                    case 'v': setTool('select'); break;
+                    case 'h': setTool('pan'); break;
+                    case 'z': setTool('zoom'); break;
+                    case 'm': setTool('measure'); break;
+                    case 'f': cmd.fit(); break;
+                    case 'r': cmd.rotate(); break;
+                    case 'b': cmd.flip(); break;
+                    case 'p': cmd.toggleRealistic(); break;
+                    case 'g': cmd.toggleSetting('grid'); break;
+                    case 'c': cmd.toggleSetting('crosshair'); break;
+                    case '2': setDim('2d'); break;
+                    case '3': setDim('3d'); break;
+                    case '1': cmd.zoomTo(1); break;
+                    case '+': case '=': cmd.zoomIn(); break;
+                    case '-': case '_': cmd.zoomOut(); break;
+                    case 'pageup': cmd.cycleLayer(-1); break;
+                    case 'pagedown': cmd.cycleLayer(1); break;
+                    case 'arrowleft': viewer.panBy(80, 0); break;
+                    case 'arrowright': viewer.panBy(-80, 0); break;
+                    case 'arrowup': viewer.panBy(0, 80); break;
+                    case 'arrowdown': viewer.panBy(0, -80); break;
+                    default: handled = false;
+                }
+            }
+            if (handled) {
+                e.preventDefault();
+            }
+        });
+        document.addEventListener('keyup', function (e) {
+            if (e.key === ' ') {
+                ptr.space = false;
+                $('cvOverlay').classList.remove('tool-pan');
+                setTool(state.tool);
+            }
+        });
+    }
+
+    /* ==================================================================
+        Boot
+    ================================================================== */
+    function onResize() {
+        viewer.resize();
+        state.miniCache = null;
+        renderMinimap();
+        if (view3d && state.dim === '3d') {
+            view3d.resize();
+        }
+        updateToolbar();
+    }
+
+    function bindChrome() {
+        document.addEventListener('click', function (e) {
+            var b = e.target.closest('[data-cmd]');
+            if (b && !b.disabled && cmd[b.dataset.cmd]) {
+                cmd[b.dataset.cmd]();
+            }
+            var t = e.target.closest('[data-tool]');
+            if (t && !t.disabled) {
+                setTool(t.dataset.tool);
+            }
+            var d = e.target.closest('[data-dim]');
+            if (d && !d.disabled) {
+                setDim(d.dataset.dim);
+            }
+            var v3 = e.target.closest('[data-view3d]');
+            if (v3 && view3d) {
+                view3d.setView(v3.dataset.view3d);
+            }
+            var tab = e.target.closest('.tabs [data-tab]');
+            if (tab) {
+                setTab(tab.dataset.tab);
+            }
+        });
+        $('tipNext').addEventListener('click', nextTip);
+        $('btnCrosshair').addEventListener('click', function () { cmd.toggleSetting('crosshair'); });
+        $('btnZoom').addEventListener('click', function () {
+            var r = this.getBoundingClientRect();
+            var items = [
+                { label: T('menu/zoomFit', 'Zoom to Fit'), icon: 'fit', key: 'F', action: cmd.fit }, { sep: true }
+            ].concat([10, 5, 4, 2, 1, 0.5, 0.25].map(function (z) {
+                return {
+                    label: z === 1 ? T('zoom/actual', '1:1 (actual size)') : (z > 1 ? z + ':1' : '1:' + (1 / z)),
+                    action: function () { cmd.zoomTo(z); }
+                };
+            }));
+            var menu = UI.showMenu(items, r.left, r.top, { level: 0 });
+            menu.style.top = Math.max(4, r.top - menu.getBoundingClientRect().height - 6) + 'px';
+        });
+        window.addEventListener('resize', onResize);
+        if (window.ResizeObserver) {
+            new ResizeObserver(function () { onResize(); }).observe($('stage'));
+        }
+    }
+
+    function applySettings(s) {
+        state.settings = Object.assign({}, DEFAULTS, s || {});
+        state.settings.layerColors = Object.assign({}, (s && s.layerColors) || {});
+        applyTheme();
+        $('tipCard').hidden = !state.settings.tips;
+    }
+
+    //Load locale/gerberviewer.json, translate the static markup, then resolve.
+    //A missing or broken locale file just leaves the English text.
+    function loadLocale() {
+        return new Promise(function (resolve) {
+            if (typeof applocale === 'undefined' || !applocale) {
+                resolve();
+                return;
+            }
+            applocale.init('locale/gerberviewer.json', function () {
+                try {
+                    applocale.translate();
+                } catch (e) {
+                    //Keep the English markup
+                }
+                resolve();
+            }, resolve);
+        });
+    }
+
+    async function boot() {
+        await loadLocale();
+        viewer = new GV.Viewer($('cvBase'), $('cvOverlay'));
+        viewer.getScene = function () { return scene(false); };
+        viewer.drawOverlay = drawOverlay;
+        viewer.onViewChange = function () {
+            updateZoomLabel();
+            renderMinimapViewport();
+            if (state.settings.grid) {
+                viewer.requestRender();
+            }
+        };
+        GV.app.viewer = viewer;
+        initTheme();
+        UI.menubar($('menubarItems'), buildMenus());
+        bindChrome();
+        bindViewer();
+        bindMinimap();
+        bindKeys();
+        bindDrop();
+        bindFileInputs();
+        updateTitle();
+        renderLayerList();
+        renderFileInfo();
+        renderProps();
+        nextTip();
+        setTool('select');
+        viewer.resize();
+        updateZoomLabel();
+
+        var boot = await S.boot();
+        applySettings(boot.settings);
+        renderRecentGrid();
+        updateToolbar();
+        if (!S.online) {
+            document.querySelectorAll('[data-cmd=openAroz]').forEach(function (b) {
+                b.disabled = true;
+                b.title = T('title/offline', 'ArozOS is not reachable from this page');
+            });
+        }
+
+        var input = null;
+        try {
+            input = ao_module_loadInputFiles();
+        } catch (e) {
+            input = null;
+        }
+        if (input && input.length && S.online) {
+            openFromAroz(input.map(function (f) { return f.filepath; }));
+        } else if (state.settings.restoreLast && S.recent.length) {
+            openRecent(S.recent[0]);
+        }
+    }
+
+    GV.app = {
+        state: state,
+        cmd: cmd,
+        viewer: null,
+        DEFAULTS: DEFAULTS,
+        MASKS: MASKS,
+        SILKS: SILKS,
+        FINISHES: FINISHES,
+        BACKGROUNDS: BACKGROUNDS,
+        PALETTE: PALETTE,
+        look: look,
+        scene: scene,
+        drawOrder: drawOrder,
+        projectBounds: projectBounds,
+        fmtLen: fmtLen,
+        fmtXY: fmtXY,
+        fmtSize: fmtSize,
+        fmtBytes: fmtBytes,
+        fmtDate: fmtDate,
+        unitInfo: unitInfo,
+        layerById: layerById,
+        layersChanged: layersChanged,
+        applySettings: applySettings,
+        applyTheme: applyTheme,
+        defaultColor: defaultColor,
+        layerLabel: layerLabel,
+        warningTexts: warningTexts,
+        reasonText: reasonText,
+        errText: errText,
+        apertureDesc: apertureDesc,
+        kindLabel: kindLabel,
+        T: T,
+        setUnits: setUnits,
+        zoomToBounds: zoomToBounds,
+        renderLayerList: renderLayerList,
+        renderMinimap: renderMinimap,
+        updateToolbar: updateToolbar,
+        schedule3D: schedule3D,
+        get3D: function () { return view3d; },
+        setDim: setDim,
+        nextTip: nextTip,
+        boardSummary: null,
+        setMarker: function (pt) {
+            state.marker = pt;
+            viewer.renderOverlay();
+        }
+    };
+
+    window.addEventListener('DOMContentLoaded', boot);
+})(window.GV = window.GV || {});

+ 318 - 0
src/web/Gerber Viewer/js/board.js

@@ -0,0 +1,318 @@
+/*
+    Gerber Viewer - board shape and drill summary
+
+    GV.board.build(layers) turns the outline layer's strokes into closed loops
+    (board edge + cut-outs) and collects every hole and slot from the drill
+    layers. The realistic renderer, the 3D view and the board report use it.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom;
+
+    //Convert outline items to open polylines (and closed contours from regions)
+    function outlinePieces(layer) {
+        var open = [], closed = [];
+        layer.image.groups.forEach(function (g) {
+            if (!g.dark) {
+                return;
+            }
+            g.items.forEach(function (it) {
+                if (it.t === 'l') {
+                    if (it.x1 !== it.x2 || it.y1 !== it.y2) {
+                        open.push([it.x1, it.y1, it.x2, it.y2]);
+                    }
+                } else if (it.t === 'a') {
+                    var s = G.arcStart(it);
+                    var pts = [s[0], s[1]];
+                    G.flattenArc(pts, it.cx, it.cy, it.r, it.a0, it.sw);
+                    if (Math.abs(it.sw) >= G.TAU - 1e-9) {
+                        G.dropClosingPoint(pts);
+                        closed.push(pts);
+                    } else {
+                        open.push(pts);
+                    }
+                } else if (it.t === 'p' && it.o && it.o.k === 'region') {
+                    it.c.forEach(function (c) { closed.push(c.slice()); });
+                }
+            });
+        });
+        return { open: open, closed: closed };
+    }
+
+    //Join polylines end to end into loops
+    function chain(polys, tol) {
+        var used = new Array(polys.length);
+        var grid = new Map();
+        var cell = tol * 2;
+        function key(x, y) {
+            return Math.floor(x / cell) + ',' + Math.floor(y / cell);
+        }
+        function index(i) {
+            var p = polys[i];
+            [[p[0], p[1]], [p[p.length - 2], p[p.length - 1]]].forEach(function (pt) {
+                var k = key(pt[0], pt[1]);
+                if (!grid.has(k)) {
+                    grid.set(k, []);
+                }
+                grid.get(k).push(i);
+            });
+        }
+        for (var i = 0; i < polys.length; i++) {
+            index(i);
+        }
+        function find(x, y) {
+            var cx = Math.floor(x / cell), cy = Math.floor(y / cell);
+            var best = -1, bestD = tol, bestRev = false;
+            for (var dx = -1; dx <= 1; dx++) {
+                for (var dy = -1; dy <= 1; dy++) {
+                    var list = grid.get((cx + dx) + ',' + (cy + dy));
+                    if (!list) {
+                        continue;
+                    }
+                    for (var k = 0; k < list.length; k++) {
+                        var j = list[k];
+                        if (used[j]) {
+                            continue;
+                        }
+                        var p = polys[j];
+                        var d0 = Math.hypot(p[0] - x, p[1] - y);
+                        var d1 = Math.hypot(p[p.length - 2] - x, p[p.length - 1] - y);
+                        if (d0 <= bestD) {
+                            best = j; bestD = d0; bestRev = false;
+                        }
+                        if (d1 <= bestD) {
+                            best = j; bestD = d1; bestRev = true;
+                        }
+                    }
+                }
+            }
+            return best < 0 ? null : { i: best, rev: bestRev };
+        }
+        function appendTo(path, p, rev) {
+            var n = p.length / 2;
+            for (var k = 1; k < n; k++) {
+                var idx = rev ? n - 1 - k : k;
+                path.push(p[idx * 2], p[idx * 2 + 1]);
+            }
+        }
+        var loops = [], openChains = [];
+        for (var s = 0; s < polys.length; s++) {
+            if (used[s]) {
+                continue;
+            }
+            used[s] = true;
+            var path = polys[s].slice();
+            var closedLoop = false;
+            for (var pass = 0; pass < 2 && !closedLoop; pass++) {
+                for (;;) {
+                    var n = path.length;
+                    if (n >= 6 && Math.hypot(path[0] - path[n - 2], path[1] - path[n - 1]) <= tol) {
+                        closedLoop = true;
+                        break;
+                    }
+                    var f = find(path[n - 2], path[n - 1]);
+                    if (!f) {
+                        break;
+                    }
+                    used[f.i] = true;
+                    appendTo(path, polys[f.i], f.rev);
+                }
+                if (!closedLoop) {
+                    //Grow from the other end too
+                    path = G.reverseContour(path);
+                }
+            }
+            if (!closedLoop && path.length >= 6) {
+                var gap = Math.hypot(path[0] - path[path.length - 2], path[1] - path[path.length - 1]);
+                var bb = boundsOf(path);
+                if (gap < Math.max(0.5, 0.02 * Math.max(bb[2] - bb[0], bb[3] - bb[1]))) {
+                    closedLoop = true;
+                }
+            }
+            if (closedLoop) {
+                G.dropClosingPoint(path);
+                if (path.length >= 6) {
+                    loops.push(path);
+                }
+            } else {
+                openChains.push(path);
+            }
+        }
+        return { loops: loops, open: openChains };
+    }
+
+    function boundsOf(c) {
+        var b = G.emptyBounds();
+        for (var i = 0; i < c.length; i += 2) {
+            if (c[i] < b[0]) b[0] = c[i];
+            if (c[i + 1] < b[1]) b[1] = c[i + 1];
+            if (c[i] > b[2]) b[2] = c[i];
+            if (c[i + 1] > b[3]) b[3] = c[i + 1];
+        }
+        return b;
+    }
+
+    function insideCount(loop, others) {
+        var x = loop[0], y = loop[1];
+        var depth = 0;
+        for (var i = 0; i < others.length; i++) {
+            if (others[i] !== loop && G.windingNumber(others[i], x, y) !== 0) {
+                depth++;
+            }
+        }
+        return depth;
+    }
+
+    //Largest drill treated as a via when no file says which holes are vias
+    var VIA_MAX_DIA = 0.45;
+
+    //EasyEDA repeats every via in the PTH file as well, so identical holes
+    //(same place and size) are only counted once. Via files are read first
+    //so a repeated via keeps its via flag.
+    function drillItems(layers) {
+        var holes = [], slots = [];
+        var seen = new Set();
+        var drills = layers.filter(function (l) { return l.type === 'drill' && l.image; });
+        var hasViaFile = drills.some(function (l) { return l.via; });
+        drills.sort(function (a, b) { return (b.via ? 1 : 0) - (a.via ? 1 : 0); });
+        drills.forEach(function (layer) {
+            layer.image.groups.forEach(function (g) {
+                g.items.forEach(function (it) {
+                    var plated = it.o && it.o.plated !== null && it.o.plated !== undefined ? it.o.plated : layer.plated;
+                    if (it.t === 'c') {
+                        var key = Math.round(it.x * 1000) + ',' + Math.round(it.y * 1000) + ',' + Math.round(it.r * 1000);
+                        if (seen.has(key)) {
+                            return;
+                        }
+                        seen.add(key);
+                        var via = hasViaFile ? !!layer.via : (plated !== false && it.r * 2 <= VIA_MAX_DIA);
+                        holes.push({ x: it.x, y: it.y, r: it.r, plated: plated !== false, via: via, layer: layer, o: it.o });
+                    } else if (it.t === 'l' || it.t === 'a') {
+                        slots.push({ item: it, plated: plated !== false, layer: layer });
+                    }
+                });
+            });
+        });
+        return { holes: holes, slots: slots };
+    }
+
+    //Slot outline (stadium) as a contour, used to cut slots out of the 3D board
+    function slotContour(it) {
+        if (it.t === 'l') {
+            var ang = Math.atan2(it.y2 - it.y1, it.x2 - it.x1);
+            var r = it.w / 2;
+            var c = [it.x2 + r * Math.cos(ang - Math.PI / 2), it.y2 + r * Math.sin(ang - Math.PI / 2)];
+            G.flattenArc(c, it.x2, it.y2, r, ang - Math.PI / 2, Math.PI);
+            c.push(it.x1 + r * Math.cos(ang + Math.PI / 2), it.y1 + r * Math.sin(ang + Math.PI / 2));
+            G.flattenArc(c, it.x1, it.y1, r, ang + Math.PI / 2, Math.PI);
+            G.dropClosingPoint(c);
+            return G.orient(c, true);
+        }
+        //Arc slot: outer arc, end cap, inner arc back, start cap
+        var w = it.w / 2;
+        var pts = [];
+        var a0 = it.a0, sw = it.sw;
+        var ro = it.r + w, ri = Math.max(0, it.r - w);
+        pts.push(it.cx + ro * Math.cos(a0), it.cy + ro * Math.sin(a0));
+        G.flattenArc(pts, it.cx, it.cy, ro, a0, sw);
+        var e = G.arcEnd(it);
+        var dir = sw > 0 ? 1 : -1;
+        G.flattenArc(pts, e[0], e[1], w, a0 + sw, dir * Math.PI);
+        G.flattenArc(pts, it.cx, it.cy, ri, a0 + sw, -sw);
+        var s = G.arcStart(it);
+        G.flattenArc(pts, s[0], s[1], w, a0 + Math.PI, dir * Math.PI);
+        G.dropClosingPoint(pts);
+        return G.orient(pts, true);
+    }
+
+    function build(layers) {
+        var outline = layers.filter(function (l) { return l.type === 'outline' && l.image && l.image.groups.length; })[0];
+        var loops = [];
+        var source = 'none';
+        if (outline) {
+            var pieces = outlinePieces(outline);
+            var widest = 0;
+            outline.image.groups.forEach(function (g) {
+                g.items.forEach(function (it) {
+                    if (it.w > widest) {
+                        widest = it.w;
+                    }
+                });
+            });
+            var tol = Math.max(0.02, Math.min(0.2, widest / 2));
+            var ch = chain(pieces.open, tol);
+            loops = ch.loops.concat(pieces.closed);
+            if (loops.length) {
+                source = 'outline';
+            }
+        }
+
+        //Drop loops that are basically a dot (tiny marks on the outline layer)
+        loops = loops.filter(function (l) {
+            return Math.abs(G.signedArea(l)) > 0.01;
+        });
+
+        var outer = [], cutouts = [];
+        loops.sort(function (a, b) { return Math.abs(G.signedArea(b)) - Math.abs(G.signedArea(a)); });
+        loops.forEach(function (l) {
+            var depth = insideCount(l, loops);
+            if (depth % 2 === 0) {
+                outer.push(G.orient(l, true));
+            } else {
+                cutouts.push(G.orient(l, false));
+            }
+        });
+
+        var bounds = G.emptyBounds();
+        outer.forEach(function (l) { G.unionBounds(bounds, boundsOf(l)); });
+
+        if (!outer.length) {
+            //No usable outline: fall back to the copper (or everything's) extent
+            var pick = layers.filter(function (l) { return l.image && /copper/.test(l.type) && G.boundsValid(l.image.bounds); });
+            if (!pick.length) {
+                pick = layers.filter(function (l) { return l.image && l.type !== 'document' && G.boundsValid(l.image.bounds); });
+            }
+            pick.forEach(function (l) { G.unionBounds(bounds, l.image.bounds); });
+            if (G.boundsValid(bounds)) {
+                var pad = 0.5;
+                bounds = [bounds[0] - pad, bounds[1] - pad, bounds[2] + pad, bounds[3] + pad];
+                outer.push([bounds[0], bounds[1], bounds[2], bounds[1], bounds[2], bounds[3], bounds[0], bounds[3]]);
+                source = 'extent';
+            }
+        }
+
+        var drills = drillItems(layers);
+        var w = G.boundsValid(bounds) ? bounds[2] - bounds[0] : 0, h = G.boundsValid(bounds) ? bounds[3] - bounds[1] : 0;
+        var outerArea = outer.reduce(function (s, l) { return s + Math.abs(G.signedArea(l)); }, 0);
+        return {
+            //A single outline that fills its bounding box: V-cut friendly
+            rect: outer.length === 1 && w > 0 && Math.abs(outerArea - w * h) < 0.01 * w * h,
+            source: source,
+            outer: outer,
+            cutouts: cutouts,
+            bounds: bounds,
+            width: G.boundsValid(bounds) ? bounds[2] - bounds[0] : 0,
+            height: G.boundsValid(bounds) ? bounds[3] - bounds[1] : 0,
+            area: outer.reduce(function (s, l) { return s + Math.abs(G.signedArea(l)); }, 0) -
+                cutouts.reduce(function (s, l) { return s + Math.abs(G.signedArea(l)); }, 0),
+            holes: drills.holes,
+            slots: drills.slots
+        };
+    }
+
+    //Is a point inside the board (outer loops minus cut-outs)?
+    function contains(board, x, y) {
+        var wn = 0;
+        board.outer.forEach(function (l) { wn += G.windingNumber(l, x, y); });
+        board.cutouts.forEach(function (l) { wn += G.windingNumber(l, x, y); });
+        return wn !== 0;
+    }
+
+    GV.board = {
+        build: build,
+        contains: contains,
+        slotContour: slotContour,
+        boundsOf: boundsOf
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 827 - 0
src/web/Gerber Viewer/js/dialogs.js

@@ -0,0 +1,827 @@
+/*
+    Gerber Viewer - dialogs
+
+    Preferences, export / print / save, go to coordinate, drill and aperture
+    tables, board report, layer source, keyboard shortcuts and about. Every
+    entry point is registered on GV.app.cmd. Text goes through T() and
+    locale/gerberviewer.json.
+*/
+(function (GV) {
+    'use strict';
+
+    var app = GV.app, cmd = app.cmd, state = app.state;
+    var UI = GV.ui, L = GV.layers, R = GV.render, S = GV.store, X = GV.exporter;
+    var icon = UI.icon, esc = UI.esc, T = UI.t;
+
+    function project() {
+        return state.project;
+    }
+
+    function safeName(s) {
+        return String(s || 'board').replace(/[\\\/:*?"<>|]+/g, '_').replace(/\s+/g, '_');
+    }
+
+    /* ==================================================================
+        Board summary (also used by the Properties panel)
+    ================================================================== */
+    app.boardSummary = function () {
+        var p = project();
+        var out = { copper: 0, holes: 0, pth: 0, npth: 0, slots: 0, minHole: 0, minTrace: 0 };
+        if (!p) {
+            return out;
+        }
+        out.copper = p.layers.filter(function (l) { return /copper/.test(l.type); }).length;
+        var b = p.board;
+        if (b) {
+            out.holes = b.holes.length;
+            b.holes.forEach(function (h) {
+                if (h.plated) {
+                    out.pth++;
+                } else {
+                    out.npth++;
+                }
+                if (h.r > 0 && (!out.minHole || h.r * 2 < out.minHole)) {
+                    out.minHole = h.r * 2;
+                }
+            });
+            out.slots = b.slots.length;
+        }
+        p.layers.forEach(function (l) {
+            if (!/copper/.test(l.type) || !l.image) {
+                return;
+            }
+            l.image.groups.forEach(function (g) {
+                if (!g.dark) {
+                    return;
+                }
+                g.items.forEach(function (it) {
+                    if ((it.t === 'l' || it.t === 'a') && it.w > 0.01 && it.o && (it.o.k === 'trace' || it.o.k === 'arc')) {
+                        if (!out.minTrace || it.w < out.minTrace) {
+                            out.minTrace = it.w;
+                        }
+                    }
+                });
+            });
+        });
+        return out;
+    };
+
+    /* ==================================================================
+        Saving helpers
+    ================================================================== */
+    //Ask where to save, then hand the data over. getData() -> Promise<Blob|string>
+    function saveOutput(filename, mime, getData, preset) {
+        function toDevice() {
+            return Promise.resolve(getData()).then(function (d) {
+                X.download(d, filename, mime);
+            });
+        }
+        function toAroz() {
+            return new Promise(function (resolve, reject) {
+                ao_module_openFileSelector(function (files) {
+                    if (!files || !files.length) {
+                        resolve(false);
+                        return;
+                    }
+                    var path = files[0].filepath;
+                    UI.toast(T('save/saving', 'Saving {name}...', { name: files[0].filename }), { icon: 'upload' });
+                    Promise.resolve(getData()).then(function (d) {
+                        return X.saveToAroz(path, d);
+                    }).then(function () {
+                        UI.toast(T('save/saved', 'Saved to {path}', { path: path }), { icon: 'check' });
+                        resolve(true);
+                    }, function (e) {
+                        UI.toast(T('save/failed', 'Unable to save: {error}', { error: app.errText(e) }), { type: 'error' });
+                        reject(e);
+                    });
+                }, 'user:/', 'new', false, { defaultName: filename, path_memory_key: 'export' });
+            });
+        }
+        if (preset === 'device' || !S.online) {
+            return toDevice();
+        }
+        if (preset === 'aroz') {
+            return toAroz();
+        }
+        UI.dialog({
+            title: T('save/title', 'Save {name}', { name: filename }),
+            icon: 'download',
+            width: 420,
+            body: '<p style="margin:0">' + esc(T('save/where', 'Where do you want to save this file?')) + '</p>',
+            buttons: [
+                { label: T('save/device', 'This Device'), icon: 'laptop', action: function () { toDevice(); } },
+                { label: T('save/aroz', 'ArozOS...'), icon: 'drive', primary: true, action: function () { toAroz(); } }
+            ]
+        });
+        return Promise.resolve();
+    }
+
+    function choice(name, options, current) {
+        return '<div class="chips" data-choice="' + name + '">' + options.map(function (o) {
+            return '<button type="button" data-v="' + esc(o[0]) + '" class="' + (String(o[0]) === String(current) ? 'active' : '') + '">' +
+                (o[2] ? '<span class="dot" style="--c:' + esc(o[2]) + '"></span>' : '') + esc(o[1]) + '</button>';
+        }).join('') + '</div>';
+    }
+
+    function bindChoices(root, onChange) {
+        root.querySelectorAll('[data-choice]').forEach(function (group) {
+            group.addEventListener('click', function (e) {
+                var b = e.target.closest('button[data-v]');
+                if (!b || b.disabled) {
+                    return;
+                }
+                group.querySelectorAll('button').forEach(function (x) { x.classList.toggle('active', x === b); });
+                if (onChange) {
+                    onChange(group.dataset.choice, b.dataset.v);
+                }
+            });
+        });
+    }
+
+    function chosen(root, name) {
+        var b = root.querySelector('[data-choice="' + name + '"] button.active');
+        return b ? b.dataset.v : null;
+    }
+
+    function setChoiceEnabled(root, name, value, on) {
+        var b = root.querySelector('[data-choice="' + name + '"] button[data-v="' + value + '"]');
+        if (b) {
+            b.disabled = !on;
+            b.style.opacity = on ? '' : '0.4';
+        }
+    }
+
+    function label(key, fallback) {
+        return '<label>' + esc(T(key, fallback)) + '</label>';
+    }
+
+    /* ==================================================================
+        Rendering for export / print
+    ================================================================== */
+    function sceneFor(render, colorsOverride, background) {
+        var base = app.scene(false);
+        var flip = render === 'bottom';
+        var layers = app.drawOrder();
+        if (render !== 'current') {
+            //drawOrder follows the live view; rebuild it for the requested side
+            layers = state.project.layers.slice().reverse();
+            if (flip) {
+                var idx = [], sided = [];
+                layers.forEach(function (l, i) {
+                    if (L.info(l.type).side !== 'all') {
+                        idx.push(i);
+                        sided.push(l);
+                    }
+                });
+                sided.reverse();
+                idx.forEach(function (p, k) { layers[p] = sided[k]; });
+            }
+        }
+        if (colorsOverride) {
+            layers = layers.map(function (l) {
+                var c = Object.create(l);
+                c.color = colorsOverride;
+                c.opacity = 1;
+                return c;
+            });
+        }
+        var mode = render === 'current' ? state.mode : (render === 'layers' ? 'layers' : 'realistic');
+        return {
+            scene: Object.assign({}, base, {
+                mode: mode,
+                side: render === 'current' ? base.side : (flip ? 'bottom' : 'top'),
+                layers: layers,
+                background: background,
+                grid: null,
+                shadow: false
+            }),
+            flip: render === 'current' ? app.viewer.view.flip : flip
+        };
+    }
+
+    function boardImage(render, dpi, background, colorsOverride, maxPx) {
+        var b = app.projectBounds(false);
+        var bw = b[2] - b[0], bh = b[3] - b[1];
+        var pxmm = dpi / 25.4;
+        maxPx = maxPx || 12000;
+        if (Math.max(bw, bh) * pxmm > maxPx) {
+            pxmm = maxPx / Math.max(bw, bh);
+        }
+        var s = sceneFor(render, colorsOverride, background);
+        var rot = render === 'current' ? app.viewer.view.rot : 0;
+        var turned = (rot / 90) % 2 !== 0;
+        var W = (turned ? bh : bw) * pxmm, H = (turned ? bw : bh) * pxmm;
+        var cv = R.renderToCanvas(s.scene, { cx: (b[0] + b[2]) / 2, cy: (b[1] + b[3]) / 2, scale: pxmm, rot: rot, flip: s.flip }, W, H);
+        return { canvas: cv, widthMM: turned ? bh : bw, heightMM: turned ? bw : bh, dpi: pxmm * 25.4 };
+    }
+
+    function renderChoices() {
+        return [['layers', T('export/layers', 'Layer view')], ['top', T('export/top', 'Realistic top')], ['bottom', T('export/bottom', 'Realistic bottom')]];
+    }
+
+    /* ==================================================================
+        Export image
+    ================================================================== */
+    cmd.exportDialog = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var is3d = state.dim === '3d';
+        var body = UI.h('div');
+        body.innerHTML = '<div class="form-grid">' +
+            label('export/area', 'Area') + choice('area', (is3d ? [['3d', T('export/3d', '3D snapshot')]] : []).concat([
+                ['view', T('export/view', 'Current view')], ['board', T('export/board', 'Whole board')]]), is3d ? '3d' : 'board') +
+            label('export/content', 'Content') + choice('render', renderChoices(), state.mode === 'realistic' ? (app.viewer.view.flip ? 'bottom' : 'top') : 'layers') +
+            label('export/format', 'Format') + choice('format', [['png', 'PNG'], ['jpg', 'JPEG'], ['svg', T('export/svg', 'SVG (vector)')]], 'png') +
+            label('export/resolution', 'Resolution') + choice('dpi', [['300', '300 dpi'], ['600', '600 dpi'], ['1200', '1200 dpi']], '600') +
+            label('export/background', 'Background') + choice('bg', [['canvas', T('export/bgCanvas', 'Canvas colour')],
+                ['transparent', T('export/bgNone', 'Transparent')], ['white', T('export/bgWhite', 'White')]], 'canvas') +
+            '</div><p class="muted small" id="exNote" style="margin:14px 0 0"></p>';
+        function refresh() {
+            var area = chosen(body, 'area'), fmt = chosen(body, 'format'), render = chosen(body, 'render');
+            var vector = area === 'board' && render === 'layers';
+            setChoiceEnabled(body, 'format', 'svg', vector);
+            if (fmt === 'svg' && !vector) {
+                body.querySelector('[data-choice=format] [data-v=png]').click();
+                return;
+            }
+            ['layers', 'top', 'bottom'].forEach(function (v) { setChoiceEnabled(body, 'render', v, area !== '3d'); });
+            ['300', '600', '1200'].forEach(function (v) { setChoiceEnabled(body, 'dpi', v, area === 'board' && fmt !== 'svg'); });
+            setChoiceEnabled(body, 'bg', 'transparent', fmt !== 'jpg' && area !== '3d');
+            if (chosen(body, 'bg') === 'transparent' && (fmt === 'jpg' || area === '3d')) {
+                body.querySelector('[data-choice=bg] [data-v=canvas]').click();
+                return;
+            }
+            var note = '';
+            if (area === 'board' && fmt !== 'svg') {
+                var b = app.projectBounds(false);
+                var pxmm = (+chosen(body, 'dpi')) / 25.4;
+                var w = Math.round((b[2] - b[0]) * pxmm), h = Math.round((b[3] - b[1]) * pxmm);
+                note = Math.max(w, h) > 12000 ? T('export/noteSizeCapped', 'Image size about {w} × {h} px (limited to 12000 px on the long side).', { w: w, h: h }) :
+                    T('export/noteSize', 'Image size about {w} × {h} px.', { w: w, h: h });
+            } else if (area === 'view') {
+                note = T('export/noteView', 'Exports exactly what is visible, at twice the screen resolution.');
+            } else if (fmt === 'svg') {
+                note = T('export/noteSvg', 'Visible layers as vector paths, sized in millimetres.');
+            }
+            body.querySelector('#exNote').textContent = note;
+        }
+        bindChoices(body, refresh);
+        refresh();
+
+        function produce() {
+            var area = chosen(body, 'area'), fmt = chosen(body, 'format'), render = chosen(body, 'render');
+            var bgSel = chosen(body, 'bg');
+            var bg = bgSel === 'transparent' ? null : (bgSel === 'white' ? '#ffffff' : state.settings.canvasBg);
+            var name = safeName(p.name) + '_' + (area === '3d' ? '3d' : render) + '.' + fmt;
+            var mime = fmt === 'svg' ? 'image/svg+xml' : (fmt === 'jpg' ? 'image/jpeg' : 'image/png');
+            var get = function () {
+                if (area === '3d') {
+                    return fetch(app.get3D().snapshot()).then(function (r) { return r.blob(); }).then(function (blob) {
+                        if (fmt === 'png') {
+                            return blob;
+                        }
+                        return createImageBitmap(blob).then(function (bmp) {
+                            var c = document.createElement('canvas');
+                            c.width = bmp.width;
+                            c.height = bmp.height;
+                            var cx = c.getContext('2d');
+                            cx.fillStyle = bg || '#000';
+                            cx.fillRect(0, 0, c.width, c.height);
+                            cx.drawImage(bmp, 0, 0);
+                            return X.canvasBlob(c, mime, 0.92);
+                        });
+                    });
+                }
+                if (fmt === 'svg') {
+                    var sc = sceneFor('layers', null, bg);
+                    return X.svg(sc.scene.layers, app.projectBounds(false), { background: bg, flip: false, title: p.name });
+                }
+                var cv;
+                if (area === 'view') {
+                    var k = 2;
+                    var v = Object.assign({}, app.viewer.view, { scale: app.viewer.view.scale * k });
+                    var s = sceneFor(render === 'layers' && state.mode === 'layers' ? 'current' : render, null, bg);
+                    v.flip = render === 'layers' ? app.viewer.view.flip : s.flip;
+                    cv = R.renderToCanvas(s.scene, v, app.viewer.cssW * k, app.viewer.cssH * k);
+                } else {
+                    var bgFill = fmt === 'jpg' && !bg ? '#ffffff' : bg;
+                    cv = boardImage(render, +chosen(body, 'dpi'), bgFill).canvas;
+                }
+                return X.canvasBlob(cv, mime, 0.92);
+            };
+            return { name: name, mime: mime, get: get };
+        }
+
+        var buttons = [{ label: T('common/cancel', 'Cancel') }];
+        if (S.online) {
+            buttons.push({
+                label: T('export/toAroz', 'Save to ArozOS...'), icon: 'drive', action: function () {
+                    var o = produce();
+                    saveOutput(o.name, o.mime, o.get, 'aroz');
+                }
+            });
+        }
+        buttons.push({
+            label: T('export/download', 'Download'), icon: 'download', primary: true, action: function () {
+                var o = produce();
+                UI.toast(T('export/rendering', 'Rendering {name}...', { name: o.name }), { icon: 'image' });
+                setTimeout(function () {
+                    saveOutput(o.name, o.mime, o.get, 'device').catch(function (e) {
+                        UI.toast(T('export/failed', 'Export failed: {error}', { error: app.errText(e) }), { type: 'error' });
+                    });
+                }, 30);
+            }
+        });
+        UI.dialog({ title: T('export/title', 'Export Image'), icon: 'image', width: 560, body: body, buttons: buttons });
+    };
+
+    /* ==================================================================
+        Save project as zip, drill CSV
+    ================================================================== */
+    cmd.saveZip = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var used = new Set();
+        var files = p.layers.map(function (l) {
+            var n = l.name.replace(/^\/+/, '');
+            while (used.has(n)) {
+                n = '_' + n;
+            }
+            used.add(n);
+            return { name: n, text: l.text };
+        });
+        saveOutput(safeName(p.name) + '_gerber.zip', 'application/zip', function () {
+            return GV.zip.create(files).then(function (bytes) { return new Blob([bytes], { type: 'application/zip' }); });
+        });
+    };
+
+    //Column titles stay in English: the CSV is meant for other tools
+    cmd.exportDrillCSV = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        saveOutput(safeName(p.name) + '_drills.csv', 'text/csv', function () { return X.drillCSV(p.layers); });
+    };
+
+    /* ==================================================================
+        Print
+    ================================================================== */
+    cmd.printDialog = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var body = UI.h('div');
+        body.innerHTML = '<div class="form-grid">' +
+            label('export/content', 'Content') + choice('render', renderChoices(), state.mode === 'realistic' ? (app.viewer.view.flip ? 'bottom' : 'top') : 'layers') +
+            label('print/scale', 'Scale') + choice('scale', [['actual', T('print/actual', 'Actual size (1:1)')], ['fit', T('print/fit', 'Fit to page')]], 'actual') +
+            label('print/colours', 'Colours') + choice('ink', [['color', T('print/colour', 'Layer colours')], ['mono', T('print/mono', 'Black on white')]], 'mono') +
+            '</div><p class="muted small" style="margin:14px 0 0">' + esc(T('print/note',
+                'Actual size prints the board 1:1, handy for checking footprints against real parts. Turn off "fit to page" or scaling in the browser print dialog.')) + '</p>';
+        bindChoices(body, function (name, v) {
+            if (name === 'render') {
+                setChoiceEnabled(body, 'ink', 'mono', v === 'layers');
+                if (v !== 'layers') {
+                    body.querySelector('[data-choice=ink] [data-v=color]').click();
+                }
+            }
+        });
+        UI.dialog({
+            title: T('print/title', 'Print'), icon: 'print', width: 520, body: body,
+            buttons: [
+                { label: T('common/cancel', 'Cancel') },
+                {
+                    label: T('print/print', 'Print'), icon: 'print', primary: true, action: function () {
+                        var render = chosen(body, 'render');
+                        var mono = chosen(body, 'ink') === 'mono' && render === 'layers';
+                        var img = boardImage(render, 600, '#ffffff', mono ? '#000000' : null, 9000);
+                        X.print(img.canvas.toDataURL('image/png'), img.widthMM, img.heightMM, p.name, chosen(body, 'scale') === 'fit');
+                    }
+                }
+            ]
+        });
+    };
+
+    /* ==================================================================
+        Preferences
+    ================================================================== */
+    cmd.settingsDialog = function () {
+        var s = JSON.parse(JSON.stringify(state.settings));
+        var body = UI.h('div');
+        var tabs = [['general', T('pref/general', 'General')], ['display', T('pref/display', 'Display')],
+            ['board', T('pref/board', 'Board')], ['colors', T('pref/layerColours', 'Layer Colours')]];
+        var thick = [0.4, 0.6, 0.8, 1.0, 1.2, 1.6, 2.0, 2.4, 3.2];
+        function check(key, textKey, text) {
+            return '<label class="field-row"><input type="checkbox" data-k="' + key + '">' + esc(T(textKey, text)) + '</label>';
+        }
+        body.innerHTML = '<div class="settings-tabs">' + tabs.map(function (t, i) {
+            return '<button data-st="' + t[0] + '" class="' + (i === 0 ? 'active' : '') + '">' + esc(t[1]) + '</button>';
+        }).join('') + '</div>' +
+            //General
+            '<div data-sp="general" class="form-grid">' +
+            label('pref/units', 'Units') + '<select data-k="units"><option value="mm">' + esc(T('pref/unitMm', 'Millimetres (mm)')) + '</option>' +
+            '<option value="in">' + esc(T('pref/unitIn', 'Inches (in)')) + '</option><option value="mil">' + esc(T('pref/unitMil', 'Mils (thou)')) + '</option></select>' +
+            label('pref/decimals', 'Decimal places') + '<select data-k="decimals"><option value="2">2</option><option value="3">3</option><option value="4">4</option></select>' +
+            label('pref/theme', 'Theme') + '<select data-k="theme"><option value="system">' + esc(T('theme/system', 'Follow ArozOS')) + '</option>' +
+            '<option value="light">' + esc(T('theme/light', 'Light')) + '</option><option value="dark">' + esc(T('theme/dark', 'Dark')) + '</option></select>' +
+            label('pref/startup', 'On startup') + check('restoreLast', 'pref/restoreLast', 'Reopen the last project') +
+            label('pref/sidePanel', 'Side panel') + check('tips', 'pref/showTips', 'Show tips') +
+            '</div>' +
+            //Display
+            '<div data-sp="display" class="form-grid" hidden>' +
+            label('pref/canvasBg', 'Canvas background') + '<div class="field-row" style="flex-wrap:wrap">' +
+            choice('canvasBg', app.BACKGROUNDS.map(function (b) { return [b.color, T('bg/' + b.label.toLowerCase(), b.label), b.color]; }), s.canvasBg) +
+            '<input type="color" data-k="canvasBg" value="' + esc(s.canvasBg) + '" title="' + esc(T('pref/customColour', 'Custom colour')) + '"></div>' +
+            label('pref/opacity', 'New layer opacity') + '<div class="field-row"><input type="range" min="30" max="100" data-k="defaultOpacity" value="' +
+            Math.round(s.defaultOpacity * 100) + '"><span data-out="defaultOpacity">' + Math.round(s.defaultOpacity * 100) + '%</span></div>' +
+            label('pref/grid', 'Grid') + check('grid', 'pref/showGrid', 'Show grid') +
+            label('pref/minimap', 'Minimap') + check('minimap', 'pref/showMinimap', 'Show minimap') +
+            label('pref/cursor', 'Cursor') + check('crosshair', 'pref/crosshair', 'Full-screen crosshair') +
+            label('pref/measuring', 'Measuring') + check('snap', 'pref/snap', 'Snap to pads, holes and line ends') +
+            '</div>' +
+            //Board look
+            '<div data-sp="board" class="form-grid" hidden>' +
+            label('pref/mask', 'Solder mask') + choice('mask', Object.keys(app.MASKS).map(function (k) {
+                return [k, T('mask/' + k, app.MASKS[k].label), app.MASKS[k].color];
+            }), s.mask) +
+            label('pref/silk', 'Silkscreen') + choice('silk', [['auto', T('silk/auto', 'Automatic')], ['white', T('silk/white', 'White'), '#f2f2f2'],
+                ['black', T('silk/black', 'Black'), '#141414'], ['yellow', T('silk/yellow', 'Yellow'), '#f2d21b']], s.silk) +
+            label('pref/finish', 'Surface finish') + choice('finish', Object.keys(app.FINISHES).map(function (k) {
+                return [k, T('finish/' + k, app.FINISHES[k].label), app.FINISHES[k].color];
+            }), s.finish) +
+            label('pref/thickness', 'Board thickness') + '<select data-k="thickness">' + thick.map(function (t) {
+                return '<option value="' + t + '">' + t.toFixed(1) + ' mm</option>';
+            }).join('') + '</select>' +
+            '</div>' +
+            //Layer colours
+            '<div data-sp="colors" hidden><div class="form-grid">' + L.TYPE_LIST.map(function (t) {
+                var c = (s.layerColors && s.layerColors[t]) || L.info(t).color;
+                return '<label>' + esc(app.layerLabel({ type: t })) + '</label><div class="field-row"><input type="color" data-lc="' + t + '" value="' + esc(c) + '">' +
+                    '<button class="btn small" data-lcr="' + t + '">' + esc(T('pref/default', 'Default')) + '</button></div>';
+            }).join('') + '</div>' +
+            '<label class="field-row" style="margin-top:14px"><input type="checkbox" id="applyOpen" checked>' + esc(T('pref/recolour', 'Also recolour layers of the open project')) + '</label></div>';
+
+        var sel = function (k) { return body.querySelector('[data-k="' + k + '"]'); };
+        ['units', 'decimals', 'theme', 'thickness'].forEach(function (k) { sel(k).value = String(s[k]); });
+        ['restoreLast', 'tips', 'grid', 'minimap', 'crosshair', 'snap'].forEach(function (k) { sel(k).checked = !!s[k]; });
+        body.querySelectorAll('[data-st]').forEach(function (b) {
+            b.addEventListener('click', function () {
+                body.querySelectorAll('[data-st]').forEach(function (x) { x.classList.toggle('active', x === b); });
+                body.querySelectorAll('[data-sp]').forEach(function (p) { p.hidden = p.dataset.sp !== b.dataset.st; });
+            });
+        });
+        sel('defaultOpacity').addEventListener('input', function () {
+            body.querySelector('[data-out=defaultOpacity]').textContent = this.value + '%';
+        });
+        bindChoices(body, function (name, v) {
+            if (name === 'canvasBg') {
+                sel('canvasBg').value = v;
+            }
+        });
+        sel('canvasBg').addEventListener('input', function () {
+            body.querySelectorAll('[data-choice=canvasBg] button').forEach(function (b) {
+                b.classList.toggle('active', b.dataset.v.toLowerCase() === sel('canvasBg').value.toLowerCase());
+            });
+        });
+        body.querySelectorAll('[data-lcr]').forEach(function (b) {
+            b.addEventListener('click', function () {
+                body.querySelector('[data-lc="' + b.dataset.lcr + '"]').value = L.info(b.dataset.lcr).color;
+            });
+        });
+
+        function collect() {
+            var n = Object.assign({}, s);
+            n.units = sel('units').value;
+            n.decimals = parseInt(sel('decimals').value, 10);
+            n.theme = sel('theme').value;
+            n.thickness = parseFloat(sel('thickness').value);
+            ['restoreLast', 'tips', 'grid', 'minimap', 'crosshair', 'snap'].forEach(function (k) { n[k] = sel(k).checked; });
+            n.canvasBg = sel('canvasBg').value;
+            n.defaultOpacity = parseInt(sel('defaultOpacity').value, 10) / 100;
+            n.mask = chosen(body, 'mask') || n.mask;
+            n.silk = chosen(body, 'silk') || n.silk;
+            n.finish = chosen(body, 'finish') || n.finish;
+            n.layerColors = {};
+            body.querySelectorAll('[data-lc]').forEach(function (i) {
+                if (i.value.toLowerCase() !== L.info(i.dataset.lc).color.toLowerCase()) {
+                    n.layerColors[i.dataset.lc] = i.value;
+                }
+            });
+            return n;
+        }
+
+        function apply(n, recolour) {
+            var oldColors = {};
+            L.TYPE_LIST.forEach(function (t) { oldColors[t] = app.defaultColor(t); });
+            var lookChanged = n.mask !== state.settings.mask || n.silk !== state.settings.silk || n.finish !== state.settings.finish ||
+                n.thickness !== state.settings.thickness || n.canvasBg !== state.settings.canvasBg;
+            app.applySettings(n);
+            S.saveSettings(state.settings);
+            if (recolour && state.project) {
+                state.project.layers.forEach(function (l) {
+                    if (l.color.toLowerCase() === String(oldColors[l.type]).toLowerCase()) {
+                        l.color = app.defaultColor(l.type);
+                    }
+                });
+            }
+            app.setUnits(n.units);
+            if (state.project) {
+                app.layersChanged();
+                if (lookChanged && state.dim === '3d') {
+                    app.schedule3D();
+                }
+            }
+            app.renderMinimap();
+            app.updateToolbar();
+        }
+
+        var dlg = UI.dialog({
+            title: T('pref/title', 'Preferences'), icon: 'gear', width: 640, body: body,
+            footerLeft: UI.h('button', { class: 'btn', onclick: function () {
+                UI.confirm(T('pref/resetTitle', 'Reset preferences'), T('pref/resetText', 'Restore every preference to its default value?'), T('common/reset', 'Reset')).then(function (ok) {
+                    if (ok) {
+                        dlg.close();
+                        apply(JSON.parse(JSON.stringify(app.DEFAULTS)), true);
+                        UI.toast(T('pref/resetDone', 'Preferences restored to their defaults'), { icon: 'check' });
+                    }
+                });
+            } }, icon('refresh') + esc(T('pref/reset', 'Reset to Defaults'))),
+            buttons: [
+                { label: T('common/cancel', 'Cancel') },
+                { label: T('common/save', 'Save'), primary: true, action: function () {
+                    var cb = body.querySelector('#applyOpen');
+                    apply(collect(), !cb || cb.checked);
+                } }
+            ]
+        });
+    };
+
+    /* ==================================================================
+        Go to coordinate
+    ================================================================== */
+    cmd.gotoDialog = function () {
+        if (!project()) {
+            return;
+        }
+        var u = app.unitInfo();
+        var c = state.cursor || [app.viewer.view.cx, app.viewer.view.cy];
+        var body = UI.h('div');
+        body.innerHTML = '<div class="form-grid">' +
+            '<label>X (' + u.label + ')</label><input type="number" step="any" id="gx" autofocus value="' + ((c[0] - state.origin[0]) * u.f).toFixed(u.dec) + '">' +
+            '<label>Y (' + u.label + ')</label><input type="number" step="any" id="gy" value="' + ((c[1] - state.origin[1]) * u.f).toFixed(u.dec) + '">' +
+            '</div><p class="muted small" style="margin:12px 0 0">' + esc(T('goto/note', 'Coordinates are relative to the current origin.')) + '</p>';
+        UI.dialog({
+            title: T('goto/title', 'Go to Coordinate'), icon: 'crosshair', width: 380, body: body,
+            buttons: [
+                { label: T('common/cancel', 'Cancel') },
+                { label: T('goto/go', 'Go'), primary: true, action: function () {
+                    var x = parseFloat(body.querySelector('#gx').value), y = parseFloat(body.querySelector('#gy').value);
+                    if (!isFinite(x) || !isFinite(y)) {
+                        UI.toast(T('goto/missing', 'Enter both coordinates'), { type: 'error' });
+                        return false;
+                    }
+                    var bx = x / u.f + state.origin[0], by = y / u.f + state.origin[1];
+                    if (app.viewer.view.scale < 12) {
+                        app.viewer.view.scale = 12;
+                    }
+                    app.viewer.centerOn(bx, by);
+                    app.setMarker([bx, by]);
+                } }
+            ]
+        });
+    };
+
+    /* ==================================================================
+        Tables and reports
+    ================================================================== */
+    function platedText(v) {
+        return v === null || v === undefined ? T('common/unknown', 'Unknown') : (v ? T('common/yes', 'Yes') : T('common/no', 'No'));
+    }
+
+    cmd.drillTable = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var drills = p.layers.filter(function (l) { return l.type === 'drill' && l.image.tools; });
+        if (!drills.length) {
+            UI.toast(T('drill/none', 'This project has no drill files.'), { type: 'error' });
+            return;
+        }
+        var html = '';
+        var total = 0;
+        drills.forEach(function (l) {
+            var holes = 0, slots = 0;
+            html += '<div class="form-section">' + esc(l.label) + ' <span class="muted small">' + esc(L.base(l.name)) + '</span></div>' +
+                '<div class="tbl-wrap"><table class="tbl"><thead><tr><th>' + esc(T('drill/tool', 'Tool')) + '</th><th class="num">' + esc(T('drill/diameter', 'Diameter')) +
+                '</th><th class="num">' + esc(T('drill/diameterIn', 'Diameter (in)')) + '</th><th>' + esc(T('drill/plated', 'Plated')) +
+                '</th><th class="num">' + esc(T('drill/holes', 'Holes')) + '</th><th class="num">' + esc(T('drill/slots', 'Slots')) + '</th></tr></thead><tbody>' +
+                l.image.tools.map(function (t) {
+                    holes += t.hits;
+                    slots += t.slots;
+                    return '<tr><td>' + esc(t.name) + '</td><td class="num">' + t.dia.toFixed(3) + ' mm</td><td class="num">' + (t.dia / 25.4).toFixed(4) + '</td><td>' +
+                        esc(platedText(t.plated)) + '</td><td class="num">' + t.hits + '</td><td class="num">' + t.slots + '</td></tr>';
+                }).join('') +
+                '</tbody><tfoot><tr><td colspan="4">' + esc(T('drill/total', 'Total')) + '</td><td class="num">' + holes + '</td><td class="num">' + slots + '</td></tr></tfoot></table></div>';
+            total += holes;
+        });
+        var sum = app.boardSummary();
+        var intro = T('drill/summary', '{n} unique holes ({p} plated, {np} non-plated) and {s} slots.', { n: sum.holes, p: sum.pth, np: sum.npth, s: sum.slots });
+        if (sum.minHole) {
+            intro += ' ' + T('drill/smallest', 'Smallest hole {d}.', { d: app.fmtLen(sum.minHole) });
+        }
+        html = '<p style="margin:0 0 6px">' + esc(intro) +
+            (total !== sum.holes ? ' <span class="muted">' + esc(T('drill/duplicates', 'The files list {n} hits; duplicates across files are counted once.', { n: total })) + '</span>' : '') + '</p>' + html;
+        UI.dialog({
+            title: T('drill/title', 'Drill Table'), icon: 'drill', width: 680, body: html,
+            buttons: [
+                { label: T('drill/csv', 'Export CSV...'), icon: 'download', action: function () { cmd.exportDrillCSV(); } },
+                { label: T('common/close', 'Close'), primary: true }
+            ]
+        });
+    };
+
+    function layerSelect(id, layers, current) {
+        return '<select id="' + id + '" style="width:100%">' + layers.map(function (l) {
+            return '<option value="' + l.id + '"' + (current && current.id === l.id ? ' selected' : '') + '>' + esc(l.label + ' – ' + L.base(l.name)) + '</option>';
+        }).join('') + '</select>';
+    }
+
+    cmd.apertureTable = function (layer) {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var gl = p.layers.filter(function (l) { return l.image.kind === 'gerber'; });
+        if (!gl.length) {
+            return;
+        }
+        layer = layer && layer.image.kind === 'gerber' ? layer : (app.layerById(state.selectedLayer) || gl[0]);
+        if (layer.image.kind !== 'gerber') {
+            layer = gl[0];
+        }
+        var body = UI.h('div');
+        body.innerHTML = layerSelect('apLayer', gl, layer) + '<div id="apTable" style="margin-top:12px"></div>';
+        function fill() {
+            var l = app.layerById(+body.querySelector('#apLayer').value);
+            var aps = l.image.apertures.slice().sort(function (a, b) { return a.num - b.num; });
+            var unit = l.image.units === 'in' ? T('unit/inch', 'Inch') : T('unit/mm', 'Millimetre');
+            body.querySelector('#apTable').innerHTML = aps.length ? '<div class="tbl-wrap"><table class="tbl"><thead><tr><th>' + esc(T('ap/dcode', 'D-code')) +
+                '</th><th>' + esc(T('ap/shape', 'Shape')) + '</th><th class="num">' + esc(T('ap/uses', 'Uses')) + '</th><th>' + esc(T('ap/function', 'Function')) + '</th></tr></thead><tbody>' +
+                aps.map(function (a) {
+                    var desc = app.apertureDesc(a) + (!a.macro && a.type !== 'block' ? ' mm' : '');
+                    return '<tr><td>D' + a.num + '</td><td>' + esc(desc) + '</td><td class="num">' + a.uses + '</td><td>' + esc(a.attrs && a.attrs.AperFunction ? a.attrs.AperFunction.replace(/,/g, ', ') : '') + '</td></tr>';
+                }).join('') + '</tbody></table></div><p class="muted small">' +
+                esc(T('ap/note', 'Standard aperture sizes are shown in millimetres; macro parameters use the unit of the file ({unit}).', { unit: unit })) + '</p>' :
+                '<p class="muted">' + esc(T('ap/none', 'No apertures are defined in this layer.')) + '</p>';
+        }
+        body.querySelector('#apLayer').addEventListener('change', fill);
+        fill();
+        UI.dialog({ title: T('ap/title', 'Aperture Table'), icon: 'table', width: 640, body: body, buttons: [{ label: T('common/close', 'Close'), primary: true }] });
+    };
+
+    cmd.viewSource = function (layer) {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        layer = layer || app.layerById(state.selectedLayer) || p.layers[0];
+        var body = UI.h('div');
+        body.innerHTML = layerSelect('srcLayer', p.layers, layer) + '<div class="muted small" id="srcInfo" style="margin:8px 0"></div><pre class="source-view" id="srcText"></pre>';
+        var LIMIT = 400 * 1024;
+        function fill() {
+            var l = app.layerById(+body.querySelector('#srcLayer').value);
+            var t = l.text || '';
+            body.querySelector('#srcText').textContent = t.length > LIMIT ? t.slice(0, LIMIT) + '\n\n' + T('source/more', '... ({size} more)', { size: app.fmtBytes(t.length - LIMIT) }) : t;
+            body.querySelector('#srcInfo').textContent = l.name + ' · ' + app.fmtBytes(t.length) + ' · ' + T('source/lines', '{n} lines', { n: t.split(/\n/).length });
+        }
+        body.querySelector('#srcLayer').addEventListener('change', fill);
+        fill();
+        UI.dialog({
+            title: T('source/title', 'Layer Source'), icon: 'code', width: 820, body: body,
+            buttons: [
+                { label: T('export/download', 'Download'), icon: 'download', action: function () {
+                    var l = app.layerById(+body.querySelector('#srcLayer').value);
+                    X.download(l.text, L.base(l.name), 'text/plain');
+                    return false;
+                } },
+                { label: T('common/close', 'Close'), primary: true }
+            ]
+        });
+    };
+
+    cmd.boardReport = function () {
+        var p = project();
+        if (!p) {
+            return;
+        }
+        var b = p.board;
+        var s = app.boardSummary();
+        var outline = '';
+        if (b) {
+            outline = b.source === 'outline' ? T('report/outlines', '{n} closed outline(s), {c} cut-out(s)', { n: b.outer.length, c: b.cutouts.length }) :
+                T('report/noOutline', 'No closed outline found');
+        }
+        var rows = [
+            [T('report/project', 'Project'), p.name],
+            [T('report/size', 'Board size'), b && b.width ? app.fmtSize(b.width, b.height) : T('common/unknown', 'Unknown')],
+            [T('report/area', 'Board area'), b && b.area ? (b.area / 100).toFixed(2) + ' cm²' : ''],
+            [T('report/outline', 'Outline'), outline],
+            [T('props/copperLayers', 'Copper layers'), s.copper],
+            [T('props/holesLabel', 'Holes'), T('props/holesValue', '{n} ({p} plated, {np} non-plated)', { n: s.holes, p: s.pth, np: s.npth })],
+            [T('props/slotsLabel', 'Slots'), s.slots],
+            [T('props/minHole', 'Smallest hole'), s.minHole ? app.fmtLen(s.minHole) : '-'],
+            [T('props/minTrace', 'Thinnest trace'), s.minTrace ? app.fmtLen(s.minTrace) : '-'],
+            [T('info/generator', 'Generator'), p.generator || '-'],
+            [T('info/created', 'Created'), app.fmtDate(p.created) || '-']
+        ];
+        var html = '<dl class="kv" style="padding:0 0 6px">' + rows.map(function (r) {
+            return '<dt>' + esc(r[0]) + '</dt><dd>' + esc(r[1]) + '</dd>';
+        }).join('') + '</dl>' +
+            '<div class="form-section">' + esc(T('report/layers', 'Layers')) + '</div><div class="tbl-wrap"><table class="tbl"><thead><tr><th>' +
+            esc(T('report/layer', 'Layer')) + '</th><th>' + esc(T('report/file', 'File')) + '</th><th class="num">' + esc(T('report/objects', 'Objects')) +
+            '</th><th>' + esc(T('report/units', 'Units')) + '</th><th>' + esc(T('report/notes', 'Notes')) + '</th></tr></thead><tbody>' +
+            p.layers.map(function (l) {
+                var st = l.image.stats;
+                var count = l.image.kind === 'drill' ? st.holes + st.slots : st.flashes + st.lines + st.arcs + st.regions;
+                return '<tr><td>' + esc(l.label) + '</td><td>' + esc(L.base(l.name)) + '</td><td class="num">' + count + '</td><td>' + (l.image.units === 'in' ? 'inch' : 'mm') +
+                    '</td><td>' + (l.image.warnings.length ? esc(T('report/warnings', '{n} warning(s)', { n: l.image.warnings.length })) : '') + '</td></tr>';
+            }).join('') + '</tbody></table></div>';
+        var warns = [];
+        p.layers.forEach(function (l) {
+            app.warningTexts(l.image).forEach(function (w) { warns.push('<li><b>' + esc(l.label) + ':</b> ' + esc(w) + '</li>'); });
+        });
+        if (warns.length) {
+            html += '<div class="form-section">' + esc(T('props/warnings', 'Warnings')) + '</div><ul class="list-plain warn-list">' + warns.join('') + '</ul>';
+        }
+        if (p.skipped.length) {
+            html += '<div class="form-section">' + esc(T('report/ignored', 'Ignored files')) + '</div><ul class="list-plain warn-list">' + p.skipped.map(function (s2) {
+                return '<li><b>' + esc(s2.name) + '</b> <span class="muted">' + esc(app.reasonText(s2)) + '</span></li>';
+            }).join('') + '</ul>';
+        }
+        UI.dialog({ title: T('report/title', 'Board Report'), icon: 'board', width: 720, body: html, buttons: [{ label: T('common/close', 'Close'), primary: true }] });
+    };
+
+    /* ==================================================================
+        Help
+    ================================================================== */
+    cmd.shortcuts = function () {
+        var k = function (s) {
+            return s.split('+').map(function (x) { return '<kbd>' + esc(x) + '</kbd>'; }).join(' + ');
+        };
+        var groups = [
+            [T('keys/files', 'Files'), [
+                [T('keys/openAroz', 'Open from ArozOS'), 'Ctrl+O'], [T('keys/openLocal', 'Open from this device'), 'Ctrl+Shift+O'],
+                [T('keys/export', 'Export image'), 'Ctrl+E'], [T('keys/saveZip', 'Save project as ZIP'), 'Ctrl+S'], [T('keys/print', 'Print'), 'Ctrl+P'],
+                [T('keys/reload', 'Reload project'), 'Ctrl+Shift+R'], [T('keys/preferences', 'Preferences'), 'Ctrl+,']]],
+            [T('keys/tools', 'Tools'), [
+                [T('keys/select', 'Select'), 'V'], [T('keys/pan', 'Pan'), 'H'], [T('keys/zoomArea', 'Zoom to area'), 'Z'], [T('keys/measure', 'Measure'), 'M'],
+                [T('keys/panAny', 'Pan with any tool'), 'Space'], [T('keys/cancel', 'Cancel / clear'), 'Esc'], [T('keys/goto', 'Go to coordinate'), 'Ctrl+G'],
+                [T('keys/copyCursor', 'Copy cursor coordinates'), 'Ctrl+Shift+C']]],
+            [T('keys/view', 'View'), [
+                [T('keys/fit', 'Zoom to fit'), 'F'], [T('keys/zoom', 'Zoom in / out'), '+ / -'], [T('keys/actual', 'Actual size'), '1'], [T('keys/rotate', 'Rotate 90°'), 'R'],
+                [T('keys/bottom', 'Bottom side'), 'B'], [T('keys/realistic', 'Realistic preview'), 'P'], [T('keys/dim', '2D / 3D'), '2 / 3'], [T('keys/grid', 'Grid'), 'G'],
+                [T('keys/crosshair', 'Crosshair'), 'C'], [T('keys/panel', 'Side panel'), 'Ctrl+B'], [T('keys/focus', 'Focus mode'), 'Shift+F'],
+                [T('keys/cycle', 'Previous / next layer'), 'PgUp / PgDn'], [T('keys/panKeys', 'Pan'), T('keys/arrows', 'Arrow keys')]]],
+            [T('keys/mouse', 'Mouse'), [
+                [T('keys/wheelZoom', 'Zoom'), T('keys/wheel', 'Wheel')], [T('keys/dragPan', 'Pan'), T('keys/drag', 'Drag')],
+                [T('keys/panAnyMouse', 'Pan (any tool)'), T('keys/rightDrag', 'Right / middle drag')],
+                [T('keys/dblclick', 'Zoom to object / fit'), T('keys/doubleClick', 'Double-click')], [T('keys/context', 'Context menu'), T('keys/rightClick', 'Right-click')],
+                [T('keys/addDrop', 'Add dropped files to the project'), 'Shift+' + T('keys/drop', 'Drop')]]]
+        ];
+        var html = groups.map(function (g) {
+            return '<div class="form-section">' + esc(g[0]) + '</div><div class="keys">' + g[1].map(function (r) {
+                return '<span>' + esc(r[0]) + '</span><span>' + (r[1].indexOf(' / ') > 0 ? r[1].split(' / ').map(k).join(' / ') : k(r[1])) + '</span>';
+            }).join('') + '</div>';
+        }).join('');
+        UI.dialog({ title: T('keys/title', 'Keyboard Shortcuts'), icon: 'keyboard', width: 560, body: html, buttons: [{ label: T('common/close', 'Close'), primary: true }] });
+    };
+
+    cmd.formats = function () {
+        var html = '<div class="form-section">Gerber</div>' +
+            '<p style="margin:0 0 6px">' + esc(T('formats/gerber',
+                'RS-274X and Gerber X2 (attributes, nets and components), including aperture macros, regions, arcs, clear polarity, step and repeat and block apertures. Common extensions: .gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho and Eagle / KiCad names.')) + '</p>' +
+            '<div class="form-section">' + esc(T('formats/drillTitle', 'Drill')) + '</div><p style="margin:0 0 6px">' + esc(T('formats/drill',
+                'Excellon (.drl .xln .exc .txt .drd) in metric or inch with leading or trailing zero suppression, slots (G85) and routed outlines.')) + '</p>' +
+            '<div class="form-section">' + esc(T('formats/packagesTitle', 'Packages')) + '</div><p style="margin:0">' + esc(T('formats/packages',
+                'Zip archives from the local device. From ArozOS: zip, 7z, tar and tar.gz archives (unpacked on the server), folders, or any set of files.')) + '</p>' +
+            '<p class="muted small" style="margin:12px 0 0">' + esc(T('formats/detect',
+                'Layer types are detected from X2 attributes and from the file naming of EasyEDA, KiCad, Altium, Eagle / Fusion, DipTrace and Proteus. You can change any type from the layer menu.')) + '</p>';
+        UI.dialog({ title: T('formats/title', 'Supported Formats'), icon: 'file', width: 560, body: html, buttons: [{ label: T('common/close', 'Close'), primary: true }] });
+    };
+
+    cmd.about = function () {
+        var html = '<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px"><img src="img/module_icon.svg" style="width:56px;height:56px">' +
+            '<div><div style="font-size:18px;font-weight:700">' + esc(T('app/title', 'Gerber Viewer')) + '</div><div class="muted">' +
+            esc(T('about/version', 'Version {v} for ArozOS', { v: '1.0' })) + '</div></div></div>' +
+            '<p style="margin:0 0 8px">' + esc(T('about/text', 'View PCB fabrication data in layers, as a realistic board preview or in 3D. Measure features, inspect apertures and drills, and export images.')) + '</p>' +
+            '<p class="muted small" style="margin:0">' + esc(T('about/credits', 'Archives on ArozOS are unpacked by the AGI backend of this app. The 3D view uses three.js (MIT License).')) + ' ' +
+            esc(S.online ? T('about/storedAroz', 'Settings and recent projects are kept per user in ArozOS.') : T('about/storedLocal', 'Settings and recent projects are kept in this browser.')) + '</p>';
+        UI.dialog({ title: T('about/title', 'About'), icon: 'info', width: 460, body: html, buttons: [{ label: T('common/close', 'Close'), primary: true }] });
+    };
+})(window.GV = window.GV || {});

+ 489 - 0
src/web/Gerber Viewer/js/excellon.js

@@ -0,0 +1,489 @@
+/*
+    Gerber Viewer - Excellon drill file parser
+
+    GV.excellon.parse(text, hint) -> layer image with an extra "tools" table.
+    hint.plated (true / false / null) comes from the file name when the file
+    itself does not say whether its holes are plated.
+
+    Handles METRIC / INCH with LZ / TZ zero suppression, explicit decimal
+    coordinates, FILE_FORMAT comments, tool definitions in the header or the
+    body, G85 slots, routed slots (G00 + M15 / G01 / G02 / G03 / M16) and
+    absolute / incremental coordinates.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom;
+    var TAU = G.TAU;
+
+    function parse(text, hint) {
+        hint = hint || {};
+        var units = null;
+        var zeros = null;       //'LZ' keeps leading zeros, 'TZ' keeps trailing zeros
+        var intDigits = null, decDigits = null;
+        var absolute = true;
+        var inHeader = false;
+        var tools = {};
+        var tool = null;
+        var x = 0, y = 0;
+        var mode = 'drill';     //drill | rout
+        var routMove = 0;       //0 rapid, 1 linear, 2 cw, 3 ccw
+        var toolDown = false;
+        var items = [];
+        var comments = [];
+        var warnings = [];
+        var notes = [];
+        var attrs = {};
+        var filePlated = null;
+        var pendingPlated = null;
+        var stats = { holes: 0, slots: 0 };
+        var bounds = G.emptyBounds();
+        var ended = false;
+
+        function unitFactor() {
+            return units === 'in' ? 25.4 : 1;
+        }
+
+        function digits() {
+            var i = intDigits, d = decDigits;
+            if (i === null || d === null) {
+                if (units === 'in') {
+                    i = 2; d = 4;
+                } else {
+                    i = 3; d = 3;
+                }
+            }
+            return [i, d];
+        }
+
+        function coord(str) {
+            if (str === undefined || str === '') {
+                return null;
+            }
+            if (str.indexOf('.') >= 0) {
+                return parseFloat(str) * unitFactor();
+            }
+            var neg = false;
+            if (str.charAt(0) === '+' || str.charAt(0) === '-') {
+                neg = str.charAt(0) === '-';
+                str = str.slice(1);
+            }
+            var dd = digits();
+            var v;
+            if (zeros === 'LZ') {
+                //Leading zeros kept, so trailing zeros may be missing
+                while (str.length < dd[0] + dd[1]) {
+                    str += '0';
+                }
+                v = parseInt(str, 10) / Math.pow(10, str.length - dd[0]);
+            } else {
+                v = parseInt(str, 10) / Math.pow(10, dd[1]);
+            }
+            if (!isFinite(v)) {
+                v = 0;
+            }
+            return (neg ? -v : v) * unitFactor();
+        }
+
+        function defineTool(name, rest) {
+            var m = /C([\d.]+)/.exec(rest);
+            if (!m) {
+                return false;
+            }
+            var dia = parseFloat(m[1]);
+            if (m[1].indexOf('.') < 0) {
+                //Tool sizes without a decimal point use the coordinate format
+                dia = coord(m[1]) / unitFactor();
+            }
+            var key = normTool(name);
+            tools[key] = {
+                name: key,
+                dia: dia * unitFactor(),
+                plated: pendingPlated !== null ? pendingPlated : null,
+                hits: 0,
+                slots: 0
+            };
+            pendingPlated = null;
+            return true;
+        }
+
+        function normTool(name) {
+            var n = parseInt(name.replace(/^T/i, ''), 10);
+            if (!isFinite(n)) {
+                return name;
+            }
+            return 'T' + (n < 10 ? '0' + n : '' + n);
+        }
+
+        function meta(kind) {
+            var t = tools[tool] || { name: tool || 'T?', dia: 0, plated: null };
+            return { k: kind, tool: t.name, dia: t.dia, plated: t.plated };
+        }
+
+        function radius() {
+            var t = tools[tool];
+            return t ? t.dia / 2 : 0;
+        }
+
+        function addItem(it) {
+            it.b = G.itemBounds(it);
+            G.unionBounds(bounds, it.b);
+            items.push(it);
+        }
+
+        function hole(px, py) {
+            if (!tool) {
+                warnOnce('notool', 'A hole was drilled before any tool was selected.');
+            }
+            var it = { t: 'c', x: px, y: py, r: radius() };
+            it.o = meta('hole');
+            it.o.x = px;
+            it.o.y = py;
+            if (tools[tool]) {
+                tools[tool].hits++;
+            }
+            stats.holes++;
+            addItem(it);
+        }
+
+        function slot(x1, y1, x2, y2) {
+            var it = { t: 'l', x1: x1, y1: y1, x2: x2, y2: y2, w: radius() * 2 };
+            it.o = meta('slot');
+            if (tools[tool]) {
+                tools[tool].slots++;
+            }
+            stats.slots++;
+            addItem(it);
+        }
+
+        function routArc(x1, y1, x2, y2, cw, r, i, j) {
+            var cx, cy;
+            if (i !== null || j !== null) {
+                cx = x1 + (i || 0);
+                cy = y1 + (j || 0);
+            } else if (r) {
+                //Center from radius: pick the side that matches the direction
+                var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
+                var dx = x2 - x1, dy = y2 - y1;
+                var q = Math.hypot(dx, dy);
+                if (q === 0 || q > 2 * r) {
+                    slot(x1, y1, x2, y2);
+                    return;
+                }
+                var h = Math.sqrt(r * r - q * q / 4);
+                var sgn = cw ? -1 : 1;
+                cx = mx - sgn * h * dy / q;
+                cy = my + sgn * h * dx / q;
+            } else {
+                slot(x1, y1, x2, y2);
+                return;
+            }
+            var a0 = Math.atan2(y1 - cy, x1 - cx), a1 = Math.atan2(y2 - cy, x2 - cx);
+            var sw = a1 - a0;
+            if (cw && sw >= 0) {
+                sw -= TAU;
+            }
+            if (!cw && sw <= 0) {
+                sw += TAU;
+            }
+            var it = { t: 'a', cx: cx, cy: cy, r: Math.hypot(x1 - cx, y1 - cy), a0: a0, sw: sw, w: radius() * 2 };
+            it.o = meta('slot');
+            if (tools[tool]) {
+                tools[tool].slots++;
+            }
+            stats.slots++;
+            addItem(it);
+        }
+
+        var warned = {};
+        function warnOnce(k, msg, code, vars) {
+            if (!warned[k]) {
+                warned[k] = true;
+                warnings.push(msg);
+                notes.push({ code: code || k, vars: vars || {} });
+            }
+        }
+
+        function platedFromText(s) {
+            if (/non[_\s-]?plated|npth|\bnp\b/i.test(s)) {
+                return false;
+            }
+            if (/plated|\bpth\b|via/i.test(s)) {
+                return true;
+            }
+            return null;
+        }
+
+        function handleComment(c) {
+            c = c.trim();
+            var m;
+            if ((m = /FILE_FORMAT\s*=\s*(\d+):(\d+)/i.exec(c))) {
+                intDigits = +m[1];
+                decDigits = +m[2];
+            }
+            if ((m = /^TYPE\s*=\s*(.+)$/i.exec(c))) {
+                filePlated = platedFromText(m[1]);
+            }
+            if ((m = /#@!\s*TF\.(\w+),(.*)$/.exec(c))) {
+                attrs[m[1]] = m[2];
+                if (m[1] === 'FileFunction') {
+                    filePlated = /^NonPlated/i.test(m[2]) ? false : (/^Plated/i.test(m[2]) ? true : filePlated);
+                }
+            }
+            if ((m = /#@!\s*TA\.AperFunction,(.*)$/.exec(c))) {
+                pendingPlated = /^NonPlated/i.test(m[1]) ? false : (/^Plated/i.test(m[1]) ? true : null);
+            }
+            if (/FORMAT\s*=\s*\{[^}]*decimal/i.test(c)) {
+                //KiCad style: coordinates carry a decimal point anyway
+            }
+            if (comments.length < 40 && c !== '') {
+                comments.push(c);
+            }
+        }
+
+        var lines = text.split(/\r\n|\r|\n/);
+        for (var li = 0; li < lines.length && !ended; li++) {
+            var raw = lines[li];
+            var semi = raw.indexOf(';');
+            if (semi >= 0) {
+                handleComment(raw.slice(semi + 1));
+                raw = raw.slice(0, semi);
+            }
+            var line = raw.replace(/\s+/g, '').toUpperCase();
+            if (line === '') {
+                continue;
+            }
+            if (line === 'M48') {
+                inHeader = true;
+                continue;
+            }
+            if (line === '%' || line === 'M95') {
+                inHeader = false;
+                continue;
+            }
+            var m;
+            //Unit and zero format lines (header, but tolerate them anywhere)
+            if ((m = /^(METRIC|INCH)(?:,(LZ|TZ))?(?:,(0*)\.(0*))?/.exec(line))) {
+                units = m[1] === 'INCH' ? 'in' : 'mm';
+                if (m[2]) {
+                    zeros = m[2];
+                }
+                if (m[3] !== undefined && m[4] !== undefined && (m[3].length || m[4].length)) {
+                    intDigits = m[3].length;
+                    decDigits = m[4].length;
+                }
+                continue;
+            }
+            if (line === 'M71') {
+                units = 'mm';
+                continue;
+            }
+            if (line === 'M72') {
+                units = 'in';
+                continue;
+            }
+            if (/^(FMAT|VER|ICI,OFF|DETECT|ATC|BLKD|SBK|SG|TCST|OSTOP|AFS|RUNTIME|M47|G93)/.test(line)) {
+                if (line === 'ICI,ON') {
+                    absolute = false;
+                }
+                continue;
+            }
+            if (line === 'ICI,ON') {
+                absolute = false;
+                continue;
+            }
+            //Tool definition or selection
+            if ((m = /^T(\d+)(.*)$/.exec(line))) {
+                if (/C[\d.]/.test(m[2])) {
+                    defineTool('T' + m[1], m[2]);
+                    if (inHeader) {
+                        continue;
+                    }
+                }
+                var tn = normTool('T' + m[1]);
+                tool = parseInt(m[1], 10) === 0 ? null : tn;
+                if (tool && !tools[tool]) {
+                    warnOnce('tool' + tool, 'Tool ' + tool + ' is used without a size.', 'toolsize', { tool: tool });
+                    tools[tool] = { name: tool, dia: 0, plated: null, hits: 0, slots: 0 };
+                }
+                continue;
+            }
+            if (inHeader) {
+                continue;
+            }
+            if (line === 'M30' || line === 'M00') {
+                ended = true;
+                continue;
+            }
+            if (line === 'G90') {
+                absolute = true;
+                continue;
+            }
+            if (line === 'G91') {
+                absolute = false;
+                continue;
+            }
+            if (line === 'G05' || line === 'G81') {
+                mode = 'drill';
+                toolDown = false;
+                continue;
+            }
+            if (line === 'M15') {
+                toolDown = true;
+                continue;
+            }
+            if (line === 'M16' || line === 'M17') {
+                toolDown = false;
+                continue;
+            }
+
+            //Coordinates (with optional G code prefix and G85 slot)
+            var g85 = line.indexOf('G85');
+            if (g85 > 0) {
+                var p1 = readXY(line.slice(0, g85));
+                var p2 = readXY(line.slice(g85 + 3));
+                var sx = p1.x !== null ? p1.x : x, sy = p1.y !== null ? p1.y : y;
+                var ex = p2.x !== null ? p2.x : sx, ey = p2.y !== null ? p2.y : sy;
+                if (!absolute) {
+                    sx = x + (p1.x || 0); sy = y + (p1.y || 0);
+                    ex = sx + (p2.x || 0); ey = sy + (p2.y || 0);
+                }
+                slot(sx, sy, ex, ey);
+                x = ex;
+                y = ey;
+                continue;
+            }
+            var gm = /^G0?([0-3])(.*)$/.exec(line);
+            var rest = line;
+            if (gm) {
+                routMove = +gm[1];
+                rest = gm[2];
+                mode = 'rout';
+                if (routMove === 0) {
+                    toolDown = false;
+                }
+            }
+            if (/^[XY]/.test(rest)) {
+                var p = readXY(rest);
+                var nx = p.x === null ? x : (absolute ? p.x : x + p.x);
+                var ny = p.y === null ? y : (absolute ? p.y : y + p.y);
+                if (mode === 'rout') {
+                    if (routMove === 0 || !toolDown) {
+                        //Rapid move or tool up: just reposition
+                    } else if (routMove === 1) {
+                        slot(x, y, nx, ny);
+                    } else {
+                        routArc(x, y, nx, ny, routMove === 2, p.a, p.i, p.j);
+                    }
+                } else {
+                    hole(nx, ny);
+                    //Repeat command (Rn) after a hole
+                }
+                x = nx;
+                y = ny;
+                continue;
+            }
+            if ((m = /^R(\d+)(.*)$/.exec(line))) {
+                var rp = readXY(m[2]);
+                for (var k = 0; k < +m[1]; k++) {
+                    x += rp.x || 0;
+                    y += rp.y || 0;
+                    hole(x, y);
+                }
+                continue;
+            }
+        }
+
+        function readXY(s) {
+            var mx = /X([+-]?[\d.]+)/.exec(s), my = /Y([+-]?[\d.]+)/.exec(s);
+            var ma = /A([+-]?[\d.]+)/.exec(s), mi = /I([+-]?[\d.]+)/.exec(s), mj = /J([+-]?[\d.]+)/.exec(s);
+            return {
+                x: mx ? coord(mx[1]) : null,
+                y: my ? coord(my[1]) : null,
+                a: ma ? coord(ma[1]) : null,
+                i: mi ? coord(mi[1]) : null,
+                j: mj ? coord(mj[1]) : null
+            };
+        }
+
+        if (units === null) {
+            units = 'in';
+            warnOnce('units', 'No METRIC / INCH statement; assuming inches.');
+        }
+
+        //Plating: per tool attribute, then file attribute, then the file name hint
+        var plated = filePlated !== null ? filePlated : (hint.plated !== undefined ? hint.plated : null);
+        Object.keys(tools).forEach(function (k) {
+            if (tools[k].plated === null) {
+                tools[k].plated = plated;
+            }
+        });
+        items.forEach(function (it) {
+            if (it.o && it.o.plated === null) {
+                it.o.plated = tools[it.o.tool] ? tools[it.o.tool].plated : plated;
+            }
+        });
+
+        var toolList = Object.keys(tools).sort().map(function (k) { return tools[k]; });
+
+        return {
+            kind: 'drill',
+            units: units,
+            formatInfo: { zeros: zeros, int: digits()[0], dec: digits()[1], abs: absolute },
+            format: (zeros === 'TZ' ? 'Trailing zeros' : (zeros === 'LZ' ? 'Leading zeros' : 'Decimal / default')) + ', ' +
+                digits()[0] + ':' + digits()[1] + (absolute ? ', absolute' : ', incremental'),
+            x2: Object.keys(attrs).length > 0,
+            tools: toolList,
+            apertures: [],
+            groups: items.length ? [{ dark: true, items: items }] : [],
+            bounds: bounds,
+            hasClear: false,
+            plated: plated,
+            stats: stats,
+            attrs: attrs,
+            layerName: '',
+            comments: comments,
+            generator: detectGenerator(comments),
+            created: detectDate(comments),
+            warnings: warnings,
+            notes: notes
+        };
+    }
+
+    function detectGenerator(comments) {
+        var names = ['EasyEDA', 'KiCad', 'Altium', 'EAGLE', 'Fusion', 'DipTrace', 'Proteus', 'OrCAD', 'Allegro', 'PADS', 'gEDA', 'Fritzing', 'Horizon', 'LibrePCB', 'CircuitMaker', 'DesignSpark'];
+        for (var i = 0; i < comments.length; i++) {
+            for (var j = 0; j < names.length; j++) {
+                var idx = comments[i].toLowerCase().indexOf(names[j].toLowerCase());
+                if (idx >= 0) {
+                    return comments[i].slice(idx).split(/[,;]/)[0].trim().slice(0, 60);
+                }
+            }
+        }
+        return '';
+    }
+
+    function detectDate(comments) {
+        var re = /(\d{4}[-\/.]\d{1,2}[-\/.]\d{1,2})(?:[ T,]+(\d{1,2}:\d{2}(?::\d{2})?))?/;
+        for (var i = 0; i < comments.length; i++) {
+            var m = re.exec(comments[i]);
+            if (m) {
+                return m[1] + (m[2] ? ' ' + m[2] : '');
+            }
+        }
+        return '';
+    }
+
+    function looksLikeExcellon(text) {
+        var head = text.slice(0, 4096);
+        if (/^\s*M48\b/m.test(head)) {
+            return true;
+        }
+        return /^T\d+C[\d.]+/m.test(head) && /^X[+-]?[\d.]+Y?/m.test(text.slice(0, 65536)) && !/%FS/.test(head);
+    }
+
+    GV.excellon = {
+        parse: parse,
+        looksLikeExcellon: looksLikeExcellon
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 223 - 0
src/web/Gerber Viewer/js/exporter.js

@@ -0,0 +1,223 @@
+/*
+    Gerber Viewer - exports
+
+    SVG of the visible layers (vector, millimetre sized), saving blobs or text
+    to the device or to ArozOS (through backend/export.agi), printing at
+    actual size, and the drill table as CSV.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom;
+
+    function n(v) {
+        var s = (Math.round(v * 10000) / 10000).toString();
+        return s === '-0' ? '0' : s;
+    }
+
+    function pathData(items, stroke) {
+        var p = new G.SvgPath(4);
+        items.forEach(function (it) {
+            if (stroke) {
+                G.emitStroke(p, it);
+            } else {
+                G.emitFill(p, it);
+            }
+        });
+        return p.toString();
+    }
+
+    //One polarity group as SVG elements in the given colour
+    function groupSVG(g, color, defs, uid) {
+        var fills = [], strokes = new Map(), out = '';
+        g.items.forEach(function (it) {
+            if (it.t === 'l' || it.t === 'a') {
+                if (!strokes.has(it.w)) {
+                    strokes.set(it.w, []);
+                }
+                strokes.get(it.w).push(it);
+            } else if (it.t === 'p' && it.clip) {
+                var id = 'c' + uid.next++;
+                var clip = new G.SvgPath(4);
+                it.clip.forEach(function (c) { G.emitContour(clip, c); });
+                defs.push('<clipPath id="' + id + '" clipPathUnits="userSpaceOnUse"><path d="' + clip + '"/></clipPath>');
+                out += '<path clip-path="url(#' + id + ')" fill="' + color + '" d="' + pathData([it], false) + '"/>';
+            } else {
+                fills.push(it);
+            }
+        });
+        if (fills.length) {
+            out += '<path fill="' + color + '" d="' + pathData(fills, false) + '"/>';
+        }
+        strokes.forEach(function (list, w) {
+            out += '<path fill="none" stroke="' + color + '" stroke-width="' + n(Math.max(w, 0.01)) + '" d="' + pathData(list, true) + '"/>';
+        });
+        return out;
+    }
+
+    /*
+        layers: draw order, bottom first (only visible ones are written)
+        bounds: [minX, minY, maxX, maxY] in mm
+        o: {background, flip, title}
+    */
+    function svg(layers, bounds, o) {
+        o = o || {};
+        var b = bounds;
+        var w = b[2] - b[0], h = b[3] - b[1];
+        var defs = [];
+        var body = [];
+        var uid = { next: 1 };
+        layers.forEach(function (layer, li) {
+            if (!layer.visible || !layer.image || !layer.image.groups.length) {
+                return;
+            }
+            var lb = layer.image.bounds;
+            var parts = '';
+            if (!layer.image.hasClear) {
+                layer.image.groups.forEach(function (g) {
+                    parts += groupSVG(g, layer.color, defs, uid);
+                });
+            } else {
+                //Clear polarity: build a luminance mask, dark = white, clear = black
+                var mid = 'm' + li + '_' + uid.next++;
+                var mask = '';
+                layer.image.groups.forEach(function (g) {
+                    mask += groupSVG(g, g.dark ? '#fff' : '#000', defs, uid);
+                });
+                defs.push('<mask id="' + mid + '" maskUnits="userSpaceOnUse" x="' + n(lb[0] - 1) + '" y="' + n(lb[1] - 1) + '" width="' +
+                    n(lb[2] - lb[0] + 2) + '" height="' + n(lb[3] - lb[1] + 2) + '">' + mask + '</mask>');
+                parts = '<rect mask="url(#' + mid + ')" fill="' + layer.color + '" x="' + n(lb[0] - 1) + '" y="' + n(lb[1] - 1) +
+                    '" width="' + n(lb[2] - lb[0] + 2) + '" height="' + n(lb[3] - lb[1] + 2) + '"/>';
+            }
+            body.push('<g id="' + xmlId(layer.label) + '" opacity="' + n(layer.opacity) + '" stroke-linecap="round" stroke-linejoin="round">' + parts + '</g>');
+        });
+        var vbX = o.flip ? -b[2] : b[0];
+        var transform = o.flip ? 'scale(-1,-1)' : 'scale(1,-1)';
+        var out = '<?xml version="1.0" encoding="UTF-8"?>\n' +
+            '<svg xmlns="http://www.w3.org/2000/svg" width="' + n(w) + 'mm" height="' + n(h) + 'mm" viewBox="' +
+            n(vbX) + ' ' + n(-b[3]) + ' ' + n(w) + ' ' + n(h) + '">\n' +
+            (o.title ? '<title>' + escXml(o.title) + '</title>\n' : '') +
+            (defs.length ? '<defs>' + defs.join('') + '</defs>\n' : '') +
+            (o.background ? '<rect x="' + n(vbX) + '" y="' + n(-b[3]) + '" width="' + n(w) + '" height="' + n(h) + '" fill="' + o.background + '"/>\n' : '') +
+            '<g transform="' + transform + '">\n' + body.join('\n') + '\n</g>\n</svg>\n';
+        return out;
+    }
+
+    function escXml(s) {
+        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+    }
+
+    function xmlId(s) {
+        return String(s).replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^([^A-Za-z_])/, '_$1');
+    }
+
+    function download(data, filename, mime) {
+        var blob = data instanceof Blob ? data : new Blob([data], { type: mime || 'application/octet-stream' });
+        var url = URL.createObjectURL(blob);
+        var a = document.createElement('a');
+        a.href = url;
+        a.download = filename;
+        document.body.appendChild(a);
+        a.click();
+        a.remove();
+        setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
+    }
+
+    var HEX = [];
+    for (var i = 0; i < 256; i++) {
+        HEX.push((i < 16 ? '0' : '') + i.toString(16));
+    }
+
+    function toHex(bytes) {
+        var parts = [];
+        var CH = 32768;
+        for (var o = 0; o < bytes.length; o += CH) {
+            var end = Math.min(bytes.length, o + CH);
+            var s = new Array(end - o);
+            for (var j = o; j < end; j++) {
+                s[j - o] = HEX[bytes[j]];
+            }
+            parts.push(s.join(''));
+        }
+        return parts.join('');
+    }
+
+    //Save text or binary data to an ArozOS virtual path through AGI
+    function saveToAroz(path, data) {
+        if (typeof data === 'string') {
+            return GV.store.agiJSON('export.agi', { opr: 'writeText', path: path, content: data });
+        }
+        var p = data instanceof Blob ? data.arrayBuffer() : Promise.resolve(data.buffer ? data : new Uint8Array(data));
+        return p.then(function (buf) {
+            var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
+            return GV.store.agiJSON('export.agi', { opr: 'writeBinary', path: path, hex: toHex(bytes) });
+        });
+    }
+
+    function canvasBlob(canvas, type, quality) {
+        return new Promise(function (resolve, reject) {
+            canvas.toBlob(function (b) {
+                if (b) {
+                    resolve(b);
+                } else {
+                    reject(new Error(GV.ui.t('error/encode', 'The image is too large to encode.')));
+                }
+            }, type || 'image/png', quality);
+        });
+    }
+
+    //Print an image at a physical size (mm) or fitted to the page
+    function print(dataURL, widthMM, heightMM, title, fit) {
+        var frame = document.createElement('iframe');
+        frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden';
+        document.body.appendChild(frame);
+        var landscape = widthMM > heightMM;
+        var size = fit ? 'max-width:100%;max-height:100vh;object-fit:contain' :
+            'width:' + widthMM.toFixed(3) + 'mm;height:' + heightMM.toFixed(3) + 'mm';
+        var doc = frame.contentWindow.document;
+        doc.open();
+        doc.write('<!DOCTYPE html><html><head><title>' + escXml(title || 'Gerber Viewer') + '</title><style>' +
+            '@page{size:' + (landscape ? 'landscape' : 'portrait') + ';margin:10mm}' +
+            'html,body{margin:0;padding:0;background:#fff}img{display:block;' + size + '}' +
+            '</style></head><body><img id="im"></body></html>');
+        doc.close();
+        var img = doc.getElementById('im');
+        img.onload = function () {
+            setTimeout(function () {
+                frame.contentWindow.focus();
+                frame.contentWindow.print();
+                setTimeout(function () { frame.remove(); }, 2000);
+            }, 60);
+        };
+        img.src = dataURL;
+    }
+
+    function csvCell(v) {
+        var s = String(v === undefined || v === null ? '' : v);
+        return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+    }
+
+    function drillCSV(layers) {
+        var rows = [['File', 'Layer', 'Tool', 'Diameter (mm)', 'Diameter (in)', 'Plated', 'Holes', 'Slots']];
+        layers.forEach(function (l) {
+            if (l.type !== 'drill' || !l.image || !l.image.tools) {
+                return;
+            }
+            l.image.tools.forEach(function (t) {
+                rows.push([l.name, l.label, t.name, t.dia.toFixed(3), (t.dia / 25.4).toFixed(4),
+                    t.plated === null ? 'unknown' : (t.plated ? 'yes' : 'no'), t.hits, t.slots]);
+            });
+        });
+        return rows.map(function (r) { return r.map(csvCell).join(','); }).join('\r\n') + '\r\n';
+    }
+
+    GV.exporter = {
+        svg: svg,
+        download: download,
+        saveToAroz: saveToAroz,
+        canvasBlob: canvasBlob,
+        toHex: toHex,
+        print: print,
+        drillCSV: drillCSV
+    };
+})(window.GV = window.GV || {});

+ 527 - 0
src/web/Gerber Viewer/js/geom.js

@@ -0,0 +1,527 @@
+/*
+    Gerber Viewer - geometry helpers
+
+    Shared by the parsers, the board builder, the renderers and the exporters.
+
+    Every parsed layer is a list of polarity groups, each holding "items" in
+    board space (millimetres, Y pointing up):
+
+        {t:'c', x, y, r}                         filled disc
+        {t:'l', x1, y1, x2, y2, w}               stroke with round caps
+        {t:'a', cx, cy, r, a0, sw, w}            arc stroke with round caps,
+                                                 a0 = start angle, sw = signed
+                                                 sweep (positive = counter
+                                                 clockwise in board space)
+        {t:'p', c:[contour, ...], clip}          filled contours, each a flat
+                                                 [x0, y0, x1, y1, ...] array.
+                                                 Outer contours are counter
+                                                 clockwise and holes clockwise,
+                                                 so a nonzero fill is correct.
+                                                 clip (optional) limits the
+                                                 fill to those contours.
+
+    Every item also carries b = [minX, minY, maxX, maxY] and o, a shared object
+    describing what produced it (flash, trace, region, hole, ...).
+*/
+(function (GV) {
+    'use strict';
+
+    var TAU = Math.PI * 2;
+    var ARC_TOL = 0.002;   //mm of chord error allowed when an arc is flattened
+
+    function arcSegments(r, sweep) {
+        var a = Math.abs(sweep);
+        if (!(r > 0) || !(a > 0)) {
+            return 1;
+        }
+        var step = 2 * Math.acos(Math.max(-1, 1 - ARC_TOL / r));
+        if (!(step > 0)) {
+            step = 0.25;
+        }
+        var n = Math.ceil(a / step);
+        n = Math.max(n, Math.ceil(16 * a / TAU), 2);
+        return Math.min(n, 720);
+    }
+
+    //Append the points of an arc to pts, excluding the start point
+    function flattenArc(pts, cx, cy, r, a0, sweep) {
+        var n = arcSegments(r, sweep);
+        for (var i = 1; i <= n; i++) {
+            var a = a0 + sweep * i / n;
+            pts.push(cx + r * Math.cos(a), cy + r * Math.sin(a));
+        }
+        return pts;
+    }
+
+    function circleContour(cx, cy, r, ccw) {
+        var n = arcSegments(r, TAU);
+        var pts = [];
+        for (var i = 0; i < n; i++) {
+            var a = (ccw === false ? -1 : 1) * TAU * i / n;
+            pts.push(cx + r * Math.cos(a), cy + r * Math.sin(a));
+        }
+        return pts;
+    }
+
+    function signedArea(c) {
+        var a = 0;
+        var n = c.length;
+        if (n < 6) {
+            return 0;
+        }
+        var px = c[n - 2], py = c[n - 1];
+        for (var i = 0; i < n; i += 2) {
+            a += px * c[i + 1] - c[i] * py;
+            px = c[i];
+            py = c[i + 1];
+        }
+        return a / 2;
+    }
+
+    function reverseContour(c) {
+        var out = new Array(c.length);
+        var n = c.length / 2;
+        for (var i = 0; i < n; i++) {
+            out[2 * i] = c[2 * (n - 1 - i)];
+            out[2 * i + 1] = c[2 * (n - 1 - i) + 1];
+        }
+        return out;
+    }
+
+    //Make a contour counter clockwise (positive) or clockwise (hole)
+    function orient(c, positive) {
+        var a = signedArea(c);
+        if ((a >= 0) !== positive) {
+            return reverseContour(c);
+        }
+        return c;
+    }
+
+    //Drop a closing point that repeats the first one
+    function dropClosingPoint(c) {
+        var n = c.length;
+        if (n >= 4 && Math.abs(c[0] - c[n - 2]) < 1e-9 && Math.abs(c[1] - c[n - 1]) < 1e-9) {
+            c.length = n - 2;
+        }
+        return c;
+    }
+
+    //Andrew's monotone chain, returns a counter clockwise contour
+    function convexHull(pts) {
+        var p = [];
+        for (var i = 0; i < pts.length; i += 2) {
+            p.push([pts[i], pts[i + 1]]);
+        }
+        p.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
+        if (p.length < 3) {
+            return pts.slice();
+        }
+        function cross(o, a, b) {
+            return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+        }
+        var lower = [], upper = [];
+        for (var j = 0; j < p.length; j++) {
+            while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p[j]) <= 0) {
+                lower.pop();
+            }
+            lower.push(p[j]);
+        }
+        for (var k = p.length - 1; k >= 0; k--) {
+            while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p[k]) <= 0) {
+                upper.pop();
+            }
+            upper.push(p[k]);
+        }
+        upper.pop();
+        lower.pop();
+        var hull = lower.concat(upper);
+        var out = [];
+        for (var h = 0; h < hull.length; h++) {
+            out.push(hull[h][0], hull[h][1]);
+        }
+        return out;
+    }
+
+    //Expand the bounding box of an arc stroke
+    function arcBounds(cx, cy, r, a0, sweep, pad) {
+        var x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
+        var x1 = cx + r * Math.cos(a0 + sweep), y1 = cy + r * Math.sin(a0 + sweep);
+        var b = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)];
+        if (Math.abs(sweep) >= TAU - 1e-9) {
+            b = [cx - r, cy - r, cx + r, cy + r];
+        } else {
+            var lo = Math.min(a0, a0 + sweep), hi = Math.max(a0, a0 + sweep);
+            var k = Math.ceil(lo / (Math.PI / 2));
+            for (var a = k * Math.PI / 2; a <= hi; a += Math.PI / 2) {
+                var px = cx + r * Math.cos(a), py = cy + r * Math.sin(a);
+                if (px < b[0]) b[0] = px;
+                if (py < b[1]) b[1] = py;
+                if (px > b[2]) b[2] = px;
+                if (py > b[3]) b[3] = py;
+            }
+        }
+        b[0] -= pad; b[1] -= pad; b[2] += pad; b[3] += pad;
+        return b;
+    }
+
+    function itemBounds(it) {
+        switch (it.t) {
+            case 'c':
+                return [it.x - it.r, it.y - it.r, it.x + it.r, it.y + it.r];
+            case 'l':
+                var h = it.w / 2;
+                return [Math.min(it.x1, it.x2) - h, Math.min(it.y1, it.y2) - h,
+                    Math.max(it.x1, it.x2) + h, Math.max(it.y1, it.y2) + h];
+            case 'a':
+                return arcBounds(it.cx, it.cy, it.r, it.a0, it.sw, it.w / 2);
+            case 'p':
+                var b = [Infinity, Infinity, -Infinity, -Infinity];
+                for (var i = 0; i < it.c.length; i++) {
+                    var c = it.c[i];
+                    for (var j = 0; j < c.length; j += 2) {
+                        if (c[j] < b[0]) b[0] = c[j];
+                        if (c[j + 1] < b[1]) b[1] = c[j + 1];
+                        if (c[j] > b[2]) b[2] = c[j];
+                        if (c[j + 1] > b[3]) b[3] = c[j + 1];
+                    }
+                }
+                return b;
+        }
+        return [0, 0, 0, 0];
+    }
+
+    function emptyBounds() {
+        return [Infinity, Infinity, -Infinity, -Infinity];
+    }
+
+    function unionBounds(a, b) {
+        if (b[0] < a[0]) a[0] = b[0];
+        if (b[1] < a[1]) a[1] = b[1];
+        if (b[2] > a[2]) a[2] = b[2];
+        if (b[3] > a[3]) a[3] = b[3];
+        return a;
+    }
+
+    function boundsValid(b) {
+        return b && isFinite(b[0]) && isFinite(b[1]) && isFinite(b[2]) && isFinite(b[3]) && b[2] >= b[0] && b[3] >= b[1];
+    }
+
+    //Affine 2x3 transform [a, b, c, d, e, f]: x' = a x + c y + e, y' = b x + d y + f
+    function applyToItem(it, m) {
+        var a = m[0], b = m[1], c = m[2], d = m[3], e = m[4], f = m[5];
+        var det = a * d - b * c;
+        var s = Math.sqrt(Math.abs(det));
+        var out;
+        switch (it.t) {
+            case 'c':
+                out = { t: 'c', x: a * it.x + c * it.y + e, y: b * it.x + d * it.y + f, r: it.r * s };
+                break;
+            case 'l':
+                out = {
+                    t: 'l',
+                    x1: a * it.x1 + c * it.y1 + e, y1: b * it.x1 + d * it.y1 + f,
+                    x2: a * it.x2 + c * it.y2 + e, y2: b * it.x2 + d * it.y2 + f,
+                    w: it.w * s
+                };
+                break;
+            case 'a':
+                //Rotate the start direction; a mirror reverses the sweep
+                var ux = Math.cos(it.a0), uy = Math.sin(it.a0);
+                out = {
+                    t: 'a',
+                    cx: a * it.cx + c * it.cy + e, cy: b * it.cx + d * it.cy + f,
+                    r: it.r * s,
+                    a0: Math.atan2(b * ux + d * uy, a * ux + c * uy),
+                    sw: det < 0 ? -it.sw : it.sw,
+                    w: it.w * s
+                };
+                break;
+            case 'p':
+                var cs = [];
+                for (var i = 0; i < it.c.length; i++) {
+                    cs.push(transformContour(it.c[i], m, det < 0));
+                }
+                out = { t: 'p', c: cs };
+                if (it.clip) {
+                    out.clip = [];
+                    for (var k = 0; k < it.clip.length; k++) {
+                        out.clip.push(transformContour(it.clip[k], m, det < 0));
+                    }
+                }
+                break;
+        }
+        out.o = it.o;
+        out.b = itemBounds(out);
+        return out;
+    }
+
+    function transformContour(c, m, flip) {
+        var out = new Array(c.length);
+        for (var j = 0; j < c.length; j += 2) {
+            out[j] = m[0] * c[j] + m[2] * c[j + 1] + m[4];
+            out[j + 1] = m[1] * c[j] + m[3] * c[j + 1] + m[5];
+        }
+        return flip ? reverseContour(out) : out;
+    }
+
+    function translateItem(it, dx, dy) {
+        var out;
+        switch (it.t) {
+            case 'c':
+                out = { t: 'c', x: it.x + dx, y: it.y + dy, r: it.r };
+                break;
+            case 'l':
+                out = { t: 'l', x1: it.x1 + dx, y1: it.y1 + dy, x2: it.x2 + dx, y2: it.y2 + dy, w: it.w };
+                break;
+            case 'a':
+                out = { t: 'a', cx: it.cx + dx, cy: it.cy + dy, r: it.r, a0: it.a0, sw: it.sw, w: it.w };
+                break;
+            case 'p':
+                out = { t: 'p', c: it.c.map(function (c) { return shiftContour(c, dx, dy); }) };
+                if (it.clip) {
+                    out.clip = it.clip.map(function (c) { return shiftContour(c, dx, dy); });
+                }
+                break;
+        }
+        out.o = it.o;
+        out.b = [it.b[0] + dx, it.b[1] + dy, it.b[2] + dx, it.b[3] + dy];
+        return out;
+    }
+
+    function shiftContour(c, dx, dy) {
+        var out = new Array(c.length);
+        for (var j = 0; j < c.length; j += 2) {
+            out[j] = c[j] + dx;
+            out[j + 1] = c[j + 1] + dy;
+        }
+        return out;
+    }
+
+    //Arc end points
+    function arcStart(it) {
+        return [it.cx + it.r * Math.cos(it.a0), it.cy + it.r * Math.sin(it.a0)];
+    }
+
+    function arcEnd(it) {
+        return [it.cx + it.r * Math.cos(it.a0 + it.sw), it.cy + it.r * Math.sin(it.a0 + it.sw)];
+    }
+
+    /*
+        Path sinks: geometry is written through a tiny canvas-like interface so
+        the same code feeds Path2D (screen), SVG path data (export) and point
+        lists (3D / board outline).
+    */
+    function emitCircle(sink, x, y, r, ccw) {
+        sink.moveTo(x + r, y);
+        sink.arc(x, y, r, 0, TAU, ccw === false);
+        sink.closePath();
+    }
+
+    function emitContour(sink, c) {
+        if (c.length < 4) {
+            return;
+        }
+        sink.moveTo(c[0], c[1]);
+        for (var i = 2; i < c.length; i += 2) {
+            sink.lineTo(c[i], c[i + 1]);
+        }
+        sink.closePath();
+    }
+
+    //Item outline used for filled drawing (strokes are handled by the caller)
+    function emitFill(sink, it) {
+        if (it.t === 'c') {
+            emitCircle(sink, it.x, it.y, it.r, true);
+        } else if (it.t === 'p') {
+            for (var i = 0; i < it.c.length; i++) {
+                emitContour(sink, it.c[i]);
+            }
+        }
+    }
+
+    function emitStroke(sink, it) {
+        if (it.t === 'l') {
+            sink.moveTo(it.x1, it.y1);
+            sink.lineTo(it.x2, it.y2);
+        } else if (it.t === 'a') {
+            var s = arcStart(it);
+            sink.moveTo(s[0], s[1]);
+            //Canvas arcs run in increasing angle when anticlockwise is false.
+            //With the Y-up board transform that is counter clockwise on screen
+            sink.arc(it.cx, it.cy, it.r, it.a0, it.a0 + it.sw, it.sw < 0);
+        }
+    }
+
+    //SVG path data sink
+    function SvgPath(precision) {
+        this.d = [];
+        this.p = precision || 4;
+        this.cx = 0;
+        this.cy = 0;
+    }
+    SvgPath.prototype.n = function (v) {
+        var s = v.toFixed(this.p);
+        if (s.indexOf('.') >= 0) {
+            s = s.replace(/0+$/, '').replace(/\.$/, '');
+        }
+        return s === '-0' ? '0' : s;
+    };
+    SvgPath.prototype.moveTo = function (x, y) {
+        this.d.push('M' + this.n(x) + ' ' + this.n(y));
+        this.cx = x;
+        this.cy = y;
+    };
+    SvgPath.prototype.lineTo = function (x, y) {
+        this.d.push('L' + this.n(x) + ' ' + this.n(y));
+        this.cx = x;
+        this.cy = y;
+    };
+    SvgPath.prototype.closePath = function () {
+        this.d.push('Z');
+    };
+    SvgPath.prototype.arc = function (cx, cy, r, a0, a1, anticlockwise) {
+        var sweep = a1 - a0;
+        if (anticlockwise && sweep > 0) {
+            sweep -= TAU;
+        }
+        if (!anticlockwise && sweep < 0) {
+            sweep += TAU;
+        }
+        if (Math.abs(sweep) >= TAU - 1e-9) {
+            sweep = anticlockwise ? -TAU : TAU;
+        }
+        var sx = cx + r * Math.cos(a0), sy = cy + r * Math.sin(a0);
+        if (Math.abs(sx - this.cx) > 1e-9 || Math.abs(sy - this.cy) > 1e-9) {
+            this.lineTo(sx, sy);
+        }
+        //Split into pieces below 180 degrees so the large-arc flag stays simple
+        var pieces = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI * 0.99)));
+        var flag = sweep > 0 ? 1 : 0;
+        for (var i = 1; i <= pieces; i++) {
+            var a = a0 + sweep * i / pieces;
+            var x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
+            this.d.push('A' + this.n(r) + ' ' + this.n(r) + ' 0 0 ' + flag + ' ' + this.n(x) + ' ' + this.n(y));
+            this.cx = x;
+            this.cy = y;
+        }
+    };
+    SvgPath.prototype.toString = function () {
+        return this.d.join('');
+    };
+
+    //Distance from point to segment
+    function distToSegment(px, py, x1, y1, x2, y2) {
+        var dx = x2 - x1, dy = y2 - y1;
+        var l2 = dx * dx + dy * dy;
+        var t = l2 > 0 ? ((px - x1) * dx + (py - y1) * dy) / l2 : 0;
+        t = Math.max(0, Math.min(1, t));
+        var qx = x1 + t * dx - px, qy = y1 + t * dy - py;
+        return Math.sqrt(qx * qx + qy * qy);
+    }
+
+    function windingNumber(c, px, py) {
+        var wn = 0;
+        var n = c.length;
+        for (var i = 0; i < n; i += 2) {
+            var x1 = c[i], y1 = c[i + 1];
+            var x2 = c[(i + 2) % n], y2 = c[(i + 3) % n];
+            if (y1 <= py) {
+                if (y2 > py && (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1) > 0) {
+                    wn++;
+                }
+            } else if (y2 <= py && (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1) < 0) {
+                wn--;
+            }
+        }
+        return wn;
+    }
+
+    function angleInSweep(a, a0, sw) {
+        var d = a - a0;
+        if (sw >= 0) {
+            d = ((d % TAU) + TAU) % TAU;
+            return d <= sw + 1e-9;
+        }
+        d = ((-d % TAU) + TAU) % TAU;
+        return d <= -sw + 1e-9;
+    }
+
+    //Does the item cover (px, py)? tol widens thin features
+    function hitItem(it, px, py, tol) {
+        var b = it.b;
+        if (px < b[0] - tol || px > b[2] + tol || py < b[1] - tol || py > b[3] + tol) {
+            return false;
+        }
+        switch (it.t) {
+            case 'c':
+                return Math.hypot(px - it.x, py - it.y) <= it.r + tol;
+            case 'l':
+                return distToSegment(px, py, it.x1, it.y1, it.x2, it.y2) <= it.w / 2 + tol;
+            case 'a':
+                var d = Math.hypot(px - it.cx, py - it.cy);
+                if (Math.abs(d - it.r) <= it.w / 2 + tol && angleInSweep(Math.atan2(py - it.cy, px - it.cx), it.a0, it.sw)) {
+                    return true;
+                }
+                var s = arcStart(it), e = arcEnd(it);
+                return Math.hypot(px - s[0], py - s[1]) <= it.w / 2 + tol || Math.hypot(px - e[0], py - e[1]) <= it.w / 2 + tol;
+            case 'p':
+                var wn = 0;
+                for (var i = 0; i < it.c.length; i++) {
+                    wn += windingNumber(it.c[i], px, py);
+                }
+                if (wn !== 0) {
+                    return true;
+                }
+                if (tol > 0) {
+                    for (var k = 0; k < it.c.length; k++) {
+                        var c = it.c[k];
+                        for (var j = 0; j < c.length; j += 2) {
+                            if (distToSegment(px, py, c[j], c[j + 1], c[(j + 2) % c.length], c[(j + 3) % c.length]) <= tol) {
+                                return true;
+                            }
+                        }
+                    }
+                }
+                return false;
+        }
+        return false;
+    }
+
+    function polygonArea(it) {
+        var a = 0;
+        for (var i = 0; i < it.c.length; i++) {
+            a += signedArea(it.c[i]);
+        }
+        return Math.abs(a);
+    }
+
+    GV.geom = {
+        TAU: TAU,
+        arcSegments: arcSegments,
+        flattenArc: flattenArc,
+        circleContour: circleContour,
+        signedArea: signedArea,
+        reverseContour: reverseContour,
+        orient: orient,
+        dropClosingPoint: dropClosingPoint,
+        convexHull: convexHull,
+        arcBounds: arcBounds,
+        itemBounds: itemBounds,
+        emptyBounds: emptyBounds,
+        unionBounds: unionBounds,
+        boundsValid: boundsValid,
+        applyToItem: applyToItem,
+        translateItem: translateItem,
+        arcStart: arcStart,
+        arcEnd: arcEnd,
+        emitCircle: emitCircle,
+        emitContour: emitContour,
+        emitFill: emitFill,
+        emitStroke: emitStroke,
+        SvgPath: SvgPath,
+        distToSegment: distToSegment,
+        windingNumber: windingNumber,
+        hitItem: hitItem,
+        polygonArea: polygonArea
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 1208 - 0
src/web/Gerber Viewer/js/gerber.js

@@ -0,0 +1,1208 @@
+/*
+    Gerber Viewer - RS-274X / Gerber X2 parser
+
+    GV.gerber.parse(text) -> layer image (see geom.js for the item format)
+
+    Supported: FS, MO, AD (C/R/O/P and macros), AM with every primitive and
+    arithmetic, LP, LM/LR/LS, SR, AB block apertures, regions (G36/G37), linear
+    and circular interpolation in single (G74) and multi (G75) quadrant mode,
+    X2 file / aperture / object attributes, and the deprecated IP, OF, G70/G71,
+    G90/G91 and modal D-code forms that older CAM software still writes.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom;
+    var TAU = G.TAU;
+    var DEG = Math.PI / 180;
+
+    /* ------------------------------------------------------------------
+        Aperture macro expressions
+    ------------------------------------------------------------------ */
+    function tokenizeExpr(src) {
+        var re = /\s*(\$\d+|\d*\.\d+(?:[eE][+-]?\d+)?|\d+\.?(?:[eE][+-]?\d+)?|[+\-xX\/()])/g;
+        var out = [];
+        var m;
+        var pos = 0;
+        while ((m = re.exec(src)) !== null) {
+            if (m.index !== pos && src.slice(pos, m.index).trim() !== '') {
+                break;
+            }
+            out.push(m[1]);
+            pos = re.lastIndex;
+        }
+        return out;
+    }
+
+    function evalExpr(src, vars) {
+        var toks = tokenizeExpr(src);
+        var i = 0;
+        function peek() { return toks[i]; }
+        function next() { return toks[i++]; }
+        function primary() {
+            var t = next();
+            if (t === undefined) {
+                return 0;
+            }
+            if (t === '(') {
+                var v = sum();
+                if (peek() === ')') {
+                    next();
+                }
+                return v;
+            }
+            if (t === '-') {
+                return -primary();
+            }
+            if (t === '+') {
+                return primary();
+            }
+            if (t.charAt(0) === '$') {
+                var val = vars[parseInt(t.slice(1), 10)];
+                return typeof val === 'number' && isFinite(val) ? val : 0;
+            }
+            var n = parseFloat(t);
+            return isFinite(n) ? n : 0;
+        }
+        function product() {
+            var v = primary();
+            while (peek() === 'x' || peek() === 'X' || peek() === '/') {
+                var op = next();
+                var r = primary();
+                v = op === '/' ? (r === 0 ? 0 : v / r) : v * r;
+            }
+            return v;
+        }
+        function sum() {
+            var v = product();
+            while (peek() === '+' || peek() === '-') {
+                var op = next();
+                var r = product();
+                v = op === '+' ? v + r : v - r;
+            }
+            return v;
+        }
+        return sum();
+    }
+
+    function parseMacro(name, blocks) {
+        var body = [];
+        for (var i = 0; i < blocks.length; i++) {
+            var b = blocks[i].replace(/\s+/g, ' ').trim();
+            if (b === '') {
+                continue;
+            }
+            if (/^0(\s|$)/.test(b)) {
+                continue;   //comment primitive
+            }
+            var vm = /^\$(\d+)\s*=\s*(.*)$/.exec(b);
+            if (vm) {
+                body.push({ v: parseInt(vm[1], 10), e: vm[2] });
+                continue;
+            }
+            var parts = b.replace(/\s/g, '').split(',');
+            body.push({ code: parseInt(parts[0], 10), args: parts.slice(1) });
+        }
+        return { name: name, body: body };
+    }
+
+    function rotatePts(pts, deg) {
+        if (!deg) {
+            return pts;
+        }
+        var c = Math.cos(deg * DEG), s = Math.sin(deg * DEG);
+        for (var i = 0; i < pts.length; i += 2) {
+            var x = pts[i], y = pts[i + 1];
+            pts[i] = x * c - y * s;
+            pts[i + 1] = x * s + y * c;
+        }
+        return pts;
+    }
+
+    function rectPts(x0, y0, x1, y1) {
+        return [x0, y0, x1, y0, x1, y1, x0, y1];
+    }
+
+    //Evaluate an aperture macro into shapes [{on, item}] in file units
+    function evalMacro(macro, params) {
+        var vars = {};
+        for (var p = 0; p < params.length; p++) {
+            vars[p + 1] = params[p];
+        }
+        var shapes = [];
+        function add(on, it) {
+            shapes.push({ on: on, it: it });
+        }
+        function poly(on, pts) {
+            G.dropClosingPoint(pts);
+            if (pts.length >= 6) {
+                add(on, { t: 'p', c: [G.orient(pts, true)] });
+            }
+        }
+        for (var i = 0; i < macro.body.length; i++) {
+            var st = macro.body[i];
+            if (st.v !== undefined) {
+                vars[st.v] = evalExpr(st.e, vars);
+                continue;
+            }
+            var a = st.args.map(function (s) { return evalExpr(s, vars); });
+            var on = a[0] !== 0;
+            var rot, pts, cx, cy;
+            switch (st.code) {
+                case 1: //circle: exposure, diameter, x, y, rotation
+                    cx = a[2] || 0;
+                    cy = a[3] || 0;
+                    pts = rotatePts([cx, cy], a[4] || 0);
+                    if (a[1] > 0) {
+                        add(on, { t: 'c', x: pts[0], y: pts[1], r: a[1] / 2 });
+                    }
+                    break;
+                case 2:
+                case 20: //vector line: exposure, width, x1, y1, x2, y2, rotation
+                    var w = a[1], x1 = a[2], y1 = a[3], x2 = a[4], y2 = a[5];
+                    var len = Math.hypot(x2 - x1, y2 - y1);
+                    if (w > 0 && len > 0) {
+                        var nx = -(y2 - y1) / len * w / 2, ny = (x2 - x1) / len * w / 2;
+                        pts = [x1 + nx, y1 + ny, x2 + nx, y2 + ny, x2 - nx, y2 - ny, x1 - nx, y1 - ny];
+                        poly(on, rotatePts(pts, a[6] || 0));
+                    }
+                    break;
+                case 21: //center line: exposure, width, height, cx, cy, rotation
+                    if (a[1] > 0 && a[2] > 0) {
+                        cx = a[3] || 0;
+                        cy = a[4] || 0;
+                        pts = rectPts(cx - a[1] / 2, cy - a[2] / 2, cx + a[1] / 2, cy + a[2] / 2);
+                        poly(on, rotatePts(pts, a[5] || 0));
+                    }
+                    break;
+                case 22: //lower left line (deprecated): exposure, width, height, x, y, rotation
+                    if (a[1] > 0 && a[2] > 0) {
+                        pts = rectPts(a[3], a[4], a[3] + a[1], a[4] + a[2]);
+                        poly(on, rotatePts(pts, a[5] || 0));
+                    }
+                    break;
+                case 4: //outline: exposure, n, x0, y0 ... xn, yn, rotation
+                    var n = Math.round(a[1]);
+                    pts = [];
+                    for (var k = 0; k <= n; k++) {
+                        var px = a[2 + k * 2], py = a[3 + k * 2];
+                        if (px === undefined || py === undefined) {
+                            break;
+                        }
+                        pts.push(px, py);
+                    }
+                    rot = a[2 + (n + 1) * 2] || 0;
+                    poly(on, rotatePts(pts, rot));
+                    break;
+                case 5: //polygon: exposure, vertices, cx, cy, diameter, rotation
+                    var nv = Math.max(3, Math.round(a[1]));
+                    cx = a[2] || 0;
+                    cy = a[3] || 0;
+                    pts = [];
+                    for (var v = 0; v < nv; v++) {
+                        var ang = TAU * v / nv;
+                        pts.push(cx + a[4] / 2 * Math.cos(ang), cy + a[4] / 2 * Math.sin(ang));
+                    }
+                    poly(on, rotatePts(pts, a[5] || 0));
+                    break;
+                case 6: //moire (deprecated): cx, cy, outer dia, thickness, gap, rings, cross thickness, cross length, rotation
+                    cx = a[0]; cy = a[1];
+                    rot = a[8] || 0;
+                    var od = a[2], th = a[3], gap = a[4], rings = Math.round(a[5]);
+                    for (var ri = 0; ri < rings; ri++) {
+                        var ro = od / 2 - ri * (th + gap);
+                        if (ro <= 0) {
+                            break;
+                        }
+                        var rin = ro - th;
+                        var outer = G.circleContour(cx, cy, ro, true);
+                        var cs = [rotatePts(outer, rot)];
+                        if (rin > 0) {
+                            cs.push(rotatePts(G.circleContour(cx, cy, rin, false), rot));
+                        }
+                        add(true, { t: 'p', c: cs });
+                    }
+                    if (a[6] > 0 && a[7] > 0) {
+                        poly(true, rotatePts(rectPts(cx - a[7] / 2, cy - a[6] / 2, cx + a[7] / 2, cy + a[6] / 2), rot));
+                        poly(true, rotatePts(rectPts(cx - a[6] / 2, cy - a[7] / 2, cx + a[6] / 2, cy + a[7] / 2), rot));
+                    }
+                    break;
+                case 7: //thermal: cx, cy, outer dia, inner dia, gap, rotation
+                    thermalPieces(a[0], a[1], a[2] / 2, a[3] / 2, a[4] / 2, a[5] || 0).forEach(function (pp) {
+                        add(true, { t: 'p', c: [G.orient(pp, true)] });
+                    });
+                    break;
+                default:
+                    break;
+            }
+        }
+        return shapes;
+    }
+
+    //Four quarter ring pieces of a thermal relief
+    function thermalPieces(cx, cy, ro, ri, hg, rot) {
+        var out = [];
+        if (!(ro > 0) || ro <= ri || hg * Math.SQRT2 >= ro) {
+            return out;
+        }
+        var ao1 = Math.asin(Math.min(1, hg / ro));
+        for (var q = 0; q < 4; q++) {
+            var pts = [];
+            var start = q * Math.PI / 2;
+            pts.push(ro * Math.cos(start + ao1), ro * Math.sin(start + ao1));
+            G.flattenArc(pts, 0, 0, ro, start + ao1, Math.PI / 2 - 2 * ao1);
+            if (ri > 0 && hg * Math.SQRT2 < ri) {
+                var ai1 = Math.asin(hg / ri);
+                pts.push(ri * Math.cos(start + Math.PI / 2 - ai1), ri * Math.sin(start + Math.PI / 2 - ai1));
+                G.flattenArc(pts, 0, 0, ri, start + Math.PI / 2 - ai1, -(Math.PI / 2 - 2 * ai1));
+            } else {
+                //Gap wider than the hole: the inner edge collapses to a corner
+                var corner = rotatePts([hg, hg], q * 90);
+                pts.push(corner[0], corner[1]);
+            }
+            for (var i = 0; i < pts.length; i += 2) {
+                pts[i] += cx;
+                pts[i + 1] += cy;
+            }
+            out.push(rotatePts(pts, rot));
+        }
+        return out;
+    }
+
+    /* ------------------------------------------------------------------
+        Aperture templates (local coordinates, millimetres)
+    ------------------------------------------------------------------ */
+    function holeContour(hole) {
+        return hole > 0 ? G.circleContour(0, 0, hole / 2, false) : null;
+    }
+
+    function standardShapes(ap) {
+        var p = ap.params;
+        var items = [];
+        var hole, outer;
+        switch (ap.type) {
+            case 'C':
+                hole = p[1] || 0;
+                if (hole > 0) {
+                    items.push({ t: 'p', c: [G.circleContour(0, 0, p[0] / 2, true), holeContour(hole)] });
+                } else if (p[0] > 0) {
+                    items.push({ t: 'c', x: 0, y: 0, r: p[0] / 2 });
+                }
+                break;
+            case 'R':
+                hole = p[2] || 0;
+                outer = rectPts(-p[0] / 2, -p[1] / 2, p[0] / 2, p[1] / 2);
+                items.push({ t: 'p', c: hole > 0 ? [outer, holeContour(hole)] : [outer] });
+                break;
+            case 'O':
+                var w = p[0], h = p[1];
+                hole = p[2] || 0;
+                if (hole > 0 || !(w > 0) || !(h > 0)) {
+                    items.push({ t: 'p', c: hole > 0 ? [obroundPts(w, h), holeContour(hole)] : [obroundPts(w, h)] });
+                } else if (Math.abs(w - h) < 1e-9) {
+                    items.push({ t: 'c', x: 0, y: 0, r: w / 2 });
+                } else if (w > h) {
+                    items.push({ t: 'l', x1: -(w - h) / 2, y1: 0, x2: (w - h) / 2, y2: 0, w: h });
+                } else {
+                    items.push({ t: 'l', x1: 0, y1: -(h - w) / 2, x2: 0, y2: (h - w) / 2, w: w });
+                }
+                break;
+            case 'P':
+                var n = Math.max(3, Math.round(p[1] || 3));
+                var r0 = (p[2] || 0) * DEG;
+                hole = p[3] || 0;
+                outer = [];
+                for (var i = 0; i < n; i++) {
+                    outer.push(p[0] / 2 * Math.cos(r0 + TAU * i / n), p[0] / 2 * Math.sin(r0 + TAU * i / n));
+                }
+                items.push({ t: 'p', c: hole > 0 ? [outer, holeContour(hole)] : [outer] });
+                break;
+        }
+        return items;
+    }
+
+    function obroundPts(w, h) {
+        var pts = [];
+        if (w >= h) {
+            var r = h / 2, dx = (w - h) / 2;
+            pts.push(dx, -r);
+            G.flattenArc(pts, dx, 0, r, -Math.PI / 2, Math.PI);
+            pts.push(-dx, r);
+            G.flattenArc(pts, -dx, 0, r, Math.PI / 2, Math.PI);
+        } else {
+            var r2 = w / 2, dy = (h - w) / 2;
+            pts.push(r2, dy);
+            G.flattenArc(pts, 0, dy, r2, 0, Math.PI);
+            pts.push(-r2, -dy);
+            G.flattenArc(pts, 0, -dy, r2, Math.PI, Math.PI);
+        }
+        G.dropClosingPoint(pts);
+        return pts;
+    }
+
+    //Macro shapes, combined into items. Exposure-off shapes cut holes only
+    //into the aperture itself, so they become a clipped compound item.
+    function macroShapes(shapes, unit) {
+        var scale = [unit, 0, 0, unit, 0, 0];
+        var anyOff = shapes.some(function (s) { return !s.on; });
+        if (!anyOff) {
+            return shapes.map(function (s) { return G.applyToItem(withBounds(s.it), scale); });
+        }
+        var all = [], clip = [];
+        shapes.forEach(function (s) {
+            var it = G.applyToItem(withBounds(s.it), scale);
+            var cs = it.t === 'c' ? [G.circleContour(it.x, it.y, it.r, true)] : (it.t === 'p' ? it.c : []);
+            cs.forEach(function (c) {
+                if (s.on) {
+                    all.push(c);
+                    clip.push(c);
+                } else {
+                    all.push(G.orient(c, false));
+                }
+            });
+        });
+        return all.length ? [withBounds({ t: 'p', c: all, clip: clip })] : [];
+    }
+
+    function withBounds(it) {
+        it.b = G.itemBounds(it);
+        return it;
+    }
+
+    function describeAperture(ap) {
+        var p = ap.params;
+        function f(v) {
+            return (Math.round(v * 10000) / 10000).toString();
+        }
+        switch (ap.type) {
+            case 'C':
+                return 'Circle Ø' + f(p[0]) + (p[1] ? ' hole ' + f(p[1]) : '');
+            case 'R':
+                return 'Rectangle ' + f(p[0]) + ' × ' + f(p[1]);
+            case 'O':
+                return 'Obround ' + f(p[0]) + ' × ' + f(p[1]);
+            case 'P':
+                return 'Polygon Ø' + f(p[0]) + ', ' + Math.round(p[1]) + ' sides';
+            case 'block':
+                return 'Block aperture';
+            default:
+                return 'Macro ' + ap.type + (ap.rawParams && ap.rawParams.length ? ' (' + ap.rawParams.map(f).join(', ') + ')' : '');
+        }
+    }
+
+    /* ------------------------------------------------------------------
+        Header helpers
+    ------------------------------------------------------------------ */
+    var GENERATORS = ['EasyEDA', 'KiCad', 'Altium', 'CircuitMaker', 'EAGLE', 'Fusion', 'DipTrace', 'Proteus', 'OrCAD',
+        'Allegro', 'PADS', 'Mentor', 'Xpedition', 'gEDA', 'pcb-rnd', 'Fritzing', 'Horizon', 'LibrePCB', 'TARGET', 'Sprint-Layout',
+        'DesignSpark', 'Pulsonix', 'Zuken', 'CAM350', 'Ultiboard', 'P-CAD', 'Protel', 'Upverter', 'Flux', 'JLC'];
+
+    function detectGenerator(comments, attrs) {
+        if (attrs.GenerationSoftware) {
+            return attrs.GenerationSoftware.split(',').filter(Boolean).join(' ');
+        }
+        for (var i = 0; i < comments.length; i++) {
+            var c = comments[i];
+            for (var j = 0; j < GENERATORS.length; j++) {
+                var idx = c.toLowerCase().indexOf(GENERATORS[j].toLowerCase());
+                if (idx >= 0) {
+                    var s = c.slice(idx).split(/[,;*]/)[0].trim();
+                    return s.length > 60 ? s.slice(0, 60) : s;
+                }
+            }
+        }
+        return '';
+    }
+
+    function detectDate(comments, attrs) {
+        if (attrs.CreationDate) {
+            return attrs.CreationDate;
+        }
+        var re = /(\d{4}[-\/.]\d{1,2}[-\/.]\d{1,2})(?:[ T,]+(\d{1,2}:\d{2}(?::\d{2})?))?/;
+        for (var i = 0; i < comments.length; i++) {
+            var m = re.exec(comments[i]);
+            if (m) {
+                return m[1] + (m[2] ? ' ' + m[2] : '');
+            }
+        }
+        return '';
+    }
+
+    /* ------------------------------------------------------------------
+        Parser
+    ------------------------------------------------------------------ */
+    function parse(text) {
+        var fmt = { zero: 'L', abs: true, xi: 2, xd: 4, yi: 2, yd: 4, set: false };
+        var units = null;           //'mm' | 'in'
+        var unitSeen = false;
+        var apertures = {};
+        var macros = {};
+        var groups = [];
+        var cur = null;
+        var dark = true;
+        var x = 0, y = 0;
+        var interp = 1;             //1 linear, 2 CW, 3 CCW
+        var multiQuadrant = true;
+        var region = false;
+        var contour = null;         //points of the open region contour
+        var contourHasSeg = false;
+        var ap = null;
+        var lastOp = 1;
+        var lm = '', lr = 0, ls = 1;
+        var sr = null;
+        var blockStack = [];
+        var attrs = {};
+        var aperAttrs = {};
+        var objAttrs = null;
+        var comments = [];
+        var warnings = [];
+        var notes = [];             //{code, vars} for each warning, used to translate them
+        var warned = {};
+        var stats = { flashes: 0, lines: 0, arcs: 0, regions: 0 };
+        var imageNegative = false;
+        var offA = 0, offB = 0;
+        var layerName = '';
+        var ended = false;
+
+        function warn(key, msg, code, vars) {
+            if (!warned[key]) {
+                warned[key] = true;
+                warnings.push(msg);
+                notes.push({ code: code || key, vars: vars || {} });
+            }
+        }
+
+        function unit() {
+            if (units === null) {
+                units = 'in';
+                warn('mo', 'No unit (%MO) command; assuming inches.');
+            }
+            return units === 'in' ? 25.4 : 1;
+        }
+
+        function out() {
+            if (!cur || cur.dark !== dark) {
+                cur = { dark: dark, items: [] };
+                groups.push(cur);
+            }
+            return cur.items;
+        }
+
+        function meta(k, extra) {
+            var o = { k: k };
+            if (ap) {
+                o.d = ap.num;
+            }
+            if (objAttrs) {
+                o.at = objAttrs;
+            }
+            if (extra) {
+                for (var key in extra) {
+                    o[key] = extra[key];
+                }
+            }
+            return o;
+        }
+
+        function push(it, o) {
+            it.o = o;
+            it.b = G.itemBounds(it);
+            out().push(it);
+        }
+
+        function num(str, axis) {
+            if (str.indexOf('.') >= 0) {
+                return parseFloat(str) * unit();
+            }
+            var neg = false;
+            if (str.charAt(0) === '+' || str.charAt(0) === '-') {
+                neg = str.charAt(0) === '-';
+                str = str.slice(1);
+            }
+            var ni = axis === 'x' ? fmt.xi : fmt.yi;
+            var nd = axis === 'x' ? fmt.xd : fmt.yd;
+            if (fmt.zero === 'T') {
+                while (str.length < ni + nd) {
+                    str += '0';
+                }
+            }
+            var v = parseInt(str, 10) / Math.pow(10, nd);
+            if (!isFinite(v)) {
+                v = 0;
+            }
+            return (neg ? -v : v) * unit();
+        }
+
+        /* ---------- aperture templates ---------- */
+        function template(a) {
+            var key = lm + '|' + lr + '|' + ls;
+            a.cache = a.cache || {};
+            if (a.cache[key]) {
+                return a.cache[key];
+            }
+            var base = a.base;
+            if (!base) {
+                if (a.macro) {
+                    base = macroShapes(evalMacro(a.macro, a.rawParams), a.unit);
+                } else {
+                    base = standardShapes(a).map(withBounds);
+                }
+                a.base = base;
+            }
+            var items = base;
+            if (lm || lr || ls !== 1) {
+                var mx = lm.indexOf('X') >= 0 ? -1 : 1;
+                var my = lm.indexOf('Y') >= 0 ? -1 : 1;
+                var c = Math.cos(lr * DEG) * ls, s = Math.sin(lr * DEG) * ls;
+                var m = [c * mx, s * mx, -s * my, c * my, 0, 0];
+                items = base.map(function (it) { return G.applyToItem(it, m); });
+            }
+            a.cache[key] = items;
+            return items;
+        }
+
+        function flash(px, py) {
+            if (!ap) {
+                warn('noap', 'Flash without a selected aperture.', 'noapflash');
+                return;
+            }
+            ap.uses++;
+            stats.flashes++;
+            if (ap.type === 'block') {
+                flashBlock(ap, px, py);
+                return;
+            }
+            var o = meta('flash', { x: px, y: py });
+            var tpl = template(ap);
+            var items = out();
+            for (var i = 0; i < tpl.length; i++) {
+                var it = G.translateItem(tpl[i], px, py);
+                it.o = o;
+                items.push(it);
+            }
+        }
+
+        function flashBlock(a, px, py) {
+            if (lm || lr || ls !== 1) {
+                warn('blocktf', 'Mirroring, rotation and scaling of block apertures are not applied.');
+            }
+            for (var i = 0; i < a.groups.length; i++) {
+                var g = a.groups[i];
+                var items = g.items.map(function (it) { return G.translateItem(it, px, py); });
+                groups.push({ dark: dark ? g.dark : !g.dark, items: items });
+            }
+            cur = null;
+        }
+
+        function strokeWidth() {
+            if (!ap) {
+                return 0;
+            }
+            if (ap.type === 'C') {
+                return (ap.params[0] || 0) * ls;
+            }
+            if (ap.type === 'O' || ap.type === 'R') {
+                return Math.min(ap.params[0], ap.params[1]) * ls;
+            }
+            if (ap.type === 'P') {
+                return (ap.params[0] || 0) * ls;
+            }
+            return 0;
+        }
+
+        function line(x1, y1, x2, y2) {
+            if (!ap) {
+                warn('noap', 'Draw without a selected aperture.', 'noapdraw');
+                return;
+            }
+            ap.uses++;
+            stats.lines++;
+            var o = meta('trace');
+            if (ap.type === 'C') {
+                var w = strokeWidth();
+                if (x1 === x2 && y1 === y2) {
+                    push({ t: 'c', x: x1, y: y1, r: w / 2 }, o);
+                } else {
+                    push({ t: 'l', x1: x1, y1: y1, x2: x2, y2: y2, w: w }, o);
+                }
+                return;
+            }
+            //Non circular aperture: sweep its outline (convex hull of both ends)
+            var tpl = template(ap);
+            var pts = [];
+            tpl.forEach(function (it) {
+                var cs = it.t === 'p' ? [it.c[0]] : (it.t === 'c' ? [G.circleContour(it.x, it.y, it.r, true)] :
+                    (it.t === 'l' ? [lineHull(it)] : []));
+                cs.forEach(function (c) {
+                    for (var i = 0; i < c.length; i += 2) {
+                        pts.push(c[i] + x1, c[i + 1] + y1, c[i] + x2, c[i + 1] + y2);
+                    }
+                });
+            });
+            if (pts.length >= 6) {
+                push({ t: 'p', c: [G.convexHull(pts)] }, o);
+            }
+        }
+
+        function lineHull(it) {
+            var c = [];
+            var ang = Math.atan2(it.y2 - it.y1, it.x2 - it.x1);
+            c.push(it.x2 + it.w / 2 * Math.cos(ang - Math.PI / 2), it.y2 + it.w / 2 * Math.sin(ang - Math.PI / 2));
+            G.flattenArc(c, it.x2, it.y2, it.w / 2, ang - Math.PI / 2, Math.PI);
+            c.push(it.x1 + it.w / 2 * Math.cos(ang + Math.PI / 2), it.y1 + it.w / 2 * Math.sin(ang + Math.PI / 2));
+            G.flattenArc(c, it.x1, it.y1, it.w / 2, ang + Math.PI / 2, Math.PI);
+            return c;
+        }
+
+        //Center, radius, start angle and signed sweep of a circular segment
+        function arcGeom(x1, y1, x2, y2, i, j, cw) {
+            var full = Math.hypot(x2 - x1, y2 - y1) < 1e-7;
+            function make(cx, cy) {
+                var r0 = Math.hypot(x1 - cx, y1 - cy), r1 = Math.hypot(x2 - cx, y2 - cy);
+                var a0 = Math.atan2(y1 - cy, x1 - cx), a1 = Math.atan2(y2 - cy, x2 - cx);
+                var sw;
+                if (full) {
+                    sw = multiQuadrant ? (cw ? -TAU : TAU) : 0;
+                } else if (cw) {
+                    sw = a1 - a0;
+                    if (sw >= 0) {
+                        sw -= TAU;
+                    }
+                } else {
+                    sw = a1 - a0;
+                    if (sw <= 0) {
+                        sw += TAU;
+                    }
+                }
+                return { cx: cx, cy: cy, r: (r0 + r1) / 2, a0: a0, sw: sw, err: Math.abs(r0 - r1) };
+            }
+            if (multiQuadrant) {
+                return make(x1 + i, y1 + j);
+            }
+            var best = null;
+            [[1, 1], [1, -1], [-1, 1], [-1, -1]].forEach(function (s) {
+                var g = make(x1 + s[0] * Math.abs(i), y1 + s[1] * Math.abs(j));
+                var ok = Math.abs(g.sw) <= Math.PI / 2 + 1e-4;
+                var score = g.err + (ok ? 0 : 1e6);
+                if (!best || score < best.score) {
+                    g.score = score;
+                    best = g;
+                }
+            });
+            return best;
+        }
+
+        function arc(x1, y1, x2, y2, i, j, cw) {
+            if (!ap) {
+                warn('noap', 'Draw without a selected aperture.', 'noapdraw');
+                return;
+            }
+            ap.uses++;
+            stats.arcs++;
+            var g = arcGeom(x1, y1, x2, y2, i, j, cw);
+            if (ap.type !== 'C') {
+                warn('arcap', 'Arcs drawn with a non circular aperture are approximated.');
+            }
+            var w = strokeWidth();
+            if (g.sw === 0 || g.r === 0) {
+                push({ t: 'c', x: x1, y: y1, r: w / 2 }, meta('arc'));
+                return;
+            }
+            push({ t: 'a', cx: g.cx, cy: g.cy, r: g.r, a0: g.a0, sw: g.sw, w: w }, meta('arc'));
+        }
+
+        /* ---------- regions ---------- */
+        function contourAdd(x1, y1, x2, y2, i, j) {
+            if (!contour) {
+                contour = [x1, y1];
+                contourHasSeg = false;
+            }
+            if (interp === 1) {
+                contour.push(x2, y2);
+            } else {
+                var g = arcGeom(x1, y1, x2, y2, i, j, interp === 2);
+                if (g.sw === 0) {
+                    contour.push(x2, y2);
+                } else {
+                    G.flattenArc(contour, g.cx, g.cy, g.r, g.a0, g.sw);
+                    //Land exactly on the declared end point
+                    contour[contour.length - 2] = x2;
+                    contour[contour.length - 1] = y2;
+                }
+            }
+            contourHasSeg = true;
+        }
+
+        function contourClose() {
+            if (contour && contourHasSeg) {
+                G.dropClosingPoint(contour);
+                if (contour.length >= 6) {
+                    stats.regions++;
+                    var o = { k: 'region' };
+                    if (objAttrs) {
+                        o.at = objAttrs;
+                    }
+                    push({ t: 'p', c: [G.orient(contour, true)] }, o);
+                }
+            }
+            contour = null;
+            contourHasSeg = false;
+        }
+
+        /* ---------- step and repeat ---------- */
+        function srClose() {
+            if (!sr) {
+                return;
+            }
+            var block = groups.slice(sr.start);
+            for (var ix = 0; ix < sr.nx; ix++) {
+                for (var iy = 0; iy < sr.ny; iy++) {
+                    if (ix === 0 && iy === 0) {
+                        continue;
+                    }
+                    var dx = ix * sr.dx, dy = iy * sr.dy;
+                    for (var gi = 0; gi < block.length; gi++) {
+                        groups.push({
+                            dark: block[gi].dark,
+                            items: block[gi].items.map(function (it) { return G.translateItem(it, dx, dy); })
+                        });
+                    }
+                }
+            }
+            sr = null;
+            cur = null;
+        }
+
+        /* ---------- extended (%) commands ---------- */
+        function attrValue(body) {
+            var idx = body.indexOf(',');
+            return idx < 0 ? [body, ''] : [body.slice(0, idx), body.slice(idx + 1)];
+        }
+
+        function handleAttr(cmd, body) {
+            var kv = attrValue(body);
+            var name = kv[0].replace(/^\./, '');
+            if (cmd === 'TF') {
+                attrs[name] = kv[1];
+            } else if (cmd === 'TA') {
+                aperAttrs = Object.assign({}, aperAttrs);
+                aperAttrs[name] = kv[1];
+            } else if (cmd === 'TO') {
+                objAttrs = Object.assign({}, objAttrs || {});
+                objAttrs[name] = kv[1];
+            } else if (cmd === 'TD') {
+                if (name === '') {
+                    objAttrs = null;
+                    aperAttrs = {};
+                } else {
+                    if (objAttrs && objAttrs[name] !== undefined) {
+                        objAttrs = Object.assign({}, objAttrs);
+                        delete objAttrs[name];
+                        if (Object.keys(objAttrs).length === 0) {
+                            objAttrs = null;
+                        }
+                    }
+                    if (aperAttrs[name] !== undefined) {
+                        aperAttrs = Object.assign({}, aperAttrs);
+                        delete aperAttrs[name];
+                    }
+                }
+            }
+        }
+
+        function extended(content) {
+            content = content.replace(/[\r\n]+/g, '');
+            var parts = content.split('*');
+            var first = parts[0].trim();
+            if (first.slice(0, 2) === 'AM') {
+                var name = first.slice(2);
+                macros[name] = parseMacro(name, parts.slice(1));
+                return;
+            }
+            for (var i = 0; i < parts.length; i++) {
+                var p = parts[i].trim();
+                if (p !== '') {
+                    extendedOne(p);
+                }
+            }
+        }
+
+        function extendedOne(p) {
+            var code = p.slice(0, 2);
+            var rest = p.slice(2);
+            var m;
+            switch (code) {
+                case 'FS':
+                    m = /^([LTD]?)([AI]?)(?:N\d+)?(?:G\d+)?X(\d)(\d)Y(\d)(\d)/.exec(rest);
+                    if (m) {
+                        fmt.zero = m[1] === 'T' ? 'T' : 'L';
+                        fmt.abs = m[2] !== 'I';
+                        fmt.xi = +m[3]; fmt.xd = +m[4]; fmt.yi = +m[5]; fmt.yd = +m[6];
+                        fmt.set = true;
+                        if (!fmt.abs) {
+                            warn('inc', 'Incremental coordinates (deprecated) are in use.');
+                        }
+                    }
+                    break;
+                case 'MO':
+                    units = rest.indexOf('IN') === 0 ? 'in' : 'mm';
+                    unitSeen = true;
+                    break;
+                case 'AD':
+                    defineAperture(rest);
+                    break;
+                case 'LP':
+                    dark = rest.charAt(0) !== 'C';
+                    break;
+                case 'LM':
+                    lm = rest === 'N' ? '' : rest;
+                    break;
+                case 'LR':
+                    lr = parseFloat(rest) || 0;
+                    break;
+                case 'LS':
+                    ls = parseFloat(rest) || 1;
+                    break;
+                case 'SR':
+                    srClose();
+                    m = /X(\d+)Y(\d+)I([-+\d.]+)J([-+\d.]+)/.exec(rest);
+                    if (m && (+m[1] > 1 || +m[2] > 1)) {
+                        sr = { nx: +m[1], ny: +m[2], dx: parseFloat(m[3]) * unit(), dy: parseFloat(m[4]) * unit(), start: groups.length };
+                        cur = null;
+                    }
+                    break;
+                case 'AB':
+                    if (rest === '') {
+                        if (blockStack.length) {
+                            var ctx = blockStack.pop();
+                            var blockGroups = groups;
+                            groups = ctx.groups;
+                            cur = null;
+                            apertures[ctx.num] = { num: ctx.num, type: 'block', params: [], groups: blockGroups, uses: 0, attrs: {} };
+                        }
+                    } else {
+                        m = /^D(\d+)/.exec(rest);
+                        if (m) {
+                            blockStack.push({ num: +m[1], groups: groups });
+                            groups = [];
+                            cur = null;
+                        }
+                    }
+                    break;
+                case 'TF':
+                case 'TA':
+                case 'TO':
+                case 'TD':
+                    handleAttr(code, rest);
+                    break;
+                case 'IP':
+                    imageNegative = rest.indexOf('NEG') === 0;
+                    break;
+                case 'OF':
+                    m = /A([-+\d.]+)/.exec(rest);
+                    offA = m ? parseFloat(m[1]) * unit() : 0;
+                    m = /B([-+\d.]+)/.exec(rest);
+                    offB = m ? parseFloat(m[1]) * unit() : 0;
+                    break;
+                case 'LN':
+                    layerName = rest;
+                    break;
+                case 'IN':
+                    if (!layerName) {
+                        layerName = rest;
+                    }
+                    break;
+                case 'SF':
+                case 'MI':
+                case 'IR':
+                    if (!/^(A1(\.0*)?B1(\.0*)?|A0B0|0|A0B0C0)$/.test(rest)) {
+                        warn(code, 'Deprecated %' + code + ' image transformation is ignored.', 'deprecated', { cmd: code });
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        function defineAperture(rest) {
+            var m = /^D(\d+)([^,]+?)(?:,(.*))?$/.exec(rest);
+            if (!m) {
+                return;
+            }
+            var numD = +m[1];
+            var type = m[2].trim();
+            var raw = m[3] !== undefined && m[3] !== '' ? m[3].split('X').map(function (v) { return parseFloat(v); }) : [];
+            var u = unit();
+            var a = { num: numD, type: type, rawParams: raw, uses: 0, attrs: aperAttrs, unit: u };
+            if (type === 'C' || type === 'R' || type === 'O' || type === 'P') {
+                a.params = raw.slice();
+                if (type === 'P') {
+                    a.params[0] = (raw[0] || 0) * u;
+                    if (raw[3]) {
+                        a.params[3] = raw[3] * u;
+                    }
+                } else {
+                    a.params = raw.map(function (v) { return (v || 0) * u; });
+                }
+            } else if (macros[type]) {
+                a.macro = macros[type];
+                a.params = raw.slice();
+            } else {
+                warn('macro:' + type, 'Unknown aperture template "' + type + '".', 'macro', { name: type });
+                a.params = [];
+                a.type = 'C';
+                a.unknown = type;
+            }
+            a.desc = describeAperture(a);
+            apertures[numD] = a;
+        }
+
+        /* ---------- word commands ---------- */
+        function comment(textC) {
+            var c = textC.trim();
+            var x2 = /^#@!\s*(T[FAOD])(.*)$/.exec(c);
+            if (x2) {
+                handleAttr(x2[1], x2[2].trim());
+                return;
+            }
+            if (comments.length < 40) {
+                comments.push(c);
+            }
+        }
+
+        function word(w) {
+            var trimmed = w.replace(/^[\s]+/, '');
+            if (/^G0*4(?!\d)/.test(trimmed)) {
+                comment(trimmed.replace(/^G0*4/, ''));
+                return;
+            }
+            w = w.replace(/\s+/g, '');
+            if (w === '') {
+                return;
+            }
+            var re = /([GDMXYIJN])([+-]?[\d.]+)/g;
+            var m;
+            var nx = null, ny = null, ni = null, nj = null, op = null;
+            while ((m = re.exec(w)) !== null) {
+                var letter = m[1];
+                var val = m[2];
+                switch (letter) {
+                    case 'G':
+                        var g = parseInt(val, 10);
+                        if (g === 1 || g === 10 || g === 11 || g === 12) {
+                            interp = 1;
+                        } else if (g === 2) {
+                            interp = 2;
+                        } else if (g === 3) {
+                            interp = 3;
+                        } else if (g === 36) {
+                            region = true;
+                            contour = null;
+                        } else if (g === 37) {
+                            contourClose();
+                            region = false;
+                        } else if (g === 74) {
+                            multiQuadrant = false;
+                        } else if (g === 75) {
+                            multiQuadrant = true;
+                        } else if (g === 70) {
+                            units = 'in';
+                        } else if (g === 71) {
+                            units = 'mm';
+                        } else if (g === 90) {
+                            fmt.abs = true;
+                        } else if (g === 91) {
+                            fmt.abs = false;
+                        }
+                        break;
+                    case 'X':
+                        nx = num(val, 'x');
+                        break;
+                    case 'Y':
+                        ny = num(val, 'y');
+                        break;
+                    case 'I':
+                        ni = num(val, 'x');
+                        break;
+                    case 'J':
+                        nj = num(val, 'y');
+                        break;
+                    case 'D':
+                        var d = parseInt(val, 10);
+                        if (d >= 10) {
+                            ap = apertures[d] || null;
+                            if (!ap) {
+                                warn('und' + d, 'Aperture D' + d + ' is used but never defined.', 'undef', { d: d });
+                            }
+                        } else if (d >= 1 && d <= 3) {
+                            op = d;
+                        }
+                        break;
+                    case 'M':
+                        if (parseInt(val, 10) === 2 || parseInt(val, 10) === 0) {
+                            ended = true;
+                        }
+                        break;
+                }
+            }
+            var hasCoord = nx !== null || ny !== null || ni !== null || nj !== null;
+            if (op === null) {
+                if (!hasCoord) {
+                    return;
+                }
+                op = lastOp;
+            }
+            lastOp = op;
+            var tx = x, ty = y;
+            if (fmt.abs) {
+                if (nx !== null) tx = nx;
+                if (ny !== null) ty = ny;
+            } else {
+                if (nx !== null) tx = x + nx;
+                if (ny !== null) ty = y + ny;
+            }
+            var ii = ni || 0, jj = nj || 0;
+            if (op === 1) {
+                if (region) {
+                    contourAdd(x, y, tx, ty, ii, jj);
+                } else if (interp === 1) {
+                    line(x, y, tx, ty);
+                } else {
+                    arc(x, y, tx, ty, ii, jj, interp === 2);
+                }
+            } else if (op === 2) {
+                if (region) {
+                    contourClose();
+                }
+            } else if (op === 3) {
+                if (region) {
+                    warn('d3reg', 'Flash inside a region is not allowed and was skipped.');
+                } else {
+                    flash(tx, ty);
+                }
+            }
+            x = tx;
+            y = ty;
+        }
+
+        /* ---------- tokenizer ---------- */
+        var i = 0;
+        var len = text.length;
+        var nextPct = -1;       //cached position of the next '%', -1 = unknown
+        while (i < len && !ended) {
+            var ch = text.charCodeAt(i);
+            if (ch === 32 || ch === 9 || ch === 10 || ch === 13) {
+                i++;
+                continue;
+            }
+            if (ch === 37) { // %
+                var end = text.indexOf('%', i + 1);
+                if (end < 0) {
+                    end = len;
+                }
+                extended(text.slice(i + 1, end));
+                i = end + 1;
+                continue;
+            }
+            var star = text.indexOf('*', i);
+            if (nextPct !== len && nextPct < i) {
+                nextPct = text.indexOf('%', i);
+                if (nextPct < 0) {
+                    nextPct = len;
+                }
+            }
+            var pct = nextPct < len ? nextPct : -1;
+            if (star < 0) {
+                star = len;
+            }
+            //A word never spans into an extended block; tolerate a missing '*'
+            if (pct >= 0 && pct < star && !/^\s*G0*4/.test(text.slice(i, i + 6))) {
+                word(text.slice(i, pct));
+                i = pct;
+                continue;
+            }
+            word(text.slice(i, star));
+            i = star + 1;
+        }
+
+        if (contour) {
+            warn('openreg', 'A region was not closed with G37.');
+            contourClose();
+        }
+        srClose();
+        while (blockStack.length) {
+            groups = blockStack.pop().groups;
+        }
+        if (!unitSeen && units === null) {
+            units = 'in';
+        }
+
+        //Deprecated image offset
+        if (offA || offB) {
+            groups.forEach(function (g) {
+                g.items = g.items.map(function (it) { return G.translateItem(it, offA, offB); });
+            });
+        }
+
+        var bounds = G.emptyBounds();
+        var hasClear = false;
+        groups = groups.filter(function (g) { return g.items.length > 0; });
+        groups.forEach(function (g) {
+            if (!g.dark) {
+                hasClear = true;
+            }
+            g.items.forEach(function (it) { G.unionBounds(bounds, it.b); });
+        });
+
+        if (imageNegative && G.boundsValid(bounds)) {
+            var pad = Math.max(1, 0.05 * Math.max(bounds[2] - bounds[0], bounds[3] - bounds[1]));
+            var rect = rectPts(bounds[0] - pad, bounds[1] - pad, bounds[2] + pad, bounds[3] + pad);
+            var bg = { t: 'p', c: [rect], o: { k: 'image' } };
+            bg.b = G.itemBounds(bg);
+            groups.forEach(function (g) { g.dark = !g.dark; });
+            groups.unshift({ dark: true, items: [bg] });
+            bounds = G.unionBounds(bounds, bg.b);
+            hasClear = true;
+        }
+
+        var apList = Object.keys(apertures).map(function (k) { return apertures[k]; });
+        apList.forEach(function (a) {
+            delete a.cache;
+            delete a.base;
+        });
+
+        return {
+            kind: 'gerber',
+            units: units,
+            formatInfo: { zero: fmt.zero, abs: fmt.abs, xi: fmt.xi, xd: fmt.xd, yi: fmt.yi, yd: fmt.yd },
+            format: (fmt.zero === 'T' ? 'Trailing' : 'Leading') + ' zero omission, ' + (fmt.abs ? 'absolute' : 'incremental') +
+                ', X ' + fmt.xi + '.' + fmt.xd + ' / Y ' + fmt.yi + '.' + fmt.yd,
+            x2: Object.keys(attrs).length > 0,
+            apertures: apList,
+            groups: groups,
+            bounds: bounds,
+            hasClear: hasClear,
+            stats: stats,
+            attrs: attrs,
+            layerName: layerName,
+            comments: comments,
+            generator: detectGenerator(comments, attrs),
+            created: detectDate(comments, attrs),
+            warnings: warnings,
+            notes: notes
+        };
+    }
+
+    //Cheap content sniffing used before a full parse
+    function looksLikeGerber(text) {
+        var head = text.slice(0, 4096);
+        return /%FS[LTD]?[AI]?/.test(head) || /%MO(MM|IN)\*%/.test(head) || /%ADD\d+/.test(head) ||
+            (/G0?4[^*]*\*/.test(head) && /D0?[123]\*/.test(text.slice(0, 65536)));
+    }
+
+    GV.gerber = {
+        parse: parse,
+        looksLikeGerber: looksLikeGerber,
+        evalExpr: evalExpr
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 252 - 0
src/web/Gerber Viewer/js/layers.js

@@ -0,0 +1,252 @@
+/*
+    Gerber Viewer - layer identification
+
+    Decides what a file is (copper, mask, silkscreen, ...), which side it is
+    on, and its default colour and stacking position. X2 FileFunction
+    attributes win over file names; file names cover EasyEDA, KiCad, Altium /
+    Protel, Eagle / Fusion, DipTrace, Proteus and generic naming.
+*/
+(function (GV) {
+    'use strict';
+
+    //type -> label, stack order (higher draws on top in the top view), default colour
+    var TYPES = {
+        'top-copper': { label: 'Top Copper', order: 60, color: '#e5484d', side: 'top', fn: 'copper' },
+        'inner-copper': { label: 'Inner Copper', order: 40, color: '#d4a72c', side: 'inner', fn: 'copper' },
+        'bottom-copper': { label: 'Bottom Copper', order: 30, color: '#3b74e8', side: 'bottom', fn: 'copper' },
+        'top-mask': { label: 'Top Solder Mask', order: 70, color: '#3fae6a', side: 'top', fn: 'mask' },
+        'bottom-mask': { label: 'Bottom Solder Mask', order: 20, color: '#9b5de5', side: 'bottom', fn: 'mask' },
+        'top-paste': { label: 'Top Paste', order: 65, color: '#94a3b8', side: 'top', fn: 'paste' },
+        'bottom-paste': { label: 'Bottom Paste', order: 25, color: '#64748b', side: 'bottom', fn: 'paste' },
+        'top-silk': { label: 'Top Silkscreen', order: 80, color: '#f4f4f5', side: 'top', fn: 'silk' },
+        'bottom-silk': { label: 'Bottom Silkscreen', order: 10, color: '#a1a1aa', side: 'bottom', fn: 'silk' },
+        'top-assembly': { label: 'Top Assembly', order: 85, color: '#22d3ee', side: 'top', fn: 'document' },
+        'bottom-assembly': { label: 'Bottom Assembly', order: 5, color: '#0ea5e9', side: 'bottom', fn: 'document' },
+        'outline': { label: 'Board Outline', order: 100, color: '#e8e8ea', side: 'all', fn: 'outline' },
+        'drill': { label: 'Drill', order: 95, color: '#f2b84b', side: 'all', fn: 'drill' },
+        'keepout': { label: 'Keep-out', order: 90, color: '#f97316', side: 'all', fn: 'document' },
+        'document': { label: 'Document', order: 2, color: '#c084fc', side: 'all', fn: 'document' },
+        'other': { label: 'Unknown Layer', order: 1, color: '#9ca3af', side: 'all', fn: 'other' }
+    };
+
+    var TYPE_LIST = ['top-copper', 'inner-copper', 'bottom-copper', 'top-mask', 'bottom-mask', 'top-paste', 'bottom-paste',
+        'top-silk', 'bottom-silk', 'top-assembly', 'bottom-assembly', 'outline', 'drill', 'keepout', 'document', 'other'];
+
+    //Extensions that are never CAM data
+    var NOT_CAM = /\.(pdf|png|jpe?g|gif|bmp|svg|webp|zip|7z|rar|gz|tgz|tar|step|stp|wrl|iges?|stl|xlsx?|csv|docx?|html?|json|xml|kicad_\w+|sch|brd|pcbdoc|schdoc|prjpcb|lib|ipc|ipc356|rep|apr|apt|drr|extrep|log|ini|md)$/i;
+
+    function base(name) {
+        return String(name).split(/[\\/]/).pop();
+    }
+
+    function ext(name) {
+        var b = base(name);
+        var i = b.lastIndexOf('.');
+        return i < 0 ? '' : b.slice(i + 1).toLowerCase();
+    }
+
+    //Map an X2 FileFunction to a type
+    function fromFileFunction(ff) {
+        if (!ff) {
+            return null;
+        }
+        var p = ff.split(',').map(function (s) { return s.trim(); });
+        var f = p[0].toLowerCase();
+        var side = (p[2] || p[1] || '').toLowerCase();
+        var top = /top/.test(side), bot = /bot/.test(side);
+        switch (f) {
+            case 'copper':
+                if (/inr/.test(side)) {
+                    var n = parseInt((p[1] || '').replace(/\D/g, ''), 10);
+                    return { type: 'inner-copper', index: isFinite(n) ? n : null };
+                }
+                return { type: top ? 'top-copper' : (bot ? 'bottom-copper' : 'inner-copper') };
+            case 'soldermask':
+                return { type: bot ? 'bottom-mask' : 'top-mask' };
+            case 'paste':
+                return { type: bot ? 'bottom-paste' : 'top-paste' };
+            case 'legend':
+                return { type: bot ? 'bottom-silk' : 'top-silk' };
+            case 'profile':
+                return { type: 'outline' };
+            case 'plated':
+            case 'nonplated':
+                return { type: 'drill', plated: f === 'plated', via: /via/i.test(ff) };
+            case 'assemblydrawing':
+            case 'component':
+                return { type: bot ? 'bottom-assembly' : 'top-assembly' };
+            case 'keepout':
+                return { type: 'keepout' };
+            case 'otherdrawing':
+            case 'drillmap':
+            case 'fabricationdrawing':
+            case 'other':
+            case 'pads':
+                return { type: 'document' };
+        }
+        return null;
+    }
+
+    //Patterns tested against the lower-case file name, first match wins
+    var NAME_RULES = [
+        //Outline / profile
+        [/edge[._-]?cuts|board[_ -]?outline|outline|profile|\.gko$|\.gm1$|\.gml$|\.gm$|\.dim$|\.oln$|\.bor$|\.mill$|\.gbr\.?ol/, 'outline'],
+        [/keep[_ -]?out|\.gkl$/, 'keepout'],
+        //KiCad
+        [/f[._]cu\b|f[._]cu\.|-f_cu|_f_cu/, 'top-copper'],
+        [/b[._]cu\b|b[._]cu\.|-b_cu|_b_cu/, 'bottom-copper'],
+        [/in(\d+)[._]cu/, 'inner-copper'],
+        [/f[._]mask/, 'top-mask'],
+        [/b[._]mask/, 'bottom-mask'],
+        [/f[._]paste/, 'top-paste'],
+        [/b[._]paste/, 'bottom-paste'],
+        [/f[._]silk/, 'top-silk'],
+        [/b[._]silk/, 'bottom-silk'],
+        [/f[._]fab|f[._]courtyard|f[._]crtyd/, 'top-assembly'],
+        [/b[._]fab|b[._]courtyard|b[._]crtyd/, 'bottom-assembly'],
+        [/dwgs[._]user|cmts[._]user|user[._]drawings|user[._]comments|eco\d[._]user|margin/, 'document'],
+        //Words (EasyEDA, generic)
+        [/top[_ -]?silk|silk[_ -]?top|top[_ -]?overlay|overlay[_ -]?top|topsilkscreen|top[_ -]?legend|silkscreen[_ -]?top|\bsst\b|\.sst$/, 'top-silk'],
+        [/bottom[_ -]?silk|silk[_ -]?bot|bot[_ -]?silk|bottom[_ -]?overlay|bottomsilkscreen|bottom[_ -]?legend|silkscreen[_ -]?bot|\.ssb$/, 'bottom-silk'],
+        [/top[_ -]?(solder)?[_ -]?mask|solder[_ -]?mask[_ -]?top|topsoldermask|\.smt$|soldermask_top/, 'top-mask'],
+        [/bottom[_ -]?(solder)?[_ -]?mask|bot[_ -]?mask|solder[_ -]?mask[_ -]?bot|bottomsoldermask|\.smb$|soldermask_bottom/, 'bottom-mask'],
+        [/top[_ -]?paste|paste[_ -]?top|toppastemask|\.spt$|solderpaste_top/, 'top-paste'],
+        [/bottom[_ -]?paste|bot[_ -]?paste|paste[_ -]?bot|bottompastemask|\.spb$|solderpaste_bottom/, 'bottom-paste'],
+        [/inner[_ -]?(layer)?[_ -]?(\d+)|inner(\d+)|mid[_ -]?layer[_ -]?(\d+)|internal[_ -]?plane|\.g(\d+)$|\.gp(\d+)$|\.in(\d+)$|\.ly(\d+)$|\.l(\d+)$/, 'inner-copper'],
+        [/top[_ -]?layer|top[_ -]?copper|copper[_ -]?top|\bcomponent\b|\bcomp\b|\.top$|\.cmp$|toplayer/, 'top-copper'],
+        [/bottom[_ -]?layer|bot[_ -]?layer|bottom[_ -]?copper|copper[_ -]?bot|\bsolder\b|\.bot$|\.sol$|bottomlayer/, 'bottom-copper'],
+        [/assembly[_ -]?top|top[_ -]?assembly|\.ast$/, 'top-assembly'],
+        [/assembly[_ -]?bot|bottom[_ -]?assembly|\.asb$/, 'bottom-assembly'],
+        [/document|drawing|mechanical|\.gdl$|\.gd\d*$|\.gg\d*$|\.gm\d+$|fab|comment/, 'document']
+    ];
+
+    var EXT_RULES = {
+        gtl: 'top-copper', gbl: 'bottom-copper', gto: 'top-silk', gbo: 'bottom-silk',
+        gts: 'top-mask', gbs: 'bottom-mask', gtp: 'top-paste', gbp: 'bottom-paste',
+        gko: 'outline', gm1: 'outline', gml: 'outline', gdl: 'document', gtd: 'document', gbd: 'document',
+        cmp: 'top-copper', sol: 'bottom-copper', plc: 'top-silk', pls: 'bottom-silk',
+        stc: 'top-mask', sts: 'bottom-mask', crc: 'top-paste', crs: 'bottom-paste', dim: 'outline',
+        gta: 'top-assembly', gba: 'bottom-assembly', gpt: 'top-copper', gpb: 'bottom-copper'
+    };
+
+    var DRILL_EXT = /^(drl|drd|xln|exc|ncd|nc|tap|txt|cnc|dri|tx\d|drill)$/;
+
+    function identify(name, text, parsed) {
+        var lower = base(name).toLowerCase();
+        var e = ext(name);
+        var info = null;
+
+        if (parsed && parsed.attrs && parsed.attrs.FileFunction) {
+            info = fromFileFunction(parsed.attrs.FileFunction);
+        }
+        if (!info && parsed && parsed.kind === 'drill') {
+            info = { type: 'drill' };
+        }
+        if (!info && EXT_RULES[e]) {
+            info = { type: EXT_RULES[e] };
+        }
+        if (!info) {
+            for (var i = 0; i < NAME_RULES.length; i++) {
+                var m = NAME_RULES[i][0].exec(lower);
+                if (m) {
+                    info = { type: NAME_RULES[i][1] };
+                    if (info.type === 'inner-copper') {
+                        for (var k = 1; k < m.length; k++) {
+                            if (m[k] && /^\d+$/.test(m[k])) {
+                                info.index = parseInt(m[k], 10);
+                                break;
+                            }
+                        }
+                    }
+                    break;
+                }
+            }
+        }
+        if (!info && parsed && parsed.layerName) {
+            info = identify(parsed.layerName + '.gbr', '', null);
+            if (info.type === 'other') {
+                info = null;
+            }
+        }
+        if (!info) {
+            info = { type: 'other' };
+        }
+        //Altium .G1 .. .G30 inner layers and GP planes
+        if (info.type === 'inner-copper' && info.index === undefined) {
+            var mm = /\.g(?:p)?(\d+)$/.exec(lower) || /in(\d+)/.exec(lower);
+            if (mm) {
+                info.index = parseInt(mm[1], 10);
+            }
+        }
+        if (info.type === 'drill') {
+            if (info.plated === undefined) {
+                if (/npth|non[_ -]?plated|unplated|-np\b|_np\b/.test(lower)) {
+                    info.plated = false;
+                } else if (/pth|plated|via/.test(lower)) {
+                    info.plated = true;
+                } else if (parsed && parsed.plated !== undefined && parsed.plated !== null) {
+                    info.plated = parsed.plated;
+                }
+            }
+            if (info.via === undefined) {
+                info.via = /via/.test(lower);
+            }
+        }
+        return info;
+    }
+
+    function label(info) {
+        var t = TYPES[info.type] || TYPES.other;
+        if (info.type === 'inner-copper' && info.index) {
+            return 'Inner Copper ' + info.index;
+        }
+        if (info.type === 'drill') {
+            if (info.via) {
+                return 'Drill (Vias)';
+            }
+            if (info.plated === true) {
+                return 'Drill (PTH)';
+            }
+            if (info.plated === false) {
+                return 'Drill (NPTH)';
+            }
+        }
+        return t.label;
+    }
+
+    //Is this file name worth reading at all?
+    function isCandidate(name) {
+        var b = base(name);
+        if (b.charAt(0) === '.' || /^__macosx/i.test(name) || /(^|\/)__MACOSX\//.test(name)) {
+            return false;
+        }
+        return !NOT_CAM.test(b);
+    }
+
+    function sniff(text) {
+        if (GV.excellon.looksLikeExcellon(text)) {
+            return 'drill';
+        }
+        if (GV.gerber.looksLikeGerber(text)) {
+            return 'gerber';
+        }
+        return null;
+    }
+
+    function info(type) {
+        return TYPES[type] || TYPES.other;
+    }
+
+    GV.layers = {
+        TYPES: TYPES,
+        TYPE_LIST: TYPE_LIST,
+        identify: identify,
+        label: label,
+        info: info,
+        isCandidate: isCandidate,
+        sniff: sniff,
+        base: base,
+        ext: ext,
+        DRILL_EXT: DRILL_EXT
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 386 - 0
src/web/Gerber Viewer/js/mfg.js

@@ -0,0 +1,386 @@
+/*
+    Gerber Viewer - Manufacturing Capabilities Preview
+
+    A panel in the 3D view that mirrors a PCB manufacturer's order form
+    (board material, layers, thickness, solder mask and
+    silkscreen colour, via covering, surface finish, delivery format and
+    panelisation) and re-renders the 3D board the way it would be made.
+
+    The choices are kept in state.settings.mfg. build() turns them into the
+    look used for the face textures plus the stack-up and panel layout that
+    view3d.js draws.
+*/
+(function (GV) {
+    'use strict';
+
+    var app = GV.app, cmd = app.cmd, state = app.state;
+    var UI = GV.ui, S = GV.store;
+    var icon = UI.icon, esc = UI.esc, T = UI.t;
+
+    //Board materials and what each one can be ordered with
+    //defThick: thickness picked when switching to the material
+    var MATERIALS = {
+        fpc: { label: 'FPC (flexible)', layers: [1, 2, 4], thick: [0.07, 0.11, 0.12, 0.2], defThick: 0.12,
+            core: '#c9831c', substrate: '#6b3f0d', coverlay: true },
+        fr4: { label: 'FR-4', layers: [1, 2, 4, 6, 8, 10, 12, 14, 16, 20], thick: [0.4, 0.6, 0.8, 1.0, 1.2, 1.6, 2.0, 2.5, 3.2],
+            defThick: 1.6, core: '#c8b27a', substrate: '#3c3923' }
+    };
+    var MATERIAL_ORDER = ['fpc', 'fr4'];
+    //Outer copper is drawn as standard 1 oz
+    var COPPER_OZ = 1;
+    var MASK_ORDER = ['green', 'red', 'yellow', 'blue', 'white', 'matte', 'purple'];
+    var FINISHES = {
+        osp: { label: 'Lead-free OSP', color: '#c98048' },
+        hasl: { label: 'Leaded HASL', color: '#c8ccd1' },
+        haslLF: { label: 'Lead-free HASL', color: '#d3d7dc' },
+        enig: { label: 'ENIG (immersion gold)', color: '#d9b45c' }
+    };
+    var VIAS = [
+        ['tent', 'Tented'], ['open', 'Untented'], ['plug', 'Plugged with mask'],
+        ['resin', 'Resin filled + capped'], ['paste', 'Copper paste + capped']
+    ];
+    var COVERLAY = { color: '#d08a1c', alpha: 0.72 };
+
+    var DEFAULTS = {
+        on: false,
+        material: 'fr4',
+        layers: 2,
+        thickness: 1.6,
+        mask: 'green',
+        silk: 'white',
+        via: 'tent',
+        finish: 'haslLF',
+        delivery: 'single',
+        cols: 2,
+        rows: 4,
+        separation: 'vcut',
+        rails: 'tb',
+        railWidth: 5
+    };
+
+    function opts() {
+        if (!state.settings.mfg) {
+            state.settings.mfg = Object.assign({}, DEFAULTS);
+        }
+        var o = state.settings.mfg;
+        Object.keys(DEFAULTS).forEach(function (k) {
+            if (o[k] === undefined) {
+                o[k] = DEFAULTS[k];
+            }
+        });
+        return o;
+    }
+
+    //Snap values the chosen material cannot be ordered with
+    function nearest(list, v) {
+        return list.reduce(function (a, b) { return Math.abs(b - v) < Math.abs(a - v) ? b : a; }, list[0]);
+    }
+
+    function normalise(o) {
+        if (!MATERIALS[o.material]) {
+            o.material = 'fr4';
+        }
+        var m = MATERIALS[o.material];
+        o.layers = m.layers.indexOf(o.layers) >= 0 ? o.layers : nearest(m.layers, o.layers);
+        o.thickness = m.thick.indexOf(o.thickness) >= 0 ? o.thickness : m.defThick;
+        if (o.delivery !== 'single' && o.delivery !== 'maker') {
+            o.delivery = 'single';
+        }
+        o.cols = Math.max(1, Math.min(10, Math.round(o.cols) || 1));
+        o.rows = Math.max(1, Math.min(10, Math.round(o.rows) || 1));
+        if (o.via === 'paste') {
+            o.via = 'resin';
+        }
+    }
+
+    function active() {
+        return !!(state.project && opts().on);
+    }
+
+    /* ------------------------------------------------------------------
+        Render inputs
+    ------------------------------------------------------------------ */
+    function build() {
+        var o = opts();
+        normalise(o);
+        var m = MATERIALS[o.material];
+        var mask = app.MASKS[o.mask] || app.MASKS.green;
+        var finish = (FINISHES[o.finish] || FINISHES.haslLF).color;
+        var maskColor = m.coverlay ? COVERLAY.color : mask.color;
+        var look = {
+            substrate: m.substrate,
+            copper: '#b8743a',
+            mask: maskColor,
+            maskAlpha: m.coverlay ? COVERLAY.alpha : mask.alpha,
+            silk: o.silk === 'black' ? '#141414' : '#f2f2f2',
+            finish: finish,
+            edge: m.core,
+            via: o.via
+        };
+        var panel = null;
+        if (o.delivery === 'maker') {
+            panel = {
+                cols: o.cols,
+                rows: o.rows,
+                separation: o.separation,
+                gap: 2,
+                rails: o.rails,
+                railWidth: o.railWidth,
+                faceColor: blend(maskColor, m.substrate, m.coverlay ? COVERLAY.alpha : mask.alpha),
+                finish: finish
+            };
+        }
+        return {
+            look: look,
+            thickness: o.thickness,
+            fillVias: o.via === 'plug' || o.via === 'resin' || o.via === 'tent',
+            stack: {
+                layers: o.layers,
+                copperOz: COPPER_OZ,
+                core: m.core,
+                base: m.base || null,
+                baseRatio: 0.85,
+                metal: !!m.metal,
+                mask: maskColor,
+                copper: '#c98a4b'
+            },
+            panel: panel
+        };
+    }
+
+    function blend(a, b, alpha) {
+        function rgb(c) {
+            var v = parseInt(c.slice(1), 16);
+            return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
+        }
+        var x = rgb(a), y = rgb(b);
+        var out = x.map(function (c, i) { return Math.round(c * alpha + y[i] * (1 - alpha)); });
+        return '#' + out.map(function (c) { return (c < 16 ? '0' : '') + c.toString(16); }).join('');
+    }
+
+    /* ------------------------------------------------------------------
+        Summary numbers
+    ------------------------------------------------------------------ */
+    function summary() {
+        var o = opts();
+        var b = state.project && state.project.board;
+        var w = b ? b.width : 0, h = b ? b.height : 0;
+        var out = { boardW: w, boardH: h, perPanel: 1, panelW: w, panelH: h, notes: [] };
+        if (o.delivery === 'maker') {
+            var gap = o.separation === 'vcut' ? 0 : 2;
+            out.perPanel = o.cols * o.rows;
+            out.panelW = o.cols * w + (o.cols - 1) * gap;
+            out.panelH = o.rows * h + (o.rows - 1) * gap;
+            if (o.rails === 'tb' || o.rails === 'all') {
+                out.panelH += 2 * (o.railWidth + gap);
+            }
+            if (o.rails === 'lr' || o.rails === 'all') {
+                out.panelW += 2 * (o.railWidth + gap);
+            }
+            if (o.separation === 'vcut' && b && !b.rect) {
+                out.notes.push(T('mfg/noteVcut', 'V-cut needs straight board edges. This outline is not a plain rectangle, so stamp holes are the safer choice.'));
+            }
+            if (out.panelW > 500 || out.panelH > 500) {
+                out.notes.push(T('mfg/noteLarge', 'The panel is larger than 500 mm on one side, beyond what most fabs accept.'));
+            }
+        }
+        if (o.material === 'fpc') {
+            out.notes.push(T('mfg/noteFpc', 'Flexible boards are covered with an amber polyimide coverlay instead of a coloured solder mask.'));
+        }
+        if (o.via === 'tent') {
+            out.notes.push(T('mfg/noteTent', 'Tented vias keep their hole; the mask only covers the ring, so a small dimple stays visible.'));
+        }
+        return out;
+    }
+
+    /* ------------------------------------------------------------------
+        Panel UI
+    ------------------------------------------------------------------ */
+    var panelOpen = false;
+    var rebuildTimer = 0;
+    var saveTimer = 0;
+
+    function changed() {
+        normalise(opts());
+        render();
+        clearTimeout(saveTimer);
+        saveTimer = setTimeout(function () { S.saveSettings(state.settings); }, 600);
+        clearTimeout(rebuildTimer);
+        rebuildTimer = setTimeout(function () { app.schedule3D(); }, 120);
+    }
+
+    function chips(key, list, current, disabled) {
+        return '<div class="chips mfg-chips" data-mk="' + key + '">' + list.map(function (it) {
+            var v = String(it[0]);
+            var off = disabled && disabled(it[0]);
+            return '<button type="button" data-v="' + esc(v) + '" class="' + (v === String(current) ? 'active' : '') + '"' + (off ? ' disabled' : '') + '>' +
+                (it[2] ? '<span class="dot" style="--c:' + esc(it[2]) + '"></span>' : '') + esc(it[1]) + '</button>';
+        }).join('') + '</div>';
+    }
+
+    function row(labelKey, label, body) {
+        return '<div class="mfg-row"><div class="mfg-label">' + esc(T(labelKey, label)) + '</div>' + body + '</div>';
+    }
+
+    function stepper(key, value, min, max) {
+        return '<div class="mfg-stepper" data-ms="' + key + '"><button type="button" data-d="-1"' + (value <= min ? ' disabled' : '') + '>' + icon('minus') +
+            '</button><span>' + value + '</span><button type="button" data-d="1"' + (value >= max ? ' disabled' : '') + '>' + icon('plus') + '</button></div>';
+    }
+
+    function fmtMM(v) {
+        return (Math.round(v * 100) / 100).toFixed(2) + ' mm';
+    }
+
+    function render() {
+        var box = document.getElementById('mfgPanel');
+        if (!box) {
+            return;
+        }
+        box.hidden = !panelOpen || state.dim !== '3d';
+        document.querySelectorAll('[data-view3d-mfg]').forEach(function (b) { b.classList.toggle('active', panelOpen); });
+        //Keep the board visible beside the panel
+        var stage = document.getElementById('stage');
+        if (stage.classList.contains('mfg-open') !== !box.hidden) {
+            stage.classList.toggle('mfg-open', !box.hidden);
+            var v = app.get3D();
+            if (v) {
+                v.resize();
+            }
+        }
+        if (box.hidden) {
+            return;
+        }
+        var oldBody = box.querySelector('.mfg-body');
+        var scroll = oldBody ? oldBody.scrollTop : 0;
+        var o = opts();
+        normalise(o);
+        var m = MATERIALS[o.material];
+        var sum = summary();
+        var html = '<div class="mfg-h">' + icon('factory') + '<span>' + esc(T('mfg/title', 'Manufacturing Preview')) + '</span>' +
+            '<label class="mfg-switch" title="' + esc(T('mfg/enableTitle', 'Render the board with these options')) + '"><input type="checkbox" data-mon' + (o.on ? ' checked' : '') + '>' +
+            esc(T('mfg/enable', 'Preview')) + '</label>' +
+            '<button class="iconbtn" data-mclose title="' + esc(T('common/close', 'Close')) + '">' + icon('close') + '</button></div>' +
+            '<div class="mfg-body' + (o.on ? '' : ' off') + '">' +
+            row('mfg/material', 'Board material', chips('material', MATERIAL_ORDER.map(function (k) { return [k, T('mat/' + k, MATERIALS[k].label)]; }), o.material)) +
+            row('mfg/layers', 'Layers', chips('layers', m.layers.map(function (n) { return [n, String(n)]; }), o.layers)) +
+            row('mfg/size', 'Board size', '<div class="mfg-value">' + esc(fmtMM(sum.boardW) + ' × ' + fmtMM(sum.boardH)) + '</div>') +
+            '<div class="mfg-sep"></div>' +
+            row('mfg/delivery', 'Delivery format', chips('delivery', [['single', T('mfg/single', 'Single')],
+                ['maker', T('mfg/maker', 'Panelization')]], o.delivery)) +
+            (o.delivery === 'maker' ?
+                row('mfg/panel', 'Panel', '<div class="mfg-grid">' + stepper('cols', o.cols, 1, 10) + '<span class="mfg-x">×</span>' + stepper('rows', o.rows, 1, 10) +
+                    '<span class="muted small">' + esc(T('mfg/panelHint', 'columns × rows')) + '</span></div>') +
+                row('mfg/separation', 'Separation', chips('separation', [['vcut', T('mfg/vcut', 'V-cut')], ['tab', T('mfg/tab', 'Stamp holes')]], o.separation)) +
+                row('mfg/rails', 'Edge rails', chips('rails', [['none', T('mfg/railsNone', 'None')], ['tb', T('mfg/railsTB', 'Top and bottom')],
+                    ['lr', T('mfg/railsLR', 'Left and right')], ['all', T('mfg/railsAll', 'All sides')]], o.rails)) +
+                (o.rails !== 'none' ? row('mfg/railWidth', 'Rail width', chips('railWidth', [[3, '3 mm'], [5, '5 mm'], [7, '7 mm'], [10, '10 mm']], o.railWidth)) : '')
+                : '') +
+            '<div class="mfg-sep"></div>' +
+            row('mfg/thickness', 'Board thickness', chips('thickness', m.thick.map(function (t) { return [t, String(t)]; }), o.thickness)) +
+            row('mfg/mask', 'Solder mask colour', chips('mask', MASK_ORDER.map(function (k) {
+                return [k, T('mask/' + k, app.MASKS[k].label), app.MASKS[k].color];
+            }), o.mask, function () { return !!m.coverlay; })) +
+            row('mfg/silk', 'Silkscreen colour', chips('silk', [['white', T('silk/white', 'White'), '#f2f2f2'], ['black', T('silk/black', 'Black'), '#141414']], o.silk)) +
+            row('mfg/via', 'Via covering', chips('via', VIAS.map(function (v) { return [v[0], T('via/' + v[0], v[1])]; }), o.via,
+                function (v) { return v === 'paste'; })) +
+            row('mfg/finish', 'Surface finish', chips('finish', Object.keys(FINISHES).map(function (k) {
+                return [k, T('mfin/' + k, FINISHES[k].label), FINISHES[k].color];
+            }), o.finish)) +
+            '<div class="mfg-sep"></div>' +
+            '<div class="mfg-summary"><div class="mfg-summary-h">' + esc(T('mfg/summary', 'Order summary')) + '</div><dl class="kv">' +
+            '<dt>' + esc(T('mfg/stack', 'Stack-up')) + '</dt><dd>' + esc(T('mfg/stackLayers', '{layers} layers, {t} mm',
+                { layers: o.layers, t: o.thickness })) + '</dd>' +
+            (o.delivery === 'maker' ?
+                '<dt>' + esc(T('mfg/panelSize', 'Panel size')) + '</dt><dd>' + esc(fmtMM(sum.panelW) + ' × ' + fmtMM(sum.panelH)) + '</dd>' +
+                '<dt>' + esc(T('mfg/perPanel', 'Boards per panel')) + '</dt><dd>' + sum.perPanel + '</dd>' : '') +
+            '</dl>' + (sum.notes.length ? '<ul class="list-plain mfg-notes">' + sum.notes.map(function (n) { return '<li>' + esc(n) + '</li>'; }).join('') + '</ul>' : '') +
+            '</div>' +
+            '<div class="mfg-actions"><button class="btn small" data-mreset>' + icon('refresh') + esc(T('mfg/reset', 'Reset options')) + '</button></div>' +
+            '</div>';
+        box.innerHTML = html;
+        box.querySelector('.mfg-body').scrollTop = scroll;
+        bind(box);
+    }
+
+    function bind(box) {
+        var o = opts();
+        box.querySelector('[data-mclose]').onclick = function () { setOpen(false); };
+        box.querySelector('[data-mon]').onchange = function () {
+            o.on = this.checked;
+            changed();
+        };
+        box.querySelectorAll('[data-mk]').forEach(function (group) {
+            group.addEventListener('click', function (e) {
+                var b = e.target.closest('button[data-v]');
+                if (!b || b.disabled) {
+                    return;
+                }
+                var key = group.dataset.mk;
+                var v = b.dataset.v;
+                o[key] = /^(layers|thickness|railWidth)$/.test(key) ? parseFloat(v) : v;
+                //A new material starts at its usual thickness (1.6 mm for FR-4)
+                if (key === 'material') {
+                    o.thickness = MATERIALS[v].defThick;
+                }
+                //A white mask gets black legend, any other colour white
+                if (key === 'mask') {
+                    o.silk = v === 'white' ? 'black' : 'white';
+                }
+                o.on = true;
+                changed();
+            });
+        });
+        box.querySelectorAll('[data-ms]').forEach(function (st) {
+            st.addEventListener('click', function (e) {
+                var b = e.target.closest('button[data-d]');
+                if (!b || b.disabled) {
+                    return;
+                }
+                o[st.dataset.ms] += parseInt(b.dataset.d, 10);
+                o.on = true;
+                changed();
+            });
+        });
+        box.querySelector('[data-mreset]').onclick = function () {
+            state.settings.mfg = Object.assign({}, DEFAULTS, { on: true });
+            changed();
+        };
+    }
+
+    function setOpen(on) {
+        panelOpen = on;
+        render();
+    }
+
+    //Tools > Manufacturing Capabilities Preview
+    cmd.mfgPreview = function () {
+        if (!state.project) {
+            return;
+        }
+        var o = opts();
+        var wasOn = o.on;
+        o.on = true;
+        panelOpen = true;
+        if (state.dim !== '3d') {
+            app.setDim('3d');
+        } else {
+            render();
+            if (!wasOn) {
+                app.schedule3D();
+            }
+        }
+    };
+
+    cmd.mfgToggle = function () {
+        setOpen(!panelOpen);
+    };
+
+    GV.mfg = {
+        active: active,
+        build: build,
+        summary: summary,
+        dimChanged: function () { render(); },
+        MATERIALS: MATERIALS,
+        FINISHES: FINISHES
+    };
+})(window.GV = window.GV || {});

+ 650 - 0
src/web/Gerber Viewer/js/render.js

@@ -0,0 +1,650 @@
+/*
+    Gerber Viewer - 2D canvas renderer
+
+    GV.render.drawScene(ctx, opts) draws a set of layers through an affine
+    board -> device pixel matrix. It is used for the interactive view, the
+    minimap, image export, printing and the 3D board textures.
+
+    Each layer image is turned once into cached Path2D objects (one fill path
+    plus one stroke path per line width, per polarity group). Every layer is
+    drawn on its own scratch canvas so clear polarity only erases that layer,
+    then composited with the layer's opacity.
+
+    GV.Viewer wraps a base canvas (layers) and an overlay canvas (crosshair,
+    measurements, selection) with pan / zoom / rotate / mirror state.
+*/
+(function (GV) {
+    'use strict';
+
+    var G = GV.geom;
+    var MM_PER_INCH = 25.4;
+    var CSS_PX_PER_MM = 96 / MM_PER_INCH;
+
+    /* ------------------------------------------------------------------
+        Path caches
+    ------------------------------------------------------------------ */
+    function buildPaths(image) {
+        return image.groups.map(function (g) {
+            var fill = new Path2D();
+            var hasFill = false;
+            var strokes = new Map();
+            var compound = [];
+            for (var i = 0; i < g.items.length; i++) {
+                var it = g.items[i];
+                if (it.t === 'l' || it.t === 'a') {
+                    var p = strokes.get(it.w);
+                    if (!p) {
+                        p = new Path2D();
+                        strokes.set(it.w, p);
+                    }
+                    G.emitStroke(p, it);
+                } else if (it.t === 'p' && it.clip) {
+                    var clip = new Path2D();
+                    it.clip.forEach(function (c) { G.emitContour(clip, c); });
+                    var f = new Path2D();
+                    G.emitFill(f, it);
+                    compound.push({ clip: clip, fill: f });
+                } else {
+                    G.emitFill(fill, it);
+                    hasFill = true;
+                }
+            }
+            var list = [];
+            strokes.forEach(function (path, w) { list.push({ w: w, p: path }); });
+            return { dark: g.dark, fill: hasFill ? fill : null, strokes: list, compound: compound };
+        });
+    }
+
+    function layerPaths(layer) {
+        if (!layer._paths) {
+            layer._paths = layer.image ? buildPaths(layer.image) : [];
+        }
+        return layer._paths;
+    }
+
+    //Board outline and drill paths, cached on the board object
+    function boardPaths(board) {
+        if (board._paths) {
+            return board._paths;
+        }
+        var shape = new Path2D();
+        board.outer.forEach(function (c) { G.emitContour(shape, c); });
+        board.cutouts.forEach(function (c) { G.emitContour(shape, c); });
+        var holes = new Path2D(), vias = new Path2D(), viaPads = new Path2D();
+        board.holes.forEach(function (h) {
+            if (h.via) {
+                G.emitCircle(vias, h.x, h.y, h.r, true);
+                //Typical via annular ring: 0.15 mm around the drill
+                G.emitCircle(viaPads, h.x, h.y, h.r + 0.15, true);
+            } else {
+                G.emitCircle(holes, h.x, h.y, h.r, true);
+            }
+        });
+        var slots = new Map();
+        board.slots.forEach(function (s) {
+            var p = slots.get(s.item.w);
+            if (!p) {
+                p = new Path2D();
+                slots.set(s.item.w, p);
+            }
+            G.emitStroke(p, s.item);
+        });
+        var slotList = [];
+        slots.forEach(function (p, w) { slotList.push({ w: w, p: p }); });
+        board._paths = { shape: shape, holes: holes, vias: vias, viaPads: viaPads, slots: slotList };
+        return board._paths;
+    }
+
+    function drawPaths(ctx, paths, color, minW) {
+        ctx.fillStyle = color;
+        ctx.strokeStyle = color;
+        ctx.lineCap = 'round';
+        ctx.lineJoin = 'round';
+        for (var i = 0; i < paths.length; i++) {
+            var g = paths[i];
+            ctx.globalCompositeOperation = g.dark ? 'source-over' : 'destination-out';
+            if (g.fill) {
+                ctx.fill(g.fill, 'nonzero');
+            }
+            for (var j = 0; j < g.strokes.length; j++) {
+                ctx.lineWidth = Math.max(g.strokes[j].w, minW);
+                ctx.stroke(g.strokes[j].p);
+            }
+            for (var k = 0; k < g.compound.length; k++) {
+                ctx.save();
+                ctx.clip(g.compound[k].clip);
+                ctx.fill(g.compound[k].fill);
+                ctx.restore();
+            }
+        }
+        ctx.globalCompositeOperation = 'source-over';
+    }
+
+    /* ------------------------------------------------------------------
+        Scratch canvases
+    ------------------------------------------------------------------ */
+    function Scratch() {
+        this.list = [];
+        this.w = 0;
+        this.h = 0;
+    }
+    Scratch.prototype.get = function (i, w, h) {
+        if (w !== this.w || h !== this.h) {
+            this.list.forEach(function (c) {
+                c.width = w;
+                c.height = h;
+            });
+            this.w = w;
+            this.h = h;
+        }
+        while (this.list.length <= i) {
+            var c = document.createElement('canvas');
+            c.width = w;
+            c.height = h;
+            this.list.push(c);
+        }
+        var cv = this.list[i];
+        var ctx = cv.getContext('2d');
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        ctx.globalAlpha = 1;
+        ctx.globalCompositeOperation = 'source-over';
+        ctx.clearRect(0, 0, w, h);
+        return { canvas: cv, ctx: ctx };
+    };
+    Scratch.prototype.release = function () {
+        this.list.forEach(function (c) {
+            c.width = 0;
+            c.height = 0;
+        });
+        this.list = [];
+        this.w = this.h = 0;
+    };
+
+    function setM(ctx, m) {
+        ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
+    }
+
+    /* ------------------------------------------------------------------
+        Scene drawing
+    ------------------------------------------------------------------ */
+    /*
+        opts:
+            width, height     device pixels
+            matrix            board mm -> device px [a, b, c, d, e, f]
+            scale             device px per mm (for hairlines)
+            mode              'layers' | 'realistic'
+            side              'top' | 'bottom' (realistic)
+            layers            draw order, bottom first (layers mode)
+            all               every layer (realistic picks by type)
+            board             GV.board.build() result
+            background        css colour or null for transparent
+            look              {substrate, mask, maskAlpha, silk, finish, copper}
+            grid              {step, color, major} or null
+            scratch           Scratch instance
+    */
+    function drawScene(ctx, opts) {
+        var W = opts.width, H = opts.height;
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        ctx.globalAlpha = 1;
+        ctx.globalCompositeOperation = 'source-over';
+        ctx.clearRect(0, 0, W, H);
+        if (opts.background) {
+            ctx.fillStyle = opts.background;
+            ctx.fillRect(0, 0, W, H);
+        }
+        var minW = 1 / Math.max(opts.scale, 1e-6);
+        if (opts.grid) {
+            drawGrid(ctx, opts);
+        }
+        if (opts.mode === 'realistic') {
+            drawRealistic(ctx, opts, minW);
+        } else {
+            for (var i = 0; i < opts.layers.length; i++) {
+                var layer = opts.layers[i];
+                if (!layer.visible || !layer.image || !layer.image.groups.length) {
+                    continue;
+                }
+                var s = opts.scratch.get(0, W, H);
+                setM(s.ctx, opts.matrix);
+                drawPaths(s.ctx, layerPaths(layer), layer.color, minW);
+                ctx.globalAlpha = layer.opacity;
+                ctx.drawImage(s.canvas, 0, 0);
+                ctx.globalAlpha = 1;
+            }
+        }
+    }
+
+    function findLayer(all, type) {
+        for (var i = 0; i < all.length; i++) {
+            if (all[i].type === type && all[i].image && all[i].image.groups.length) {
+                return all[i];
+            }
+        }
+        return null;
+    }
+
+    function drawRealistic(ctx, opts, minW) {
+        var W = opts.width, H = opts.height, m = opts.matrix;
+        var look = opts.look;
+        var side = opts.side === 'bottom' ? 'bottom' : 'top';
+        var board = opts.board;
+        if (!board || !board.outer.length) {
+            return;
+        }
+        var bp = boardPaths(board);
+        var copper = findLayer(opts.all, side + '-copper');
+        var silk = findLayer(opts.all, side + '-silk');
+        //The mask always comes from the file (its layer-view visibility does not
+        //matter here); without a mask file the board is fully covered.
+        //look.mask === null means the user picked a bare board.
+        var mask = findLayer(opts.all, side + '-mask');
+        var maskOn = !!look.mask;
+
+        var B = opts.scratch.get(0, W, H);
+        setM(B.ctx, m);
+        B.ctx.fillStyle = look.substrate;
+        B.ctx.fill(bp.shape, 'nonzero');
+
+        var C = null;
+        if (copper && copper.visible) {
+            C = opts.scratch.get(1, W, H);
+            setM(C.ctx, m);
+            drawPaths(C.ctx, layerPaths(copper), look.copper, minW);
+            B.ctx.setTransform(1, 0, 0, 1, 0, 0);
+            B.ctx.drawImage(C.canvas, 0, 0);
+        }
+
+        /*
+            look.via (manufacturing preview) overrides how the fab covers vias:
+            'tent' / 'plug' mask over the ring, 'open' exposes it, 'resin' fills
+            and caps it with plated copper. Undefined keeps the files as drawn.
+        */
+        var via = look.via;
+        var viaPadShown = via === 'open' || via === 'resin';
+        var O = null;
+        if (maskOn && (mask || viaPadShown)) {
+            O = opts.scratch.get(2, W, H);
+            setM(O.ctx, m);
+            if (mask) {
+                drawPaths(O.ctx, layerPaths(mask), '#000', minW);
+            }
+            O.ctx.fillStyle = '#000';
+            if (viaPadShown) {
+                O.ctx.fill(bp.viaPads, 'nonzero');
+            } else if (via === 'tent' || via === 'plug') {
+                O.ctx.globalCompositeOperation = 'destination-out';
+                O.ctx.fill(bp.viaPads, 'nonzero');
+                O.ctx.globalCompositeOperation = 'source-over';
+            }
+        }
+        if (C && via === 'resin') {
+            //The cap is plated copper over the filled hole
+            setM(C.ctx, m);
+            C.ctx.fillStyle = look.copper;
+            C.ctx.fill(bp.viaPads, 'nonzero');
+        }
+
+        if (maskOn) {
+            var M = opts.scratch.get(3, W, H);
+            setM(M.ctx, m);
+            M.ctx.fillStyle = look.mask;
+            M.ctx.fill(bp.shape, 'nonzero');
+            if (O) {
+                M.ctx.setTransform(1, 0, 0, 1, 0, 0);
+                M.ctx.globalCompositeOperation = 'destination-out';
+                M.ctx.drawImage(O.canvas, 0, 0);
+                M.ctx.globalCompositeOperation = 'source-over';
+            }
+            B.ctx.setTransform(1, 0, 0, 1, 0, 0);
+            B.ctx.globalAlpha = look.maskAlpha;
+            B.ctx.drawImage(M.canvas, 0, 0);
+            B.ctx.globalAlpha = 1;
+            //Copper inside the mask openings gets the surface finish
+            if (C && O) {
+                C.ctx.setTransform(1, 0, 0, 1, 0, 0);
+                C.ctx.globalCompositeOperation = 'source-in';
+                C.ctx.fillStyle = look.finish;
+                C.ctx.fillRect(0, 0, W, H);
+                C.ctx.globalCompositeOperation = 'destination-in';
+                C.ctx.drawImage(O.canvas, 0, 0);
+                C.ctx.globalCompositeOperation = 'source-over';
+                B.ctx.drawImage(C.canvas, 0, 0);
+            }
+        } else if (C) {
+            C.ctx.setTransform(1, 0, 0, 1, 0, 0);
+            C.ctx.globalCompositeOperation = 'source-in';
+            C.ctx.fillStyle = look.finish;
+            C.ctx.fillRect(0, 0, W, H);
+            C.ctx.globalCompositeOperation = 'source-over';
+            B.ctx.setTransform(1, 0, 0, 1, 0, 0);
+            B.ctx.drawImage(C.canvas, 0, 0);
+        }
+
+        if (silk && silk.visible) {
+            var S = opts.scratch.get(3, W, H);
+            setM(S.ctx, m);
+            drawPaths(S.ctx, layerPaths(silk), look.silk, minW);
+            if (O) {
+                //Fabs clip the legend away from exposed pads
+                S.ctx.setTransform(1, 0, 0, 1, 0, 0);
+                S.ctx.globalCompositeOperation = 'destination-out';
+                S.ctx.drawImage(O.canvas, 0, 0);
+                S.ctx.globalCompositeOperation = 'source-over';
+            }
+            B.ctx.setTransform(1, 0, 0, 1, 0, 0);
+            B.ctx.drawImage(S.canvas, 0, 0);
+        }
+
+        //Trim everything to the board and punch the holes
+        setM(B.ctx, m);
+        B.ctx.globalCompositeOperation = 'destination-in';
+        B.ctx.fillStyle = '#000';
+        B.ctx.fill(bp.shape, 'nonzero');
+        var drillsVisible = opts.all.some(function (l) { return l.type === 'drill' && l.visible; });
+        if (drillsVisible || !opts.all.some(function (l) { return l.type === 'drill'; })) {
+            B.ctx.globalCompositeOperation = 'destination-out';
+            B.ctx.fill(bp.holes, 'nonzero');
+            if (!via || via === 'open') {
+                B.ctx.fill(bp.vias, 'nonzero');
+            } else if (via === 'tent') {
+                //Mask stretched over the hole shows as a small dimple
+                B.ctx.globalCompositeOperation = 'source-atop';
+                B.ctx.fillStyle = 'rgba(0,0,0,0.35)';
+                B.ctx.fill(bp.vias, 'nonzero');
+                B.ctx.globalCompositeOperation = 'destination-out';
+            }
+            B.ctx.strokeStyle = '#000';
+            B.ctx.lineCap = 'round';
+            bp.slots.forEach(function (s) {
+                B.ctx.lineWidth = Math.max(s.w, minW);
+                B.ctx.stroke(s.p);
+            });
+        }
+        B.ctx.globalCompositeOperation = 'source-over';
+
+        //Soft drop shadow lifts the board off the background
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        if (opts.shadow) {
+            ctx.save();
+            ctx.shadowColor = 'rgba(0,0,0,0.45)';
+            ctx.shadowBlur = Math.min(24, Math.max(4, opts.scale * 0.6));
+            ctx.shadowOffsetY = Math.min(8, Math.max(1, opts.scale * 0.15));
+            ctx.drawImage(B.canvas, 0, 0);
+            ctx.restore();
+        } else {
+            ctx.drawImage(B.canvas, 0, 0);
+        }
+    }
+
+    //Grid lines in board space, drawn in screen space for crisp 1px lines
+    function drawGrid(ctx, opts) {
+        var inv = invert(opts.matrix);
+        var corners = [[0, 0], [opts.width, 0], [0, opts.height], [opts.width, opts.height]].map(function (p) {
+            return apply(inv, p[0], p[1]);
+        });
+        var x0 = Math.min.apply(null, corners.map(function (c) { return c[0]; }));
+        var x1 = Math.max.apply(null, corners.map(function (c) { return c[0]; }));
+        var y0 = Math.min.apply(null, corners.map(function (c) { return c[1]; }));
+        var y1 = Math.max.apply(null, corners.map(function (c) { return c[1]; }));
+        var step = opts.grid.step;
+        if (!(step > 0) || (x1 - x0) / step > 2000 || (y1 - y0) / step > 2000) {
+            return;
+        }
+        var major = opts.grid.major || 5;
+        ctx.save();
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        ctx.lineWidth = 1;
+        var m = opts.matrix;
+        function seg(ax, ay, bx, by) {
+            var a = apply(m, ax, ay), b = apply(m, bx, by);
+            ctx.moveTo(Math.round(a[0]) + 0.5, Math.round(a[1]) + 0.5);
+            ctx.lineTo(Math.round(b[0]) + 0.5, Math.round(b[1]) + 0.5);
+        }
+        [false, true].forEach(function (isMajor) {
+            ctx.beginPath();
+            for (var x = Math.ceil(x0 / step) * step; x <= x1; x += step) {
+                var mj = Math.abs(Math.round(x / step) % major) === 0;
+                if (mj === isMajor) {
+                    seg(x, y0, x, y1);
+                }
+            }
+            for (var y = Math.ceil(y0 / step) * step; y <= y1; y += step) {
+                var mj2 = Math.abs(Math.round(y / step) % major) === 0;
+                if (mj2 === isMajor) {
+                    seg(x0, y, x1, y);
+                }
+            }
+            ctx.strokeStyle = isMajor ? opts.grid.majorColor : opts.grid.color;
+            ctx.stroke();
+        });
+        ctx.restore();
+    }
+
+    function apply(m, x, y) {
+        return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
+    }
+
+    function invert(m) {
+        var det = m[0] * m[3] - m[1] * m[2];
+        if (!det) {
+            return [1, 0, 0, 1, 0, 0];
+        }
+        var a = m[3] / det, b = -m[1] / det, c = -m[2] / det, d = m[0] / det;
+        return [a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])];
+    }
+
+    //Board -> device matrix for a view {cx, cy, scale, rot, flip} on a W x H canvas
+    function viewMatrix(view, W, H, k) {
+        var s = view.scale * k;
+        var th = (view.rot || 0) * Math.PI / 180;
+        var cs = Math.cos(th), sn = Math.sin(th);
+        cs = Math.abs(cs) < 1e-12 ? 0 : cs;
+        sn = Math.abs(sn) < 1e-12 ? 0 : sn;
+        var mx = view.flip ? -1 : 1;
+        var a = s * mx * cs, c = -s * mx * sn, b = -s * sn, d = -s * cs;
+        return [a, b, c, d, W / 2 - (a * view.cx + c * view.cy), H / 2 - (b * view.cx + d * view.cy)];
+    }
+
+    /* ------------------------------------------------------------------
+        Interactive viewer
+    ------------------------------------------------------------------ */
+    function Viewer(base, overlay) {
+        this.base = base;
+        this.overlay = overlay;
+        this.view = { cx: 0, cy: 0, scale: 8, rot: 0, flip: false };
+        this.scratch = new Scratch();
+        this.dpr = 1;
+        this.cssW = 1;
+        this.cssH = 1;
+        this.getScene = null;       //() -> partial drawScene opts
+        this.drawOverlay = null;    //(ctx, viewer) -> void
+        this.onViewChange = null;
+        this._raf = 0;
+        this._ovRaf = 0;
+        this.lastRenderMs = 0;
+    }
+
+    Viewer.prototype.resize = function () {
+        var r = this.base.parentNode.getBoundingClientRect();
+        this.dpr = Math.min(window.devicePixelRatio || 1, 2.5);
+        this.cssW = Math.max(1, r.width);
+        this.cssH = Math.max(1, r.height);
+        var w = Math.round(this.cssW * this.dpr), h = Math.round(this.cssH * this.dpr);
+        [this.base, this.overlay].forEach(function (c) {
+            if (c.width !== w || c.height !== h) {
+                c.width = w;
+                c.height = h;
+            }
+        });
+        this.requestRender();
+    };
+
+    Viewer.prototype.matrix = function (k) {
+        return viewMatrix(this.view, this.cssW * (k || 1), this.cssH * (k || 1), k || 1);
+    };
+
+    Viewer.prototype.toBoard = function (x, y) {
+        return apply(invert(this.matrix(1)), x, y);
+    };
+
+    Viewer.prototype.toScreen = function (x, y) {
+        return apply(this.matrix(1), x, y);
+    };
+
+    Viewer.prototype.fit = function (b, pad) {
+        if (!G.boundsValid(b)) {
+            return;
+        }
+        pad = pad === undefined ? 40 : pad;
+        var rotated = (this.view.rot / 90) % 2 !== 0;
+        var bw = Math.max(b[2] - b[0], 1e-3), bh = Math.max(b[3] - b[1], 1e-3);
+        if (rotated) {
+            var t = bw;
+            bw = bh;
+            bh = t;
+        }
+        this.view.scale = Math.max(1e-3, Math.min((this.cssW - pad * 2) / bw, (this.cssH - pad * 2) / bh));
+        this.view.cx = (b[0] + b[2]) / 2;
+        this.view.cy = (b[1] + b[3]) / 2;
+        this.changed();
+    };
+
+    Viewer.prototype.clampScale = function (s) {
+        return Math.max(0.05, Math.min(s, 20000));
+    };
+
+    Viewer.prototype.zoomAt = function (factor, sx, sy) {
+        var before = this.toBoard(sx, sy);
+        this.view.scale = this.clampScale(this.view.scale * factor);
+        var after = this.toBoard(sx, sy);
+        this.view.cx += before[0] - after[0];
+        this.view.cy += before[1] - after[1];
+        this.changed();
+    };
+
+    Viewer.prototype.panBy = function (dx, dy) {
+        var a = this.toBoard(0, 0), b = this.toBoard(dx, dy);
+        this.view.cx -= b[0] - a[0];
+        this.view.cy -= b[1] - a[1];
+        this.changed();
+    };
+
+    Viewer.prototype.centerOn = function (x, y) {
+        this.view.cx = x;
+        this.view.cy = y;
+        this.changed();
+    };
+
+    Viewer.prototype.changed = function () {
+        this.requestRender();
+        if (this.onViewChange) {
+            this.onViewChange(this);
+        }
+    };
+
+    Viewer.prototype.requestRender = function () {
+        var self = this;
+        if (this._raf) {
+            return;
+        }
+        this._raf = requestAnimationFrame(function () {
+            self._raf = 0;
+            self.renderNow();
+        });
+    };
+
+    Viewer.prototype.renderNow = function () {
+        if (this._raf) {
+            cancelAnimationFrame(this._raf);
+            this._raf = 0;
+        }
+        var t0 = performance.now();
+        var ctx = this.base.getContext('2d');
+        var scene = this.getScene ? this.getScene() : null;
+        if (!scene) {
+            ctx.setTransform(1, 0, 0, 1, 0, 0);
+            ctx.clearRect(0, 0, this.base.width, this.base.height);
+        } else {
+            scene.width = this.base.width;
+            scene.height = this.base.height;
+            scene.matrix = this.matrix(this.dpr);
+            scene.scale = this.view.scale * this.dpr;
+            scene.scratch = this.scratch;
+            drawScene(ctx, scene);
+        }
+        this.lastRenderMs = performance.now() - t0;
+        this.renderOverlay();
+    };
+
+    Viewer.prototype.requestOverlay = function () {
+        var self = this;
+        if (this._ovRaf || this._raf) {
+            return;
+        }
+        this._ovRaf = requestAnimationFrame(function () {
+            self._ovRaf = 0;
+            self.renderOverlay();
+        });
+    };
+
+    Viewer.prototype.renderOverlay = function () {
+        var ctx = this.overlay.getContext('2d');
+        ctx.setTransform(1, 0, 0, 1, 0, 0);
+        ctx.clearRect(0, 0, this.overlay.width, this.overlay.height);
+        if (this.drawOverlay) {
+            ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+            this.drawOverlay(ctx, this);
+        }
+    };
+
+    //Render an arbitrary view to a new canvas (export, print, thumbnails)
+    function renderToCanvas(scene, view, width, height, scratch) {
+        var cv = document.createElement('canvas');
+        cv.width = Math.max(1, Math.round(width));
+        cv.height = Math.max(1, Math.round(height));
+        var own = !scratch;
+        scratch = scratch || new Scratch();
+        var opts = Object.assign({}, scene, {
+            width: cv.width,
+            height: cv.height,
+            matrix: viewMatrix(view, cv.width, cv.height, 1),
+            scale: view.scale,
+            scratch: scratch
+        });
+        drawScene(cv.getContext('2d'), opts);
+        if (own) {
+            scratch.release();
+        }
+        return cv;
+    }
+
+    //Grid step (mm) so lines are at least minPx apart
+    function niceStep(scale, minPx, imperial) {
+        var base = imperial ? MM_PER_INCH / 1000 : 0.001; //1 mil or 1 um
+        var steps = [1, 2, 5];
+        for (var e = 0; e < 12; e++) {
+            for (var i = 0; i < steps.length; i++) {
+                var st = base * steps[i] * Math.pow(10, e);
+                if (st * scale >= minPx) {
+                    return st;
+                }
+            }
+        }
+        return 1000;
+    }
+
+    GV.render = {
+        CSS_PX_PER_MM: CSS_PX_PER_MM,
+        layerPaths: layerPaths,
+        boardPaths: boardPaths,
+        drawPaths: drawPaths,
+        drawScene: drawScene,
+        viewMatrix: viewMatrix,
+        renderToCanvas: renderToCanvas,
+        Scratch: Scratch,
+        apply: apply,
+        invert: invert,
+        niceStep: niceStep
+    };
+    GV.Viewer = Viewer;
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 247 - 0
src/web/Gerber Viewer/js/store.js

@@ -0,0 +1,247 @@
+/*
+    Gerber Viewer - persistence
+
+    Settings and recent projects live in the ArozOS database through
+    backend/prefs.agi. When the page runs outside ArozOS (no AGI gateway) the
+    same data falls back to localStorage. Projects opened from the local
+    device are cached in IndexedDB so they can be reopened from Recents in
+    the same browser.
+*/
+(function (GV) {
+    'use strict';
+
+    var BACKEND = 'Gerber Viewer/backend/';
+    var LS_SETTINGS = 'gerberviewer.settings';
+    var LS_RECENT = 'gerberviewer.recent';
+    var MAX_LOCAL_RECENT = 16;
+
+    var store = {
+        online: false,          //AGI gateway reachable
+        recent: []
+    };
+
+    function agiURL(script) {
+        var root = typeof ao_root === 'string' ? ao_root : '../';
+        return root + 'system/ajgi/interface?script=' + encodeURIComponent(BACKEND + script);
+    }
+
+    function T(key, fallback, vars) {
+        return GV.ui.t(key, fallback, vars);
+    }
+
+    //Backend errors carry a code; error/<code> in the locale file translates them
+    function parse(data) {
+        if (typeof data === 'string') {
+            try {
+                data = JSON.parse(data);
+            } catch (e) {
+                throw new Error(T('error/reply', 'Unexpected reply from the server'));
+            }
+        }
+        if (data && data.error) {
+            var err = new Error(data.code ? T('error/' + data.code, data.error) : data.error);
+            err.code = data.code || '';
+            throw err;
+        }
+        return data;
+    }
+
+    //url-encoded POST through the standard gateway
+    store.agi = function (script, data, timeout) {
+        return new Promise(function (resolve, reject) {
+            $.ajax({
+                url: agiURL(script),
+                method: 'POST',
+                data: data || {},
+                timeout: timeout || 0,
+                success: function (d) {
+                    try {
+                        resolve(parse(d));
+                    } catch (e) {
+                        reject(e);
+                    }
+                },
+                error: function (xhr) {
+                    reject(new Error(xhr && xhr.status ? T('error/server', 'Server error {status}', { status: xhr.status }) : T('error/offline', 'Unable to reach ArozOS')));
+                }
+            });
+        });
+    };
+
+    //JSON body POST, used for large payloads (exported images)
+    store.agiJSON = function (script, data) {
+        return fetch(agiURL(script), {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(data),
+            credentials: 'same-origin'
+        }).then(function (r) {
+            if (!r.ok) {
+                throw new Error(T('error/server', 'Server error {status}', { status: r.status }));
+            }
+            return r.text();
+        }).then(parse);
+    };
+
+    function lsGet(k, def) {
+        try {
+            var v = localStorage.getItem(k);
+            return v ? JSON.parse(v) : def;
+        } catch (e) {
+            return def;
+        }
+    }
+
+    function lsSet(k, v) {
+        try {
+            localStorage.setItem(k, JSON.stringify(v));
+        } catch (e) {
+            //Storage full or blocked: settings simply do not persist
+        }
+    }
+
+    store.boot = function () {
+        return store.agi('prefs.agi', { opr: 'boot' }, 8000).then(function (d) {
+            store.online = true;
+            store.recent = Array.isArray(d.recent) ? d.recent : [];
+            return { settings: d.settings || {}, recent: store.recent };
+        }, function () {
+            store.online = false;
+            store.recent = lsGet(LS_RECENT, []);
+            return { settings: lsGet(LS_SETTINGS, {}), recent: store.recent };
+        });
+    };
+
+    store.saveSettings = function (s) {
+        if (store.online) {
+            return store.agi('prefs.agi', { opr: 'saveSettings', settings: JSON.stringify(s) }).catch(function () {
+                lsSet(LS_SETTINGS, s);
+            });
+        }
+        lsSet(LS_SETTINGS, s);
+        return Promise.resolve();
+    };
+
+    function localRecentWrite(list) {
+        store.recent = list.slice(0, MAX_LOCAL_RECENT);
+        lsSet(LS_RECENT, store.recent);
+        return store.recent;
+    }
+
+    store.recentAdd = function (entry) {
+        if (store.online) {
+            return store.agi('prefs.agi', { opr: 'recentAdd', entry: JSON.stringify(entry) }).then(function (d) {
+                store.recent = d.recent || store.recent;
+                return store.recent;
+            });
+        }
+        var old = store.recent.filter(function (e) { return e.key === entry.key; })[0];
+        if (old) {
+            entry.state = entry.state || old.state;
+            entry.thumb = entry.thumb || old.thumb;
+        }
+        var list = store.recent.filter(function (e) { return e.key !== entry.key; });
+        list.unshift(entry);
+        return Promise.resolve(localRecentWrite(list));
+    };
+
+    store.recentUpdate = function (key, patch) {
+        store.recent.forEach(function (e) {
+            if (e.key === key) {
+                Object.assign(e, patch);
+            }
+        });
+        if (store.online) {
+            return store.agi('prefs.agi', { opr: 'recentUpdate', key: key, patch: JSON.stringify(patch) }).catch(function () { });
+        }
+        localRecentWrite(store.recent);
+        return Promise.resolve();
+    };
+
+    store.recentRemove = function (key) {
+        var entry = store.recent.filter(function (e) { return e.key === key; })[0];
+        if (entry && entry.cacheId) {
+            store.cacheDelete(entry.cacheId);
+        }
+        if (store.online) {
+            return store.agi('prefs.agi', { opr: 'recentRemove', key: key }).then(function (d) {
+                store.recent = d.recent || [];
+                return store.recent;
+            });
+        }
+        return Promise.resolve(localRecentWrite(store.recent.filter(function (e) { return e.key !== key; })));
+    };
+
+    store.recentClear = function () {
+        store.recent.forEach(function (e) {
+            if (e.cacheId) {
+                store.cacheDelete(e.cacheId);
+            }
+        });
+        if (store.online) {
+            return store.agi('prefs.agi', { opr: 'recentClear' }).then(function () {
+                store.recent = [];
+                return [];
+            });
+        }
+        return Promise.resolve(localRecentWrite([]));
+    };
+
+    /* ---------------- IndexedDB cache for local projects ---------------- */
+    var dbp = null;
+    function db() {
+        if (!dbp) {
+            dbp = new Promise(function (resolve, reject) {
+                if (!window.indexedDB) {
+                    reject(new Error('IndexedDB unavailable'));
+                    return;
+                }
+                var req = indexedDB.open('gerberviewer', 1);
+                req.onupgradeneeded = function () {
+                    req.result.createObjectStore('projects');
+                };
+                req.onsuccess = function () { resolve(req.result); };
+                req.onerror = function () { reject(req.error); };
+            });
+        }
+        return dbp;
+    }
+
+    function tx(mode, fn) {
+        return db().then(function (d) {
+            return new Promise(function (resolve, reject) {
+                var t = d.transaction('projects', mode);
+                var r = fn(t.objectStore('projects'));
+                t.oncomplete = function () { resolve(r && r.result); };
+                t.onerror = function () { reject(t.error); };
+                t.onabort = function () { reject(t.error); };
+            });
+        });
+    }
+
+    store.cachePut = function (id, data) {
+        return tx('readwrite', function (s) { return s.put(data, id); }).catch(function () { });
+    };
+
+    store.cacheGet = function (id) {
+        return tx('readonly', function (s) { return s.get(id); }).catch(function () { return null; });
+    };
+
+    store.cacheDelete = function (id) {
+        return tx('readwrite', function (s) { return s.delete(id); }).catch(function () { });
+    };
+
+    //Drop cached projects no recent entry points at any more
+    store.cachePrune = function () {
+        var keep = new Set(store.recent.map(function (e) { return e.cacheId; }).filter(Boolean));
+        return tx('readonly', function (s) { return s.getAllKeys(); }).then(function (keys) {
+            (keys || []).forEach(function (k) {
+                if (!keep.has(k)) {
+                    store.cacheDelete(k);
+                }
+            });
+        }).catch(function () { });
+    };
+
+    GV.store = store;
+})(window.GV = window.GV || {});

+ 565 - 0
src/web/Gerber Viewer/js/ui.js

@@ -0,0 +1,565 @@
+/*
+    Gerber Viewer - UI toolkit
+
+    Desktop style menu bar with dropdowns, submenus, context menus and
+    keyboard navigation, plus dialogs, toasts and popovers.
+
+    Menu item:
+        { label, icon, key, action(), enabled: bool|fn, checked: bool|fn,
+          submenu: items|fn, sep: true, header: 'text' }
+*/
+(function (GV) {
+    'use strict';
+
+    function icon(id, cls) {
+        return '<svg' + (cls ? ' class="' + cls + '"' : '') + '><use href="#ic-' + id + '"/></svg>';
+    }
+
+    function esc(s) {
+        return String(s === undefined || s === null ? '' : s)
+            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+    }
+
+    function h(tag, attrs, html) {
+        var e = document.createElement(tag);
+        if (attrs) {
+            for (var k in attrs) {
+                if (k === 'class') {
+                    e.className = attrs[k];
+                } else if (k === 'style') {
+                    e.setAttribute('style', attrs[k]);
+                } else if (k.indexOf('on') === 0 && typeof attrs[k] === 'function') {
+                    e.addEventListener(k.slice(2), attrs[k]);
+                } else if (attrs[k] !== undefined && attrs[k] !== null && attrs[k] !== false) {
+                    e.setAttribute(k, attrs[k] === true ? '' : attrs[k]);
+                }
+            }
+        }
+        if (html !== undefined) {
+            e.innerHTML = html;
+        }
+        return e;
+    }
+
+    function val(v) {
+        return typeof v === 'function' ? v() : v;
+    }
+
+    /*
+        Localised string from locale/gerberviewer.json (loaded by app.js through
+        script/applocale.js), falling back to the English text. {name}
+        placeholders are filled from vars.
+    */
+    function t(key, fallback, vars) {
+        var s = fallback;
+        if (typeof applocale !== 'undefined' && applocale && applocale.localData && applocale.localData.keys) {
+            s = applocale.getString(key, fallback);
+        }
+        s = String(s === undefined || s === null ? '' : s);
+        if (vars) {
+            Object.keys(vars).forEach(function (k) {
+                s = s.split('{' + k + '}').join(vars[k]);
+            });
+        }
+        return s;
+    }
+
+    /* ------------------------------------------------------------------
+        Menus
+    ------------------------------------------------------------------ */
+    var stack = [];         //open menu levels: {el, items, rows, focus}
+    var bar = null;         //menubar state
+    var onCloseAll = null;
+
+    function closeFrom(level) {
+        while (stack.length > level) {
+            var m = stack.pop();
+            m.el.remove();
+        }
+        if (level === 0) {
+            if (bar) {
+                bar.items.forEach(function (b) { b.classList.remove('open'); });
+                bar.open = -1;
+            }
+            if (onCloseAll) {
+                var f = onCloseAll;
+                onCloseAll = null;
+                f();
+            }
+        }
+    }
+
+    function closeMenus() {
+        closeFrom(0);
+    }
+
+    function isOpen() {
+        return stack.length > 0;
+    }
+
+    function enabled(it) {
+        return it.enabled === undefined ? true : !!val(it.enabled);
+    }
+
+    function showMenu(items, x, y, opts) {
+        opts = opts || {};
+        var level = opts.level || 0;
+        if (stack.length > level) {
+            closeFrom(level);
+        }
+        items = val(items) || [];
+        var menu = h('div', { class: 'menu', role: 'menu' });
+        var rows = [];
+        items.forEach(function (it) {
+            if (!it) {
+                return;
+            }
+            if (it.sep) {
+                menu.appendChild(h('div', { class: 'menu-sep' }));
+                return;
+            }
+            if (it.header) {
+                menu.appendChild(h('div', { class: 'menu-header' }, esc(it.header)));
+                return;
+            }
+            if (it.empty) {
+                menu.appendChild(h('div', { class: 'menu-empty' }, esc(it.empty)));
+                return;
+            }
+            var on = enabled(it);
+            var checked = it.checked !== undefined ? !!val(it.checked) : false;
+            var ic = checked ? icon('check') : (it.icon ? icon(it.icon) : '');
+            if (it.swatch) {
+                ic = '<span style="width:12px;height:12px;border-radius:3px;background:' + esc(it.swatch) + ';box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)"></span>';
+            }
+            var row = h('button', { class: 'menu-item' + (on ? '' : ' disabled'), role: 'menuitem', tabindex: '-1' },
+                '<span class="mi-icon">' + ic + '</span>' +
+                '<span class="mi-label">' + esc(val(it.label)) + '</span>' +
+                (it.key ? '<span class="mi-key">' + esc(it.key) + '</span>' : '') +
+                (it.submenu ? '<span class="mi-sub">' + icon('chevron-right') + '</span>' : ''));
+            if (it.title) {
+                row.title = it.title;
+            }
+            row._item = it;
+            row.addEventListener('mouseenter', function () {
+                focusRow(level, rows.indexOf(row));
+                if (it.submenu && on) {
+                    openSub(level, row);
+                } else {
+                    closeFrom(level + 1);
+                }
+            });
+            row.addEventListener('click', function (e) {
+                e.stopPropagation();
+                activate(level, row);
+            });
+            rows.push(row);
+            menu.appendChild(row);
+        });
+        if (!rows.length && !menu.children.length) {
+            menu.appendChild(h('div', { class: 'menu-empty' }, esc(t('menu/empty', 'Nothing here'))));
+        }
+        document.body.appendChild(menu);
+        var r = menu.getBoundingClientRect();
+        var vw = window.innerWidth, vh = window.innerHeight;
+        if (x + r.width > vw - 4) {
+            x = opts.flipX !== undefined ? opts.flipX - r.width : vw - r.width - 4;
+        }
+        if (y + r.height > vh - 4) {
+            y = Math.max(4, vh - r.height - 4);
+        }
+        menu.style.left = Math.max(4, x) + 'px';
+        menu.style.top = Math.max(4, y) + 'px';
+        var entry = { el: menu, rows: rows, focus: -1 };
+        stack.push(entry);
+        if (opts.focusFirst) {
+            focusRow(level, nextEnabled(rows, -1, 1));
+        }
+        return menu;
+    }
+
+    function nextEnabled(rows, from, dir) {
+        for (var i = 1; i <= rows.length; i++) {
+            var idx = (from + dir * i + rows.length * 2) % rows.length;
+            if (enabled(rows[idx]._item)) {
+                return idx;
+            }
+        }
+        return -1;
+    }
+
+    function focusRow(level, idx) {
+        var m = stack[level];
+        if (!m) {
+            return;
+        }
+        m.rows.forEach(function (r, i) { r.classList.toggle('focus', i === idx); });
+        m.focus = idx;
+        if (idx >= 0 && m.rows[idx]) {
+            m.rows[idx].scrollIntoView({ block: 'nearest' });
+        }
+    }
+
+    function openSub(level, row) {
+        var r = row.getBoundingClientRect();
+        showMenu(row._item.submenu, r.right - 2, r.top - 5, { level: level + 1, flipX: r.left + 2 });
+    }
+
+    function activate(level, row) {
+        var it = row._item;
+        if (!enabled(it)) {
+            return;
+        }
+        if (it.submenu) {
+            openSub(level, row);
+            focusRow(level + 1, nextEnabled(stack[level + 1].rows, -1, 1));
+            return;
+        }
+        closeMenus();
+        if (it.action) {
+            setTimeout(function () { it.action(); }, 0);
+        }
+    }
+
+    function contextMenu(items, x, y, onClose) {
+        closeMenus();
+        onCloseAll = onClose || null;
+        showMenu(items, x, y, { level: 0 });
+    }
+
+    //Keyboard handling while a menu is open. Returns true when consumed.
+    function menuKey(e) {
+        if (!stack.length) {
+            return false;
+        }
+        var level = stack.length - 1;
+        var m = stack[level];
+        switch (e.key) {
+            case 'Escape':
+                if (level > 0) {
+                    closeFrom(level);
+                } else {
+                    closeMenus();
+                }
+                return true;
+            case 'ArrowDown':
+                focusRow(level, nextEnabled(m.rows, m.focus, 1));
+                return true;
+            case 'ArrowUp':
+                focusRow(level, nextEnabled(m.rows, m.focus < 0 ? 0 : m.focus, -1));
+                return true;
+            case 'ArrowRight':
+                if (m.focus >= 0 && m.rows[m.focus]._item.submenu) {
+                    activate(level, m.rows[m.focus]);
+                } else if (bar && bar.open >= 0) {
+                    openBar((bar.open + 1) % bar.items.length, true);
+                }
+                return true;
+            case 'ArrowLeft':
+                if (level > 0) {
+                    closeFrom(level);
+                } else if (bar && bar.open >= 0) {
+                    openBar((bar.open - 1 + bar.items.length) % bar.items.length, true);
+                }
+                return true;
+            case 'Enter':
+            case ' ':
+                if (m.focus >= 0) {
+                    activate(level, m.rows[m.focus]);
+                }
+                return true;
+            case 'Tab':
+                closeMenus();
+                return true;
+        }
+        return false;
+    }
+
+    function openBar(i, focusFirst) {
+        var btn = bar.items[i];
+        closeMenus();
+        var r = btn.getBoundingClientRect();
+        //showMenu clears level 0 (and with it bar.open), so mark the bar afterwards
+        showMenu(bar.menus[i].items, r.left, r.bottom + 3, { level: 0, focusFirst: focusFirst });
+        bar.items.forEach(function (b, j) { b.classList.toggle('open', j === i); });
+        bar.open = i;
+    }
+
+    function menubar(root, menus) {
+        root.innerHTML = '';
+        bar = { menus: menus, items: [], open: -1 };
+        menus.forEach(function (m, i) {
+            var b = h('button', { class: 'mb-item', role: 'menuitem' }, esc(m.label));
+            b.addEventListener('mousedown', function (e) {
+                e.preventDefault();
+                e.stopPropagation();
+                if (bar.open === i) {
+                    closeMenus();
+                } else {
+                    openBar(i, false);
+                }
+            });
+            b.addEventListener('mouseenter', function () {
+                if (bar.open >= 0 && bar.open !== i) {
+                    openBar(i, false);
+                }
+            });
+            bar.items.push(b);
+            root.appendChild(b);
+        });
+        return {
+            open: function (id) {
+                for (var i = 0; i < menus.length; i++) {
+                    if (menus[i].id === id) {
+                        openBar(i, true);
+                    }
+                }
+            }
+        };
+    }
+
+    document.addEventListener('mousedown', function (e) {
+        if (!stack.length) {
+            return;
+        }
+        for (var i = 0; i < stack.length; i++) {
+            if (stack[i].el.contains(e.target)) {
+                return;
+            }
+        }
+        if (bar && bar.items.some(function (b) { return b.contains(e.target); })) {
+            return;
+        }
+        closeMenus();
+    }, true);
+
+    window.addEventListener('blur', function () {
+        closeMenus();
+    });
+    window.addEventListener('resize', function () {
+        closeMenus();
+    });
+
+    /* ------------------------------------------------------------------
+        Dialogs
+    ------------------------------------------------------------------ */
+    var dialogs = [];
+
+    function dialog(o) {
+        var back = h('div', { class: 'modal-back' });
+        var box = h('div', { class: 'modal', role: 'dialog', style: '--w:' + (o.width || 520) + 'px' });
+        var head = h('div', { class: 'modal-h' }, (o.icon ? icon(o.icon) : '') + '<span>' + esc(o.title || '') + '</span>');
+        var x = h('button', { class: 'iconbtn', title: t('common/close', 'Close') }, icon('close'));
+        head.appendChild(x);
+        var body = h('div', { class: 'modal-b' });
+        if (typeof o.body === 'string') {
+            body.innerHTML = o.body;
+        } else if (o.body) {
+            body.appendChild(o.body);
+        }
+        box.appendChild(head);
+        box.appendChild(body);
+        var foot = null;
+        var api = { el: box, body: body, close: close, buttons: [] };
+        if (o.buttons && o.buttons.length || o.footerLeft) {
+            foot = h('div', { class: 'modal-f' });
+            if (o.footerLeft) {
+                var left = typeof o.footerLeft === 'string' ? h('div', { class: 'left' }, o.footerLeft) : o.footerLeft;
+                left.classList.add('left');
+                foot.appendChild(left);
+            }
+            (o.buttons || []).forEach(function (b) {
+                var btn = h('button', { class: 'btn' + (b.primary ? ' primary' : '') + (b.cls ? ' ' + b.cls : '') },
+                    (b.icon ? icon(b.icon) : '') + esc(b.label));
+                btn.addEventListener('click', function () {
+                    if (b.action) {
+                        var r = b.action(api);
+                        if (r === false) {
+                            return;
+                        }
+                        if (r && typeof r.then === 'function') {
+                            btn.disabled = true;
+                            r.then(function (keep) {
+                                btn.disabled = false;
+                                if (keep !== false) {
+                                    close();
+                                }
+                            }, function () {
+                                btn.disabled = false;
+                            });
+                            return;
+                        }
+                    }
+                    close();
+                });
+                if (b.primary) {
+                    api.primary = btn;
+                }
+                api.buttons.push(btn);
+                foot.appendChild(btn);
+            });
+            box.appendChild(foot);
+        }
+        back.appendChild(box);
+        document.body.appendChild(back);
+        closeMenus();
+        var closed = false;
+        function close() {
+            if (closed) {
+                return;
+            }
+            closed = true;
+            back.remove();
+            dialogs.splice(dialogs.indexOf(api), 1);
+            if (o.onClose) {
+                o.onClose();
+            }
+        }
+        x.addEventListener('click', close);
+        back.addEventListener('mousedown', function (e) {
+            if (e.target === back && o.dismissable !== false) {
+                close();
+            }
+        });
+        dialogs.push(api);
+        setTimeout(function () {
+            var f = box.querySelector('[autofocus]') || box.querySelector('input,select') || api.primary;
+            if (f) {
+                f.focus();
+            }
+        }, 30);
+        return api;
+    }
+
+    function dialogKey(e) {
+        if (!dialogs.length) {
+            return false;
+        }
+        var top = dialogs[dialogs.length - 1];
+        if (e.key === 'Escape') {
+            top.close();
+            return true;
+        }
+        if (e.key === 'Enter' && top.primary && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'BUTTON') {
+            top.primary.click();
+            return true;
+        }
+        return true; //dialogs swallow other shortcuts
+    }
+
+    function hasDialog() {
+        return dialogs.length > 0;
+    }
+
+    function confirm(title, text, ok) {
+        return new Promise(function (resolve) {
+            var done = false;
+            dialog({
+                title: title,
+                icon: 'warning',
+                width: 420,
+                body: '<p style="margin:0">' + esc(text) + '</p>',
+                buttons: [
+                    { label: t('common/cancel', 'Cancel'), action: function () { done = true; resolve(false); } },
+                    { label: ok || t('common/ok', 'OK'), primary: true, action: function () { done = true; resolve(true); } }
+                ],
+                onClose: function () {
+                    if (!done) {
+                        resolve(false);
+                    }
+                }
+            });
+        });
+    }
+
+    /* ------------------------------------------------------------------
+        Toasts & popovers
+    ------------------------------------------------------------------ */
+    function toast(msg, o) {
+        o = o || {};
+        var root = document.getElementById('toasts');
+        var t = h('div', { class: 'toast' + (o.type === 'error' ? ' error' : '') },
+            (o.icon ? icon(o.icon) : (o.type === 'error' ? icon('warning') : '')) + '<span>' + esc(msg) + '</span>');
+        var timer = null;
+        function remove() {
+            clearTimeout(timer);
+            t.remove();
+        }
+        if (o.action) {
+            var b = h('button', null, esc(o.action.label));
+            b.addEventListener('click', function () {
+                remove();
+                o.action.fn();
+            });
+            t.appendChild(b);
+        }
+        root.appendChild(t);
+        timer = setTimeout(remove, o.timeout || (o.action ? 9000 : 3200));
+        return remove;
+    }
+
+    var pop = null;
+    function popover(anchor, content, o) {
+        closePopover();
+        o = o || {};
+        var p = h('div', { class: 'popover' });
+        if (typeof content === 'string') {
+            p.innerHTML = content;
+        } else {
+            p.appendChild(content);
+        }
+        document.body.appendChild(p);
+        var r = anchor.getBoundingClientRect();
+        var pr = p.getBoundingClientRect();
+        var x = r.left, y = r.bottom + 6;
+        if (x + pr.width > window.innerWidth - 8) {
+            x = window.innerWidth - pr.width - 8;
+        }
+        if (y + pr.height > window.innerHeight - 8) {
+            y = Math.max(8, r.top - pr.height - 6);
+        }
+        p.style.left = Math.max(8, x) + 'px';
+        p.style.top = y + 'px';
+        pop = { el: p, onClose: o.onClose, anchor: anchor };
+        return p;
+    }
+
+    function closePopover() {
+        if (pop) {
+            var f = pop.onClose;
+            pop.el.remove();
+            pop = null;
+            if (f) {
+                f();
+            }
+        }
+    }
+
+    document.addEventListener('mousedown', function (e) {
+        if (pop && !pop.el.contains(e.target) && !pop.anchor.contains(e.target)) {
+            closePopover();
+        }
+    }, true);
+
+    GV.ui = {
+        t: t,
+        icon: icon,
+        esc: esc,
+        h: h,
+        menubar: menubar,
+        showMenu: showMenu,
+        contextMenu: contextMenu,
+        closeMenus: closeMenus,
+        isMenuOpen: isOpen,
+        menuKey: menuKey,
+        dialog: dialog,
+        dialogKey: dialogKey,
+        hasDialog: hasDialog,
+        confirm: confirm,
+        toast: toast,
+        popover: popover,
+        closePopover: closePopover,
+        hasPopover: function () { return !!pop; }
+    };
+})(window.GV = window.GV || {});

+ 564 - 0
src/web/Gerber Viewer/js/view3d.js

@@ -0,0 +1,564 @@
+/*
+    Gerber Viewer - 3D board view
+
+    Loaded on demand (dynamic import) the first time the 3D view is opened.
+    The board is the outline extruded to the configured thickness with every
+    cut-out, hole and slot removed. The top and bottom faces are textured with
+    the realistic 2D render, plated holes get copper barrels, and the edge
+    shows the layer stack-up.
+
+    With the manufacturing preview the board can also be laid out as the
+    manufacturer's panel: copies in rows and columns, V-cut grooves or
+    stamp-hole tabs between them, and edge rails with tooling holes and
+    fiducials.
+
+    three.js r185 is vendored in ../lib/three (MIT, see its LICENSE).
+*/
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+function windingNumber(c, px, py) {
+    var wn = 0;
+    var n = c.length;
+    for (var i = 0; i < n; i += 2) {
+        var x1 = c[i], y1 = c[i + 1], x2 = c[(i + 2) % n], y2 = c[(i + 3) % n];
+        if (y1 <= py) {
+            if (y2 > py && (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1) > 0) {
+                wn++;
+            }
+        } else if (y2 <= py && (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1) < 0) {
+            wn--;
+        }
+    }
+    return wn;
+}
+
+function toVec(c) {
+    var pts = [];
+    for (var i = 0; i < c.length; i += 2) {
+        pts.push(new THREE.Vector2(c[i], c[i + 1]));
+    }
+    return pts;
+}
+
+function circlePts(x, y, r) {
+    var n = Math.max(10, Math.min(36, Math.round(r * 24)));
+    var pts = [];
+    for (var i = 0; i < n; i++) {
+        var a = -Math.PI * 2 * i / n;
+        pts.push(new THREE.Vector2(x + r * Math.cos(a), y + r * Math.sin(a)));
+    }
+    return pts;
+}
+
+/*
+    Edge texture: a vertical strip, top of the canvas = top of the board.
+    stack: {layers, copperOz, core, base, baseRatio, mask, copper}
+*/
+function stackTexture(stack, t) {
+    var H = 512;
+    var cv = document.createElement('canvas');
+    cv.width = 4;
+    cv.height = H;
+    var ctx = cv.getContext('2d');
+    ctx.fillStyle = stack.core;
+    ctx.fillRect(0, 0, 4, H);
+    var px = H / t;
+    if (stack.base) {
+        //Metal core boards: thin dielectric on top of a thick metal base
+        var top = Math.round(H * (1 - stack.baseRatio));
+        ctx.fillStyle = stack.base;
+        ctx.fillRect(0, top, 4, H - top);
+    }
+    //Copper is 35 um per oz; drawn at least 1.5 % of the stack so it reads
+    var cu = Math.max(Math.round(H * 0.015), Math.round(stack.copperOz * 0.035 * px));
+    var n = Math.max(1, stack.layers);
+    ctx.fillStyle = stack.copper;
+    for (var i = 0; i < n; i++) {
+        var f = n === 1 ? 0 : i / (n - 1);
+        //Inner copper is thinner (0.5 oz) than the outer layers
+        var h = i === 0 || i === n - 1 ? cu : Math.max(1, Math.round(cu * 0.5));
+        var y = Math.round(f * (H - h));
+        ctx.fillRect(0, y, 4, h);
+    }
+    if (stack.mask) {
+        ctx.fillStyle = stack.mask;
+        ctx.fillRect(0, 0, 4, 2);
+        if (n > 1 || !stack.base) {
+            ctx.fillRect(0, H - 2, 4, 2);
+        }
+    }
+    var tex = new THREE.CanvasTexture(cv);
+    tex.colorSpace = THREE.SRGBColorSpace;
+    tex.wrapS = THREE.RepeatWrapping;
+    tex.wrapT = THREE.ClampToEdgeWrapping;
+    //Side wall UVs are (along the edge, 1 - z); map z = 0..t onto the strip
+    tex.repeat.set(1, -1 / t);
+    tex.offset.set(0, 1 / t);
+    tex.magFilter = THREE.LinearFilter;
+    return tex;
+}
+
+export function createView(container) {
+    var renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, alpha: false });
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+    renderer.outputColorSpace = THREE.SRGBColorSpace;
+    container.appendChild(renderer.domElement);
+
+    var scene = new THREE.Scene();
+    var camera = new THREE.PerspectiveCamera(35, 1, 0.1, 10000);
+    camera.up.set(0, 0, 1);
+
+    scene.add(new THREE.HemisphereLight(0xffffff, 0x444450, 1.6));
+    var key = new THREE.DirectionalLight(0xffffff, 1.6);
+    key.position.set(-80, -120, 200);
+    scene.add(key);
+    var fill = new THREE.DirectionalLight(0xffffff, 0.6);
+    fill.position.set(120, 80, -150);
+    scene.add(fill);
+
+    var controls = new OrbitControls(camera, renderer.domElement);
+    controls.enableDamping = true;
+    controls.dampingFactor = 0.12;
+    controls.screenSpacePanning = true;
+
+    var group = new THREE.Group();
+    scene.add(group);
+    var size = 100;
+    var running = false;
+    var disposables = [];
+
+    function keep(x) {
+        disposables.push(x);
+        return x;
+    }
+
+    function resize() {
+        var r = container.getBoundingClientRect();
+        var w = Math.max(1, r.width), h = Math.max(1, r.height);
+        renderer.setSize(w, h, false);
+        renderer.domElement.style.width = w + 'px';
+        renderer.domElement.style.height = h + 'px';
+        camera.aspect = w / h;
+        camera.updateProjectionMatrix();
+    }
+
+    function loop() {
+        controls.update();
+        renderer.render(scene, camera);
+    }
+
+    function start() {
+        if (!running) {
+            running = true;
+            resize();
+            renderer.setAnimationLoop(loop);
+        }
+    }
+
+    function stop() {
+        running = false;
+        renderer.setAnimationLoop(null);
+    }
+
+    function clear() {
+        while (group.children.length) {
+            group.remove(group.children[0]);
+        }
+        disposables.forEach(function (d) { d.dispose(); });
+        disposables = [];
+    }
+
+    function setView(dir) {
+        //Distance at which the long side fills about 85 % of the view
+        var aspect = Math.max(0.3, camera.aspect || 1);
+        var d = size / (2 * Math.tan(camera.fov * Math.PI / 360) * Math.min(1, aspect)) / 0.85;
+        controls.target.set(0, 0, 0);
+        if (dir === 'top') {
+            camera.position.set(0, -0.001, d);
+        } else if (dir === 'bottom') {
+            camera.position.set(0, 0.001, -d);
+        } else if (dir === 'front') {
+            camera.position.set(0, -d * 1.05, d * 0.15);
+        } else {
+            //Seen at an angle the board spans more of the view: back off a little
+            camera.position.set(-d * 0.38, -d * 0.8, d * 0.8);
+        }
+        camera.near = size / 100;
+        camera.far = size * 50;
+        camera.updateProjectionMatrix();
+        controls.update();
+    }
+
+    function faceTexture(canvas) {
+        var tex = keep(new THREE.CanvasTexture(canvas));
+        tex.colorSpace = THREE.SRGBColorSpace;
+        tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
+        tex.generateMipmaps = true;
+        tex.minFilter = THREE.LinearMipmapLinearFilter;
+        return tex;
+    }
+
+    function flatFace(shapeList, b, tex, z, back) {
+        var w = b[2] - b[0], h = b[3] - b[1];
+        var geo = keep(new THREE.ShapeGeometry(shapeList, 8));
+        var pos = geo.attributes.position, uv = geo.attributes.uv;
+        for (var i = 0; i < pos.count; i++) {
+            uv.setXY(i, (pos.getX(i) - b[0]) / w, (pos.getY(i) - b[1]) / h);
+            pos.setZ(i, z);
+        }
+        uv.needsUpdate = true;
+        pos.needsUpdate = true;
+        geo.computeVertexNormals();
+        var mat = keep(new THREE.MeshStandardMaterial({
+            map: tex,
+            roughness: 0.55,
+            metalness: 0,
+            side: back ? THREE.BackSide : THREE.FrontSide
+        }));
+        return new THREE.Mesh(geo, mat);
+    }
+
+    //One board: laminate body, textured faces and plated barrels
+    function buildBoard(data, edgeMat) {
+        var board = data.board;
+        var b = board.bounds;
+        var t = data.thickness;
+        var fillVias = !!data.fillVias;
+        var g = new THREE.Group();
+
+        //Holes and cut-outs belong to the outer loop that contains them
+        var shapes = board.outer.map(function (c) {
+            return { contour: c, shape: new THREE.Shape(toVec(c)) };
+        });
+        function owner(x, y) {
+            for (var i = 0; i < shapes.length; i++) {
+                if (windingNumber(shapes[i].contour, x, y) !== 0) {
+                    return shapes[i].shape;
+                }
+            }
+            return null;
+        }
+        board.cutouts.forEach(function (c) {
+            var s = owner(c[0], c[1]);
+            if (s) {
+                s.holes.push(new THREE.Path(toVec(c)));
+            }
+        });
+        var drilled = board.holes.filter(function (hole) {
+            return hole.r > 0.01 && !(fillVias && hole.via);
+        });
+        drilled.forEach(function (hole) {
+            var s = owner(hole.x, hole.y);
+            if (s) {
+                s.holes.push(new THREE.Path(circlePts(hole.x, hole.y, hole.r)));
+            }
+        });
+        board.slots.forEach(function (sl) {
+            var c = data.slotContour(sl.item);
+            var s = owner(c[0], c[1]);
+            if (s) {
+                s.holes.push(new THREE.Path(toVec(c)));
+            }
+        });
+        var shapeList = shapes.map(function (s) { return s.shape; });
+
+        //Laminate walls (the extruded caps are hidden, textured faces replace them)
+        var body = keep(new THREE.ExtrudeGeometry(shapeList, { depth: t, bevelEnabled: false, curveSegments: 8 }));
+        var hiddenCaps = keep(new THREE.MeshBasicMaterial({ visible: false }));
+        g.add(new THREE.Mesh(body, [hiddenCaps, edgeMat]));
+        g.add(flatFace(shapeList, b, data.topTex, t, false));
+        g.add(flatFace(shapeList, b, data.bottomTex, 0, true));
+
+        //Copper barrels in plated holes
+        var plated = drilled.filter(function (hl) { return hl.plated; });
+        if (plated.length) {
+            var cyl = keep(new THREE.CylinderGeometry(1, 1, t, 20, 1, true));
+            cyl.rotateX(Math.PI / 2);
+            var barrelMat = keep(new THREE.MeshStandardMaterial({ color: new THREE.Color(data.barrel), roughness: 0.35, metalness: 0.55, side: THREE.DoubleSide }));
+            var inst = new THREE.InstancedMesh(cyl, barrelMat, plated.length);
+            var m = new THREE.Matrix4();
+            plated.forEach(function (hl, i) {
+                var r = hl.r * 0.985;
+                m.makeScale(r, r, 1);
+                m.setPosition(hl.x, hl.y, t / 2);
+                inst.setMatrixAt(i, m);
+            });
+            inst.instanceMatrix.needsUpdate = true;
+            g.add(inst);
+        }
+        return g;
+    }
+
+    //Plain laminate block (rails, tabs) with mask coloured faces
+    function slab(x0, y0, x1, y1, t, faceMat, edgeMat, holes) {
+        var s = new THREE.Shape([
+            new THREE.Vector2(x0, y0), new THREE.Vector2(x1, y0), new THREE.Vector2(x1, y1), new THREE.Vector2(x0, y1)
+        ]);
+        (holes || []).forEach(function (h) {
+            s.holes.push(new THREE.Path(circlePts(h[0], h[1], h[2])));
+        });
+        var geo = keep(new THREE.ExtrudeGeometry(s, { depth: t, bevelEnabled: false, curveSegments: 8 }));
+        return new THREE.Mesh(geo, [faceMat, edgeMat]);
+    }
+
+    function disc(x, y, z, r, mat) {
+        var geo = keep(new THREE.CircleGeometry(r, 24));
+        var mesh = new THREE.Mesh(geo, mat);
+        mesh.position.set(x, y, z);
+        if (z < 0) {
+            mesh.rotation.x = Math.PI;
+        }
+        return mesh;
+    }
+
+    /*
+        data: {
+            board        GV.board.build() result
+            thickness    mm
+            top, bottom  canvases covering board.bounds (realistic renders)
+            edge         css colour of the laminate edge
+            barrel       css colour of plated barrels
+            background   css colour
+            slotContour  GV.board.slotContour
+            fillVias     true when vias are plugged or capped (no hole)
+            stack        edge stack-up {layers, copperOz, core, base, baseRatio, mask, copper, metal}
+            panel        {cols, rows, separation: 'vcut'|'tab', gap, rails: 'none'|'tb'|'lr'|'all',
+                          railWidth, faceColor, finish} or null for a single board
+            keepView     true to leave the camera where the user put it
+        }
+    */
+    var hasBoard = false;
+    function setBoard(data) {
+        var keepCam = data.keepView && hasBoard;
+        var camPos = camera.position.clone(), camTarget = controls.target.clone();
+        clear();
+        var b = data.board.bounds;
+        var t = data.thickness;
+        scene.background = new THREE.Color(data.background);
+
+        data.topTex = faceTexture(data.top);
+        data.bottomTex = faceTexture(data.bottom);
+        var edgeMat;
+        if (data.stack) {
+            edgeMat = keep(new THREE.MeshStandardMaterial({
+                map: keep(stackTexture(data.stack, t)),
+                roughness: data.stack.metal ? 0.45 : 0.85,
+                metalness: data.stack.metal ? 0.5 : 0
+            }));
+        } else {
+            edgeMat = keep(new THREE.MeshStandardMaterial({ color: new THREE.Color(data.edge), roughness: 0.85, metalness: 0 }));
+        }
+
+        var unit = buildBoard(data, edgeMat);
+        var p = data.panel;
+        var extent;
+        if (!p || (p.cols <= 1 && p.rows <= 1 && p.rails === 'none')) {
+            group.add(unit);
+            extent = [b[0], b[1], b[2], b[3]];
+        } else {
+            extent = buildPanel(unit, data, edgeMat);
+        }
+        size = Math.max(extent[2] - extent[0], extent[3] - extent[1], 1);
+        group.position.set(-(extent[0] + extent[2]) / 2, -(extent[1] + extent[3]) / 2, -t / 2);
+        hasBoard = true;
+        if (keepCam) {
+            camera.position.copy(camPos);
+            controls.target.copy(camTarget);
+            camera.near = size / 100;
+            camera.far = size * 50;
+            camera.updateProjectionMatrix();
+            controls.update();
+        } else {
+            setView('iso');
+        }
+    }
+
+    //Copies of the board in a grid, V-cut grooves or tabs, edge rails
+    function buildPanel(unit, data, edgeMat) {
+        var p = data.panel;
+        var b = data.board.bounds;
+        var t = data.thickness;
+        var w = b[2] - b[0], h = b[3] - b[1];
+        var vcut = p.separation === 'vcut';
+        var gap = vcut ? 0 : p.gap;
+        var cols = Math.max(1, p.cols), rows = Math.max(1, p.rows);
+        var W = cols * w + (cols - 1) * gap, H = rows * h + (rows - 1) * gap;
+
+        for (var i = 0; i < cols; i++) {
+            for (var j = 0; j < rows; j++) {
+                var copy = i === 0 && j === 0 ? unit : unit.clone();
+                copy.position.set(i * (w + gap) - b[0], j * (h + gap) - b[1], 0);
+                group.add(copy);
+            }
+        }
+
+        var faceMat = keep(new THREE.MeshStandardMaterial({ color: new THREE.Color(p.faceColor), roughness: 0.55 }));
+        var finishMat = keep(new THREE.MeshStandardMaterial({ color: new THREE.Color(p.finish), roughness: 0.35, metalness: 0.5 }));
+        var holeMat = keep(new THREE.MeshBasicMaterial({ color: 0x111111 }));
+        var x0 = 0, y0 = 0, x1 = W, y1 = H;
+        var rw = p.railWidth, rg = gap;
+        var rails = [];
+        if (p.rails === 'tb' || p.rails === 'all') {
+            rails.push({ x0: p.rails === 'all' ? -rg - rw : 0, y0: H + rg, x1: p.rails === 'all' ? W + rg + rw : W, y1: H + rg + rw, horiz: true });
+            rails.push({ x0: p.rails === 'all' ? -rg - rw : 0, y0: -rg - rw, x1: p.rails === 'all' ? W + rg + rw : W, y1: -rg, horiz: true });
+        }
+        if (p.rails === 'lr' || p.rails === 'all') {
+            rails.push({ x0: -rg - rw, y0: 0, x1: -rg, y1: H, horiz: false });
+            rails.push({ x0: W + rg, y0: 0, x1: W + rg + rw, y1: H, horiz: false });
+        }
+        rails.forEach(function (r, k) {
+            //Tooling holes (2 mm NPTH) near the ends, fiducials next to them
+            var holes = [], fids = [];
+            var len = r.horiz ? r.x1 - r.x0 : r.y1 - r.y0;
+            var mid = r.horiz ? (r.y0 + r.y1) / 2 : (r.x0 + r.x1) / 2;
+            [5, len - 5].forEach(function (d) {
+                if (len > 14) {
+                    holes.push(r.horiz ? [r.x0 + d, mid, 1.0] : [mid, r.y0 + d, 1.0]);
+                }
+            });
+            if (len > 30 && k < 2) {
+                fids.push(r.horiz ? [r.x0 + 12, mid] : [mid, r.y0 + 12]);
+                fids.push(r.horiz ? [r.x1 - 12, mid] : [mid, r.y1 - 12]);
+            }
+            group.add(slab(r.x0, r.y0, r.x1, r.y1, t, faceMat, edgeMat, holes));
+            fids.forEach(function (f) {
+                group.add(disc(f[0], f[1], t + 0.004, 0.5, finishMat));
+                group.add(disc(f[0], f[1], -0.004, 0.5, finishMat));
+            });
+            if (r.horiz) {
+                x0 = Math.min(x0, r.x0);
+                x1 = Math.max(x1, r.x1);
+                y0 = Math.min(y0, r.y0);
+                y1 = Math.max(y1, r.y1);
+            } else {
+                x0 = Math.min(x0, r.x0);
+                x1 = Math.max(x1, r.x1);
+            }
+        });
+
+        if (vcut) {
+            //V grooves on both faces across the whole panel
+            var lineMat = keep(new THREE.MeshBasicMaterial({ color: 0x0a0a0a, transparent: true, opacity: 0.65 }));
+            var xs = [], ys = [];
+            for (var c = 1; c < cols; c++) {
+                xs.push(c * w);
+            }
+            for (var rr = 1; rr < rows; rr++) {
+                ys.push(rr * h);
+            }
+            if (p.rails === 'tb' || p.rails === 'all') {
+                ys.push(0, H);
+            }
+            if (p.rails === 'lr' || p.rails === 'all') {
+                xs.push(0, W);
+            }
+            var groove = 0.35;
+            xs.forEach(function (x) {
+                [t + 0.006, -0.006].forEach(function (z) {
+                    var m = new THREE.Mesh(keep(new THREE.PlaneGeometry(groove, y1 - y0)), lineMat);
+                    m.position.set(x, (y0 + y1) / 2, z);
+                    if (z < 0) {
+                        m.rotation.x = Math.PI;
+                    }
+                    group.add(m);
+                });
+            });
+            ys.forEach(function (y) {
+                [t + 0.006, -0.006].forEach(function (z) {
+                    var m = new THREE.Mesh(keep(new THREE.PlaneGeometry(x1 - x0, groove)), lineMat);
+                    m.position.set((x0 + x1) / 2, y, z);
+                    if (z < 0) {
+                        m.rotation.x = Math.PI;
+                    }
+                    group.add(m);
+                });
+            });
+        } else {
+            //Breakaway tabs with a row of stamp holes on each side
+            var tabs = [];
+            function edgeTabs(fixed, from, to, vertical) {
+                var len = to - from;
+                var at = len < 25 ? [0.5] : [0.25, 0.75];
+                at.forEach(function (f) {
+                    tabs.push({ c: from + f * len, fixed: fixed, vertical: vertical });
+                });
+            }
+            for (var ci = 0; ci < cols; ci++) {
+                for (var rj = 0; rj < rows; rj++) {
+                    var bx = ci * (w + gap), by = rj * (h + gap);
+                    if (ci < cols - 1) {
+                        edgeTabs(bx + w + gap / 2, by, by + h, true);
+                    }
+                    if (rj < rows - 1) {
+                        edgeTabs(by + h + gap / 2, bx, bx + w, false);
+                    }
+                    if (rj === 0 && (p.rails === 'tb' || p.rails === 'all')) {
+                        edgeTabs(-gap / 2, bx, bx + w, false);
+                    }
+                    if (rj === rows - 1 && (p.rails === 'tb' || p.rails === 'all')) {
+                        edgeTabs(H + gap / 2, bx, bx + w, false);
+                    }
+                    if (ci === 0 && (p.rails === 'lr' || p.rails === 'all')) {
+                        edgeTabs(-gap / 2, by, by + h, true);
+                    }
+                    if (ci === cols - 1 && (p.rails === 'lr' || p.rails === 'all')) {
+                        edgeTabs(W + gap / 2, by, by + h, true);
+                    }
+                }
+            }
+            var tl = 5;
+            tabs.forEach(function (tb) {
+                var m;
+                if (tb.vertical) {
+                    m = slab(tb.fixed - gap / 2 - 0.2, tb.c - tl / 2, tb.fixed + gap / 2 + 0.2, tb.c + tl / 2, t, faceMat, edgeMat);
+                } else {
+                    m = slab(tb.c - tl / 2, tb.fixed - gap / 2 - 0.2, tb.c + tl / 2, tb.fixed + gap / 2 + 0.2, t, faceMat, edgeMat);
+                }
+                group.add(m);
+                //Five 0.5 mm stamp holes along each side of the tab
+                [-1, 1].forEach(function (s) {
+                    for (var k = 0; k < 5; k++) {
+                        var along = tb.c - tl / 2 + 0.5 + k * (tl - 1) / 4;
+                        var across = tb.fixed + s * (gap / 2 - 0.1);
+                        var x = tb.vertical ? across : along, y = tb.vertical ? along : across;
+                        group.add(disc(x, y, t + 0.005, 0.25, holeMat));
+                        group.add(disc(x, y, -0.005, 0.25, holeMat));
+                    }
+                });
+            });
+        }
+        return [x0, y0, x1, y1];
+    }
+
+    function setBackground(color) {
+        scene.background = new THREE.Color(color);
+    }
+
+    function snapshot() {
+        renderer.render(scene, camera);
+        return renderer.domElement.toDataURL('image/png');
+    }
+
+    function dispose() {
+        stop();
+        clear();
+        controls.dispose();
+        renderer.dispose();
+        if (renderer.domElement.parentNode) {
+            renderer.domElement.parentNode.removeChild(renderer.domElement);
+        }
+    }
+
+    return {
+        setBoard: setBoard,
+        setView: setView,
+        setBackground: setBackground,
+        resize: resize,
+        start: start,
+        stop: stop,
+        snapshot: snapshot,
+        dispose: dispose,
+        maxTexture: function () { return renderer.capabilities.maxTextureSize; }
+    };
+}

+ 219 - 0
src/web/Gerber Viewer/js/zip.js

@@ -0,0 +1,219 @@
+/*
+    Gerber Viewer - minimal zip reader / writer
+
+    Used for archives picked from the local device (archives stored on ArozOS
+    are unpacked by the AGI backend instead) and for "Save Project as ZIP".
+    Inflate / deflate use the browser's built-in (De)CompressionStream with
+    the "deflate-raw" format, so no third party library is needed.
+*/
+(function (GV) {
+    'use strict';
+
+    function u16(dv, o) { return dv.getUint16(o, true); }
+
+    //Errors carry a code (and values) so the UI can show them translated
+    function zipError(code, msg, vars) {
+        var e = new Error(msg);
+        e.code = code;
+        e.vars = vars || {};
+        return e;
+    }
+    function u32(dv, o) { return dv.getUint32(o, true); }
+
+    async function streamThrough(bytes, stream) {
+        var blob = new Blob([bytes]);
+        var out = blob.stream().pipeThrough(stream);
+        return new Uint8Array(await new Response(out).arrayBuffer());
+    }
+
+    function inflateRaw(bytes) {
+        if (typeof DecompressionStream === 'undefined') {
+            throw zipError('zipNoInflate', 'This browser cannot decompress zip files.');
+        }
+        return streamThrough(bytes, new DecompressionStream('deflate-raw'));
+    }
+
+    function deflateRaw(bytes) {
+        return streamThrough(bytes, new CompressionStream('deflate-raw'));
+    }
+
+    //Names are UTF-8 when flagged; otherwise try UTF-8 and fall back to Latin-1
+    function decodeName(bytes, utf8) {
+        try {
+            return new TextDecoder('utf-8', { fatal: !utf8 }).decode(bytes);
+        } catch (e) {
+            var s = '';
+            for (var i = 0; i < bytes.length; i++) {
+                s += String.fromCharCode(bytes[i]);
+            }
+            return s;
+        }
+    }
+
+    //List the entries of a zip held in an ArrayBuffer / Uint8Array
+    function list(buffer) {
+        var bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
+        var dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+        var eocd = -1;
+        for (var i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
+            if (u32(dv, i) === 0x06054b50) {
+                eocd = i;
+                break;
+            }
+        }
+        if (eocd < 0) {
+            throw zipError('zipInvalid', 'Not a zip archive.');
+        }
+        var count = u16(dv, eocd + 10);
+        var cdOffset = u32(dv, eocd + 16);
+        if (cdOffset === 0xffffffff || count === 0xffff) {
+            throw zipError('zip64', 'ZIP64 archives are not supported.');
+        }
+        var entries = [];
+        var p = cdOffset;
+        for (var n = 0; n < count; n++) {
+            if (p + 46 > bytes.length || u32(dv, p) !== 0x02014b50) {
+                break;
+            }
+            var flags = u16(dv, p + 8);
+            var method = u16(dv, p + 10);
+            var csize = u32(dv, p + 20);
+            var usize = u32(dv, p + 24);
+            var nameLen = u16(dv, p + 28);
+            var extraLen = u16(dv, p + 30);
+            var commentLen = u16(dv, p + 32);
+            var local = u32(dv, p + 42);
+            var name = decodeName(bytes.subarray(p + 46, p + 46 + nameLen), (flags & 0x800) !== 0);
+            entries.push({
+                name: name,
+                dir: /\/$/.test(name),
+                method: method,
+                encrypted: (flags & 1) !== 0,
+                compressedSize: csize,
+                size: usize,
+                localOffset: local
+            });
+            p += 46 + nameLen + extraLen + commentLen;
+        }
+        return { bytes: bytes, dv: dv, entries: entries };
+    }
+
+    async function extract(zip, entry) {
+        var dv = zip.dv;
+        var o = entry.localOffset;
+        if (u32(dv, o) !== 0x04034b50) {
+            throw zipError('zipCorrupt', 'Corrupt zip entry: ' + entry.name, { name: entry.name });
+        }
+        var start = o + 30 + u16(dv, o + 26) + u16(dv, o + 28);
+        var data = zip.bytes.subarray(start, start + entry.compressedSize);
+        if (entry.encrypted) {
+            throw zipError('zipEncrypted', 'Encrypted zip entries are not supported: ' + entry.name, { name: entry.name });
+        }
+        if (entry.method === 0) {
+            return data;
+        }
+        if (entry.method === 8) {
+            return inflateRaw(data);
+        }
+        throw zipError('zipMethod', 'Unsupported zip compression method ' + entry.method + ' for ' + entry.name, { method: entry.method, name: entry.name });
+    }
+
+    var CRC_TABLE = null;
+    function crc32(bytes) {
+        if (!CRC_TABLE) {
+            CRC_TABLE = new Uint32Array(256);
+            for (var n = 0; n < 256; n++) {
+                var c = n;
+                for (var k = 0; k < 8; k++) {
+                    c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+                }
+                CRC_TABLE[n] = c >>> 0;
+            }
+        }
+        var crc = 0xffffffff;
+        for (var i = 0; i < bytes.length; i++) {
+            crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
+        }
+        return (crc ^ 0xffffffff) >>> 0;
+    }
+
+    //files: [{name, text | bytes}] -> Uint8Array of a zip archive
+    async function create(files) {
+        var enc = new TextEncoder();
+        var chunks = [];
+        var central = [];
+        var offset = 0;
+        var now = new Date();
+        var dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);
+        var dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
+        for (var i = 0; i < files.length; i++) {
+            var f = files[i];
+            var data = f.bytes || enc.encode(f.text || '');
+            var name = enc.encode(f.name);
+            var crc = crc32(data);
+            var comp = await deflateRaw(data);
+            var method = 8;
+            if (comp.length >= data.length) {
+                comp = data;
+                method = 0;
+            }
+            var lh = new DataView(new ArrayBuffer(30));
+            lh.setUint32(0, 0x04034b50, true);
+            lh.setUint16(4, 20, true);
+            lh.setUint16(6, 0x800, true);
+            lh.setUint16(8, method, true);
+            lh.setUint16(10, dosTime, true);
+            lh.setUint16(12, dosDate, true);
+            lh.setUint32(14, crc, true);
+            lh.setUint32(18, comp.length, true);
+            lh.setUint32(22, data.length, true);
+            lh.setUint16(26, name.length, true);
+            lh.setUint16(28, 0, true);
+            chunks.push(new Uint8Array(lh.buffer), name, comp);
+
+            var ch = new DataView(new ArrayBuffer(46));
+            ch.setUint32(0, 0x02014b50, true);
+            ch.setUint16(4, 20, true);
+            ch.setUint16(6, 20, true);
+            ch.setUint16(8, 0x800, true);
+            ch.setUint16(10, method, true);
+            ch.setUint16(12, dosTime, true);
+            ch.setUint16(14, dosDate, true);
+            ch.setUint32(16, crc, true);
+            ch.setUint32(20, comp.length, true);
+            ch.setUint32(24, data.length, true);
+            ch.setUint16(28, name.length, true);
+            ch.setUint32(42, offset, true);
+            central.push(new Uint8Array(ch.buffer), name);
+            offset += 30 + name.length + comp.length;
+        }
+        var cdSize = central.reduce(function (s, c) { return s + c.length; }, 0);
+        var end = new DataView(new ArrayBuffer(22));
+        end.setUint32(0, 0x06054b50, true);
+        end.setUint16(8, files.length, true);
+        end.setUint16(10, files.length, true);
+        end.setUint32(12, cdSize, true);
+        end.setUint32(16, offset, true);
+        var all = chunks.concat(central, [new Uint8Array(end.buffer)]);
+        var total = all.reduce(function (s, c) { return s + c.length; }, 0);
+        var out = new Uint8Array(total);
+        var pos = 0;
+        all.forEach(function (c) {
+            out.set(c, pos);
+            pos += c.length;
+        });
+        return out;
+    }
+
+    function isZip(bytes) {
+        return bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 3 || bytes[2] === 5);
+    }
+
+    GV.zip = {
+        list: list,
+        extract: extract,
+        create: create,
+        isZip: isZip,
+        crc32: crc32
+    };
+})(typeof window !== 'undefined' ? (window.GV = window.GV || {}) : (global.GV = global.GV || {}));

+ 21 - 0
src/web/Gerber Viewer/lib/three/LICENSE

@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright © 2010-2026 three.js authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 1963 - 0
src/web/Gerber Viewer/lib/three/addons/controls/OrbitControls.js

@@ -0,0 +1,1963 @@
+import {
+	Controls,
+	MOUSE,
+	Quaternion,
+	Spherical,
+	TOUCH,
+	Vector2,
+	Vector3,
+	Plane,
+	Ray,
+	MathUtils
+} from 'three';
+
+/**
+ * Fires when the camera has been transformed by the controls.
+ *
+ * @event OrbitControls#change
+ * @type {Object}
+ */
+const _changeEvent = { type: 'change' };
+
+/**
+ * Fires when an interaction was initiated.
+ *
+ * @event OrbitControls#start
+ * @type {Object}
+ */
+const _startEvent = { type: 'start' };
+
+/**
+ * Fires when an interaction has finished.
+ *
+ * @event OrbitControls#end
+ * @type {Object}
+ */
+const _endEvent = { type: 'end' };
+
+const _ray = new Ray();
+const _plane = new Plane();
+const _TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
+
+const _v = new Vector3();
+const _twoPI = 2 * Math.PI;
+
+const _STATE = {
+	NONE: - 1,
+	ROTATE: 0,
+	DOLLY: 1,
+	PAN: 2,
+	TOUCH_ROTATE: 3,
+	TOUCH_PAN: 4,
+	TOUCH_DOLLY_PAN: 5,
+	TOUCH_DOLLY_ROTATE: 6
+};
+const _EPS = 0.000001;
+
+
+/**
+ * Orbit controls allow the camera to orbit around a target.
+ *
+ * OrbitControls performs orbiting, dollying (zooming), and panning. Unlike {@link TrackballControls},
+ * it maintains the "up" direction `object.up` (+Y by default).
+ *
+ * - Orbit: Left mouse / touch: one-finger move.
+ * - Zoom: Middle mouse, or mousewheel / touch: two-finger spread or squish.
+ * - Pan: Right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move.
+ *
+ * ```js
+ * const controls = new OrbitControls( camera, renderer.domElement );
+ *
+ * // controls.update() must be called after any manual changes to the camera's transform
+ * camera.position.set( 0, 20, 100 );
+ * controls.update();
+ *
+ * function animate() {
+ *
+ * 	// required if controls.enableDamping or controls.autoRotate are set to true
+ * 	controls.update();
+ *
+ * 	renderer.render( scene, camera );
+ *
+ * }
+ * ```
+ *
+ * @augments Controls
+ * @three_import import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+ */
+class OrbitControls extends Controls {
+
+	/**
+	 * Constructs a new controls instance.
+	 *
+	 * @param {Object3D} object - The object that is managed by the controls.
+	 * @param {?HTMLElement} domElement - The HTML element used for event listeners.
+	 */
+	constructor( object, domElement = null ) {
+
+		super( object, domElement );
+
+		this.state = _STATE.NONE;
+
+		/**
+		 * The focus point of the controls, the `object` orbits around this.
+		 * It can be updated manually at any point to change the focus of the controls.
+		 *
+		 * @type {Vector3}
+		 */
+		this.target = new Vector3();
+
+		/**
+		 * The focus point of the `minTargetRadius` and `maxTargetRadius` limits.
+		 * It can be updated manually at any point to change the center of interest
+		 * for the `target`.
+		 *
+		 * @type {Vector3}
+		 */
+		this.cursor = new Vector3();
+
+		/**
+		 * How far you can dolly in (perspective camera only).
+		 *
+		 * @type {number}
+		 * @default 0
+		 */
+		this.minDistance = 0;
+
+		/**
+		 * How far you can dolly out (perspective camera only).
+		 *
+		 * @type {number}
+		 * @default Infinity
+		 */
+		this.maxDistance = Infinity;
+
+		/**
+		 * How far you can zoom in (orthographic camera only).
+		 *
+		 * @type {number}
+		 * @default 0
+		 */
+		this.minZoom = 0;
+
+		/**
+		 * How far you can zoom out (orthographic camera only).
+		 *
+		 * @type {number}
+		 * @default Infinity
+		 */
+		this.maxZoom = Infinity;
+
+		/**
+		 * How close you can get the target to the 3D `cursor`.
+		 *
+		 * @type {number}
+		 * @default 0
+		 */
+		this.minTargetRadius = 0;
+
+		/**
+		 * How far you can move the target from the 3D `cursor`.
+		 *
+		 * @type {number}
+		 * @default Infinity
+		 */
+		this.maxTargetRadius = Infinity;
+
+		/**
+		 * How far you can orbit vertically, lower limit. Range is `[0, Math.PI]` radians.
+		 *
+		 * @type {number}
+		 * @default 0
+		 */
+		this.minPolarAngle = 0;
+
+		/**
+		 * How far you can orbit vertically, upper limit. Range is `[0, Math.PI]` radians.
+		 *
+		 * @type {number}
+		 * @default Math.PI
+		 */
+		this.maxPolarAngle = Math.PI;
+
+		/**
+		 * How far you can orbit horizontally, lower limit. If set, the interval `[ min, max ]`
+		 * must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
+		 *
+		 * @type {number}
+		 * @default -Infinity
+		 */
+		this.minAzimuthAngle = - Infinity;
+
+		/**
+		 * How far you can orbit horizontally, upper limit. If set, the interval `[ min, max ]`
+		 * must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
+		 *
+		 * @type {number}
+		 * @default -Infinity
+		 */
+		this.maxAzimuthAngle = Infinity;
+
+		/**
+		 * Set to `true` to enable damping (inertia), which can be used to give a sense of weight
+		 * to the controls. Note that if this is enabled, you must call `update()` in your animation
+		 * loop.
+		 *
+		 * @type {boolean}
+		 * @default false
+		 */
+		this.enableDamping = false;
+
+		/**
+		 * The damping inertia used if `enableDamping` is set to `true`.
+		 *
+		 * Note that for this to work, you must call `update()` in your animation loop.
+		 *
+		 * @type {number}
+		 * @default 0.05
+		 */
+		this.dampingFactor = 0.05;
+
+		/**
+		 * Enable or disable zooming (dollying) of the camera.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.enableZoom = true;
+
+		/**
+		 * Speed of zooming / dollying.
+		 *
+		 * @type {number}
+		 * @default 1
+		 */
+		this.zoomSpeed = 1.0;
+
+		/**
+		 * Enable or disable horizontal and vertical rotation of the camera.
+		 *
+		 * Note that it is possible to disable a single axis by setting the min and max of the
+		 * `minPolarAngle` or `minAzimuthAngle` to the same value, which will cause the vertical
+		 * or horizontal rotation to be fixed at that value.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.enableRotate = true;
+
+		/**
+		 * Speed of rotation.
+		 *
+		 * @type {number}
+		 * @default 1
+		 */
+		this.rotateSpeed = 1.0;
+
+		/**
+		 * How fast to rotate the camera when the keyboard is used.
+		 *
+		 * @type {number}
+		 * @default 1
+		 */
+		this.keyRotateSpeed = 1.0;
+
+		/**
+		 * Enable or disable camera panning.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.enablePan = true;
+
+		/**
+		 * Speed of panning.
+		 *
+		 * @type {number}
+		 * @default 1
+		 */
+		this.panSpeed = 1.0;
+
+		/**
+		 * Defines how the camera's position is translated when panning. If `true`, the camera pans
+		 * in screen space. Otherwise, the camera pans in the plane orthogonal to the camera's up
+		 * direction.
+		 *
+		 * @type {boolean}
+		 * @default true
+		 */
+		this.screenSpacePanning = true;
+
+		/**
+		 * How fast to pan the camera when the keyboard is used in
+		 * pixels per keypress.
+		 *
+		 * @type {number}
+		 * @default 7
+		 */
+		this.keyPanSpeed = 7.0;
+
+		/**
+		 * Setting this property to `true` allows to zoom to the cursor's position.
+		 *
+		 * @type {boolean}
+		 * @default false
+		 */
+		this.zoomToCursor = false;
+
+		/**
+		 * Set to true to automatically rotate around the target
+		 *
+		 * Note that if this is enabled, you must call `update()` in your animation loop.
+		 * If you want the auto-rotate speed to be independent of the frame rate (the refresh
+		 * rate of the display), you must pass the time `deltaTime`, in seconds, to `update()`.
+		 *
+		 * @type {boolean}
+		 * @default false
+		 */
+		this.autoRotate = false;
+
+		/**
+		 * How fast to rotate around the target if `autoRotate` is `true`. The default  equates to 30 seconds
+		 * per orbit at 60fps.
+		 *
+		 * Note that if `autoRotate` is enabled, you must call `update()` in your animation loop.
+		 *
+		 * @type {number}
+		 * @default 2
+		 */
+		this.autoRotateSpeed = 2.0;
+
+		/**
+		 * This object contains references to the keycodes for controlling camera panning.
+		 *
+		 * ```js
+		 * controls.keys = {
+		 * 	LEFT: 'ArrowLeft', //left arrow
+		 * 	UP: 'ArrowUp', // up arrow
+		 * 	RIGHT: 'ArrowRight', // right arrow
+		 * 	BOTTOM: 'ArrowDown' // down arrow
+		 * }
+		 * ```
+		 * @type {Object}
+		 */
+		this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };
+
+		/**
+		 * This object contains references to the mouse actions used by the controls.
+		 *
+		 * ```js
+		 * controls.mouseButtons = {
+		 * 	LEFT: THREE.MOUSE.ROTATE,
+		 * 	MIDDLE: THREE.MOUSE.DOLLY,
+		 * 	RIGHT: THREE.MOUSE.PAN
+		 * }
+		 * ```
+		 * @type {Object}
+		 */
+		this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
+
+		/**
+		 * This object contains references to the touch actions used by the controls.
+		 *
+		 * ```js
+		 * controls.mouseButtons = {
+		 * 	ONE: THREE.TOUCH.ROTATE,
+		 * 	TWO: THREE.TOUCH.DOLLY_PAN
+		 * }
+		 * ```
+		 * @type {Object}
+		 */
+		this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
+
+		/**
+		 * Used internally by `saveState()` and `reset()`.
+		 *
+		 * @type {Vector3}
+		 */
+		this.target0 = this.target.clone();
+
+		/**
+		 * Used internally by `saveState()` and `reset()`.
+		 *
+		 * @type {Vector3}
+		 */
+		this.position0 = this.object.position.clone();
+
+		/**
+		 * Used internally by `saveState()` and `reset()`.
+		 *
+		 * @type {number}
+		 */
+		this.zoom0 = this.object.zoom;
+
+		this._cursorStyle = 'auto';
+
+		// the target DOM element for key events
+		this._domElementKeyEvents = null;
+
+		// internals
+
+		this._lastPosition = new Vector3();
+		this._lastQuaternion = new Quaternion();
+		this._lastTargetPosition = new Vector3();
+
+		// so camera.up is the orbit axis
+		this._quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) );
+		this._quatInverse = this._quat.clone().invert();
+
+		// current position in spherical coordinates
+		this._spherical = new Spherical();
+		this._sphericalDelta = new Spherical();
+
+		this._scale = 1;
+		this._panOffset = new Vector3();
+
+		this._rotateStart = new Vector2();
+		this._rotateEnd = new Vector2();
+		this._rotateDelta = new Vector2();
+
+		this._panStart = new Vector2();
+		this._panEnd = new Vector2();
+		this._panDelta = new Vector2();
+
+		this._dollyStart = new Vector2();
+		this._dollyEnd = new Vector2();
+		this._dollyDelta = new Vector2();
+
+		this._dollyDirection = new Vector3();
+		this._mouse = new Vector2();
+		this._performCursorZoom = false;
+
+		this._pointers = [];
+		this._pointerPositions = {};
+
+		this._controlActive = false;
+
+		// event listeners
+
+		this._onPointerMove = onPointerMove.bind( this );
+		this._onPointerDown = onPointerDown.bind( this );
+		this._onPointerUp = onPointerUp.bind( this );
+		this._onContextMenu = onContextMenu.bind( this );
+		this._onMouseWheel = onMouseWheel.bind( this );
+		this._onKeyDown = onKeyDown.bind( this );
+
+		this._onTouchStart = onTouchStart.bind( this );
+		this._onTouchMove = onTouchMove.bind( this );
+
+		this._onMouseDown = onMouseDown.bind( this );
+		this._onMouseMove = onMouseMove.bind( this );
+
+		this._interceptControlDown = interceptControlDown.bind( this );
+		this._interceptControlUp = interceptControlUp.bind( this );
+
+		//
+
+		if ( this.domElement !== null ) {
+
+			this.connect( this.domElement );
+
+		}
+
+		this.update();
+
+	}
+
+	/**
+	 * Defines the visual representation of the cursor.
+	 *
+	 * @type {('auto'|'grab')}
+	 * @default 'auto'
+	 */
+	set cursorStyle( type ) {
+
+		this._cursorStyle = type;
+
+		if ( type === 'grab' ) {
+
+			this.domElement.style.cursor = 'grab';
+
+		} else {
+
+			this.domElement.style.cursor = 'auto';
+
+		}
+
+	}
+
+	get cursorStyle() {
+
+		return this._cursorStyle;
+
+	}
+
+	connect( element ) {
+
+		super.connect( element );
+
+		this.domElement.addEventListener( 'pointerdown', this._onPointerDown );
+		this.domElement.addEventListener( 'pointercancel', this._onPointerUp );
+
+		this.domElement.addEventListener( 'contextmenu', this._onContextMenu );
+		this.domElement.addEventListener( 'wheel', this._onMouseWheel, { passive: false } );
+
+		const document = this.domElement.getRootNode(); // offscreen canvas compatibility
+		document.addEventListener( 'keydown', this._interceptControlDown, { passive: true, capture: true } );
+
+		this.domElement.style.touchAction = 'none'; // Disable touch scroll
+
+	}
+
+	disconnect() {
+
+		this.domElement.removeEventListener( 'pointerdown', this._onPointerDown );
+		this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove );
+		this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp );
+		this.domElement.removeEventListener( 'pointercancel', this._onPointerUp );
+
+		this.domElement.removeEventListener( 'wheel', this._onMouseWheel );
+		this.domElement.removeEventListener( 'contextmenu', this._onContextMenu );
+
+		this.stopListenToKeyEvents();
+
+		const document = this.domElement.getRootNode(); // offscreen canvas compatibility
+		document.removeEventListener( 'keydown', this._interceptControlDown, { capture: true } );
+
+		this.domElement.style.touchAction = ''; // Restore touch scroll
+
+	}
+
+	dispose() {
+
+		this.disconnect();
+
+	}
+
+	/**
+	 * Get the current vertical rotation, in radians.
+	 *
+	 * @return {number} The current vertical rotation, in radians.
+	 */
+	getPolarAngle() {
+
+		return this._spherical.phi;
+
+	}
+
+	/**
+	 * Get the current horizontal rotation, in radians.
+	 *
+	 * @return {number} The current horizontal rotation, in radians.
+	 */
+	getAzimuthalAngle() {
+
+		return this._spherical.theta;
+
+	}
+
+	/**
+	 * Returns the distance from the camera to the target.
+	 *
+	 * @return {number} The distance from the camera to the target.
+	 */
+	getDistance() {
+
+		return this.object.position.distanceTo( this.target );
+
+	}
+
+	/**
+	 * Adds key event listeners to the given DOM element.
+	 * `window` is a recommended argument for using this method.
+	 *
+	 * @param {HTMLElement} domElement - The DOM element
+	 */
+	listenToKeyEvents( domElement ) {
+
+		domElement.addEventListener( 'keydown', this._onKeyDown );
+		this._domElementKeyEvents = domElement;
+
+	}
+
+	/**
+	 * Removes the key event listener previously defined with `listenToKeyEvents()`.
+	 */
+	stopListenToKeyEvents() {
+
+		if ( this._domElementKeyEvents !== null ) {
+
+			this._domElementKeyEvents.removeEventListener( 'keydown', this._onKeyDown );
+			this._domElementKeyEvents = null;
+
+		}
+
+	}
+
+	/**
+	 * Save the current state of the controls. This can later be recovered with `reset()`.
+	 */
+	saveState() {
+
+		this.target0.copy( this.target );
+		this.position0.copy( this.object.position );
+		this.zoom0 = this.object.zoom;
+
+	}
+
+	/**
+	 * Reset the controls to their state from either the last time the `saveState()`
+	 * was called, or the initial state.
+	 */
+	reset() {
+
+		this.target.copy( this.target0 );
+		this.object.position.copy( this.position0 );
+		this.object.zoom = this.zoom0;
+
+		this.object.updateProjectionMatrix();
+		this.dispatchEvent( _changeEvent );
+
+		this.update();
+
+		this.state = _STATE.NONE;
+
+	}
+
+	/**
+	 * Programmatically pan the camera.
+	 *
+	 * @param {number} deltaX - The horizontal pan amount in pixels.
+	 * @param {number} deltaY - The vertical pan amount in pixels.
+	 */
+	pan( deltaX, deltaY ) {
+
+		this._pan( deltaX, deltaY );
+		this.update();
+
+	}
+
+	/**
+	 * Programmatically dolly in (zoom in for perspective camera).
+	 *
+	 * @param {number} dollyScale - The dolly scale factor.
+	 */
+	dollyIn( dollyScale ) {
+
+		this._dollyIn( dollyScale );
+		this.update();
+
+	}
+
+	/**
+	 * Programmatically dolly out (zoom out for perspective camera).
+	 *
+	 * @param {number} dollyScale - The dolly scale factor.
+	 */
+	dollyOut( dollyScale ) {
+
+		this._dollyOut( dollyScale );
+		this.update();
+
+	}
+
+	/**
+	 * Programmatically rotate the camera left (around the vertical axis).
+	 *
+	 * @param {number} angle - The rotation angle in radians.
+	 */
+	rotateLeft( angle ) {
+
+		this._rotateLeft( angle );
+		this.update();
+
+	}
+
+	/**
+	 * Programmatically rotate the camera up (around the horizontal axis).
+	 *
+	 * @param {number} angle - The rotation angle in radians.
+	 */
+	rotateUp( angle ) {
+
+		this._rotateUp( angle );
+		this.update();
+
+	}
+
+	update( deltaTime = null ) {
+
+		const position = this.object.position;
+
+		_v.copy( position ).sub( this.target );
+
+		// rotate offset to "y-axis-is-up" space
+		_v.applyQuaternion( this._quat );
+
+		// angle from z-axis around y-axis
+		this._spherical.setFromVector3( _v );
+
+		if ( this.autoRotate && this.state === _STATE.NONE ) {
+
+			this._rotateLeft( this._getAutoRotationAngle( deltaTime ) );
+
+		}
+
+		if ( this.enableDamping ) {
+
+			this._spherical.theta += this._sphericalDelta.theta * this.dampingFactor;
+			this._spherical.phi += this._sphericalDelta.phi * this.dampingFactor;
+
+		} else {
+
+			this._spherical.theta += this._sphericalDelta.theta;
+			this._spherical.phi += this._sphericalDelta.phi;
+
+		}
+
+		// restrict theta to be between desired limits
+
+		let min = this.minAzimuthAngle;
+		let max = this.maxAzimuthAngle;
+
+		if ( isFinite( min ) && isFinite( max ) ) {
+
+			if ( min < - Math.PI ) min += _twoPI; else if ( min > Math.PI ) min -= _twoPI;
+
+			if ( max < - Math.PI ) max += _twoPI; else if ( max > Math.PI ) max -= _twoPI;
+
+			if ( min <= max ) {
+
+				this._spherical.theta = Math.max( min, Math.min( max, this._spherical.theta ) );
+
+			} else {
+
+				this._spherical.theta = ( this._spherical.theta > ( min + max ) / 2 ) ?
+					Math.max( min, this._spherical.theta ) :
+					Math.min( max, this._spherical.theta );
+
+			}
+
+		}
+
+		// restrict phi to be between desired limits
+		this._spherical.phi = Math.max( this.minPolarAngle, Math.min( this.maxPolarAngle, this._spherical.phi ) );
+
+		this._spherical.makeSafe();
+
+
+		// move target to panned location
+
+		if ( this.enableDamping === true ) {
+
+			this.target.addScaledVector( this._panOffset, this.dampingFactor );
+
+		} else {
+
+			this.target.add( this._panOffset );
+
+		}
+
+		// Limit the target distance from the cursor to create a sphere around the center of interest
+		this.target.sub( this.cursor );
+		this.target.clampLength( this.minTargetRadius, this.maxTargetRadius );
+		this.target.add( this.cursor );
+
+		let zoomChanged = false;
+		// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
+		// we adjust zoom later in these cases
+		if ( this.zoomToCursor && this._performCursorZoom || this.object.isOrthographicCamera ) {
+
+			this._spherical.radius = this._clampDistance( this._spherical.radius );
+
+		} else {
+
+			const prevRadius = this._spherical.radius;
+			this._spherical.radius = this._clampDistance( this._spherical.radius * this._scale );
+			zoomChanged = prevRadius != this._spherical.radius;
+
+		}
+
+		_v.setFromSpherical( this._spherical );
+
+		// rotate offset back to "camera-up-vector-is-up" space
+		_v.applyQuaternion( this._quatInverse );
+
+		position.copy( this.target ).add( _v );
+
+		this.object.lookAt( this.target );
+
+		if ( this.enableDamping === true ) {
+
+			this._sphericalDelta.theta *= ( 1 - this.dampingFactor );
+			this._sphericalDelta.phi *= ( 1 - this.dampingFactor );
+
+			this._panOffset.multiplyScalar( 1 - this.dampingFactor );
+
+		} else {
+
+			this._sphericalDelta.set( 0, 0, 0 );
+
+			this._panOffset.set( 0, 0, 0 );
+
+		}
+
+		// adjust camera position
+		if ( this.zoomToCursor && this._performCursorZoom ) {
+
+			let newRadius = null;
+			if ( this.object.isPerspectiveCamera ) {
+
+				// move the camera down the pointer ray
+				// this method avoids floating point error
+				const prevRadius = _v.length();
+				newRadius = this._clampDistance( prevRadius * this._scale );
+
+				const radiusDelta = prevRadius - newRadius;
+				this.object.position.addScaledVector( this._dollyDirection, radiusDelta );
+				this.object.updateMatrixWorld();
+
+				zoomChanged = !! radiusDelta;
+
+			} else if ( this.object.isOrthographicCamera ) {
+
+				// adjust the ortho camera position based on zoom changes
+				const mouseBefore = new Vector3( this._mouse.x, this._mouse.y, 0 );
+				mouseBefore.unproject( this.object );
+
+				const prevZoom = this.object.zoom;
+				this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
+				this.object.updateProjectionMatrix();
+
+				zoomChanged = prevZoom !== this.object.zoom;
+
+				const mouseAfter = new Vector3( this._mouse.x, this._mouse.y, 0 );
+				mouseAfter.unproject( this.object );
+
+				this.object.position.sub( mouseAfter ).add( mouseBefore );
+				this.object.updateMatrixWorld();
+
+				newRadius = _v.length();
+
+			} else {
+
+				console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
+				this.zoomToCursor = false;
+
+			}
+
+			// handle the placement of the target
+			if ( newRadius !== null ) {
+
+				if ( this.screenSpacePanning ) {
+
+					// position the orbit target in front of the new camera position
+					this.target.set( 0, 0, - 1 )
+						.transformDirection( this.object.matrix )
+						.multiplyScalar( newRadius )
+						.add( this.object.position );
+
+				} else {
+
+					// get the ray and translation plane to compute target
+					_ray.origin.copy( this.object.position );
+					_ray.direction.set( 0, 0, - 1 ).transformDirection( this.object.matrix );
+
+					// if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
+					// extremely large values
+					if ( Math.abs( this.object.up.dot( _ray.direction ) ) < _TILT_LIMIT ) {
+
+						this.object.lookAt( this.target );
+
+					} else {
+
+						_plane.setFromNormalAndCoplanarPoint( this.object.up, this.target );
+						_ray.intersectPlane( _plane, this.target );
+
+					}
+
+				}
+
+			}
+
+		} else if ( this.object.isOrthographicCamera ) {
+
+			const prevZoom = this.object.zoom;
+			this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
+
+			if ( prevZoom !== this.object.zoom ) {
+
+				this.object.updateProjectionMatrix();
+				zoomChanged = true;
+
+			}
+
+		}
+
+		this._scale = 1;
+		this._performCursorZoom = false;
+
+		// update condition is:
+		// min(camera displacement, camera rotation in radians)^2 > EPS
+		// using small-angle approximation cos(x/2) = 1 - x^2 / 8
+
+		if ( zoomChanged ||
+			this._lastPosition.distanceToSquared( this.object.position ) > _EPS ||
+			8 * ( 1 - this._lastQuaternion.dot( this.object.quaternion ) ) > _EPS ||
+			this._lastTargetPosition.distanceToSquared( this.target ) > _EPS ) {
+
+			this.dispatchEvent( _changeEvent );
+
+			this._lastPosition.copy( this.object.position );
+			this._lastQuaternion.copy( this.object.quaternion );
+			this._lastTargetPosition.copy( this.target );
+
+			return true;
+
+		}
+
+		return false;
+
+	}
+
+	_getAutoRotationAngle( deltaTime ) {
+
+		if ( deltaTime !== null ) {
+
+			return ( _twoPI / 60 * this.autoRotateSpeed ) * deltaTime;
+
+		} else {
+
+			return _twoPI / 60 / 60 * this.autoRotateSpeed;
+
+		}
+
+	}
+
+	_getZoomScale( delta ) {
+
+		const normalizedDelta = Math.abs( delta * 0.01 );
+		return Math.pow( 0.95, this.zoomSpeed * normalizedDelta );
+
+	}
+
+	_rotateLeft( angle ) {
+
+		this._sphericalDelta.theta -= angle;
+
+	}
+
+	_rotateUp( angle ) {
+
+		this._sphericalDelta.phi -= angle;
+
+	}
+
+	_panLeft( distance, objectMatrix ) {
+
+		_v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
+		_v.multiplyScalar( - distance );
+
+		this._panOffset.add( _v );
+
+	}
+
+	_panUp( distance, objectMatrix ) {
+
+		if ( this.screenSpacePanning === true ) {
+
+			_v.setFromMatrixColumn( objectMatrix, 1 );
+
+		} else {
+
+			_v.setFromMatrixColumn( objectMatrix, 0 );
+			_v.crossVectors( this.object.up, _v );
+
+		}
+
+		_v.multiplyScalar( distance );
+
+		this._panOffset.add( _v );
+
+	}
+
+	// deltaX and deltaY are in pixels; right and down are positive
+	_pan( deltaX, deltaY ) {
+
+		const element = this.domElement;
+
+		if ( this.object.isPerspectiveCamera ) {
+
+			// perspective
+			const position = this.object.position;
+			_v.copy( position ).sub( this.target );
+			let targetDistance = _v.length();
+
+			// half of the fov is center to top of screen
+			targetDistance *= Math.tan( ( this.object.fov / 2 ) * Math.PI / 180.0 );
+
+			// we use only clientHeight here so aspect ratio does not distort speed
+			this._panLeft( 2 * deltaX * targetDistance / element.clientHeight, this.object.matrix );
+			this._panUp( 2 * deltaY * targetDistance / element.clientHeight, this.object.matrix );
+
+		} else if ( this.object.isOrthographicCamera ) {
+
+			// orthographic
+			this._panLeft( deltaX * ( this.object.right - this.object.left ) / this.object.zoom / element.clientWidth, this.object.matrix );
+			this._panUp( deltaY * ( this.object.top - this.object.bottom ) / this.object.zoom / element.clientHeight, this.object.matrix );
+
+		} else {
+
+			// camera neither orthographic nor perspective
+			console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
+			this.enablePan = false;
+
+		}
+
+	}
+
+	_dollyOut( dollyScale ) {
+
+		if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
+
+			this._scale /= dollyScale;
+
+		} else {
+
+			console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
+			this.enableZoom = false;
+
+		}
+
+	}
+
+	_dollyIn( dollyScale ) {
+
+		if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
+
+			this._scale *= dollyScale;
+
+		} else {
+
+			console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
+			this.enableZoom = false;
+
+		}
+
+	}
+
+	_updateZoomParameters( x, y ) {
+
+		if ( ! this.zoomToCursor ) {
+
+			return;
+
+		}
+
+		this._performCursorZoom = true;
+
+		const rect = this.domElement.getBoundingClientRect();
+		const dx = x - rect.left;
+		const dy = y - rect.top;
+		const w = rect.width;
+		const h = rect.height;
+
+		this._mouse.x = ( dx / w ) * 2 - 1;
+		this._mouse.y = - ( dy / h ) * 2 + 1;
+
+		this._dollyDirection.set( this._mouse.x, this._mouse.y, 1 ).unproject( this.object ).sub( this.object.position ).normalize();
+
+	}
+
+	_clampDistance( dist ) {
+
+		return Math.max( this.minDistance, Math.min( this.maxDistance, dist ) );
+
+	}
+
+	//
+	// event callbacks - update the object state
+	//
+
+	_handleMouseDownRotate( event ) {
+
+		this._rotateStart.set( event.clientX, event.clientY );
+
+	}
+
+	_handleMouseDownDolly( event ) {
+
+		this._updateZoomParameters( event.clientX, event.clientX );
+		this._dollyStart.set( event.clientX, event.clientY );
+
+	}
+
+	_handleMouseDownPan( event ) {
+
+		this._panStart.set( event.clientX, event.clientY );
+
+	}
+
+	_handleMouseMoveRotate( event ) {
+
+		this._rotateEnd.set( event.clientX, event.clientY );
+
+		this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
+
+		const element = this.domElement;
+
+		this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
+
+		this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
+
+		this._rotateStart.copy( this._rotateEnd );
+
+		this.update();
+
+	}
+
+	_handleMouseMoveDolly( event ) {
+
+		this._dollyEnd.set( event.clientX, event.clientY );
+
+		this._dollyDelta.subVectors( this._dollyEnd, this._dollyStart );
+
+		if ( this._dollyDelta.y > 0 ) {
+
+			this._dollyOut( this._getZoomScale( this._dollyDelta.y ) );
+
+		} else if ( this._dollyDelta.y < 0 ) {
+
+			this._dollyIn( this._getZoomScale( this._dollyDelta.y ) );
+
+		}
+
+		this._dollyStart.copy( this._dollyEnd );
+
+		this.update();
+
+	}
+
+	_handleMouseMovePan( event ) {
+
+		this._panEnd.set( event.clientX, event.clientY );
+
+		this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
+
+		this._pan( this._panDelta.x, this._panDelta.y );
+
+		this._panStart.copy( this._panEnd );
+
+		this.update();
+
+	}
+
+	_handleMouseWheel( event ) {
+
+		this._updateZoomParameters( event.clientX, event.clientY );
+
+		if ( event.deltaY < 0 ) {
+
+			this._dollyIn( this._getZoomScale( event.deltaY ) );
+
+		} else if ( event.deltaY > 0 ) {
+
+			this._dollyOut( this._getZoomScale( event.deltaY ) );
+
+		}
+
+		this.update();
+
+	}
+
+	_handleKeyDown( event ) {
+
+		let needsUpdate = false;
+
+		switch ( event.code ) {
+
+			case this.keys.UP:
+
+				if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+					if ( this.enableRotate ) {
+
+						this._rotateUp( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
+
+					}
+
+				} else {
+
+					if ( this.enablePan ) {
+
+						this._pan( 0, this.keyPanSpeed );
+
+					}
+
+				}
+
+				needsUpdate = true;
+				break;
+
+			case this.keys.BOTTOM:
+
+				if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+					if ( this.enableRotate ) {
+
+						this._rotateUp( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
+
+					}
+
+				} else {
+
+					if ( this.enablePan ) {
+
+						this._pan( 0, - this.keyPanSpeed );
+
+					}
+
+				}
+
+				needsUpdate = true;
+				break;
+
+			case this.keys.LEFT:
+
+				if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+					if ( this.enableRotate ) {
+
+						this._rotateLeft( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
+
+					}
+
+				} else {
+
+					if ( this.enablePan ) {
+
+						this._pan( this.keyPanSpeed, 0 );
+
+					}
+
+				}
+
+				needsUpdate = true;
+				break;
+
+			case this.keys.RIGHT:
+
+				if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+					if ( this.enableRotate ) {
+
+						this._rotateLeft( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
+
+					}
+
+				} else {
+
+					if ( this.enablePan ) {
+
+						this._pan( - this.keyPanSpeed, 0 );
+
+					}
+
+				}
+
+				needsUpdate = true;
+				break;
+
+		}
+
+		if ( needsUpdate ) {
+
+			// prevent the browser from scrolling on cursor keys
+			event.preventDefault();
+
+			this.update();
+
+		}
+
+
+	}
+
+	_handleTouchStartRotate( event ) {
+
+		if ( this._pointers.length === 1 ) {
+
+			this._rotateStart.set( event.pageX, event.pageY );
+
+		} else {
+
+			const position = this._getSecondPointerPosition( event );
+
+			const x = 0.5 * ( event.pageX + position.x );
+			const y = 0.5 * ( event.pageY + position.y );
+
+			this._rotateStart.set( x, y );
+
+		}
+
+	}
+
+	_handleTouchStartPan( event ) {
+
+		if ( this._pointers.length === 1 ) {
+
+			this._panStart.set( event.pageX, event.pageY );
+
+		} else {
+
+			const position = this._getSecondPointerPosition( event );
+
+			const x = 0.5 * ( event.pageX + position.x );
+			const y = 0.5 * ( event.pageY + position.y );
+
+			this._panStart.set( x, y );
+
+		}
+
+	}
+
+	_handleTouchStartDolly( event ) {
+
+		const position = this._getSecondPointerPosition( event );
+
+		const dx = event.pageX - position.x;
+		const dy = event.pageY - position.y;
+
+		const distance = Math.sqrt( dx * dx + dy * dy );
+
+		this._dollyStart.set( 0, distance );
+
+	}
+
+	_handleTouchStartDollyPan( event ) {
+
+		if ( this.enableZoom ) this._handleTouchStartDolly( event );
+
+		if ( this.enablePan ) this._handleTouchStartPan( event );
+
+	}
+
+	_handleTouchStartDollyRotate( event ) {
+
+		if ( this.enableZoom ) this._handleTouchStartDolly( event );
+
+		if ( this.enableRotate ) this._handleTouchStartRotate( event );
+
+	}
+
+	_handleTouchMoveRotate( event ) {
+
+		if ( this._pointers.length == 1 ) {
+
+			this._rotateEnd.set( event.pageX, event.pageY );
+
+		} else {
+
+			const position = this._getSecondPointerPosition( event );
+
+			const x = 0.5 * ( event.pageX + position.x );
+			const y = 0.5 * ( event.pageY + position.y );
+
+			this._rotateEnd.set( x, y );
+
+		}
+
+		this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
+
+		const element = this.domElement;
+
+		this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
+
+		this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
+
+		this._rotateStart.copy( this._rotateEnd );
+
+	}
+
+	_handleTouchMovePan( event ) {
+
+		if ( this._pointers.length === 1 ) {
+
+			this._panEnd.set( event.pageX, event.pageY );
+
+		} else {
+
+			const position = this._getSecondPointerPosition( event );
+
+			const x = 0.5 * ( event.pageX + position.x );
+			const y = 0.5 * ( event.pageY + position.y );
+
+			this._panEnd.set( x, y );
+
+		}
+
+		this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
+
+		this._pan( this._panDelta.x, this._panDelta.y );
+
+		this._panStart.copy( this._panEnd );
+
+	}
+
+	_handleTouchMoveDolly( event ) {
+
+		const position = this._getSecondPointerPosition( event );
+
+		const dx = event.pageX - position.x;
+		const dy = event.pageY - position.y;
+
+		const distance = Math.sqrt( dx * dx + dy * dy );
+
+		this._dollyEnd.set( 0, distance );
+
+		this._dollyDelta.set( 0, Math.pow( this._dollyEnd.y / this._dollyStart.y, this.zoomSpeed ) );
+
+		this._dollyOut( this._dollyDelta.y );
+
+		this._dollyStart.copy( this._dollyEnd );
+
+		const centerX = ( event.pageX + position.x ) * 0.5;
+		const centerY = ( event.pageY + position.y ) * 0.5;
+
+		this._updateZoomParameters( centerX, centerY );
+
+	}
+
+	_handleTouchMoveDollyPan( event ) {
+
+		if ( this.enableZoom ) this._handleTouchMoveDolly( event );
+
+		if ( this.enablePan ) this._handleTouchMovePan( event );
+
+	}
+
+	_handleTouchMoveDollyRotate( event ) {
+
+		if ( this.enableZoom ) this._handleTouchMoveDolly( event );
+
+		if ( this.enableRotate ) this._handleTouchMoveRotate( event );
+
+	}
+
+	// pointers
+
+	_addPointer( event ) {
+
+		this._pointers.push( event.pointerId );
+
+	}
+
+	_removePointer( event ) {
+
+		delete this._pointerPositions[ event.pointerId ];
+
+		for ( let i = 0; i < this._pointers.length; i ++ ) {
+
+			if ( this._pointers[ i ] == event.pointerId ) {
+
+				this._pointers.splice( i, 1 );
+				return;
+
+			}
+
+		}
+
+	}
+
+	_isTrackingPointer( event ) {
+
+		for ( let i = 0; i < this._pointers.length; i ++ ) {
+
+			if ( this._pointers[ i ] == event.pointerId ) return true;
+
+		}
+
+		return false;
+
+	}
+
+	_trackPointer( event ) {
+
+		let position = this._pointerPositions[ event.pointerId ];
+
+		if ( position === undefined ) {
+
+			position = new Vector2();
+			this._pointerPositions[ event.pointerId ] = position;
+
+		}
+
+		position.set( event.pageX, event.pageY );
+
+	}
+
+	_getSecondPointerPosition( event ) {
+
+		const pointerId = ( event.pointerId === this._pointers[ 0 ] ) ? this._pointers[ 1 ] : this._pointers[ 0 ];
+
+		return this._pointerPositions[ pointerId ];
+
+	}
+
+	//
+
+	_customWheelEvent( event ) {
+
+		const mode = event.deltaMode;
+
+		// minimal wheel event altered to meet delta-zoom demand
+		const newEvent = {
+			clientX: event.clientX,
+			clientY: event.clientY,
+			deltaY: event.deltaY,
+		};
+
+		switch ( mode ) {
+
+			case 1: // LINE_MODE
+				newEvent.deltaY *= 16;
+				break;
+
+			case 2: // PAGE_MODE
+				newEvent.deltaY *= 100;
+				break;
+
+		}
+
+		// detect if event was triggered by pinching
+		if ( event.ctrlKey && ! this._controlActive ) {
+
+			newEvent.deltaY *= 10;
+
+		}
+
+		return newEvent;
+
+	}
+
+}
+
+function onPointerDown( event ) {
+
+	if ( this.enabled === false ) return;
+
+	if ( this._pointers.length === 0 ) {
+
+		this.domElement.setPointerCapture( event.pointerId );
+
+		this.domElement.ownerDocument.addEventListener( 'pointermove', this._onPointerMove );
+		this.domElement.ownerDocument.addEventListener( 'pointerup', this._onPointerUp );
+
+	}
+
+	//
+
+	if ( this._isTrackingPointer( event ) ) return;
+
+	//
+
+	this._addPointer( event );
+
+	if ( event.pointerType === 'touch' ) {
+
+		this._onTouchStart( event );
+
+	} else {
+
+		this._onMouseDown( event );
+
+	}
+
+	if ( this._cursorStyle === 'grab' ) {
+
+		this.domElement.style.cursor = 'grabbing';
+
+	}
+
+}
+
+function onPointerMove( event ) {
+
+	if ( this.enabled === false ) return;
+
+	if ( event.pointerType === 'touch' ) {
+
+		this._onTouchMove( event );
+
+	} else {
+
+		this._onMouseMove( event );
+
+	}
+
+}
+
+function onPointerUp( event ) {
+
+	this._removePointer( event );
+
+	switch ( this._pointers.length ) {
+
+		case 0:
+
+			this.domElement.releasePointerCapture( event.pointerId );
+
+			this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove );
+			this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp );
+
+			this.dispatchEvent( _endEvent );
+
+			this.state = _STATE.NONE;
+
+			if ( this._cursorStyle === 'grab' ) {
+
+				this.domElement.style.cursor = 'grab';
+
+			}
+
+			break;
+
+		case 1:
+
+			const pointerId = this._pointers[ 0 ];
+			const position = this._pointerPositions[ pointerId ];
+
+			// minimal placeholder event - allows state correction on pointer-up
+			this._onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } );
+
+			break;
+
+	}
+
+}
+
+function onMouseDown( event ) {
+
+	let mouseAction;
+
+	switch ( event.button ) {
+
+		case 0:
+
+			mouseAction = this.mouseButtons.LEFT;
+			break;
+
+		case 1:
+
+			mouseAction = this.mouseButtons.MIDDLE;
+			break;
+
+		case 2:
+
+			mouseAction = this.mouseButtons.RIGHT;
+			break;
+
+		default:
+
+			mouseAction = - 1;
+
+	}
+
+	switch ( mouseAction ) {
+
+		case MOUSE.DOLLY:
+
+			if ( this.enableZoom === false ) return;
+
+			this._handleMouseDownDolly( event );
+
+			this.state = _STATE.DOLLY;
+
+			break;
+
+		case MOUSE.ROTATE:
+
+			if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+				if ( this.enablePan === false ) return;
+
+				this._handleMouseDownPan( event );
+
+				this.state = _STATE.PAN;
+
+			} else {
+
+				if ( this.enableRotate === false ) return;
+
+				this._handleMouseDownRotate( event );
+
+				this.state = _STATE.ROTATE;
+
+			}
+
+			break;
+
+		case MOUSE.PAN:
+
+			if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
+
+				if ( this.enableRotate === false ) return;
+
+				this._handleMouseDownRotate( event );
+
+				this.state = _STATE.ROTATE;
+
+			} else {
+
+				if ( this.enablePan === false ) return;
+
+				this._handleMouseDownPan( event );
+
+				this.state = _STATE.PAN;
+
+			}
+
+			break;
+
+		default:
+
+			this.state = _STATE.NONE;
+
+	}
+
+	if ( this.state !== _STATE.NONE ) {
+
+		this.dispatchEvent( _startEvent );
+
+	}
+
+}
+
+function onMouseMove( event ) {
+
+	switch ( this.state ) {
+
+		case _STATE.ROTATE:
+
+			if ( this.enableRotate === false ) return;
+
+			this._handleMouseMoveRotate( event );
+
+			break;
+
+		case _STATE.DOLLY:
+
+			if ( this.enableZoom === false ) return;
+
+			this._handleMouseMoveDolly( event );
+
+			break;
+
+		case _STATE.PAN:
+
+			if ( this.enablePan === false ) return;
+
+			this._handleMouseMovePan( event );
+
+			break;
+
+	}
+
+}
+
+function onMouseWheel( event ) {
+
+	if ( this.enabled === false || this.enableZoom === false || this.state !== _STATE.NONE ) return;
+
+	event.preventDefault();
+
+	this.dispatchEvent( _startEvent );
+
+	this._handleMouseWheel( this._customWheelEvent( event ) );
+
+	this.dispatchEvent( _endEvent );
+
+}
+
+function onKeyDown( event ) {
+
+	if ( this.enabled === false ) return;
+
+	this._handleKeyDown( event );
+
+}
+
+function onTouchStart( event ) {
+
+	this._trackPointer( event );
+
+	switch ( this._pointers.length ) {
+
+		case 1:
+
+			switch ( this.touches.ONE ) {
+
+				case TOUCH.ROTATE:
+
+					if ( this.enableRotate === false ) return;
+
+					this._handleTouchStartRotate( event );
+
+					this.state = _STATE.TOUCH_ROTATE;
+
+					break;
+
+				case TOUCH.PAN:
+
+					if ( this.enablePan === false ) return;
+
+					this._handleTouchStartPan( event );
+
+					this.state = _STATE.TOUCH_PAN;
+
+					break;
+
+				default:
+
+					this.state = _STATE.NONE;
+
+			}
+
+			break;
+
+		case 2:
+
+			switch ( this.touches.TWO ) {
+
+				case TOUCH.DOLLY_PAN:
+
+					if ( this.enableZoom === false && this.enablePan === false ) return;
+
+					this._handleTouchStartDollyPan( event );
+
+					this.state = _STATE.TOUCH_DOLLY_PAN;
+
+					break;
+
+				case TOUCH.DOLLY_ROTATE:
+
+					if ( this.enableZoom === false && this.enableRotate === false ) return;
+
+					this._handleTouchStartDollyRotate( event );
+
+					this.state = _STATE.TOUCH_DOLLY_ROTATE;
+
+					break;
+
+				default:
+
+					this.state = _STATE.NONE;
+
+			}
+
+			break;
+
+		default:
+
+			this.state = _STATE.NONE;
+
+	}
+
+	if ( this.state !== _STATE.NONE ) {
+
+		this.dispatchEvent( _startEvent );
+
+	}
+
+}
+
+function onTouchMove( event ) {
+
+	this._trackPointer( event );
+
+	switch ( this.state ) {
+
+		case _STATE.TOUCH_ROTATE:
+
+			if ( this.enableRotate === false ) return;
+
+			this._handleTouchMoveRotate( event );
+
+			this.update();
+
+			break;
+
+		case _STATE.TOUCH_PAN:
+
+			if ( this.enablePan === false ) return;
+
+			this._handleTouchMovePan( event );
+
+			this.update();
+
+			break;
+
+		case _STATE.TOUCH_DOLLY_PAN:
+
+			if ( this.enableZoom === false && this.enablePan === false ) return;
+
+			this._handleTouchMoveDollyPan( event );
+
+			this.update();
+
+			break;
+
+		case _STATE.TOUCH_DOLLY_ROTATE:
+
+			if ( this.enableZoom === false && this.enableRotate === false ) return;
+
+			this._handleTouchMoveDollyRotate( event );
+
+			this.update();
+
+			break;
+
+		default:
+
+			this.state = _STATE.NONE;
+
+	}
+
+}
+
+function onContextMenu( event ) {
+
+	if ( this.enabled === false ) return;
+
+	event.preventDefault();
+
+}
+
+function interceptControlDown( event ) {
+
+	if ( event.key === 'Control' ) {
+
+		this._controlActive = true;
+
+		const document = this.domElement.getRootNode(); // offscreen canvas compatibility
+
+		document.addEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
+
+	}
+
+}
+
+function interceptControlUp( event ) {
+
+	if ( event.key === 'Control' ) {
+
+		this._controlActive = false;
+
+		const document = this.domElement.getRootNode(); // offscreen canvas compatibility
+
+		document.removeEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
+
+	}
+
+}
+
+export { OrbitControls };

File diff ditekan karena terlalu besar
+ 5 - 0
src/web/Gerber Viewer/lib/three/three.core.min.js


File diff ditekan karena terlalu besar
+ 5 - 0
src/web/Gerber Viewer/lib/three/three.module.min.js


+ 3978 - 0
src/web/Gerber Viewer/locale/gerberviewer.json

@@ -0,0 +1,3978 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fwtitle": "Gerber 檢視器",
+            "strings": {
+                "about/credits": "ArozOS 上的壓縮檔由本應用的 AGI 後端解壓。3D 檢視使用 three.js(MIT 授權)。",
+                "about/storedAroz": "設定與最近的專案依使用者儲存在 ArozOS 中。",
+                "about/storedLocal": "設定與最近的專案儲存在此瀏覽器中。",
+                "about/text": "以圖層、擬真電路板預覽或 3D 檢視 PCB 製造資料。可測量特徵、檢查光圈與鑽孔,並匯出圖片。",
+                "about/title": "關於",
+                "about/version": "版本 {v}(ArozOS 版)",
+                "ap/block": "區塊光圈",
+                "ap/circle": "圓形 Ø{d}",
+                "ap/circleHole": "圓形 Ø{d},孔 {h}",
+                "ap/dcode": "D 碼",
+                "ap/function": "功能",
+                "ap/macro": "巨集 {name}",
+                "ap/none": "此圖層未定義任何光圈。",
+                "ap/note": "標準光圈尺寸以公釐顯示;巨集參數使用檔案本身的單位({unit})。",
+                "ap/obround": "長圓形 {w} × {h}",
+                "ap/polygon": "多邊形 Ø{d},{n} 邊",
+                "ap/rect": "矩形 {w} × {h}",
+                "ap/shape": "形狀",
+                "ap/title": "光圈表",
+                "ap/uses": "使用次數",
+                "app/title": "Gerber 檢視器",
+                "badge/none": "未開啟專案",
+                "badge/project": "Gerber 專案",
+                "bg/black": "黑色",
+                "bg/graphite": "石墨灰",
+                "bg/midnight": "午夜藍",
+                "bg/paper": "紙白",
+                "bg/slate": "板岩灰",
+                "card/fileInfo": "檔案資訊",
+                "card/tip": "提示",
+                "card/visibility": "圖層顯示",
+                "chip/bottom": "底面(鏡像)",
+                "chip/layers": "圖層",
+                "chip/realistic": "擬真",
+                "chip/top": "頂面",
+                "colour/custom": "自訂",
+                "colour/opacity": "不透明度",
+                "common/cancel": "取消",
+                "common/clear": "清除",
+                "common/close": "關閉",
+                "common/no": "否",
+                "common/ok": "確定",
+                "common/reset": "重設",
+                "common/save": "儲存",
+                "common/unknown": "未知",
+                "common/yes": "是",
+                "confirm/clearRecent": "清除最近的專案",
+                "confirm/clearRecentText": "要從最近清單中移除所有專案嗎?",
+                "ctx/center": "以此處為中心",
+                "ctx/copyCoords": "複製座標",
+                "ctx/measureFrom": "從此處開始測量",
+                "ctx/objectOn": "{layer} 上的{object}",
+                "ctx/select": "選取",
+                "ctx/setOrigin": "將原點設於此處",
+                "ctx/showOnly": "只顯示{layer}",
+                "ctx/viewTop": "檢視頂面",
+                "ctx/zoomObject": "縮放至物件",
+                "drill/csv": "匯出 CSV...",
+                "drill/diameter": "直徑",
+                "drill/diameterIn": "直徑(英吋)",
+                "drill/duplicates": "檔案中共有 {n} 次鑽孔;跨檔案重複的孔只計算一次。",
+                "drill/holes": "孔數",
+                "drill/none": "此專案沒有鑽孔檔。",
+                "drill/plated": "電鍍",
+                "drill/slots": "槽孔",
+                "drill/smallest": "最小孔徑 {d}。",
+                "drill/summary": "共 {n} 個孔({p} 個電鍍、{np} 個非電鍍)及 {s} 個槽孔。",
+                "drill/title": "鑽孔表",
+                "drill/tool": "刀具",
+                "drill/total": "合計",
+                "drop/hint": "放開以開啟",
+                "empty/drop": "或將 .zip、資料夾或 Gerber 檔拖放到此視窗任何位置",
+                "empty/intro": "開啟 PCB 製造檔案包(zip)或個別的 Gerber 與鑽孔檔。",
+                "empty/openAroz": "從 ArozOS 開啟",
+                "empty/openFolder": "開啟資料夾",
+                "empty/openLocal": "從此裝置開啟",
+                "empty/recent": "最近的專案",
+                "error/baddata": "檔案資料無效或過大",
+                "error/badentry": "無效的項目",
+                "error/badop": "未知的操作",
+                "error/badpath": "無效的目的地路徑",
+                "error/badpaths": "無效的路徑清單",
+                "error/badsettings": "無效的設定",
+                "error/badtype": "Gerber 檢視器無法儲存此檔案類型",
+                "error/denied": "您沒有寫入此位置的權限",
+                "error/nofolder": "目的地資料夾不存在",
+                "error/notcam": "不是 Gerber 或鑽孔檔",
+                "error/notfound": "找不到檔案",
+                "error/nothing": "沒有可開啟的項目",
+                "error/offline": "無法連線至 ArozOS",
+                "error/projecttoolarge": "專案大於 96 MB",
+                "error/read": "無法讀取 {name}",
+                "error/readfail": "無法讀取檔案",
+                "error/reply": "伺服器回應異常",
+                "error/server": "伺服器錯誤 {status}",
+                "error/toolarge": "檔案過大",
+                "error/toomany": "檔案過多",
+                "error/writefail": "無法寫入檔案",
+                "error/zip64": "不支援 ZIP64 壓縮檔。",
+                "error/zipCorrupt": "壓縮檔項目損毀:{name}",
+                "error/zipEncrypted": "不支援加密的壓縮檔項目:{name}",
+                "error/zipInvalid": "不是 zip 壓縮檔。",
+                "error/zipMethod": "不支援的 zip 壓縮方式 {method}({name})",
+                "error/zipNoInflate": "此瀏覽器無法解壓縮 zip 檔。",
+                "export/3d": "3D 快照",
+                "export/area": "範圍",
+                "export/background": "背景",
+                "export/bgCanvas": "畫布顏色",
+                "export/bgNone": "透明",
+                "export/bgWhite": "白色",
+                "export/board": "整塊電路板",
+                "export/bottom": "擬真底面",
+                "export/content": "內容",
+                "export/download": "下載",
+                "export/failed": "匯出失敗:{error}",
+                "export/format": "格式",
+                "export/layers": "圖層檢視",
+                "export/noteSize": "圖片尺寸約 {w} × {h} 像素。",
+                "export/noteSizeCapped": "圖片尺寸約 {w} × {h} 像素(長邊上限 12000 像素)。",
+                "export/noteSvg": "將可見圖層匯出為向量路徑,尺寸以公釐為單位。",
+                "export/noteView": "以兩倍螢幕解析度匯出目前可見的畫面。",
+                "export/rendering": "正在繪製 {name}...",
+                "export/resolution": "解析度",
+                "export/svg": "SVG(向量)",
+                "export/title": "匯出圖片",
+                "export/toAroz": "儲存至 ArozOS...",
+                "export/top": "擬真頂面",
+                "export/view": "目前畫面",
+                "finish/enig": "ENIG(化金)",
+                "finish/hasl": "HASL(噴錫)",
+                "finish/osp": "OSP/裸銅",
+                "fmt/absolute": "絕對座標",
+                "fmt/decimal": "小數/預設",
+                "fmt/incremental": "增量座標",
+                "fmt/leading": "省略前導零",
+                "fmt/lz": "保留前導零",
+                "fmt/trailing": "省略尾隨零",
+                "fmt/tz": "保留尾隨零",
+                "formats/detect": "圖層類型會依 X2 屬性,以及 EasyEDA、KiCad、Altium、Eagle/Fusion、DipTrace 與 Proteus 的檔案命名方式判斷。您可以在圖層選單中更改任何類型。",
+                "formats/drill": "Excellon(.drl .xln .exc .txt .drd),支援公制或英制、省略前導或尾隨零、槽孔(G85)及銑削外形。",
+                "formats/drillTitle": "鑽孔",
+                "formats/gerber": "RS-274X 與 Gerber X2(屬性、網路與元件),包含光圈巨集、區域、圓弧、清除極性、步進重複與區塊光圈。常見副檔名:.gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho 以及 Eagle/KiCad 命名。",
+                "formats/packages": "從本機裝置開啟 zip 壓縮檔。從 ArozOS 開啟:zip、7z、tar 與 tar.gz 壓縮檔(在伺服器上解壓)、資料夾或任意檔案組合。",
+                "formats/packagesTitle": "檔案包",
+                "formats/title": "支援的格式",
+                "goto/go": "前往",
+                "goto/missing": "請輸入兩個座標",
+                "goto/note": "座標相對於目前的原點。",
+                "goto/title": "前往座標",
+                "info/created": "建立時間",
+                "info/device": "此裝置",
+                "info/file1": "1 個檔案",
+                "info/files": "{n} 個檔案",
+                "info/filesIgnored": "{files}(忽略 {n} 個)",
+                "info/filesLabel": "檔案",
+                "info/format": "格式",
+                "info/generator": "產生軟體",
+                "info/itemsOnAroz": "ArozOS 上的 {n} 個項目",
+                "info/layers": "圖層",
+                "info/layersCopper": "{n}({c} 層銅箔)",
+                "info/name": "專案名稱",
+                "info/noOutline": "{size}(無板框)",
+                "info/none": "無",
+                "info/project": "專案",
+                "info/size": "尺寸",
+                "info/source": "來源",
+                "keys/actual": "實際大小",
+                "keys/addDrop": "將拖放的檔案加入專案",
+                "keys/arrows": "方向鍵",
+                "keys/bottom": "底面",
+                "keys/cancel": "取消/清除",
+                "keys/context": "右鍵選單",
+                "keys/copyCursor": "複製游標座標",
+                "keys/crosshair": "十字游標",
+                "keys/cycle": "上一個/下一個圖層",
+                "keys/dblclick": "縮放至物件/適當大小",
+                "keys/dim": "2D/3D",
+                "keys/doubleClick": "按兩下",
+                "keys/drag": "拖曳",
+                "keys/dragPan": "平移",
+                "keys/drop": "拖放",
+                "keys/export": "匯出圖片",
+                "keys/files": "檔案",
+                "keys/fit": "縮放至適當大小",
+                "keys/focus": "專注模式",
+                "keys/goto": "前往座標",
+                "keys/grid": "格線",
+                "keys/measure": "測量",
+                "keys/mouse": "滑鼠",
+                "keys/openAroz": "從 ArozOS 開啟",
+                "keys/openLocal": "從此裝置開啟",
+                "keys/pan": "平移",
+                "keys/panAny": "任何工具下平移",
+                "keys/panAnyMouse": "平移(任何工具)",
+                "keys/panKeys": "平移",
+                "keys/panel": "側邊欄",
+                "keys/preferences": "偏好設定",
+                "keys/print": "列印",
+                "keys/realistic": "擬真預覽",
+                "keys/reload": "重新載入專案",
+                "keys/rightClick": "按右鍵",
+                "keys/rightDrag": "右鍵/中鍵拖曳",
+                "keys/rotate": "旋轉 90°",
+                "keys/saveZip": "將專案存為 ZIP",
+                "keys/select": "選取",
+                "keys/title": "鍵盤快速鍵",
+                "keys/tools": "工具",
+                "keys/view": "檢視",
+                "keys/wheel": "滾輪",
+                "keys/wheelZoom": "縮放",
+                "keys/zoom": "放大/縮小",
+                "keys/zoomArea": "縮放至區域",
+                "kind/arc": "圓弧",
+                "kind/flash": "焊墊(Flash)",
+                "kind/hole": "鑽孔",
+                "kind/image": "影像填滿",
+                "kind/object": "物件",
+                "kind/region": "區域/鋪銅",
+                "kind/slot": "槽孔",
+                "kind/trace": "走線",
+                "layer/colourTitle": "顏色與不透明度",
+                "layer/hide": "隱藏圖層",
+                "layer/show": "顯示圖層",
+                "layers/empty": "尚未載入圖層。",
+                "loading/3dBuild": "正在建立 3D 電路板...",
+                "loading/3dStart": "正在啟動 3D 檢視...",
+                "loading/aroz": "正在從 ArozOS 讀取...",
+                "loading/default": "載入中...",
+                "loading/files": "正在讀取檔案...",
+                "loading/opening": "正在開啟 {name}...",
+                "loading/reading": "正在讀取 {name}({i} / {n})",
+                "loading/reloading": "正在重新載入 {name}",
+                "loading/unpack": "正在解壓 {name}",
+                "mask/black": "黑色",
+                "mask/blue": "藍色",
+                "mask/green": "綠色",
+                "mask/matte": "霧面黑",
+                "mask/none": "無(裸板)",
+                "mask/purple": "紫色",
+                "mask/red": "紅色",
+                "mask/white": "白色",
+                "mask/yellow": "黃色",
+                "mat/fpc": "FPC 軟板",
+                "mat/fr4": "FR-4",
+                "measure/angle": "角度",
+                "measure/clear": "清除測量",
+                "measure/from": "起點",
+                "measure/live": "測量中...",
+                "measure/title": "測量",
+                "measure/to": "終點",
+                "menu/2d": "2D 檢視",
+                "menu/3d": "3D 檢視",
+                "menu/about": "關於 Gerber 檢視器",
+                "menu/actualSize": "實際大小(1:1)",
+                "menu/addAroz": "從 ArozOS 加入圖層...",
+                "menu/addLocal": "從此裝置加入圖層...",
+                "menu/apertureTable": "光圈表...",
+                "menu/boardReport": "電路板報告...",
+                "menu/bottomOnly": "只顯示底面",
+                "menu/bottomSide": "底面(鏡像)",
+                "menu/clearMeasure": "清除測量",
+                "menu/clearRecent": "清除最近的專案",
+                "menu/clearSelection": "清除選取",
+                "menu/close": "關閉專案",
+                "menu/colour": "顏色與不透明度...",
+                "menu/copperOnly": "只顯示銅箔",
+                "menu/copyCursor": "複製游標座標",
+                "menu/copyImage": "將畫面複製為圖片",
+                "menu/crosshair": "十字游標",
+                "menu/drillTable": "鑽孔表...",
+                "menu/edit": "編輯",
+                "menu/empty": "沒有項目",
+                "menu/exit": "結束",
+                "menu/export": "匯出圖片...",
+                "menu/exportCsv": "匯出鑽孔表(CSV)...",
+                "menu/file": "檔案",
+                "menu/focus": "專注模式",
+                "menu/formats": "支援的格式",
+                "menu/goto": "前往座標...",
+                "menu/grid": "格線",
+                "menu/help": "說明",
+                "menu/hideAll": "隱藏所有圖層",
+                "menu/hideLayer": "隱藏圖層",
+                "menu/layerType": "圖層類型",
+                "menu/layerView": "圖層檢視",
+                "menu/layers": "圖層",
+                "menu/measure": "測量",
+                "menu/mfg": "製造能力預覽...",
+                "menu/minimap": "縮圖導覽",
+                "menu/moveDown": "下移",
+                "menu/moveUp": "上移",
+                "menu/nextLayer": "下一個圖層",
+                "menu/openAroz": "從 ArozOS 開啟...",
+                "menu/openArozFolder": "從 ArozOS 開啟資料夾...",
+                "menu/openLocal": "從此裝置開啟...",
+                "menu/openLocalFolder": "從此裝置開啟資料夾...",
+                "menu/openRecent": "開啟最近的專案",
+                "menu/originCorner": "原點設於電路板角落",
+                "menu/pan": "平移",
+                "menu/preferences": "偏好設定...",
+                "menu/prevLayer": "上一個圖層",
+                "menu/print": "列印...",
+                "menu/properties": "屬性",
+                "menu/realistic": "擬真預覽",
+                "menu/redetect": "重新偵測圖層類型",
+                "menu/reload": "重新載入專案",
+                "menu/reloadLayer": "重新載入圖層",
+                "menu/removeLayer": "移除圖層",
+                "menu/resetColours": "重設圖層顏色",
+                "menu/resetOrder": "重設圖層順序",
+                "menu/resetOrigin": "重設原點",
+                "menu/rotate": "旋轉 90°",
+                "menu/saveZip": "將專案存為 ZIP...",
+                "menu/select": "選取",
+                "menu/setOrigin": "設定原點...",
+                "menu/shortcuts": "鍵盤快速鍵",
+                "menu/showAll": "顯示所有圖層",
+                "menu/showLayer": "顯示圖層",
+                "menu/showOnly": "只顯示此圖層",
+                "menu/sidePanel": "側邊欄",
+                "menu/snap": "吸附至特徵",
+                "menu/theme": "主題",
+                "menu/tips": "提示",
+                "menu/tools": "工具",
+                "menu/topOnly": "只顯示頂面",
+                "menu/topSide": "頂面",
+                "menu/units": "單位",
+                "menu/view": "檢視",
+                "menu/viewBottom": "檢視底面",
+                "menu/viewLayerSource": "檢視圖層原始碼...",
+                "menu/viewSource": "檢視原始碼...",
+                "menu/zoomArea": "縮放至區域",
+                "menu/zoomFit": "縮放至適當大小",
+                "menu/zoomIn": "放大",
+                "menu/zoomLayer": "縮放至圖層",
+                "menu/zoomOut": "縮小",
+                "menu/zoomSelection": "縮放至選取項目",
+                "mfg/delivery": "出貨方式",
+                "mfg/enable": "預覽",
+                "mfg/enableTitle": "以這些選項繪製電路板",
+                "mfg/finish": "焊盤表面處理",
+                "mfg/layers": "板子層數",
+                "mfg/maker": "拼板",
+                "mfg/mask": "防焊顏色",
+                "mfg/material": "板材類別",
+                "mfg/noteFpc": "軟板以琥珀色聚醯亞胺覆蓋膜取代彩色防焊。",
+                "mfg/noteLarge": "拼板有一邊超過 500 mm,超出多數板廠可接受的範圍。",
+                "mfg/noteTent": "蓋油的過孔仍保留孔,防焊只覆蓋孔環,因此仍可看到小凹點。",
+                "mfg/noteVcut": "V-cut 需要筆直的板邊。此外形不是單純的矩形,使用郵票孔較安全。",
+                "mfg/panel": "拼板",
+                "mfg/panelHint": "欄 × 列",
+                "mfg/panelSize": "拼板尺寸",
+                "mfg/perPanel": "每片拼板的電路板數",
+                "mfg/railWidth": "工藝邊寬度",
+                "mfg/rails": "工藝邊",
+                "mfg/railsAll": "四邊",
+                "mfg/railsLR": "左右",
+                "mfg/railsNone": "無",
+                "mfg/railsTB": "上下",
+                "mfg/reset": "重設選項",
+                "mfg/separation": "分板方式",
+                "mfg/silk": "絲印顏色",
+                "mfg/single": "單片",
+                "mfg/size": "板子尺寸",
+                "mfg/stack": "疊構",
+                "mfg/stackLayers": "{layers} 層,{t} mm",
+                "mfg/summary": "訂單摘要",
+                "mfg/tab": "郵票孔",
+                "mfg/thickness": "成品板厚",
+                "mfg/title": "製造預覽",
+                "mfg/vcut": "V-cut",
+                "mfg/via": "防焊覆蓋",
+                "mfin/enig": "化金(ENIG)",
+                "mfin/hasl": "有鉛噴錫",
+                "mfin/haslLF": "無鉛噴錫",
+                "mfin/osp": "無鉛 OSP",
+                "pref/board": "電路板",
+                "pref/canvasBg": "畫布背景",
+                "pref/crosshair": "全畫面十字游標",
+                "pref/cursor": "游標",
+                "pref/customColour": "自訂顏色",
+                "pref/decimals": "小數位數",
+                "pref/default": "預設",
+                "pref/display": "顯示",
+                "pref/finish": "表面處理",
+                "pref/general": "一般",
+                "pref/grid": "格線",
+                "pref/layerColours": "圖層顏色",
+                "pref/mask": "防焊顏色",
+                "pref/measuring": "測量",
+                "pref/minimap": "縮圖導覽",
+                "pref/opacity": "新圖層不透明度",
+                "pref/recolour": "同時重新上色已開啟專案的圖層",
+                "pref/reset": "重設為預設值",
+                "pref/resetDone": "偏好設定已還原為預設值",
+                "pref/resetText": "要將所有偏好設定還原為預設值嗎?",
+                "pref/resetTitle": "重設偏好設定",
+                "pref/restoreLast": "重新開啟上次的專案",
+                "pref/showGrid": "顯示格線",
+                "pref/showMinimap": "顯示縮圖導覽",
+                "pref/showTips": "顯示提示",
+                "pref/sidePanel": "側邊欄",
+                "pref/silk": "絲印顏色",
+                "pref/snap": "吸附至焊墊、孔與線段端點",
+                "pref/startup": "啟動時",
+                "pref/theme": "主題",
+                "pref/thickness": "板厚",
+                "pref/title": "偏好設定",
+                "pref/unitIn": "英吋(in)",
+                "pref/unitMil": "密爾(mil)",
+                "pref/unitMm": "公釐(mm)",
+                "pref/units": "單位",
+                "print/actual": "實際大小(1:1)",
+                "print/colour": "圖層顏色",
+                "print/colours": "顏色",
+                "print/fit": "符合頁面",
+                "print/mono": "白底黑字",
+                "print/note": "實際大小會以 1:1 列印電路板,方便將封裝與實際零件比對。請在瀏覽器的列印對話方塊中關閉「符合頁面」或縮放。",
+                "print/print": "列印",
+                "print/scale": "比例",
+                "print/title": "列印",
+                "project/untitled": "未命名專案",
+                "prop/angle": "角度",
+                "prop/aperture": "光圈",
+                "prop/area": "面積",
+                "prop/ccw": "逆時針",
+                "prop/center": "中心",
+                "prop/component": "元件",
+                "prop/cw": "順時針",
+                "prop/diameter": "直徑",
+                "prop/end": "終點",
+                "prop/extent": "範圍",
+                "prop/function": "功能",
+                "prop/layer": "圖層",
+                "prop/length": "長度",
+                "prop/net": "網路",
+                "prop/noNet": "(無網路)",
+                "prop/object": "物件",
+                "prop/pin": "腳位",
+                "prop/pinValue": "{ref} 第 {pin} 腳",
+                "prop/plated": "電鍍",
+                "prop/position": "位置",
+                "prop/radius": "半徑",
+                "prop/start": "起點",
+                "prop/sweep": "掃掠角",
+                "prop/tool": "刀具",
+                "prop/vertices": "頂點數",
+                "prop/width": "寬度",
+                "props/apertures": "光圈",
+                "props/aperturesBtn": "光圈",
+                "props/arcsN": "{n} 個圓弧",
+                "props/area": "面積",
+                "props/board": "電路板",
+                "props/colour": "顏色與不透明度",
+                "props/coordinates": "座標格式",
+                "props/copperLayers": "銅箔層",
+                "props/created": "建立時間",
+                "props/drillTable": "鑽孔表",
+                "props/empty": "空白",
+                "props/excellon": "Excellon 鑽孔",
+                "props/extent": "範圍",
+                "props/file": "檔案",
+                "props/flashesN": "{n} 個 Flash",
+                "props/format": "格式",
+                "props/function": "功能",
+                "props/generator": "產生軟體",
+                "props/hint": "選取圖層或點擊電路板上的物件以查看詳細資訊。",
+                "props/holesLabel": "孔",
+                "props/holesN": "{n} 個孔",
+                "props/holesValue": "{n}({p} 個電鍍、{np} 個非電鍍)",
+                "props/layerType": "圖層類型",
+                "props/linesN": "{n} 條線",
+                "props/measureFrom": "從此測量",
+                "props/minHole": "最小孔徑",
+                "props/minTrace": "最細走線",
+                "props/noOutline": "找不到,改用圖層範圍",
+                "props/noProject": "開啟專案以查看其屬性。",
+                "props/objects": "物件",
+                "props/originOffset": "原點偏移",
+                "props/outline": "板框",
+                "props/outlineClosed": "{n} 個封閉外框",
+                "props/outlineCut": "{n} 個封閉外框、{c} 個挖空",
+                "props/polarity": "極性",
+                "props/polarityBoth": "暗與清除",
+                "props/polarityDark": "暗",
+                "props/regionsN": "{n} 個區域",
+                "props/remove": "移除",
+                "props/size": "尺寸",
+                "props/slotsLabel": "槽孔",
+                "props/slotsN": "{n} 個槽孔",
+                "props/source": "原始碼",
+                "props/tools": "刀具",
+                "props/units": "單位",
+                "props/visible": "顯示",
+                "props/warnings": "警告",
+                "props/zoomTo": "縮放至此",
+                "recent/device": "從此裝置開啟",
+                "recent/none": "沒有最近的專案",
+                "recent/remove": "從清單移除",
+                "report/area": "電路板面積",
+                "report/file": "檔案",
+                "report/ignored": "已忽略的檔案",
+                "report/layer": "圖層",
+                "report/layers": "圖層",
+                "report/noOutline": "找不到封閉的板框",
+                "report/notes": "備註",
+                "report/objects": "物件",
+                "report/outline": "板框",
+                "report/outlines": "{n} 個封閉外框、{c} 個挖空",
+                "report/project": "專案",
+                "report/size": "電路板尺寸",
+                "report/title": "電路板報告",
+                "report/units": "單位",
+                "report/warnings": "{n} 個警告",
+                "save/aroz": "ArozOS...",
+                "save/device": "此裝置",
+                "save/failed": "無法儲存:{error}",
+                "save/saved": "已儲存至 {path}",
+                "save/saving": "正在儲存 {name}...",
+                "save/title": "儲存 {name}",
+                "save/where": "要將此檔案儲存到哪裡?",
+                "silk/auto": "自動",
+                "silk/black": "黑色",
+                "silk/white": "白色",
+                "silk/yellow": "黃色",
+                "skip/archivetype": "不支援的壓縮檔類型",
+                "skip/expanded": "壓縮檔解壓後的 CAM 資料超過 512 MB",
+                "skip/none": "找不到 Gerber 或鑽孔檔。",
+                "skip/notcam": "不是 Gerber 或鑽孔檔",
+                "skip/notfound": "找不到檔案",
+                "skip/parse": "無法解析:{error}",
+                "skip/projecttoolarge": "專案大於 96 MB",
+                "skip/tartoolarge": "不支援大於 64 MB 的 tar 壓縮檔",
+                "skip/title": "部分檔案已略過",
+                "skip/toolarge": "檔案大於 32 MB",
+                "skip/toomany": "檔案過多",
+                "skip/ziponly": "從此裝置只能開啟 zip 壓縮檔",
+                "source/lines": "{n} 行",
+                "source/more": "...(還有 {size})",
+                "source/title": "圖層原始碼",
+                "status/layers": "{n} 個圖層",
+                "tab/layers": "圖層",
+                "tab/props": "屬性",
+                "theme/dark": "深色",
+                "theme/light": "淺色",
+                "theme/system": "跟隨 ArozOS",
+                "time/day": "{n} 天前",
+                "time/hour": "{n} 小時前",
+                "time/min": "{n} 分鐘前",
+                "time/now": "剛剛",
+                "tip/0": "拖曳清單中的圖層可變更繪製順序,清單最上方的圖層會畫在最上層。",
+                "tip/1": "點擊色塊可變更圖層的顏色與不透明度。",
+                "tip/2": "按 M 進行測量。測量會吸附至焊墊、導通孔與線段端點。",
+                "tip/3": "按 P 切換擬真電路板預覽,按 B 檢視底面。",
+                "tip/4": "在圖層上按右鍵可使用更多選項,例如只顯示該圖層。",
+                "tip/5": "工具 > 鑽孔表會列出每種孔徑及其孔數與電鍍狀態。",
+                "tip/6": "按住空白鍵拖曳可在任何工具下平移。在空白處按兩下可縮放至適當大小。",
+                "tip/7": "按 3 以 3D 檢視電路板。板厚與顏色可在偏好設定中調整。",
+                "tip/8": "最近的專案會記住圖層的顏色、順序與顯示狀態。",
+                "tip/9": "點擊焊墊或走線,即可在屬性中查看其光圈、尺寸與位置。",
+                "title/offline": "此頁面無法連線至 ArozOS",
+                "toast/3dFailed": "無法啟動 3D 檢視:{error}",
+                "toast/added": "已加入 {n} 個圖層",
+                "toast/addedOne": "已加入 1 個圖層",
+                "toast/copied": "已複製 {text}",
+                "toast/copyFailed": "無法複製:{error}",
+                "toast/imageCopied": "已將畫面複製到剪貼簿",
+                "toast/loadAll": "全部載入",
+                "toast/no3d": "3D 檢視需要板框或銅箔圖層。",
+                "toast/noClipboard": "此處不支援將圖片複製到剪貼簿",
+                "toast/noReload": "此專案無法重新載入,請重新開啟檔案。",
+                "toast/notCached": "「{name}」是從裝置開啟的,並未快取在此瀏覽器中,請重新開啟。",
+                "toast/offline": "無法連線至 ArozOS,請改用「從此裝置開啟」。",
+                "toast/offlineShort": "無法連線至 ArozOS。",
+                "toast/openFailed": "無法開啟:{error}",
+                "toast/originCorner": "原點已移至電路板左下角",
+                "toast/originSet": "已將原點設於選取的位置",
+                "toast/pickOrigin": "點擊一個位置作為原點,按 Esc 取消。",
+                "toast/readFailed": "無法讀取檔案:{error}",
+                "toast/reloadFailed": "無法重新載入:{error}",
+                "toast/reloaded": "已重新載入{name}",
+                "toast/sibling": "此資料夾中還有 1 個 Gerber/鑽孔檔。",
+                "toast/siblings": "此資料夾中還有 {n} 個 Gerber/鑽孔檔。",
+                "type/bottom-assembly": "底層組裝圖",
+                "type/bottom-copper": "底層銅箔",
+                "type/bottom-mask": "底層防焊",
+                "type/bottom-paste": "底層錫膏",
+                "type/bottom-silk": "底層絲印",
+                "type/document": "文件層",
+                "type/drill": "鑽孔",
+                "type/drill-npth": "鑽孔(NPTH)",
+                "type/drill-pth": "鑽孔(PTH)",
+                "type/drill-via": "鑽孔(導通孔)",
+                "type/inner-copper": "內層銅箔",
+                "type/inner-n": "內層銅箔 {n}",
+                "type/keepout": "禁止區",
+                "type/other": "未知圖層",
+                "type/outline": "板框",
+                "type/top-assembly": "頂層組裝圖",
+                "type/top-copper": "頂層銅箔",
+                "type/top-mask": "頂層防焊",
+                "type/top-paste": "頂層錫膏",
+                "type/top-silk": "頂層絲印",
+                "unit/inLong": "英吋",
+                "unit/inch": "英吋",
+                "unit/milLong": "密爾",
+                "unit/mm": "公釐",
+                "unit/mmLong": "公釐",
+                "via/open": "過孔開窗",
+                "via/paste": "過孔塞銅漿 + 電鍍蓋帽",
+                "via/plug": "過孔塞油",
+                "via/resin": "過孔塞樹脂 + 電鍍蓋帽",
+                "via/tent": "過孔蓋油",
+                "view3d/bottom": "底面",
+                "view3d/front": "正面",
+                "view3d/iso": "等角",
+                "view3d/mfg": "製造",
+                "view3d/top": "頂面",
+                "warn/arcap": "以非圓形光圈繪製的圓弧為近似顯示。",
+                "warn/blocktf": "區塊光圈的鏡像、旋轉與縮放未套用。",
+                "warn/d3reg": "區域內不允許 Flash,已略過。",
+                "warn/deprecated": "已忽略過時的 %{cmd} 影像轉換。",
+                "warn/inc": "使用了增量座標(已過時)。",
+                "warn/macro": "未知的光圈範本「{name}」。",
+                "warn/mo": "沒有單位(%MO)指令,假設為英吋。",
+                "warn/noapdraw": "繪製時未選取光圈。",
+                "warn/noapflash": "Flash 時未選取光圈。",
+                "warn/notool": "在選取刀具前就已鑽孔。",
+                "warn/openreg": "有區域未以 G37 封閉。",
+                "warn/toolsize": "刀具 {tool} 未指定尺寸。",
+                "warn/undef": "使用了未定義的光圈 D{d}。",
+                "warn/units": "沒有 METRIC/INCH 宣告,假設為英吋。",
+                "zoom/actual": "1:1(實際大小)"
+            },
+            "titles": {
+                "2D view": "2D 檢視",
+                "3D view (3)": "3D 檢視 (3)",
+                "Click or drag to move the view": "點擊或拖曳以移動檢視範圍",
+                "Crosshair cursor": "十字游標",
+                "Focus mode": "專注模式",
+                "Gerber Viewer": "Gerber 檢視器",
+                "Hide all layers": "隱藏所有圖層",
+                "Measure (M)": "測量 (M)",
+                "More": "更多",
+                "Next tip": "下一個提示",
+                "Pan (H)": "平移 (H)",
+                "Render mode": "顯示模式",
+                "Rotate 90 degrees (R)": "旋轉 90 度 (R)",
+                "Select / inspect (V)": "選取/檢查 (V)",
+                "Show / hide side panel": "顯示/隱藏側邊欄",
+                "Show all layers": "顯示所有圖層",
+                "View bottom side (B)": "檢視底面 (B)",
+                "Zoom": "縮放",
+                "Zoom to area (Z)": "縮放至區域 (Z)",
+                "Zoom to fit (F)": "縮放至適當大小 (F)"
+            },
+            "placeholder": {}
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fwtitle": "Gerber 檢視器",
+            "strings": {
+                "about/credits": "ArozOS 上的壓縮檔由本應用的 AGI 後端解壓。3D 檢視使用 three.js(MIT 授權)。",
+                "about/storedAroz": "設定與最近的專案依使用者儲存在 ArozOS 中。",
+                "about/storedLocal": "設定與最近的專案儲存在此瀏覽器中。",
+                "about/text": "以圖層、擬真電路板預覽或 3D 檢視 PCB 製造資料。可測量特徵、檢查光圈與鑽孔,並匯出圖片。",
+                "about/title": "關於",
+                "about/version": "版本 {v}(ArozOS 版)",
+                "ap/block": "區塊光圈",
+                "ap/circle": "圓形 Ø{d}",
+                "ap/circleHole": "圓形 Ø{d},孔 {h}",
+                "ap/dcode": "D 碼",
+                "ap/function": "功能",
+                "ap/macro": "巨集 {name}",
+                "ap/none": "此圖層未定義任何光圈。",
+                "ap/note": "標準光圈尺寸以公釐顯示;巨集參數使用檔案本身的單位({unit})。",
+                "ap/obround": "長圓形 {w} × {h}",
+                "ap/polygon": "多邊形 Ø{d},{n} 邊",
+                "ap/rect": "矩形 {w} × {h}",
+                "ap/shape": "形狀",
+                "ap/title": "光圈表",
+                "ap/uses": "使用次數",
+                "app/title": "Gerber 檢視器",
+                "badge/none": "未開啟專案",
+                "badge/project": "Gerber 專案",
+                "bg/black": "黑色",
+                "bg/graphite": "石墨灰",
+                "bg/midnight": "午夜藍",
+                "bg/paper": "紙白",
+                "bg/slate": "板岩灰",
+                "card/fileInfo": "檔案資訊",
+                "card/tip": "提示",
+                "card/visibility": "圖層顯示",
+                "chip/bottom": "底面(鏡像)",
+                "chip/layers": "圖層",
+                "chip/realistic": "擬真",
+                "chip/top": "頂面",
+                "colour/custom": "自訂",
+                "colour/opacity": "不透明度",
+                "common/cancel": "取消",
+                "common/clear": "清除",
+                "common/close": "關閉",
+                "common/no": "否",
+                "common/ok": "確定",
+                "common/reset": "重設",
+                "common/save": "儲存",
+                "common/unknown": "未知",
+                "common/yes": "是",
+                "confirm/clearRecent": "清除最近的專案",
+                "confirm/clearRecentText": "要從最近清單中移除所有專案嗎?",
+                "ctx/center": "以此處為中心",
+                "ctx/copyCoords": "複製座標",
+                "ctx/measureFrom": "從此處開始測量",
+                "ctx/objectOn": "{layer} 上的{object}",
+                "ctx/select": "選取",
+                "ctx/setOrigin": "將原點設於此處",
+                "ctx/showOnly": "只顯示{layer}",
+                "ctx/viewTop": "檢視頂面",
+                "ctx/zoomObject": "縮放至物件",
+                "drill/csv": "匯出 CSV...",
+                "drill/diameter": "直徑",
+                "drill/diameterIn": "直徑(英吋)",
+                "drill/duplicates": "檔案中共有 {n} 次鑽孔;跨檔案重複的孔只計算一次。",
+                "drill/holes": "孔數",
+                "drill/none": "此專案沒有鑽孔檔。",
+                "drill/plated": "電鍍",
+                "drill/slots": "槽孔",
+                "drill/smallest": "最小孔徑 {d}。",
+                "drill/summary": "共 {n} 個孔({p} 個電鍍、{np} 個非電鍍)及 {s} 個槽孔。",
+                "drill/title": "鑽孔表",
+                "drill/tool": "刀具",
+                "drill/total": "合計",
+                "drop/hint": "放開以開啟",
+                "empty/drop": "或將 .zip、資料夾或 Gerber 檔拖放到此視窗任何位置",
+                "empty/intro": "開啟 PCB 製造檔案包(zip)或個別的 Gerber 與鑽孔檔。",
+                "empty/openAroz": "從 ArozOS 開啟",
+                "empty/openFolder": "開啟資料夾",
+                "empty/openLocal": "從此裝置開啟",
+                "empty/recent": "最近的專案",
+                "error/baddata": "檔案資料無效或過大",
+                "error/badentry": "無效的項目",
+                "error/badop": "未知的操作",
+                "error/badpath": "無效的目的地路徑",
+                "error/badpaths": "無效的路徑清單",
+                "error/badsettings": "無效的設定",
+                "error/badtype": "Gerber 檢視器無法儲存此檔案類型",
+                "error/denied": "您沒有寫入此位置的權限",
+                "error/nofolder": "目的地資料夾不存在",
+                "error/notcam": "不是 Gerber 或鑽孔檔",
+                "error/notfound": "找不到檔案",
+                "error/nothing": "沒有可開啟的項目",
+                "error/offline": "無法連線至 ArozOS",
+                "error/projecttoolarge": "專案大於 96 MB",
+                "error/read": "無法讀取 {name}",
+                "error/readfail": "無法讀取檔案",
+                "error/reply": "伺服器回應異常",
+                "error/server": "伺服器錯誤 {status}",
+                "error/toolarge": "檔案過大",
+                "error/toomany": "檔案過多",
+                "error/writefail": "無法寫入檔案",
+                "error/zip64": "不支援 ZIP64 壓縮檔。",
+                "error/zipCorrupt": "壓縮檔項目損毀:{name}",
+                "error/zipEncrypted": "不支援加密的壓縮檔項目:{name}",
+                "error/zipInvalid": "不是 zip 壓縮檔。",
+                "error/zipMethod": "不支援的 zip 壓縮方式 {method}({name})",
+                "error/zipNoInflate": "此瀏覽器無法解壓縮 zip 檔。",
+                "export/3d": "3D 快照",
+                "export/area": "範圍",
+                "export/background": "背景",
+                "export/bgCanvas": "畫布顏色",
+                "export/bgNone": "透明",
+                "export/bgWhite": "白色",
+                "export/board": "整塊電路板",
+                "export/bottom": "擬真底面",
+                "export/content": "內容",
+                "export/download": "下載",
+                "export/failed": "匯出失敗:{error}",
+                "export/format": "格式",
+                "export/layers": "圖層檢視",
+                "export/noteSize": "圖片尺寸約 {w} × {h} 像素。",
+                "export/noteSizeCapped": "圖片尺寸約 {w} × {h} 像素(長邊上限 12000 像素)。",
+                "export/noteSvg": "將可見圖層匯出為向量路徑,尺寸以公釐為單位。",
+                "export/noteView": "以兩倍螢幕解析度匯出目前可見的畫面。",
+                "export/rendering": "正在繪製 {name}...",
+                "export/resolution": "解析度",
+                "export/svg": "SVG(向量)",
+                "export/title": "匯出圖片",
+                "export/toAroz": "儲存至 ArozOS...",
+                "export/top": "擬真頂面",
+                "export/view": "目前畫面",
+                "finish/enig": "ENIG(化金)",
+                "finish/hasl": "HASL(噴錫)",
+                "finish/osp": "OSP/裸銅",
+                "fmt/absolute": "絕對座標",
+                "fmt/decimal": "小數/預設",
+                "fmt/incremental": "增量座標",
+                "fmt/leading": "省略前導零",
+                "fmt/lz": "保留前導零",
+                "fmt/trailing": "省略尾隨零",
+                "fmt/tz": "保留尾隨零",
+                "formats/detect": "圖層類型會依 X2 屬性,以及 EasyEDA、KiCad、Altium、Eagle/Fusion、DipTrace 與 Proteus 的檔案命名方式判斷。您可以在圖層選單中更改任何類型。",
+                "formats/drill": "Excellon(.drl .xln .exc .txt .drd),支援公制或英制、省略前導或尾隨零、槽孔(G85)及銑削外形。",
+                "formats/drillTitle": "鑽孔",
+                "formats/gerber": "RS-274X 與 Gerber X2(屬性、網路與元件),包含光圈巨集、區域、圓弧、清除極性、步進重複與區塊光圈。常見副檔名:.gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho 以及 Eagle/KiCad 命名。",
+                "formats/packages": "從本機裝置開啟 zip 壓縮檔。從 ArozOS 開啟:zip、7z、tar 與 tar.gz 壓縮檔(在伺服器上解壓)、資料夾或任意檔案組合。",
+                "formats/packagesTitle": "檔案包",
+                "formats/title": "支援的格式",
+                "goto/go": "前往",
+                "goto/missing": "請輸入兩個座標",
+                "goto/note": "座標相對於目前的原點。",
+                "goto/title": "前往座標",
+                "info/created": "建立時間",
+                "info/device": "此裝置",
+                "info/file1": "1 個檔案",
+                "info/files": "{n} 個檔案",
+                "info/filesIgnored": "{files}(忽略 {n} 個)",
+                "info/filesLabel": "檔案",
+                "info/format": "格式",
+                "info/generator": "產生軟體",
+                "info/itemsOnAroz": "ArozOS 上的 {n} 個項目",
+                "info/layers": "圖層",
+                "info/layersCopper": "{n}({c} 層銅箔)",
+                "info/name": "專案名稱",
+                "info/noOutline": "{size}(無板框)",
+                "info/none": "無",
+                "info/project": "專案",
+                "info/size": "尺寸",
+                "info/source": "來源",
+                "keys/actual": "實際大小",
+                "keys/addDrop": "將拖放的檔案加入專案",
+                "keys/arrows": "方向鍵",
+                "keys/bottom": "底面",
+                "keys/cancel": "取消/清除",
+                "keys/context": "右鍵選單",
+                "keys/copyCursor": "複製游標座標",
+                "keys/crosshair": "十字游標",
+                "keys/cycle": "上一個/下一個圖層",
+                "keys/dblclick": "縮放至物件/適當大小",
+                "keys/dim": "2D/3D",
+                "keys/doubleClick": "按兩下",
+                "keys/drag": "拖曳",
+                "keys/dragPan": "平移",
+                "keys/drop": "拖放",
+                "keys/export": "匯出圖片",
+                "keys/files": "檔案",
+                "keys/fit": "縮放至適當大小",
+                "keys/focus": "專注模式",
+                "keys/goto": "前往座標",
+                "keys/grid": "格線",
+                "keys/measure": "測量",
+                "keys/mouse": "滑鼠",
+                "keys/openAroz": "從 ArozOS 開啟",
+                "keys/openLocal": "從此裝置開啟",
+                "keys/pan": "平移",
+                "keys/panAny": "任何工具下平移",
+                "keys/panAnyMouse": "平移(任何工具)",
+                "keys/panKeys": "平移",
+                "keys/panel": "側邊欄",
+                "keys/preferences": "偏好設定",
+                "keys/print": "列印",
+                "keys/realistic": "擬真預覽",
+                "keys/reload": "重新載入專案",
+                "keys/rightClick": "按右鍵",
+                "keys/rightDrag": "右鍵/中鍵拖曳",
+                "keys/rotate": "旋轉 90°",
+                "keys/saveZip": "將專案存為 ZIP",
+                "keys/select": "選取",
+                "keys/title": "鍵盤快速鍵",
+                "keys/tools": "工具",
+                "keys/view": "檢視",
+                "keys/wheel": "滾輪",
+                "keys/wheelZoom": "縮放",
+                "keys/zoom": "放大/縮小",
+                "keys/zoomArea": "縮放至區域",
+                "kind/arc": "圓弧",
+                "kind/flash": "焊墊(Flash)",
+                "kind/hole": "鑽孔",
+                "kind/image": "影像填滿",
+                "kind/object": "物件",
+                "kind/region": "區域/鋪銅",
+                "kind/slot": "槽孔",
+                "kind/trace": "走線",
+                "layer/colourTitle": "顏色與不透明度",
+                "layer/hide": "隱藏圖層",
+                "layer/show": "顯示圖層",
+                "layers/empty": "尚未載入圖層。",
+                "loading/3dBuild": "正在建立 3D 電路板...",
+                "loading/3dStart": "正在啟動 3D 檢視...",
+                "loading/aroz": "正在從 ArozOS 讀取...",
+                "loading/default": "載入中...",
+                "loading/files": "正在讀取檔案...",
+                "loading/opening": "正在開啟 {name}...",
+                "loading/reading": "正在讀取 {name}({i} / {n})",
+                "loading/reloading": "正在重新載入 {name}",
+                "loading/unpack": "正在解壓 {name}",
+                "mask/black": "黑色",
+                "mask/blue": "藍色",
+                "mask/green": "綠色",
+                "mask/matte": "霧面黑",
+                "mask/none": "無(裸板)",
+                "mask/purple": "紫色",
+                "mask/red": "紅色",
+                "mask/white": "白色",
+                "mask/yellow": "黃色",
+                "mat/fpc": "FPC 軟板",
+                "mat/fr4": "FR-4",
+                "measure/angle": "角度",
+                "measure/clear": "清除測量",
+                "measure/from": "起點",
+                "measure/live": "測量中...",
+                "measure/title": "測量",
+                "measure/to": "終點",
+                "menu/2d": "2D 檢視",
+                "menu/3d": "3D 檢視",
+                "menu/about": "關於 Gerber 檢視器",
+                "menu/actualSize": "實際大小(1:1)",
+                "menu/addAroz": "從 ArozOS 加入圖層...",
+                "menu/addLocal": "從此裝置加入圖層...",
+                "menu/apertureTable": "光圈表...",
+                "menu/boardReport": "電路板報告...",
+                "menu/bottomOnly": "只顯示底面",
+                "menu/bottomSide": "底面(鏡像)",
+                "menu/clearMeasure": "清除測量",
+                "menu/clearRecent": "清除最近的專案",
+                "menu/clearSelection": "清除選取",
+                "menu/close": "關閉專案",
+                "menu/colour": "顏色與不透明度...",
+                "menu/copperOnly": "只顯示銅箔",
+                "menu/copyCursor": "複製游標座標",
+                "menu/copyImage": "將畫面複製為圖片",
+                "menu/crosshair": "十字游標",
+                "menu/drillTable": "鑽孔表...",
+                "menu/edit": "編輯",
+                "menu/empty": "沒有項目",
+                "menu/exit": "結束",
+                "menu/export": "匯出圖片...",
+                "menu/exportCsv": "匯出鑽孔表(CSV)...",
+                "menu/file": "檔案",
+                "menu/focus": "專注模式",
+                "menu/formats": "支援的格式",
+                "menu/goto": "前往座標...",
+                "menu/grid": "格線",
+                "menu/help": "說明",
+                "menu/hideAll": "隱藏所有圖層",
+                "menu/hideLayer": "隱藏圖層",
+                "menu/layerType": "圖層類型",
+                "menu/layerView": "圖層檢視",
+                "menu/layers": "圖層",
+                "menu/measure": "測量",
+                "menu/mfg": "製造能力預覽...",
+                "menu/minimap": "縮圖導覽",
+                "menu/moveDown": "下移",
+                "menu/moveUp": "上移",
+                "menu/nextLayer": "下一個圖層",
+                "menu/openAroz": "從 ArozOS 開啟...",
+                "menu/openArozFolder": "從 ArozOS 開啟資料夾...",
+                "menu/openLocal": "從此裝置開啟...",
+                "menu/openLocalFolder": "從此裝置開啟資料夾...",
+                "menu/openRecent": "開啟最近的專案",
+                "menu/originCorner": "原點設於電路板角落",
+                "menu/pan": "平移",
+                "menu/preferences": "偏好設定...",
+                "menu/prevLayer": "上一個圖層",
+                "menu/print": "列印...",
+                "menu/properties": "屬性",
+                "menu/realistic": "擬真預覽",
+                "menu/redetect": "重新偵測圖層類型",
+                "menu/reload": "重新載入專案",
+                "menu/reloadLayer": "重新載入圖層",
+                "menu/removeLayer": "移除圖層",
+                "menu/resetColours": "重設圖層顏色",
+                "menu/resetOrder": "重設圖層順序",
+                "menu/resetOrigin": "重設原點",
+                "menu/rotate": "旋轉 90°",
+                "menu/saveZip": "將專案存為 ZIP...",
+                "menu/select": "選取",
+                "menu/setOrigin": "設定原點...",
+                "menu/shortcuts": "鍵盤快速鍵",
+                "menu/showAll": "顯示所有圖層",
+                "menu/showLayer": "顯示圖層",
+                "menu/showOnly": "只顯示此圖層",
+                "menu/sidePanel": "側邊欄",
+                "menu/snap": "吸附至特徵",
+                "menu/theme": "主題",
+                "menu/tips": "提示",
+                "menu/tools": "工具",
+                "menu/topOnly": "只顯示頂面",
+                "menu/topSide": "頂面",
+                "menu/units": "單位",
+                "menu/view": "檢視",
+                "menu/viewBottom": "檢視底面",
+                "menu/viewLayerSource": "檢視圖層原始碼...",
+                "menu/viewSource": "檢視原始碼...",
+                "menu/zoomArea": "縮放至區域",
+                "menu/zoomFit": "縮放至適當大小",
+                "menu/zoomIn": "放大",
+                "menu/zoomLayer": "縮放至圖層",
+                "menu/zoomOut": "縮小",
+                "menu/zoomSelection": "縮放至選取項目",
+                "mfg/delivery": "出貨方式",
+                "mfg/enable": "預覽",
+                "mfg/enableTitle": "以這些選項繪製電路板",
+                "mfg/finish": "焊盤表面處理",
+                "mfg/layers": "板子層數",
+                "mfg/maker": "拼板",
+                "mfg/mask": "防焊顏色",
+                "mfg/material": "板材類別",
+                "mfg/noteFpc": "軟板以琥珀色聚醯亞胺覆蓋膜取代彩色防焊。",
+                "mfg/noteLarge": "拼板有一邊超過 500 mm,超出多數板廠可接受的範圍。",
+                "mfg/noteTent": "蓋油的過孔仍保留孔,防焊只覆蓋孔環,因此仍可看到小凹點。",
+                "mfg/noteVcut": "V-cut 需要筆直的板邊。此外形不是單純的矩形,使用郵票孔較安全。",
+                "mfg/panel": "拼板",
+                "mfg/panelHint": "欄 × 列",
+                "mfg/panelSize": "拼板尺寸",
+                "mfg/perPanel": "每片拼板的電路板數",
+                "mfg/railWidth": "工藝邊寬度",
+                "mfg/rails": "工藝邊",
+                "mfg/railsAll": "四邊",
+                "mfg/railsLR": "左右",
+                "mfg/railsNone": "無",
+                "mfg/railsTB": "上下",
+                "mfg/reset": "重設選項",
+                "mfg/separation": "分板方式",
+                "mfg/silk": "絲印顏色",
+                "mfg/single": "單片",
+                "mfg/size": "板子尺寸",
+                "mfg/stack": "疊構",
+                "mfg/stackLayers": "{layers} 層,{t} mm",
+                "mfg/summary": "訂單摘要",
+                "mfg/tab": "郵票孔",
+                "mfg/thickness": "成品板厚",
+                "mfg/title": "製造預覽",
+                "mfg/vcut": "V-cut",
+                "mfg/via": "防焊覆蓋",
+                "mfin/enig": "化金(ENIG)",
+                "mfin/hasl": "有鉛噴錫",
+                "mfin/haslLF": "無鉛噴錫",
+                "mfin/osp": "無鉛 OSP",
+                "pref/board": "電路板",
+                "pref/canvasBg": "畫布背景",
+                "pref/crosshair": "全畫面十字游標",
+                "pref/cursor": "游標",
+                "pref/customColour": "自訂顏色",
+                "pref/decimals": "小數位數",
+                "pref/default": "預設",
+                "pref/display": "顯示",
+                "pref/finish": "表面處理",
+                "pref/general": "一般",
+                "pref/grid": "格線",
+                "pref/layerColours": "圖層顏色",
+                "pref/mask": "防焊顏色",
+                "pref/measuring": "測量",
+                "pref/minimap": "縮圖導覽",
+                "pref/opacity": "新圖層不透明度",
+                "pref/recolour": "同時重新上色已開啟專案的圖層",
+                "pref/reset": "重設為預設值",
+                "pref/resetDone": "偏好設定已還原為預設值",
+                "pref/resetText": "要將所有偏好設定還原為預設值嗎?",
+                "pref/resetTitle": "重設偏好設定",
+                "pref/restoreLast": "重新開啟上次的專案",
+                "pref/showGrid": "顯示格線",
+                "pref/showMinimap": "顯示縮圖導覽",
+                "pref/showTips": "顯示提示",
+                "pref/sidePanel": "側邊欄",
+                "pref/silk": "絲印顏色",
+                "pref/snap": "吸附至焊墊、孔與線段端點",
+                "pref/startup": "啟動時",
+                "pref/theme": "主題",
+                "pref/thickness": "板厚",
+                "pref/title": "偏好設定",
+                "pref/unitIn": "英吋(in)",
+                "pref/unitMil": "密爾(mil)",
+                "pref/unitMm": "公釐(mm)",
+                "pref/units": "單位",
+                "print/actual": "實際大小(1:1)",
+                "print/colour": "圖層顏色",
+                "print/colours": "顏色",
+                "print/fit": "符合頁面",
+                "print/mono": "白底黑字",
+                "print/note": "實際大小會以 1:1 列印電路板,方便將封裝與實際零件比對。請在瀏覽器的列印對話方塊中關閉「符合頁面」或縮放。",
+                "print/print": "列印",
+                "print/scale": "比例",
+                "print/title": "列印",
+                "project/untitled": "未命名專案",
+                "prop/angle": "角度",
+                "prop/aperture": "光圈",
+                "prop/area": "面積",
+                "prop/ccw": "逆時針",
+                "prop/center": "中心",
+                "prop/component": "元件",
+                "prop/cw": "順時針",
+                "prop/diameter": "直徑",
+                "prop/end": "終點",
+                "prop/extent": "範圍",
+                "prop/function": "功能",
+                "prop/layer": "圖層",
+                "prop/length": "長度",
+                "prop/net": "網路",
+                "prop/noNet": "(無網路)",
+                "prop/object": "物件",
+                "prop/pin": "腳位",
+                "prop/pinValue": "{ref} 第 {pin} 腳",
+                "prop/plated": "電鍍",
+                "prop/position": "位置",
+                "prop/radius": "半徑",
+                "prop/start": "起點",
+                "prop/sweep": "掃掠角",
+                "prop/tool": "刀具",
+                "prop/vertices": "頂點數",
+                "prop/width": "寬度",
+                "props/apertures": "光圈",
+                "props/aperturesBtn": "光圈",
+                "props/arcsN": "{n} 個圓弧",
+                "props/area": "面積",
+                "props/board": "電路板",
+                "props/colour": "顏色與不透明度",
+                "props/coordinates": "座標格式",
+                "props/copperLayers": "銅箔層",
+                "props/created": "建立時間",
+                "props/drillTable": "鑽孔表",
+                "props/empty": "空白",
+                "props/excellon": "Excellon 鑽孔",
+                "props/extent": "範圍",
+                "props/file": "檔案",
+                "props/flashesN": "{n} 個 Flash",
+                "props/format": "格式",
+                "props/function": "功能",
+                "props/generator": "產生軟體",
+                "props/hint": "選取圖層或點擊電路板上的物件以查看詳細資訊。",
+                "props/holesLabel": "孔",
+                "props/holesN": "{n} 個孔",
+                "props/holesValue": "{n}({p} 個電鍍、{np} 個非電鍍)",
+                "props/layerType": "圖層類型",
+                "props/linesN": "{n} 條線",
+                "props/measureFrom": "從此測量",
+                "props/minHole": "最小孔徑",
+                "props/minTrace": "最細走線",
+                "props/noOutline": "找不到,改用圖層範圍",
+                "props/noProject": "開啟專案以查看其屬性。",
+                "props/objects": "物件",
+                "props/originOffset": "原點偏移",
+                "props/outline": "板框",
+                "props/outlineClosed": "{n} 個封閉外框",
+                "props/outlineCut": "{n} 個封閉外框、{c} 個挖空",
+                "props/polarity": "極性",
+                "props/polarityBoth": "暗與清除",
+                "props/polarityDark": "暗",
+                "props/regionsN": "{n} 個區域",
+                "props/remove": "移除",
+                "props/size": "尺寸",
+                "props/slotsLabel": "槽孔",
+                "props/slotsN": "{n} 個槽孔",
+                "props/source": "原始碼",
+                "props/tools": "刀具",
+                "props/units": "單位",
+                "props/visible": "顯示",
+                "props/warnings": "警告",
+                "props/zoomTo": "縮放至此",
+                "recent/device": "從此裝置開啟",
+                "recent/none": "沒有最近的專案",
+                "recent/remove": "從清單移除",
+                "report/area": "電路板面積",
+                "report/file": "檔案",
+                "report/ignored": "已忽略的檔案",
+                "report/layer": "圖層",
+                "report/layers": "圖層",
+                "report/noOutline": "找不到封閉的板框",
+                "report/notes": "備註",
+                "report/objects": "物件",
+                "report/outline": "板框",
+                "report/outlines": "{n} 個封閉外框、{c} 個挖空",
+                "report/project": "專案",
+                "report/size": "電路板尺寸",
+                "report/title": "電路板報告",
+                "report/units": "單位",
+                "report/warnings": "{n} 個警告",
+                "save/aroz": "ArozOS...",
+                "save/device": "此裝置",
+                "save/failed": "無法儲存:{error}",
+                "save/saved": "已儲存至 {path}",
+                "save/saving": "正在儲存 {name}...",
+                "save/title": "儲存 {name}",
+                "save/where": "要將此檔案儲存到哪裡?",
+                "silk/auto": "自動",
+                "silk/black": "黑色",
+                "silk/white": "白色",
+                "silk/yellow": "黃色",
+                "skip/archivetype": "不支援的壓縮檔類型",
+                "skip/expanded": "壓縮檔解壓後的 CAM 資料超過 512 MB",
+                "skip/none": "找不到 Gerber 或鑽孔檔。",
+                "skip/notcam": "不是 Gerber 或鑽孔檔",
+                "skip/notfound": "找不到檔案",
+                "skip/parse": "無法解析:{error}",
+                "skip/projecttoolarge": "專案大於 96 MB",
+                "skip/tartoolarge": "不支援大於 64 MB 的 tar 壓縮檔",
+                "skip/title": "部分檔案已略過",
+                "skip/toolarge": "檔案大於 32 MB",
+                "skip/toomany": "檔案過多",
+                "skip/ziponly": "從此裝置只能開啟 zip 壓縮檔",
+                "source/lines": "{n} 行",
+                "source/more": "...(還有 {size})",
+                "source/title": "圖層原始碼",
+                "status/layers": "{n} 個圖層",
+                "tab/layers": "圖層",
+                "tab/props": "屬性",
+                "theme/dark": "深色",
+                "theme/light": "淺色",
+                "theme/system": "跟隨 ArozOS",
+                "time/day": "{n} 天前",
+                "time/hour": "{n} 小時前",
+                "time/min": "{n} 分鐘前",
+                "time/now": "剛剛",
+                "tip/0": "拖曳清單中的圖層可變更繪製順序,清單最上方的圖層會畫在最上層。",
+                "tip/1": "點擊色塊可變更圖層的顏色與不透明度。",
+                "tip/2": "按 M 進行測量。測量會吸附至焊墊、導通孔與線段端點。",
+                "tip/3": "按 P 切換擬真電路板預覽,按 B 檢視底面。",
+                "tip/4": "在圖層上按右鍵可使用更多選項,例如只顯示該圖層。",
+                "tip/5": "工具 > 鑽孔表會列出每種孔徑及其孔數與電鍍狀態。",
+                "tip/6": "按住空白鍵拖曳可在任何工具下平移。在空白處按兩下可縮放至適當大小。",
+                "tip/7": "按 3 以 3D 檢視電路板。板厚與顏色可在偏好設定中調整。",
+                "tip/8": "最近的專案會記住圖層的顏色、順序與顯示狀態。",
+                "tip/9": "點擊焊墊或走線,即可在屬性中查看其光圈、尺寸與位置。",
+                "title/offline": "此頁面無法連線至 ArozOS",
+                "toast/3dFailed": "無法啟動 3D 檢視:{error}",
+                "toast/added": "已加入 {n} 個圖層",
+                "toast/addedOne": "已加入 1 個圖層",
+                "toast/copied": "已複製 {text}",
+                "toast/copyFailed": "無法複製:{error}",
+                "toast/imageCopied": "已將畫面複製到剪貼簿",
+                "toast/loadAll": "全部載入",
+                "toast/no3d": "3D 檢視需要板框或銅箔圖層。",
+                "toast/noClipboard": "此處不支援將圖片複製到剪貼簿",
+                "toast/noReload": "此專案無法重新載入,請重新開啟檔案。",
+                "toast/notCached": "「{name}」是從裝置開啟的,並未快取在此瀏覽器中,請重新開啟。",
+                "toast/offline": "無法連線至 ArozOS,請改用「從此裝置開啟」。",
+                "toast/offlineShort": "無法連線至 ArozOS。",
+                "toast/openFailed": "無法開啟:{error}",
+                "toast/originCorner": "原點已移至電路板左下角",
+                "toast/originSet": "已將原點設於選取的位置",
+                "toast/pickOrigin": "點擊一個位置作為原點,按 Esc 取消。",
+                "toast/readFailed": "無法讀取檔案:{error}",
+                "toast/reloadFailed": "無法重新載入:{error}",
+                "toast/reloaded": "已重新載入{name}",
+                "toast/sibling": "此資料夾中還有 1 個 Gerber/鑽孔檔。",
+                "toast/siblings": "此資料夾中還有 {n} 個 Gerber/鑽孔檔。",
+                "type/bottom-assembly": "底層組裝圖",
+                "type/bottom-copper": "底層銅箔",
+                "type/bottom-mask": "底層防焊",
+                "type/bottom-paste": "底層錫膏",
+                "type/bottom-silk": "底層絲印",
+                "type/document": "文件層",
+                "type/drill": "鑽孔",
+                "type/drill-npth": "鑽孔(NPTH)",
+                "type/drill-pth": "鑽孔(PTH)",
+                "type/drill-via": "鑽孔(導通孔)",
+                "type/inner-copper": "內層銅箔",
+                "type/inner-n": "內層銅箔 {n}",
+                "type/keepout": "禁止區",
+                "type/other": "未知圖層",
+                "type/outline": "板框",
+                "type/top-assembly": "頂層組裝圖",
+                "type/top-copper": "頂層銅箔",
+                "type/top-mask": "頂層防焊",
+                "type/top-paste": "頂層錫膏",
+                "type/top-silk": "頂層絲印",
+                "unit/inLong": "英吋",
+                "unit/inch": "英吋",
+                "unit/milLong": "密爾",
+                "unit/mm": "公釐",
+                "unit/mmLong": "公釐",
+                "via/open": "過孔開窗",
+                "via/paste": "過孔塞銅漿 + 電鍍蓋帽",
+                "via/plug": "過孔塞油",
+                "via/resin": "過孔塞樹脂 + 電鍍蓋帽",
+                "via/tent": "過孔蓋油",
+                "view3d/bottom": "底面",
+                "view3d/front": "正面",
+                "view3d/iso": "等角",
+                "view3d/mfg": "製造",
+                "view3d/top": "頂面",
+                "warn/arcap": "以非圓形光圈繪製的圓弧為近似顯示。",
+                "warn/blocktf": "區塊光圈的鏡像、旋轉與縮放未套用。",
+                "warn/d3reg": "區域內不允許 Flash,已略過。",
+                "warn/deprecated": "已忽略過時的 %{cmd} 影像轉換。",
+                "warn/inc": "使用了增量座標(已過時)。",
+                "warn/macro": "未知的光圈範本「{name}」。",
+                "warn/mo": "沒有單位(%MO)指令,假設為英吋。",
+                "warn/noapdraw": "繪製時未選取光圈。",
+                "warn/noapflash": "Flash 時未選取光圈。",
+                "warn/notool": "在選取刀具前就已鑽孔。",
+                "warn/openreg": "有區域未以 G37 封閉。",
+                "warn/toolsize": "刀具 {tool} 未指定尺寸。",
+                "warn/undef": "使用了未定義的光圈 D{d}。",
+                "warn/units": "沒有 METRIC/INCH 宣告,假設為英吋。",
+                "zoom/actual": "1:1(實際大小)"
+            },
+            "titles": {
+                "2D view": "2D 檢視",
+                "3D view (3)": "3D 檢視 (3)",
+                "Click or drag to move the view": "點擊或拖曳以移動檢視範圍",
+                "Crosshair cursor": "十字游標",
+                "Focus mode": "專注模式",
+                "Gerber Viewer": "Gerber 檢視器",
+                "Hide all layers": "隱藏所有圖層",
+                "Measure (M)": "測量 (M)",
+                "More": "更多",
+                "Next tip": "下一個提示",
+                "Pan (H)": "平移 (H)",
+                "Render mode": "顯示模式",
+                "Rotate 90 degrees (R)": "旋轉 90 度 (R)",
+                "Select / inspect (V)": "選取/檢查 (V)",
+                "Show / hide side panel": "顯示/隱藏側邊欄",
+                "Show all layers": "顯示所有圖層",
+                "View bottom side (B)": "檢視底面 (B)",
+                "Zoom": "縮放",
+                "Zoom to area (Z)": "縮放至區域 (Z)",
+                "Zoom to fit (F)": "縮放至適當大小 (F)"
+            },
+            "placeholder": {}
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fwtitle": "Gerber 查看器",
+            "strings": {
+                "about/credits": "ArozOS 上的压缩包由本应用的 AGI 后端解压。3D 视图使用 three.js(MIT 许可证)。",
+                "about/storedAroz": "设置和最近的项目按用户保存在 ArozOS 中。",
+                "about/storedLocal": "设置和最近的项目保存在此浏览器中。",
+                "about/text": "以图层、真实电路板预览或 3D 方式查看 PCB 生产数据。可测量特征、检查光圈和钻孔,并导出图片。",
+                "about/title": "关于",
+                "about/version": "版本 {v}(ArozOS 版)",
+                "ap/block": "块光圈",
+                "ap/circle": "圆形 Ø{d}",
+                "ap/circleHole": "圆形 Ø{d},孔 {h}",
+                "ap/dcode": "D 码",
+                "ap/function": "功能",
+                "ap/macro": "宏 {name}",
+                "ap/none": "此图层未定义任何光圈。",
+                "ap/note": "标准光圈尺寸以毫米显示;宏参数使用文件自身的单位({unit})。",
+                "ap/obround": "长圆形 {w} × {h}",
+                "ap/polygon": "多边形 Ø{d},{n} 条边",
+                "ap/rect": "矩形 {w} × {h}",
+                "ap/shape": "形状",
+                "ap/title": "光圈表",
+                "ap/uses": "使用次数",
+                "app/title": "Gerber 查看器",
+                "badge/none": "未打开项目",
+                "badge/project": "Gerber 项目",
+                "bg/black": "黑色",
+                "bg/graphite": "石墨灰",
+                "bg/midnight": "午夜蓝",
+                "bg/paper": "纸白",
+                "bg/slate": "石板灰",
+                "card/fileInfo": "文件信息",
+                "card/tip": "提示",
+                "card/visibility": "图层显示",
+                "chip/bottom": "底面(镜像)",
+                "chip/layers": "图层",
+                "chip/realistic": "真实",
+                "chip/top": "顶面",
+                "colour/custom": "自定义",
+                "colour/opacity": "不透明度",
+                "common/cancel": "取消",
+                "common/clear": "清除",
+                "common/close": "关闭",
+                "common/no": "否",
+                "common/ok": "确定",
+                "common/reset": "重置",
+                "common/save": "保存",
+                "common/unknown": "未知",
+                "common/yes": "是",
+                "confirm/clearRecent": "清除最近的项目",
+                "confirm/clearRecentText": "要从最近列表中移除所有项目吗?",
+                "ctx/center": "以此处为中心",
+                "ctx/copyCoords": "复制坐标",
+                "ctx/measureFrom": "从此处开始测量",
+                "ctx/objectOn": "{layer}上的{object}",
+                "ctx/select": "选择",
+                "ctx/setOrigin": "将原点设在此处",
+                "ctx/showOnly": "仅显示{layer}",
+                "ctx/viewTop": "查看顶面",
+                "ctx/zoomObject": "缩放到对象",
+                "drill/csv": "导出 CSV...",
+                "drill/diameter": "直径",
+                "drill/diameterIn": "直径(英寸)",
+                "drill/duplicates": "文件中共有 {n} 次钻孔;跨文件重复的孔只计一次。",
+                "drill/holes": "孔数",
+                "drill/none": "此项目没有钻孔文件。",
+                "drill/plated": "金属化",
+                "drill/slots": "槽孔",
+                "drill/smallest": "最小孔径 {d}。",
+                "drill/summary": "共 {n} 个孔({p} 个金属化、{np} 个非金属化)和 {s} 个槽孔。",
+                "drill/title": "钻孔表",
+                "drill/tool": "刀具",
+                "drill/total": "合计",
+                "drop/hint": "松开以打开",
+                "empty/drop": "或将 .zip、文件夹或 Gerber 文件拖放到此窗口的任意位置",
+                "empty/intro": "打开 PCB 生产文件包(zip)或单独的 Gerber 和钻孔文件。",
+                "empty/openAroz": "从 ArozOS 打开",
+                "empty/openFolder": "打开文件夹",
+                "empty/openLocal": "从本设备打开",
+                "empty/recent": "最近的项目",
+                "error/baddata": "文件数据无效或过大",
+                "error/badentry": "无效的条目",
+                "error/badop": "未知的操作",
+                "error/badpath": "无效的目标路径",
+                "error/badpaths": "无效的路径列表",
+                "error/badsettings": "无效的设置",
+                "error/badtype": "Gerber 查看器无法保存此文件类型",
+                "error/denied": "您没有写入此位置的权限",
+                "error/nofolder": "目标文件夹不存在",
+                "error/notcam": "不是 Gerber 或钻孔文件",
+                "error/notfound": "找不到文件",
+                "error/nothing": "没有可打开的内容",
+                "error/offline": "无法连接到 ArozOS",
+                "error/projecttoolarge": "项目大于 96 MB",
+                "error/read": "无法读取 {name}",
+                "error/readfail": "无法读取文件",
+                "error/reply": "服务器响应异常",
+                "error/server": "服务器错误 {status}",
+                "error/toolarge": "文件过大",
+                "error/toomany": "文件过多",
+                "error/writefail": "无法写入文件",
+                "error/zip64": "不支持 ZIP64 压缩包。",
+                "error/zipCorrupt": "压缩包条目已损坏:{name}",
+                "error/zipEncrypted": "不支持加密的压缩包条目:{name}",
+                "error/zipInvalid": "不是 zip 压缩包。",
+                "error/zipMethod": "不支持的 zip 压缩方式 {method}({name})",
+                "error/zipNoInflate": "此浏览器无法解压 zip 文件。",
+                "export/3d": "3D 快照",
+                "export/area": "范围",
+                "export/background": "背景",
+                "export/bgCanvas": "画布颜色",
+                "export/bgNone": "透明",
+                "export/bgWhite": "白色",
+                "export/board": "整块电路板",
+                "export/bottom": "真实底面",
+                "export/content": "内容",
+                "export/download": "下载",
+                "export/failed": "导出失败:{error}",
+                "export/format": "格式",
+                "export/layers": "图层视图",
+                "export/noteSize": "图片尺寸约 {w} × {h} 像素。",
+                "export/noteSizeCapped": "图片尺寸约 {w} × {h} 像素(长边上限 12000 像素)。",
+                "export/noteSvg": "将可见图层导出为矢量路径,尺寸以毫米为单位。",
+                "export/noteView": "以两倍屏幕分辨率导出当前可见的画面。",
+                "export/rendering": "正在渲染 {name}...",
+                "export/resolution": "分辨率",
+                "export/svg": "SVG(矢量)",
+                "export/title": "导出图片",
+                "export/toAroz": "保存到 ArozOS...",
+                "export/top": "真实顶面",
+                "export/view": "当前画面",
+                "finish/enig": "ENIG(沉金)",
+                "finish/hasl": "HASL(喷锡)",
+                "finish/osp": "OSP/裸铜",
+                "fmt/absolute": "绝对坐标",
+                "fmt/decimal": "小数/默认",
+                "fmt/incremental": "增量坐标",
+                "fmt/leading": "省略前导零",
+                "fmt/lz": "保留前导零",
+                "fmt/trailing": "省略后导零",
+                "fmt/tz": "保留后导零",
+                "formats/detect": "图层类型根据 X2 属性以及 EasyEDA、KiCad、Altium、Eagle/Fusion、DipTrace 和 Proteus 的文件命名方式识别。您可以在图层菜单中更改任何类型。",
+                "formats/drill": "Excellon(.drl .xln .exc .txt .drd),支持公制或英制、省略前导或后导零、槽孔(G85)和铣削外形。",
+                "formats/drillTitle": "钻孔",
+                "formats/gerber": "RS-274X 和 Gerber X2(属性、网络和元件),包括光圈宏、区域、圆弧、清除极性、步进重复和块光圈。常见扩展名:.gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho 以及 Eagle/KiCad 命名。",
+                "formats/packages": "从本地设备打开 zip 压缩包。从 ArozOS 打开:zip、7z、tar 和 tar.gz 压缩包(在服务器上解压)、文件夹或任意文件组合。",
+                "formats/packagesTitle": "文件包",
+                "formats/title": "支持的格式",
+                "goto/go": "转到",
+                "goto/missing": "请输入两个坐标",
+                "goto/note": "坐标相对于当前原点。",
+                "goto/title": "转到坐标",
+                "info/created": "创建时间",
+                "info/device": "本设备",
+                "info/file1": "1 个文件",
+                "info/files": "{n} 个文件",
+                "info/filesIgnored": "{files}(忽略 {n} 个)",
+                "info/filesLabel": "文件",
+                "info/format": "格式",
+                "info/generator": "生成软件",
+                "info/itemsOnAroz": "ArozOS 上的 {n} 个项目",
+                "info/layers": "图层",
+                "info/layersCopper": "{n}({c} 层铜)",
+                "info/name": "项目名称",
+                "info/noOutline": "{size}(无板框)",
+                "info/none": "无",
+                "info/project": "项目",
+                "info/size": "尺寸",
+                "info/source": "来源",
+                "keys/actual": "实际大小",
+                "keys/addDrop": "将拖放的文件加入项目",
+                "keys/arrows": "方向键",
+                "keys/bottom": "底面",
+                "keys/cancel": "取消/清除",
+                "keys/context": "右键菜单",
+                "keys/copyCursor": "复制光标坐标",
+                "keys/crosshair": "十字光标",
+                "keys/cycle": "上一个/下一个图层",
+                "keys/dblclick": "缩放到对象/合适大小",
+                "keys/dim": "2D/3D",
+                "keys/doubleClick": "双击",
+                "keys/drag": "拖动",
+                "keys/dragPan": "平移",
+                "keys/drop": "拖放",
+                "keys/export": "导出图片",
+                "keys/files": "文件",
+                "keys/fit": "缩放到合适大小",
+                "keys/focus": "专注模式",
+                "keys/goto": "转到坐标",
+                "keys/grid": "网格",
+                "keys/measure": "测量",
+                "keys/mouse": "鼠标",
+                "keys/openAroz": "从 ArozOS 打开",
+                "keys/openLocal": "从本设备打开",
+                "keys/pan": "平移",
+                "keys/panAny": "在任何工具下平移",
+                "keys/panAnyMouse": "平移(任何工具)",
+                "keys/panKeys": "平移",
+                "keys/panel": "侧边栏",
+                "keys/preferences": "偏好设置",
+                "keys/print": "打印",
+                "keys/realistic": "真实预览",
+                "keys/reload": "重新加载项目",
+                "keys/rightClick": "右键单击",
+                "keys/rightDrag": "右键/中键拖动",
+                "keys/rotate": "旋转 90°",
+                "keys/saveZip": "将项目保存为 ZIP",
+                "keys/select": "选择",
+                "keys/title": "键盘快捷键",
+                "keys/tools": "工具",
+                "keys/view": "视图",
+                "keys/wheel": "滚轮",
+                "keys/wheelZoom": "缩放",
+                "keys/zoom": "放大/缩小",
+                "keys/zoomArea": "缩放到区域",
+                "kind/arc": "圆弧",
+                "kind/flash": "焊盘(Flash)",
+                "kind/hole": "钻孔",
+                "kind/image": "图像填充",
+                "kind/object": "对象",
+                "kind/region": "区域/铺铜",
+                "kind/slot": "槽孔",
+                "kind/trace": "走线",
+                "layer/colourTitle": "颜色和不透明度",
+                "layer/hide": "隐藏图层",
+                "layer/show": "显示图层",
+                "layers/empty": "尚未加载图层。",
+                "loading/3dBuild": "正在生成 3D 电路板...",
+                "loading/3dStart": "正在启动 3D 视图...",
+                "loading/aroz": "正在从 ArozOS 读取...",
+                "loading/default": "加载中...",
+                "loading/files": "正在读取文件...",
+                "loading/opening": "正在打开 {name}...",
+                "loading/reading": "正在读取 {name}({i} / {n})",
+                "loading/reloading": "正在重新加载 {name}",
+                "loading/unpack": "正在解压 {name}",
+                "mask/black": "黑色",
+                "mask/blue": "蓝色",
+                "mask/green": "绿色",
+                "mask/matte": "哑光黑",
+                "mask/none": "无(裸板)",
+                "mask/purple": "紫色",
+                "mask/red": "红色",
+                "mask/white": "白色",
+                "mask/yellow": "黄色",
+                "mat/fpc": "FPC 软板",
+                "mat/fr4": "FR-4",
+                "measure/angle": "角度",
+                "measure/clear": "清除测量",
+                "measure/from": "起点",
+                "measure/live": "测量中...",
+                "measure/title": "测量",
+                "measure/to": "终点",
+                "menu/2d": "2D 视图",
+                "menu/3d": "3D 视图",
+                "menu/about": "关于 Gerber 查看器",
+                "menu/actualSize": "实际大小(1:1)",
+                "menu/addAroz": "从 ArozOS 添加图层...",
+                "menu/addLocal": "从本设备添加图层...",
+                "menu/apertureTable": "光圈表...",
+                "menu/boardReport": "电路板报告...",
+                "menu/bottomOnly": "仅显示底面",
+                "menu/bottomSide": "底面(镜像)",
+                "menu/clearMeasure": "清除测量",
+                "menu/clearRecent": "清除最近的项目",
+                "menu/clearSelection": "清除选择",
+                "menu/close": "关闭项目",
+                "menu/colour": "颜色和不透明度...",
+                "menu/copperOnly": "仅显示铜层",
+                "menu/copyCursor": "复制光标坐标",
+                "menu/copyImage": "将视图复制为图片",
+                "menu/crosshair": "十字光标",
+                "menu/drillTable": "钻孔表...",
+                "menu/edit": "编辑",
+                "menu/empty": "没有内容",
+                "menu/exit": "退出",
+                "menu/export": "导出图片...",
+                "menu/exportCsv": "导出钻孔表(CSV)...",
+                "menu/file": "文件",
+                "menu/focus": "专注模式",
+                "menu/formats": "支持的格式",
+                "menu/goto": "转到坐标...",
+                "menu/grid": "网格",
+                "menu/help": "帮助",
+                "menu/hideAll": "隐藏所有图层",
+                "menu/hideLayer": "隐藏图层",
+                "menu/layerType": "图层类型",
+                "menu/layerView": "图层视图",
+                "menu/layers": "图层",
+                "menu/measure": "测量",
+                "menu/mfg": "制造能力预览...",
+                "menu/minimap": "缩略导航",
+                "menu/moveDown": "下移",
+                "menu/moveUp": "上移",
+                "menu/nextLayer": "下一个图层",
+                "menu/openAroz": "从 ArozOS 打开...",
+                "menu/openArozFolder": "从 ArozOS 打开文件夹...",
+                "menu/openLocal": "从本设备打开...",
+                "menu/openLocalFolder": "从本设备打开文件夹...",
+                "menu/openRecent": "打开最近的项目",
+                "menu/originCorner": "原点设在电路板角上",
+                "menu/pan": "平移",
+                "menu/preferences": "偏好设置...",
+                "menu/prevLayer": "上一个图层",
+                "menu/print": "打印...",
+                "menu/properties": "属性",
+                "menu/realistic": "真实预览",
+                "menu/redetect": "重新识别图层类型",
+                "menu/reload": "重新加载项目",
+                "menu/reloadLayer": "重新加载图层",
+                "menu/removeLayer": "移除图层",
+                "menu/resetColours": "重置图层颜色",
+                "menu/resetOrder": "重置图层顺序",
+                "menu/resetOrigin": "重置原点",
+                "menu/rotate": "旋转 90°",
+                "menu/saveZip": "将项目保存为 ZIP...",
+                "menu/select": "选择",
+                "menu/setOrigin": "设置原点...",
+                "menu/shortcuts": "键盘快捷键",
+                "menu/showAll": "显示所有图层",
+                "menu/showLayer": "显示图层",
+                "menu/showOnly": "仅显示此图层",
+                "menu/sidePanel": "侧边栏",
+                "menu/snap": "吸附到特征",
+                "menu/theme": "主题",
+                "menu/tips": "提示",
+                "menu/tools": "工具",
+                "menu/topOnly": "仅显示顶面",
+                "menu/topSide": "顶面",
+                "menu/units": "单位",
+                "menu/view": "视图",
+                "menu/viewBottom": "查看底面",
+                "menu/viewLayerSource": "查看图层源码...",
+                "menu/viewSource": "查看源码...",
+                "menu/zoomArea": "缩放到区域",
+                "menu/zoomFit": "缩放到合适大小",
+                "menu/zoomIn": "放大",
+                "menu/zoomLayer": "缩放到图层",
+                "menu/zoomOut": "缩小",
+                "menu/zoomSelection": "缩放到所选内容",
+                "mfg/delivery": "出货方式",
+                "mfg/enable": "预览",
+                "mfg/enableTitle": "按这些选项渲染电路板",
+                "mfg/finish": "焊盘喷镀",
+                "mfg/layers": "板子层数",
+                "mfg/maker": "拼板",
+                "mfg/mask": "阻焊颜色",
+                "mfg/material": "板材类别",
+                "mfg/noteFpc": "软板使用琥珀色聚酰亚胺覆盖膜,而不是彩色阻焊。",
+                "mfg/noteLarge": "拼板有一边超过 500 mm,超出大多数板厂的加工范围。",
+                "mfg/noteTent": "盖油过孔仍保留孔,阻焊只覆盖焊环,因此仍可看到小凹点。",
+                "mfg/noteVcut": "V 割需要笔直的板边。此外形不是简单的矩形,使用邮票孔更稳妥。",
+                "mfg/panel": "拼板",
+                "mfg/panelHint": "列 × 行",
+                "mfg/panelSize": "拼板尺寸",
+                "mfg/perPanel": "每拼板的板数",
+                "mfg/railWidth": "工艺边宽度",
+                "mfg/rails": "工艺边",
+                "mfg/railsAll": "四边",
+                "mfg/railsLR": "左右",
+                "mfg/railsNone": "无",
+                "mfg/railsTB": "上下",
+                "mfg/reset": "重置选项",
+                "mfg/separation": "分板方式",
+                "mfg/silk": "字符颜色",
+                "mfg/single": "单片",
+                "mfg/size": "板子尺寸",
+                "mfg/stack": "叠层",
+                "mfg/stackLayers": "{layers} 层,{t} mm",
+                "mfg/summary": "订单摘要",
+                "mfg/tab": "邮票孔",
+                "mfg/thickness": "成品板厚",
+                "mfg/title": "制造预览",
+                "mfg/vcut": "V 割",
+                "mfg/via": "阻焊覆盖",
+                "mfin/enig": "沉金(ENIG)",
+                "mfin/hasl": "有铅喷锡",
+                "mfin/haslLF": "无铅喷锡",
+                "mfin/osp": "无铅 OSP",
+                "pref/board": "电路板",
+                "pref/canvasBg": "画布背景",
+                "pref/crosshair": "全屏十字光标",
+                "pref/cursor": "光标",
+                "pref/customColour": "自定义颜色",
+                "pref/decimals": "小数位数",
+                "pref/default": "默认",
+                "pref/display": "显示",
+                "pref/finish": "表面处理",
+                "pref/general": "常规",
+                "pref/grid": "网格",
+                "pref/layerColours": "图层颜色",
+                "pref/mask": "阻焊颜色",
+                "pref/measuring": "测量",
+                "pref/minimap": "缩略导航",
+                "pref/opacity": "新图层不透明度",
+                "pref/recolour": "同时为已打开项目的图层重新着色",
+                "pref/reset": "恢复默认值",
+                "pref/resetDone": "偏好设置已恢复为默认值",
+                "pref/resetText": "要将所有偏好设置恢复为默认值吗?",
+                "pref/resetTitle": "重置偏好设置",
+                "pref/restoreLast": "重新打开上次的项目",
+                "pref/showGrid": "显示网格",
+                "pref/showMinimap": "显示缩略导航",
+                "pref/showTips": "显示提示",
+                "pref/sidePanel": "侧边栏",
+                "pref/silk": "字符颜色",
+                "pref/snap": "吸附到焊盘、孔和线段端点",
+                "pref/startup": "启动时",
+                "pref/theme": "主题",
+                "pref/thickness": "板厚",
+                "pref/title": "偏好设置",
+                "pref/unitIn": "英寸(in)",
+                "pref/unitMil": "密耳(mil)",
+                "pref/unitMm": "毫米(mm)",
+                "pref/units": "单位",
+                "print/actual": "实际大小(1:1)",
+                "print/colour": "图层颜色",
+                "print/colours": "颜色",
+                "print/fit": "适合页面",
+                "print/mono": "白底黑色",
+                "print/note": "实际大小会按 1:1 打印电路板,便于将封装与实际元件比对。请在浏览器的打印对话框中关闭“适合页面”或缩放。",
+                "print/print": "打印",
+                "print/scale": "比例",
+                "print/title": "打印",
+                "project/untitled": "未命名项目",
+                "prop/angle": "角度",
+                "prop/aperture": "光圈",
+                "prop/area": "面积",
+                "prop/ccw": "逆时针",
+                "prop/center": "中心",
+                "prop/component": "元件",
+                "prop/cw": "顺时针",
+                "prop/diameter": "直径",
+                "prop/end": "终点",
+                "prop/extent": "范围",
+                "prop/function": "功能",
+                "prop/layer": "图层",
+                "prop/length": "长度",
+                "prop/net": "网络",
+                "prop/noNet": "(无网络)",
+                "prop/object": "对象",
+                "prop/pin": "引脚",
+                "prop/pinValue": "{ref} 的 {pin} 号引脚",
+                "prop/plated": "金属化",
+                "prop/position": "位置",
+                "prop/radius": "半径",
+                "prop/start": "起点",
+                "prop/sweep": "扫掠角",
+                "prop/tool": "刀具",
+                "prop/vertices": "顶点数",
+                "prop/width": "宽度",
+                "props/apertures": "光圈",
+                "props/aperturesBtn": "光圈",
+                "props/arcsN": "{n} 个圆弧",
+                "props/area": "面积",
+                "props/board": "电路板",
+                "props/colour": "颜色和不透明度",
+                "props/coordinates": "坐标格式",
+                "props/copperLayers": "铜层",
+                "props/created": "创建时间",
+                "props/drillTable": "钻孔表",
+                "props/empty": "空",
+                "props/excellon": "Excellon 钻孔",
+                "props/extent": "范围",
+                "props/file": "文件",
+                "props/flashesN": "{n} 个 Flash",
+                "props/format": "格式",
+                "props/function": "功能",
+                "props/generator": "生成软件",
+                "props/hint": "选择一个图层或点击电路板上的对象以查看详细信息。",
+                "props/holesLabel": "孔",
+                "props/holesN": "{n} 个孔",
+                "props/holesValue": "{n}({p} 个金属化、{np} 个非金属化)",
+                "props/layerType": "图层类型",
+                "props/linesN": "{n} 条线",
+                "props/measureFrom": "从此测量",
+                "props/minHole": "最小孔径",
+                "props/minTrace": "最细走线",
+                "props/noOutline": "未找到,改用图层范围",
+                "props/noProject": "打开项目以查看其属性。",
+                "props/objects": "对象",
+                "props/originOffset": "原点偏移",
+                "props/outline": "板框",
+                "props/outlineClosed": "{n} 个封闭外框",
+                "props/outlineCut": "{n} 个封闭外框、{c} 个挖空",
+                "props/polarity": "极性",
+                "props/polarityBoth": "暗和清除",
+                "props/polarityDark": "暗",
+                "props/regionsN": "{n} 个区域",
+                "props/remove": "移除",
+                "props/size": "尺寸",
+                "props/slotsLabel": "槽孔",
+                "props/slotsN": "{n} 个槽孔",
+                "props/source": "源码",
+                "props/tools": "刀具",
+                "props/units": "单位",
+                "props/visible": "显示",
+                "props/warnings": "警告",
+                "props/zoomTo": "缩放到此",
+                "recent/device": "从本设备打开",
+                "recent/none": "没有最近的项目",
+                "recent/remove": "从列表中移除",
+                "report/area": "电路板面积",
+                "report/file": "文件",
+                "report/ignored": "已忽略的文件",
+                "report/layer": "图层",
+                "report/layers": "图层",
+                "report/noOutline": "未找到封闭的板框",
+                "report/notes": "备注",
+                "report/objects": "对象",
+                "report/outline": "板框",
+                "report/outlines": "{n} 个封闭外框、{c} 个挖空",
+                "report/project": "项目",
+                "report/size": "电路板尺寸",
+                "report/title": "电路板报告",
+                "report/units": "单位",
+                "report/warnings": "{n} 个警告",
+                "save/aroz": "ArozOS...",
+                "save/device": "本设备",
+                "save/failed": "无法保存:{error}",
+                "save/saved": "已保存到 {path}",
+                "save/saving": "正在保存 {name}...",
+                "save/title": "保存 {name}",
+                "save/where": "要将此文件保存到哪里?",
+                "silk/auto": "自动",
+                "silk/black": "黑色",
+                "silk/white": "白色",
+                "silk/yellow": "黄色",
+                "skip/archivetype": "不支持的压缩包类型",
+                "skip/expanded": "压缩包解压后的 CAM 数据超过 512 MB",
+                "skip/none": "未找到 Gerber 或钻孔文件。",
+                "skip/notcam": "不是 Gerber 或钻孔文件",
+                "skip/notfound": "找不到文件",
+                "skip/parse": "无法解析:{error}",
+                "skip/projecttoolarge": "项目大于 96 MB",
+                "skip/tartoolarge": "不支持大于 64 MB 的 tar 压缩包",
+                "skip/title": "部分文件已跳过",
+                "skip/toolarge": "文件大于 32 MB",
+                "skip/toomany": "文件过多",
+                "skip/ziponly": "从本设备只能打开 zip 压缩包",
+                "source/lines": "{n} 行",
+                "source/more": "...(还有 {size})",
+                "source/title": "图层源码",
+                "status/layers": "{n} 个图层",
+                "tab/layers": "图层",
+                "tab/props": "属性",
+                "theme/dark": "深色",
+                "theme/light": "浅色",
+                "theme/system": "跟随 ArozOS",
+                "time/day": "{n} 天前",
+                "time/hour": "{n} 小时前",
+                "time/min": "{n} 分钟前",
+                "time/now": "刚刚",
+                "tip/0": "拖动列表中的图层可更改绘制顺序,列表最上方的图层绘制在最上层。",
+                "tip/1": "点击色块可更改图层的颜色和不透明度。",
+                "tip/2": "按 M 进行测量。测量会吸附到焊盘、过孔和线段端点。",
+                "tip/3": "按 P 切换真实电路板预览,按 B 查看底面。",
+                "tip/4": "在图层上右键单击可使用更多选项,例如仅显示该图层。",
+                "tip/5": "工具 > 钻孔表会列出每种孔径及其孔数和金属化情况。",
+                "tip/6": "按住空格键拖动可在任何工具下平移。在空白处双击可缩放到合适大小。",
+                "tip/7": "按 3 以 3D 方式查看电路板。板厚和颜色可在偏好设置中调整。",
+                "tip/8": "最近的项目会记住图层的颜色、顺序和显示状态。",
+                "tip/9": "点击焊盘或走线,即可在属性中查看其光圈、尺寸和位置。",
+                "title/offline": "此页面无法连接到 ArozOS",
+                "toast/3dFailed": "无法启动 3D 视图:{error}",
+                "toast/added": "已添加 {n} 个图层",
+                "toast/addedOne": "已添加 1 个图层",
+                "toast/copied": "已复制 {text}",
+                "toast/copyFailed": "无法复制:{error}",
+                "toast/imageCopied": "已将视图复制到剪贴板",
+                "toast/loadAll": "全部加载",
+                "toast/no3d": "3D 视图需要板框或铜层。",
+                "toast/noClipboard": "此处不支持将图片复制到剪贴板",
+                "toast/noReload": "此项目无法重新加载,请重新打开文件。",
+                "toast/notCached": "“{name}”是从设备打开的,未缓存在此浏览器中,请重新打开。",
+                "toast/offline": "无法连接到 ArozOS,请改用“从本设备打开”。",
+                "toast/offlineShort": "无法连接到 ArozOS。",
+                "toast/openFailed": "无法打开:{error}",
+                "toast/originCorner": "原点已移到电路板左下角",
+                "toast/originSet": "已将原点设在所选位置",
+                "toast/pickOrigin": "点击一个位置作为原点,按 Esc 取消。",
+                "toast/readFailed": "无法读取文件:{error}",
+                "toast/reloadFailed": "无法重新加载:{error}",
+                "toast/reloaded": "已重新加载{name}",
+                "toast/sibling": "此文件夹中还有 1 个 Gerber/钻孔文件。",
+                "toast/siblings": "此文件夹中还有 {n} 个 Gerber/钻孔文件。",
+                "type/bottom-assembly": "底层装配图",
+                "type/bottom-copper": "底层铜",
+                "type/bottom-mask": "底层阻焊",
+                "type/bottom-paste": "底层锡膏",
+                "type/bottom-silk": "底层丝印",
+                "type/document": "文档层",
+                "type/drill": "钻孔",
+                "type/drill-npth": "钻孔(NPTH)",
+                "type/drill-pth": "钻孔(PTH)",
+                "type/drill-via": "钻孔(过孔)",
+                "type/inner-copper": "内层铜",
+                "type/inner-n": "内层铜 {n}",
+                "type/keepout": "禁布区",
+                "type/other": "未知图层",
+                "type/outline": "板框",
+                "type/top-assembly": "顶层装配图",
+                "type/top-copper": "顶层铜",
+                "type/top-mask": "顶层阻焊",
+                "type/top-paste": "顶层锡膏",
+                "type/top-silk": "顶层丝印",
+                "unit/inLong": "英寸",
+                "unit/inch": "英寸",
+                "unit/milLong": "密耳",
+                "unit/mm": "毫米",
+                "unit/mmLong": "毫米",
+                "via/open": "过孔开窗",
+                "via/paste": "过孔塞铜浆 + 电镀盖帽",
+                "via/plug": "过孔塞油",
+                "via/resin": "过孔塞树脂 + 电镀盖帽",
+                "via/tent": "过孔盖油",
+                "view3d/bottom": "底面",
+                "view3d/front": "正面",
+                "view3d/iso": "等轴",
+                "view3d/mfg": "制造",
+                "view3d/top": "顶面",
+                "warn/arcap": "用非圆形光圈绘制的圆弧为近似显示。",
+                "warn/blocktf": "块光圈的镜像、旋转和缩放未应用。",
+                "warn/d3reg": "区域内不允许 Flash,已跳过。",
+                "warn/deprecated": "已忽略过时的 %{cmd} 图像变换。",
+                "warn/inc": "使用了增量坐标(已过时)。",
+                "warn/macro": "未知的光圈模板“{name}”。",
+                "warn/mo": "没有单位(%MO)指令,假定为英寸。",
+                "warn/noapdraw": "绘制时未选择光圈。",
+                "warn/noapflash": "Flash 时未选择光圈。",
+                "warn/notool": "在选择刀具前已钻孔。",
+                "warn/openreg": "有区域未用 G37 封闭。",
+                "warn/toolsize": "刀具 {tool} 未指定尺寸。",
+                "warn/undef": "使用了未定义的光圈 D{d}。",
+                "warn/units": "没有 METRIC/INCH 声明,假定为英寸。",
+                "zoom/actual": "1:1(实际大小)"
+            },
+            "titles": {
+                "2D view": "2D 视图",
+                "3D view (3)": "3D 视图 (3)",
+                "Click or drag to move the view": "点击或拖动以移动视图",
+                "Crosshair cursor": "十字光标",
+                "Focus mode": "专注模式",
+                "Gerber Viewer": "Gerber 查看器",
+                "Hide all layers": "隐藏所有图层",
+                "Measure (M)": "测量 (M)",
+                "More": "更多",
+                "Next tip": "下一条提示",
+                "Pan (H)": "平移 (H)",
+                "Render mode": "显示模式",
+                "Rotate 90 degrees (R)": "旋转 90 度 (R)",
+                "Select / inspect (V)": "选择/检查 (V)",
+                "Show / hide side panel": "显示/隐藏侧边栏",
+                "Show all layers": "显示所有图层",
+                "View bottom side (B)": "查看底面 (B)",
+                "Zoom": "缩放",
+                "Zoom to area (Z)": "缩放到区域 (Z)",
+                "Zoom to fit (F)": "缩放到合适大小 (F)"
+            },
+            "placeholder": {}
+        },
+        "en-us": {
+            "name": "English",
+            "fwtitle": "Gerber Viewer",
+            "strings": {
+                "about/credits": "Archives on ArozOS are unpacked by the AGI backend of this app. The 3D view uses three.js (MIT License).",
+                "about/storedAroz": "Settings and recent projects are kept per user in ArozOS.",
+                "about/storedLocal": "Settings and recent projects are kept in this browser.",
+                "about/text": "View PCB fabrication data in layers, as a realistic board preview or in 3D. Measure features, inspect apertures and drills, and export images.",
+                "about/title": "About",
+                "about/version": "Version {v} for ArozOS",
+                "ap/block": "Block aperture",
+                "ap/circle": "Circle Ø{d}",
+                "ap/circleHole": "Circle Ø{d}, hole {h}",
+                "ap/dcode": "D-code",
+                "ap/function": "Function",
+                "ap/macro": "Macro {name}",
+                "ap/none": "No apertures are defined in this layer.",
+                "ap/note": "Standard aperture sizes are shown in millimetres; macro parameters use the unit of the file ({unit}).",
+                "ap/obround": "Obround {w} × {h}",
+                "ap/polygon": "Polygon Ø{d}, {n} sides",
+                "ap/rect": "Rectangle {w} × {h}",
+                "ap/shape": "Shape",
+                "ap/title": "Aperture Table",
+                "ap/uses": "Uses",
+                "app/title": "Gerber Viewer",
+                "badge/none": "No Project",
+                "badge/project": "Gerber Project",
+                "bg/black": "Black",
+                "bg/graphite": "Graphite",
+                "bg/midnight": "Midnight",
+                "bg/paper": "Paper",
+                "bg/slate": "Slate",
+                "card/fileInfo": "File Information",
+                "card/tip": "Tip",
+                "card/visibility": "Layer Visibility",
+                "chip/bottom": "Bottom side (mirrored)",
+                "chip/layers": "Layers",
+                "chip/realistic": "Realistic",
+                "chip/top": "Top side",
+                "colour/custom": "Custom",
+                "colour/opacity": "Opacity",
+                "common/cancel": "Cancel",
+                "common/clear": "Clear",
+                "common/close": "Close",
+                "common/no": "No",
+                "common/ok": "OK",
+                "common/reset": "Reset",
+                "common/save": "Save",
+                "common/unknown": "Unknown",
+                "common/yes": "Yes",
+                "confirm/clearRecent": "Clear recent projects",
+                "confirm/clearRecentText": "Remove every project from the recent list?",
+                "ctx/center": "Center Here",
+                "ctx/copyCoords": "Copy Coordinates",
+                "ctx/measureFrom": "Measure From Here",
+                "ctx/objectOn": "{object} on {layer}",
+                "ctx/select": "Select",
+                "ctx/setOrigin": "Set Origin Here",
+                "ctx/showOnly": "Show Only {layer}",
+                "ctx/viewTop": "View Top Side",
+                "ctx/zoomObject": "Zoom to Object",
+                "drill/csv": "Export CSV...",
+                "drill/diameter": "Diameter",
+                "drill/diameterIn": "Diameter (in)",
+                "drill/duplicates": "The files list {n} hits; duplicates across files are counted once.",
+                "drill/holes": "Holes",
+                "drill/none": "This project has no drill files.",
+                "drill/plated": "Plated",
+                "drill/slots": "Slots",
+                "drill/smallest": "Smallest hole {d}.",
+                "drill/summary": "{n} unique holes ({p} plated, {np} non-plated) and {s} slots.",
+                "drill/title": "Drill Table",
+                "drill/tool": "Tool",
+                "drill/total": "Total",
+                "drop/hint": "Drop to open",
+                "empty/drop": "or drop a .zip, a folder or Gerber files anywhere in this window",
+                "empty/intro": "Open a PCB fabrication package (zip) or individual Gerber and drill files.",
+                "empty/openAroz": "Open from ArozOS",
+                "empty/openFolder": "Open Folder",
+                "empty/openLocal": "Open from This Device",
+                "empty/recent": "Recent projects",
+                "error/baddata": "Invalid or oversized file data",
+                "error/badentry": "Invalid entry",
+                "error/badop": "Unknown operation",
+                "error/badpath": "Invalid destination path",
+                "error/badpaths": "Invalid path list",
+                "error/badsettings": "Invalid settings",
+                "error/badtype": "This file type cannot be saved by Gerber Viewer",
+                "error/denied": "You do not have permission to write to this location",
+                "error/nofolder": "The destination folder does not exist",
+                "error/notcam": "Not a Gerber or drill file",
+                "error/notfound": "File not found",
+                "error/nothing": "Nothing to open",
+                "error/offline": "Unable to reach ArozOS",
+                "error/projecttoolarge": "Project is larger than 96 MB",
+                "error/read": "Cannot read {name}",
+                "error/readfail": "Unable to read file",
+                "error/reply": "Unexpected reply from the server",
+                "error/server": "Server error {status}",
+                "error/toolarge": "File too large",
+                "error/toomany": "Too many files",
+                "error/writefail": "Unable to write the file",
+                "error/zip64": "ZIP64 archives are not supported.",
+                "error/zipCorrupt": "Corrupt zip entry: {name}",
+                "error/zipEncrypted": "Encrypted zip entries are not supported: {name}",
+                "error/zipInvalid": "Not a zip archive.",
+                "error/zipMethod": "Unsupported zip compression method {method} for {name}",
+                "error/zipNoInflate": "This browser cannot decompress zip files.",
+                "export/3d": "3D snapshot",
+                "export/area": "Area",
+                "export/background": "Background",
+                "export/bgCanvas": "Canvas colour",
+                "export/bgNone": "Transparent",
+                "export/bgWhite": "White",
+                "export/board": "Whole board",
+                "export/bottom": "Realistic bottom",
+                "export/content": "Content",
+                "export/download": "Download",
+                "export/failed": "Export failed: {error}",
+                "export/format": "Format",
+                "export/layers": "Layer view",
+                "export/noteSize": "Image size about {w} × {h} px.",
+                "export/noteSizeCapped": "Image size about {w} × {h} px (limited to 12000 px on the long side).",
+                "export/noteSvg": "Visible layers as vector paths, sized in millimetres.",
+                "export/noteView": "Exports exactly what is visible, at twice the screen resolution.",
+                "export/rendering": "Rendering {name}...",
+                "export/resolution": "Resolution",
+                "export/svg": "SVG (vector)",
+                "export/title": "Export Image",
+                "export/toAroz": "Save to ArozOS...",
+                "export/top": "Realistic top",
+                "export/view": "Current view",
+                "finish/enig": "ENIG (gold)",
+                "finish/hasl": "HASL (silver)",
+                "finish/osp": "OSP / bare copper",
+                "fmt/absolute": "absolute",
+                "fmt/decimal": "Decimal / default",
+                "fmt/incremental": "incremental",
+                "fmt/leading": "Leading zero omission",
+                "fmt/lz": "Leading zeros",
+                "fmt/trailing": "Trailing zero omission",
+                "fmt/tz": "Trailing zeros",
+                "formats/detect": "Layer types are detected from X2 attributes and from the file naming of EasyEDA, KiCad, Altium, Eagle / Fusion, DipTrace and Proteus. You can change any type from the layer menu.",
+                "formats/drill": "Excellon (.drl .xln .exc .txt .drd) in metric or inch with leading or trailing zero suppression, slots (G85) and routed outlines.",
+                "formats/drillTitle": "Drill",
+                "formats/gerber": "RS-274X and Gerber X2 (attributes, nets and components), including aperture macros, regions, arcs, clear polarity, step and repeat and block apertures. Common extensions: .gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho and Eagle / KiCad names.",
+                "formats/packages": "Zip archives from the local device. From ArozOS: zip, 7z, tar and tar.gz archives (unpacked on the server), folders, or any set of files.",
+                "formats/packagesTitle": "Packages",
+                "formats/title": "Supported Formats",
+                "goto/go": "Go",
+                "goto/missing": "Enter both coordinates",
+                "goto/note": "Coordinates are relative to the current origin.",
+                "goto/title": "Go to Coordinate",
+                "info/created": "Created",
+                "info/device": "This device",
+                "info/file1": "1 file",
+                "info/files": "{n} files",
+                "info/filesIgnored": "{files} ({n} ignored)",
+                "info/filesLabel": "Files",
+                "info/format": "Format",
+                "info/generator": "Generator",
+                "info/itemsOnAroz": "{n} items on ArozOS",
+                "info/layers": "Layers",
+                "info/layersCopper": "{n} ({c} copper)",
+                "info/name": "Project Name",
+                "info/noOutline": "{size} (no outline)",
+                "info/none": "None",
+                "info/project": "Project",
+                "info/size": "Size",
+                "info/source": "Source",
+                "keys/actual": "Actual size",
+                "keys/addDrop": "Add dropped files to the project",
+                "keys/arrows": "Arrow keys",
+                "keys/bottom": "Bottom side",
+                "keys/cancel": "Cancel / clear",
+                "keys/context": "Context menu",
+                "keys/copyCursor": "Copy cursor coordinates",
+                "keys/crosshair": "Crosshair",
+                "keys/cycle": "Previous / next layer",
+                "keys/dblclick": "Zoom to object / fit",
+                "keys/dim": "2D / 3D",
+                "keys/doubleClick": "Double-click",
+                "keys/drag": "Drag",
+                "keys/dragPan": "Pan",
+                "keys/drop": "Drop",
+                "keys/export": "Export image",
+                "keys/files": "Files",
+                "keys/fit": "Zoom to fit",
+                "keys/focus": "Focus mode",
+                "keys/goto": "Go to coordinate",
+                "keys/grid": "Grid",
+                "keys/measure": "Measure",
+                "keys/mouse": "Mouse",
+                "keys/openAroz": "Open from ArozOS",
+                "keys/openLocal": "Open from this device",
+                "keys/pan": "Pan",
+                "keys/panAny": "Pan with any tool",
+                "keys/panAnyMouse": "Pan (any tool)",
+                "keys/panKeys": "Pan",
+                "keys/panel": "Side panel",
+                "keys/preferences": "Preferences",
+                "keys/print": "Print",
+                "keys/realistic": "Realistic preview",
+                "keys/reload": "Reload project",
+                "keys/rightClick": "Right-click",
+                "keys/rightDrag": "Right / middle drag",
+                "keys/rotate": "Rotate 90°",
+                "keys/saveZip": "Save project as ZIP",
+                "keys/select": "Select",
+                "keys/title": "Keyboard Shortcuts",
+                "keys/tools": "Tools",
+                "keys/view": "View",
+                "keys/wheel": "Wheel",
+                "keys/wheelZoom": "Zoom",
+                "keys/zoom": "Zoom in / out",
+                "keys/zoomArea": "Zoom to area",
+                "kind/arc": "Arc",
+                "kind/flash": "Pad (flash)",
+                "kind/hole": "Drill hole",
+                "kind/image": "Image fill",
+                "kind/object": "Object",
+                "kind/region": "Region / pour",
+                "kind/slot": "Slot",
+                "kind/trace": "Trace",
+                "layer/colourTitle": "Colour and opacity",
+                "layer/hide": "Hide layer",
+                "layer/show": "Show layer",
+                "layers/empty": "No layers loaded.",
+                "loading/3dBuild": "Building 3D board...",
+                "loading/3dStart": "Starting 3D view...",
+                "loading/aroz": "Reading from ArozOS...",
+                "loading/default": "Loading...",
+                "loading/files": "Reading files...",
+                "loading/opening": "Opening {name}...",
+                "loading/reading": "Reading {name} ({i} of {n})",
+                "loading/reloading": "Reloading {name}",
+                "loading/unpack": "Unpacking {name}",
+                "mask/black": "Black",
+                "mask/blue": "Blue",
+                "mask/green": "Green",
+                "mask/matte": "Matte black",
+                "mask/none": "None (bare board)",
+                "mask/purple": "Purple",
+                "mask/red": "Red",
+                "mask/white": "White",
+                "mask/yellow": "Yellow",
+                "mat/fpc": "FPC (flexible)",
+                "mat/fr4": "FR-4",
+                "measure/angle": "Angle",
+                "measure/clear": "Clear measurement",
+                "measure/from": "From",
+                "measure/live": "Measuring...",
+                "measure/title": "Measurement",
+                "measure/to": "To",
+                "menu/2d": "2D View",
+                "menu/3d": "3D View",
+                "menu/about": "About Gerber Viewer",
+                "menu/actualSize": "Actual Size (1:1)",
+                "menu/addAroz": "Add Layers from ArozOS...",
+                "menu/addLocal": "Add Layers from This Device...",
+                "menu/apertureTable": "Aperture Table...",
+                "menu/boardReport": "Board Report...",
+                "menu/bottomOnly": "Bottom Side Only",
+                "menu/bottomSide": "Bottom Side (Mirrored)",
+                "menu/clearMeasure": "Clear Measurement",
+                "menu/clearRecent": "Clear Recent Projects",
+                "menu/clearSelection": "Clear Selection",
+                "menu/close": "Close Project",
+                "menu/colour": "Colour and Opacity...",
+                "menu/copperOnly": "Copper Only",
+                "menu/copyCursor": "Copy Cursor Coordinates",
+                "menu/copyImage": "Copy View as Image",
+                "menu/crosshair": "Crosshair Cursor",
+                "menu/drillTable": "Drill Table...",
+                "menu/edit": "Edit",
+                "menu/empty": "Nothing here",
+                "menu/exit": "Exit",
+                "menu/export": "Export Image...",
+                "menu/exportCsv": "Export Drill Table (CSV)...",
+                "menu/file": "File",
+                "menu/focus": "Focus Mode",
+                "menu/formats": "Supported Formats",
+                "menu/goto": "Go to Coordinate...",
+                "menu/grid": "Grid",
+                "menu/help": "Help",
+                "menu/hideAll": "Hide All Layers",
+                "menu/hideLayer": "Hide Layer",
+                "menu/layerType": "Layer Type",
+                "menu/layerView": "Layer View",
+                "menu/layers": "Layers",
+                "menu/measure": "Measure",
+                "menu/mfg": "Manufacturing Capabilities Preview...",
+                "menu/minimap": "Minimap",
+                "menu/moveDown": "Move Down",
+                "menu/moveUp": "Move Up",
+                "menu/nextLayer": "Next Layer",
+                "menu/openAroz": "Open from ArozOS...",
+                "menu/openArozFolder": "Open Folder from ArozOS...",
+                "menu/openLocal": "Open from This Device...",
+                "menu/openLocalFolder": "Open Folder from This Device...",
+                "menu/openRecent": "Open Recent",
+                "menu/originCorner": "Origin at Board Corner",
+                "menu/pan": "Pan",
+                "menu/preferences": "Preferences...",
+                "menu/prevLayer": "Previous Layer",
+                "menu/print": "Print...",
+                "menu/properties": "Properties",
+                "menu/realistic": "Realistic Preview",
+                "menu/redetect": "Re-detect Layer Types",
+                "menu/reload": "Reload Project",
+                "menu/reloadLayer": "Reload Layer",
+                "menu/removeLayer": "Remove Layer",
+                "menu/resetColours": "Reset Layer Colours",
+                "menu/resetOrder": "Reset Layer Order",
+                "menu/resetOrigin": "Reset Origin",
+                "menu/rotate": "Rotate 90°",
+                "menu/saveZip": "Save Project as ZIP...",
+                "menu/select": "Select",
+                "menu/setOrigin": "Set Origin...",
+                "menu/shortcuts": "Keyboard Shortcuts",
+                "menu/showAll": "Show All Layers",
+                "menu/showLayer": "Show Layer",
+                "menu/showOnly": "Show Only This Layer",
+                "menu/sidePanel": "Side Panel",
+                "menu/snap": "Snap to Features",
+                "menu/theme": "Theme",
+                "menu/tips": "Tips",
+                "menu/tools": "Tools",
+                "menu/topOnly": "Top Side Only",
+                "menu/topSide": "Top Side",
+                "menu/units": "Units",
+                "menu/view": "View",
+                "menu/viewBottom": "View Bottom Side",
+                "menu/viewLayerSource": "View Layer Source...",
+                "menu/viewSource": "View Source...",
+                "menu/zoomArea": "Zoom to Area",
+                "menu/zoomFit": "Zoom to Fit",
+                "menu/zoomIn": "Zoom In",
+                "menu/zoomLayer": "Zoom to Layer",
+                "menu/zoomOut": "Zoom Out",
+                "menu/zoomSelection": "Zoom to Selection",
+                "mfg/delivery": "Delivery format",
+                "mfg/enable": "Preview",
+                "mfg/enableTitle": "Render the board with these options",
+                "mfg/finish": "Surface finish",
+                "mfg/layers": "Layers",
+                "mfg/maker": "Panelization",
+                "mfg/mask": "Solder mask colour",
+                "mfg/material": "Board material",
+                "mfg/noteFpc": "Flexible boards are covered with an amber polyimide coverlay instead of a coloured solder mask.",
+                "mfg/noteLarge": "The panel is larger than 500 mm on one side, beyond what most fabs accept.",
+                "mfg/noteTent": "Tented vias keep their hole; the mask only covers the ring, so a small dimple stays visible.",
+                "mfg/noteVcut": "V-cut needs straight board edges. This outline is not a plain rectangle, so stamp holes are the safer choice.",
+                "mfg/panel": "Panel",
+                "mfg/panelHint": "columns × rows",
+                "mfg/panelSize": "Panel size",
+                "mfg/perPanel": "Boards per panel",
+                "mfg/railWidth": "Rail width",
+                "mfg/rails": "Edge rails",
+                "mfg/railsAll": "All sides",
+                "mfg/railsLR": "Left and right",
+                "mfg/railsNone": "None",
+                "mfg/railsTB": "Top and bottom",
+                "mfg/reset": "Reset options",
+                "mfg/separation": "Separation",
+                "mfg/silk": "Silkscreen colour",
+                "mfg/single": "Single",
+                "mfg/size": "Board size",
+                "mfg/stack": "Stack-up",
+                "mfg/stackLayers": "{layers} layers, {t} mm",
+                "mfg/summary": "Order summary",
+                "mfg/tab": "Stamp holes",
+                "mfg/thickness": "Board thickness",
+                "mfg/title": "Manufacturing Preview",
+                "mfg/vcut": "V-cut",
+                "mfg/via": "Via covering",
+                "mfin/enig": "ENIG (immersion gold)",
+                "mfin/hasl": "Leaded HASL",
+                "mfin/haslLF": "Lead-free HASL",
+                "mfin/osp": "Lead-free OSP",
+                "pref/board": "Board",
+                "pref/canvasBg": "Canvas background",
+                "pref/crosshair": "Full-screen crosshair",
+                "pref/cursor": "Cursor",
+                "pref/customColour": "Custom colour",
+                "pref/decimals": "Decimal places",
+                "pref/default": "Default",
+                "pref/display": "Display",
+                "pref/finish": "Surface finish",
+                "pref/general": "General",
+                "pref/grid": "Grid",
+                "pref/layerColours": "Layer Colours",
+                "pref/mask": "Solder mask",
+                "pref/measuring": "Measuring",
+                "pref/minimap": "Minimap",
+                "pref/opacity": "New layer opacity",
+                "pref/recolour": "Also recolour layers of the open project",
+                "pref/reset": "Reset to Defaults",
+                "pref/resetDone": "Preferences restored to their defaults",
+                "pref/resetText": "Restore every preference to its default value?",
+                "pref/resetTitle": "Reset preferences",
+                "pref/restoreLast": "Reopen the last project",
+                "pref/showGrid": "Show grid",
+                "pref/showMinimap": "Show minimap",
+                "pref/showTips": "Show tips",
+                "pref/sidePanel": "Side panel",
+                "pref/silk": "Silkscreen",
+                "pref/snap": "Snap to pads, holes and line ends",
+                "pref/startup": "On startup",
+                "pref/theme": "Theme",
+                "pref/thickness": "Board thickness",
+                "pref/title": "Preferences",
+                "pref/unitIn": "Inches (in)",
+                "pref/unitMil": "Mils (thou)",
+                "pref/unitMm": "Millimetres (mm)",
+                "pref/units": "Units",
+                "print/actual": "Actual size (1:1)",
+                "print/colour": "Layer colours",
+                "print/colours": "Colours",
+                "print/fit": "Fit to page",
+                "print/mono": "Black on white",
+                "print/note": "Actual size prints the board 1:1, handy for checking footprints against real parts. Turn off \"fit to page\" or scaling in the browser print dialog.",
+                "print/print": "Print",
+                "print/scale": "Scale",
+                "print/title": "Print",
+                "project/untitled": "Untitled project",
+                "prop/angle": "Angle",
+                "prop/aperture": "Aperture",
+                "prop/area": "Area",
+                "prop/ccw": "CCW",
+                "prop/center": "Center",
+                "prop/component": "Component",
+                "prop/cw": "CW",
+                "prop/diameter": "Diameter",
+                "prop/end": "End",
+                "prop/extent": "Extent",
+                "prop/function": "Function",
+                "prop/layer": "Layer",
+                "prop/length": "Length",
+                "prop/net": "Net",
+                "prop/noNet": "(no net)",
+                "prop/object": "Object",
+                "prop/pin": "Pin",
+                "prop/pinValue": "{ref} pin {pin}",
+                "prop/plated": "Plated",
+                "prop/position": "Position",
+                "prop/radius": "Radius",
+                "prop/start": "Start",
+                "prop/sweep": "Sweep",
+                "prop/tool": "Tool",
+                "prop/vertices": "Vertices",
+                "prop/width": "Width",
+                "props/apertures": "Apertures",
+                "props/aperturesBtn": "Apertures",
+                "props/arcsN": "{n} arcs",
+                "props/area": "Area",
+                "props/board": "Board",
+                "props/colour": "Colour and opacity",
+                "props/coordinates": "Coordinates",
+                "props/copperLayers": "Copper layers",
+                "props/created": "Created",
+                "props/drillTable": "Drill table",
+                "props/empty": "Empty",
+                "props/excellon": "Excellon drill",
+                "props/extent": "Extent",
+                "props/file": "File",
+                "props/flashesN": "{n} flashes",
+                "props/format": "Format",
+                "props/function": "Function",
+                "props/generator": "Generator",
+                "props/hint": "Select a layer or click an object on the board to see its details.",
+                "props/holesLabel": "Holes",
+                "props/holesN": "{n} holes",
+                "props/holesValue": "{n} ({p} plated, {np} non-plated)",
+                "props/layerType": "Layer type",
+                "props/linesN": "{n} lines",
+                "props/measureFrom": "Measure from",
+                "props/minHole": "Smallest hole",
+                "props/minTrace": "Thinnest trace",
+                "props/noOutline": "Not found, using the layer extent",
+                "props/noProject": "Open a project to see its properties.",
+                "props/objects": "Objects",
+                "props/originOffset": "Origin offset",
+                "props/outline": "Outline",
+                "props/outlineClosed": "{n} closed",
+                "props/outlineCut": "{n} closed, {c} cut-outs",
+                "props/polarity": "Polarity",
+                "props/polarityBoth": "Dark and clear",
+                "props/polarityDark": "Dark",
+                "props/regionsN": "{n} regions",
+                "props/remove": "Remove",
+                "props/size": "Size",
+                "props/slotsLabel": "Slots",
+                "props/slotsN": "{n} slots",
+                "props/source": "Source",
+                "props/tools": "Tools",
+                "props/units": "Units",
+                "props/visible": "Visible",
+                "props/warnings": "Warnings",
+                "props/zoomTo": "Zoom to",
+                "recent/device": "Opened from this device",
+                "recent/none": "No recent projects",
+                "recent/remove": "Remove from list",
+                "report/area": "Board area",
+                "report/file": "File",
+                "report/ignored": "Ignored files",
+                "report/layer": "Layer",
+                "report/layers": "Layers",
+                "report/noOutline": "No closed outline found",
+                "report/notes": "Notes",
+                "report/objects": "Objects",
+                "report/outline": "Outline",
+                "report/outlines": "{n} closed outline(s), {c} cut-out(s)",
+                "report/project": "Project",
+                "report/size": "Board size",
+                "report/title": "Board Report",
+                "report/units": "Units",
+                "report/warnings": "{n} warning(s)",
+                "save/aroz": "ArozOS...",
+                "save/device": "This Device",
+                "save/failed": "Unable to save: {error}",
+                "save/saved": "Saved to {path}",
+                "save/saving": "Saving {name}...",
+                "save/title": "Save {name}",
+                "save/where": "Where do you want to save this file?",
+                "silk/auto": "Automatic",
+                "silk/black": "Black",
+                "silk/white": "White",
+                "silk/yellow": "Yellow",
+                "skip/archivetype": "Unsupported archive type",
+                "skip/expanded": "The archive expands to more than 512 MB of CAM data",
+                "skip/none": "No Gerber or drill files were found.",
+                "skip/notcam": "Not a Gerber or drill file",
+                "skip/notfound": "File not found",
+                "skip/parse": "Could not be parsed: {error}",
+                "skip/projecttoolarge": "Project is larger than 96 MB",
+                "skip/tartoolarge": "Tar archives larger than 64 MB are not supported",
+                "skip/title": "Some files were skipped",
+                "skip/toolarge": "File is larger than 32 MB",
+                "skip/toomany": "Too many files",
+                "skip/ziponly": "Only zip archives can be opened from this device",
+                "source/lines": "{n} lines",
+                "source/more": "... ({size} more)",
+                "source/title": "Layer Source",
+                "status/layers": "{n} layers",
+                "tab/layers": "Layers",
+                "tab/props": "Properties",
+                "theme/dark": "Dark",
+                "theme/light": "Light",
+                "theme/system": "Follow ArozOS",
+                "time/day": "{n} d ago",
+                "time/hour": "{n} h ago",
+                "time/min": "{n} min ago",
+                "time/now": "just now",
+                "tip/0": "Drag layers in the list to change the drawing order. The top of the list is drawn on top.",
+                "tip/1": "Click a colour swatch to change a layer's colour and opacity.",
+                "tip/2": "Press M to measure. Measurements snap to pads, vias and line end points.",
+                "tip/3": "Press P for the realistic board preview and B to look at the bottom side.",
+                "tip/4": "Right-click a layer for more options, such as showing only that layer.",
+                "tip/5": "Tools > Drill Table lists every drill size with its hole count and plating.",
+                "tip/6": "Hold Space and drag to pan with any tool. Double-click empty space to zoom to fit.",
+                "tip/7": "Press 3 to see the board in 3D. Board thickness and colours are in Preferences.",
+                "tip/8": "Recent projects remember their layer colours, order and visibility.",
+                "tip/9": "Click a pad or trace to see its aperture, size and position under Properties.",
+                "title/offline": "ArozOS is not reachable from this page",
+                "toast/3dFailed": "Unable to start the 3D view: {error}",
+                "toast/added": "Added {n} layers",
+                "toast/addedOne": "Added 1 layer",
+                "toast/copied": "Copied {text}",
+                "toast/copyFailed": "Unable to copy: {error}",
+                "toast/imageCopied": "View copied to the clipboard",
+                "toast/loadAll": "Load all",
+                "toast/no3d": "The 3D view needs a board outline or copper layers.",
+                "toast/noClipboard": "Clipboard images are not supported here",
+                "toast/noReload": "This project cannot be reloaded. Open the files again.",
+                "toast/notCached": "\"{name}\" was opened from a device and is not cached in this browser. Open it again.",
+                "toast/offline": "ArozOS is not reachable. Use Open from This Device instead.",
+                "toast/offlineShort": "ArozOS is not reachable.",
+                "toast/openFailed": "Unable to open: {error}",
+                "toast/originCorner": "Origin moved to the lower-left corner of the board",
+                "toast/originSet": "Origin set to the picked point",
+                "toast/pickOrigin": "Click a point to use as the origin. Esc cancels.",
+                "toast/readFailed": "Unable to read the files: {error}",
+                "toast/reloadFailed": "Unable to reload: {error}",
+                "toast/reloaded": "Reloaded {name}",
+                "toast/sibling": "1 more Gerber / drill file is in this folder.",
+                "toast/siblings": "{n} more Gerber / drill files are in this folder.",
+                "type/bottom-assembly": "Bottom Assembly",
+                "type/bottom-copper": "Bottom Copper",
+                "type/bottom-mask": "Bottom Solder Mask",
+                "type/bottom-paste": "Bottom Paste",
+                "type/bottom-silk": "Bottom Silkscreen",
+                "type/document": "Document",
+                "type/drill": "Drill",
+                "type/drill-npth": "Drill (NPTH)",
+                "type/drill-pth": "Drill (PTH)",
+                "type/drill-via": "Drill (Vias)",
+                "type/inner-copper": "Inner Copper",
+                "type/inner-n": "Inner Copper {n}",
+                "type/keepout": "Keep-out",
+                "type/other": "Unknown Layer",
+                "type/outline": "Board Outline",
+                "type/top-assembly": "Top Assembly",
+                "type/top-copper": "Top Copper",
+                "type/top-mask": "Top Solder Mask",
+                "type/top-paste": "Top Paste",
+                "type/top-silk": "Top Silkscreen",
+                "unit/inLong": "Inches",
+                "unit/inch": "Inch",
+                "unit/milLong": "Mils",
+                "unit/mm": "Millimetre",
+                "unit/mmLong": "Millimetres",
+                "via/open": "Untented",
+                "via/paste": "Copper paste + capped",
+                "via/plug": "Plugged with mask",
+                "via/resin": "Resin filled + capped",
+                "via/tent": "Tented",
+                "view3d/bottom": "Bottom",
+                "view3d/front": "Front",
+                "view3d/iso": "Iso",
+                "view3d/mfg": "Manufacturing",
+                "view3d/top": "Top",
+                "warn/arcap": "Arcs drawn with a non circular aperture are approximated.",
+                "warn/blocktf": "Mirroring, rotation and scaling of block apertures are not applied.",
+                "warn/d3reg": "Flash inside a region is not allowed and was skipped.",
+                "warn/deprecated": "Deprecated %{cmd} image transformation is ignored.",
+                "warn/inc": "Incremental coordinates (deprecated) are in use.",
+                "warn/macro": "Unknown aperture template \"{name}\".",
+                "warn/mo": "No unit (%MO) command; assuming inches.",
+                "warn/noapdraw": "Draw without a selected aperture.",
+                "warn/noapflash": "Flash without a selected aperture.",
+                "warn/notool": "A hole was drilled before any tool was selected.",
+                "warn/openreg": "A region was not closed with G37.",
+                "warn/toolsize": "Tool {tool} is used without a size.",
+                "warn/undef": "Aperture D{d} is used but never defined.",
+                "warn/units": "No METRIC / INCH statement; assuming inches.",
+                "zoom/actual": "1:1 (actual size)"
+            },
+            "titles": {
+                "2D view": "2D view",
+                "3D view (3)": "3D view (3)",
+                "Click or drag to move the view": "Click or drag to move the view",
+                "Crosshair cursor": "Crosshair cursor",
+                "Focus mode": "Focus mode",
+                "Gerber Viewer": "Gerber Viewer",
+                "Hide all layers": "Hide all layers",
+                "Measure (M)": "Measure (M)",
+                "More": "More",
+                "Next tip": "Next tip",
+                "Pan (H)": "Pan (H)",
+                "Render mode": "Render mode",
+                "Rotate 90 degrees (R)": "Rotate 90 degrees (R)",
+                "Select / inspect (V)": "Select / inspect (V)",
+                "Show / hide side panel": "Show / hide side panel",
+                "Show all layers": "Show all layers",
+                "View bottom side (B)": "View bottom side (B)",
+                "Zoom": "Zoom",
+                "Zoom to area (Z)": "Zoom to area (Z)",
+                "Zoom to fit (F)": "Zoom to fit (F)"
+            },
+            "placeholder": {}
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fwtitle": "Gerber ビューアー",
+            "strings": {
+                "about/credits": "ArozOS 上のアーカイブはこのアプリの AGI バックエンドで展開されます。3D 表示には three.js(MIT ライセンス)を使用しています。",
+                "about/storedAroz": "設定と最近のプロジェクトはユーザーごとに ArozOS に保存されます。",
+                "about/storedLocal": "設定と最近のプロジェクトはこのブラウザーに保存されます。",
+                "about/text": "PCB 製造データをレイヤー、リアルな基板プレビュー、または 3D で表示します。形状の測定、アパーチャやドリルの確認、画像のエクスポートができます。",
+                "about/title": "バージョン情報",
+                "about/version": "バージョン {v}(ArozOS 版)",
+                "ap/block": "ブロックアパーチャ",
+                "ap/circle": "円 Ø{d}",
+                "ap/circleHole": "円 Ø{d}、穴 {h}",
+                "ap/dcode": "D コード",
+                "ap/function": "機能",
+                "ap/macro": "マクロ {name}",
+                "ap/none": "このレイヤーにはアパーチャが定義されていません。",
+                "ap/note": "標準アパーチャのサイズはミリメートルで表示されます。マクロのパラメーターはファイルの単位({unit})のままです。",
+                "ap/obround": "長円 {w} × {h}",
+                "ap/polygon": "多角形 Ø{d}、{n} 辺",
+                "ap/rect": "長方形 {w} × {h}",
+                "ap/shape": "形状",
+                "ap/title": "アパーチャ一覧",
+                "ap/uses": "使用回数",
+                "app/title": "Gerber ビューアー",
+                "badge/none": "プロジェクトなし",
+                "badge/project": "Gerber プロジェクト",
+                "bg/black": "ブラック",
+                "bg/graphite": "グラファイト",
+                "bg/midnight": "ミッドナイト",
+                "bg/paper": "ペーパー",
+                "bg/slate": "スレート",
+                "card/fileInfo": "ファイル情報",
+                "card/tip": "ヒント",
+                "card/visibility": "レイヤーの表示",
+                "chip/bottom": "ボトム面(反転)",
+                "chip/layers": "レイヤー",
+                "chip/realistic": "リアル",
+                "chip/top": "トップ面",
+                "colour/custom": "カスタム",
+                "colour/opacity": "不透明度",
+                "common/cancel": "キャンセル",
+                "common/clear": "クリア",
+                "common/close": "閉じる",
+                "common/no": "いいえ",
+                "common/ok": "OK",
+                "common/reset": "リセット",
+                "common/save": "保存",
+                "common/unknown": "不明",
+                "common/yes": "はい",
+                "confirm/clearRecent": "最近のプロジェクトをクリア",
+                "confirm/clearRecentText": "最近の一覧からすべてのプロジェクトを削除しますか?",
+                "ctx/center": "ここを中心にする",
+                "ctx/copyCoords": "座標をコピー",
+                "ctx/measureFrom": "ここから測定",
+                "ctx/objectOn": "{layer} の{object}",
+                "ctx/select": "選択",
+                "ctx/setOrigin": "ここを原点にする",
+                "ctx/showOnly": "{layer} のみ表示",
+                "ctx/viewTop": "トップ面を表示",
+                "ctx/zoomObject": "オブジェクトを拡大",
+                "drill/csv": "CSV をエクスポート...",
+                "drill/diameter": "径",
+                "drill/diameterIn": "径(インチ)",
+                "drill/duplicates": "ファイル上のヒット数は {n} です。ファイル間で重複する穴は 1 つとして数えます。",
+                "drill/holes": "穴数",
+                "drill/none": "このプロジェクトにはドリルファイルがありません。",
+                "drill/plated": "めっき",
+                "drill/slots": "スロット",
+                "drill/smallest": "最小穴径 {d}。",
+                "drill/summary": "穴 {n} 個(めっき {p} 個、めっきなし {np} 個)、スロット {s} 個。",
+                "drill/title": "ドリル一覧",
+                "drill/tool": "ツール",
+                "drill/total": "合計",
+                "drop/hint": "ドロップして開く",
+                "empty/drop": "または .zip、フォルダー、Gerber ファイルをこのウィンドウにドロップ",
+                "empty/intro": "PCB 製造データ(zip)または個別の Gerber ファイルとドリルファイルを開きます。",
+                "empty/openAroz": "ArozOS から開く",
+                "empty/openFolder": "フォルダーを開く",
+                "empty/openLocal": "このデバイスから開く",
+                "empty/recent": "最近のプロジェクト",
+                "error/baddata": "ファイルデータが無効か大きすぎます",
+                "error/badentry": "無効な項目です",
+                "error/badop": "不明な操作です",
+                "error/badpath": "保存先のパスが無効です",
+                "error/badpaths": "パスの一覧が無効です",
+                "error/badsettings": "設定が無効です",
+                "error/badtype": "この種類のファイルは Gerber ビューアーでは保存できません",
+                "error/denied": "この場所に書き込む権限がありません",
+                "error/nofolder": "保存先のフォルダーが存在しません",
+                "error/notcam": "Gerber ファイルでもドリルファイルでもありません",
+                "error/notfound": "ファイルが見つかりません",
+                "error/nothing": "開くものがありません",
+                "error/offline": "ArozOS に接続できません",
+                "error/projecttoolarge": "プロジェクトが 96 MB を超えています",
+                "error/read": "{name} を読み込めません",
+                "error/readfail": "ファイルを読み込めません",
+                "error/reply": "サーバーから予期しない応答がありました",
+                "error/server": "サーバーエラー {status}",
+                "error/toolarge": "ファイルが大きすぎます",
+                "error/toomany": "ファイルが多すぎます",
+                "error/writefail": "ファイルを書き込めません",
+                "error/zip64": "ZIP64 アーカイブには対応していません。",
+                "error/zipCorrupt": "zip の項目が壊れています:{name}",
+                "error/zipEncrypted": "暗号化された zip の項目には対応していません:{name}",
+                "error/zipInvalid": "zip アーカイブではありません。",
+                "error/zipMethod": "zip の圧縮方式 {method} には対応していません({name})",
+                "error/zipNoInflate": "このブラウザーでは zip ファイルを展開できません。",
+                "export/3d": "3D スナップショット",
+                "export/area": "範囲",
+                "export/background": "背景",
+                "export/bgCanvas": "キャンバスの色",
+                "export/bgNone": "透明",
+                "export/bgWhite": "白",
+                "export/board": "基板全体",
+                "export/bottom": "リアル(ボトム)",
+                "export/content": "内容",
+                "export/download": "ダウンロード",
+                "export/failed": "エクスポートに失敗しました:{error}",
+                "export/format": "形式",
+                "export/layers": "レイヤー表示",
+                "export/noteSize": "画像サイズは約 {w} × {h} ピクセルです。",
+                "export/noteSizeCapped": "画像サイズは約 {w} × {h} ピクセルです(長辺は最大 12000 ピクセル)。",
+                "export/noteSvg": "表示中のレイヤーをミリメートル単位のベクターパスで出力します。",
+                "export/noteView": "表示されている範囲を画面の 2 倍の解像度で出力します。",
+                "export/rendering": "{name} を描画しています...",
+                "export/resolution": "解像度",
+                "export/svg": "SVG(ベクター)",
+                "export/title": "画像をエクスポート",
+                "export/toAroz": "ArozOS に保存...",
+                "export/top": "リアル(トップ)",
+                "export/view": "現在の表示",
+                "finish/enig": "ENIG(無電解金めっき)",
+                "finish/hasl": "HASL(はんだレベラー)",
+                "finish/osp": "OSP/銅素地",
+                "fmt/absolute": "絶対座標",
+                "fmt/decimal": "小数/既定",
+                "fmt/incremental": "相対座標",
+                "fmt/leading": "先頭ゼロ省略",
+                "fmt/lz": "先頭ゼロあり",
+                "fmt/trailing": "末尾ゼロ省略",
+                "fmt/tz": "末尾ゼロあり",
+                "formats/detect": "レイヤーの種類は X2 属性と、EasyEDA、KiCad、Altium、Eagle/Fusion、DipTrace、Proteus のファイル命名から判定します。種類はレイヤーメニューからいつでも変更できます。",
+                "formats/drill": "Excellon(.drl .xln .exc .txt .drd)。メートル/インチ、先頭または末尾ゼロ省略、スロット(G85)、ルーティング外形に対応します。",
+                "formats/drillTitle": "ドリル",
+                "formats/gerber": "RS-274X と Gerber X2(属性、ネット、部品)。アパーチャマクロ、領域、円弧、クリア極性、ステップ&リピート、ブロックアパーチャに対応します。主な拡張子:.gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho および Eagle/KiCad の命名。",
+                "formats/packages": "ローカルデバイスからは zip アーカイブ。ArozOS からは zip、7z、tar、tar.gz アーカイブ(サーバーで展開)、フォルダー、または任意のファイルの組み合わせ。",
+                "formats/packagesTitle": "パッケージ",
+                "formats/title": "対応フォーマット",
+                "goto/go": "移動",
+                "goto/missing": "両方の座標を入力してください",
+                "goto/note": "座標は現在の原点からの相対値です。",
+                "goto/title": "座標へ移動",
+                "info/created": "作成日時",
+                "info/device": "このデバイス",
+                "info/file1": "1 ファイル",
+                "info/files": "{n} ファイル",
+                "info/filesIgnored": "{files}({n} 件を無視)",
+                "info/filesLabel": "ファイル",
+                "info/format": "フォーマット",
+                "info/generator": "作成ソフト",
+                "info/itemsOnAroz": "ArozOS 上の {n} 項目",
+                "info/layers": "レイヤー",
+                "info/layersCopper": "{n}(銅箔 {c} 層)",
+                "info/name": "プロジェクト名",
+                "info/noOutline": "{size}(外形なし)",
+                "info/none": "なし",
+                "info/project": "プロジェクト",
+                "info/size": "サイズ",
+                "info/source": "場所",
+                "keys/actual": "実寸",
+                "keys/addDrop": "ドロップしたファイルをプロジェクトに追加",
+                "keys/arrows": "矢印キー",
+                "keys/bottom": "ボトム面",
+                "keys/cancel": "キャンセル/クリア",
+                "keys/context": "コンテキストメニュー",
+                "keys/copyCursor": "カーソル座標をコピー",
+                "keys/crosshair": "十字カーソル",
+                "keys/cycle": "前/次のレイヤー",
+                "keys/dblclick": "オブジェクトを拡大/全体表示",
+                "keys/dim": "2D/3D",
+                "keys/doubleClick": "ダブルクリック",
+                "keys/drag": "ドラッグ",
+                "keys/dragPan": "パン",
+                "keys/drop": "ドロップ",
+                "keys/export": "画像をエクスポート",
+                "keys/files": "ファイル",
+                "keys/fit": "全体を表示",
+                "keys/focus": "集中モード",
+                "keys/goto": "座標へ移動",
+                "keys/grid": "グリッド",
+                "keys/measure": "測定",
+                "keys/mouse": "マウス",
+                "keys/openAroz": "ArozOS から開く",
+                "keys/openLocal": "このデバイスから開く",
+                "keys/pan": "パン",
+                "keys/panAny": "どのツールでもパン",
+                "keys/panAnyMouse": "パン(どのツールでも)",
+                "keys/panKeys": "パン",
+                "keys/panel": "サイドパネル",
+                "keys/preferences": "環境設定",
+                "keys/print": "印刷",
+                "keys/realistic": "リアルプレビュー",
+                "keys/reload": "プロジェクトを再読み込み",
+                "keys/rightClick": "右クリック",
+                "keys/rightDrag": "右/中ボタンでドラッグ",
+                "keys/rotate": "90° 回転",
+                "keys/saveZip": "プロジェクトを ZIP で保存",
+                "keys/select": "選択",
+                "keys/title": "キーボードショートカット",
+                "keys/tools": "ツール",
+                "keys/view": "表示",
+                "keys/wheel": "ホイール",
+                "keys/wheelZoom": "ズーム",
+                "keys/zoom": "拡大/縮小",
+                "keys/zoomArea": "範囲を拡大",
+                "kind/arc": "円弧",
+                "kind/flash": "パッド(フラッシュ)",
+                "kind/hole": "ドリル穴",
+                "kind/image": "イメージ塗りつぶし",
+                "kind/object": "オブジェクト",
+                "kind/region": "領域/ベタ",
+                "kind/slot": "スロット",
+                "kind/trace": "配線",
+                "layer/colourTitle": "色と不透明度",
+                "layer/hide": "レイヤーを非表示",
+                "layer/show": "レイヤーを表示",
+                "layers/empty": "レイヤーが読み込まれていません。",
+                "loading/3dBuild": "3D 基板を作成しています...",
+                "loading/3dStart": "3D 表示を起動しています...",
+                "loading/aroz": "ArozOS から読み込んでいます...",
+                "loading/default": "読み込み中...",
+                "loading/files": "ファイルを読み込んでいます...",
+                "loading/opening": "{name} を開いています...",
+                "loading/reading": "{name} を読み込んでいます({i} / {n})",
+                "loading/reloading": "{name} を再読み込みしています",
+                "loading/unpack": "{name} を展開しています",
+                "mask/black": "ブラック",
+                "mask/blue": "ブルー",
+                "mask/green": "グリーン",
+                "mask/matte": "マットブラック",
+                "mask/none": "なし(素基板)",
+                "mask/purple": "パープル",
+                "mask/red": "レッド",
+                "mask/white": "ホワイト",
+                "mask/yellow": "イエロー",
+                "mat/fpc": "FPC(フレキシブル)",
+                "mat/fr4": "FR-4",
+                "measure/angle": "角度",
+                "measure/clear": "測定をクリア",
+                "measure/from": "始点",
+                "measure/live": "測定中...",
+                "measure/title": "測定結果",
+                "measure/to": "終点",
+                "menu/2d": "2D 表示",
+                "menu/3d": "3D 表示",
+                "menu/about": "Gerber ビューアーについて",
+                "menu/actualSize": "実寸(1:1)",
+                "menu/addAroz": "ArozOS からレイヤーを追加...",
+                "menu/addLocal": "このデバイスからレイヤーを追加...",
+                "menu/apertureTable": "アパーチャ一覧...",
+                "menu/boardReport": "基板レポート...",
+                "menu/bottomOnly": "ボトム面のみ",
+                "menu/bottomSide": "ボトム面(反転)",
+                "menu/clearMeasure": "測定をクリア",
+                "menu/clearRecent": "最近のプロジェクトをクリア",
+                "menu/clearSelection": "選択を解除",
+                "menu/close": "プロジェクトを閉じる",
+                "menu/colour": "色と不透明度...",
+                "menu/copperOnly": "銅箔のみ",
+                "menu/copyCursor": "カーソル座標をコピー",
+                "menu/copyImage": "表示を画像としてコピー",
+                "menu/crosshair": "十字カーソル",
+                "menu/drillTable": "ドリル一覧...",
+                "menu/edit": "編集",
+                "menu/empty": "項目がありません",
+                "menu/exit": "終了",
+                "menu/export": "画像をエクスポート...",
+                "menu/exportCsv": "ドリル一覧をエクスポート(CSV)...",
+                "menu/file": "ファイル",
+                "menu/focus": "集中モード",
+                "menu/formats": "対応フォーマット",
+                "menu/goto": "座標へ移動...",
+                "menu/grid": "グリッド",
+                "menu/help": "ヘルプ",
+                "menu/hideAll": "すべてのレイヤーを非表示",
+                "menu/hideLayer": "レイヤーを非表示",
+                "menu/layerType": "レイヤーの種類",
+                "menu/layerView": "レイヤー表示",
+                "menu/layers": "レイヤー",
+                "menu/measure": "測定",
+                "menu/mfg": "製造仕様プレビュー...",
+                "menu/minimap": "ミニマップ",
+                "menu/moveDown": "下へ移動",
+                "menu/moveUp": "上へ移動",
+                "menu/nextLayer": "次のレイヤー",
+                "menu/openAroz": "ArozOS から開く...",
+                "menu/openArozFolder": "ArozOS のフォルダーを開く...",
+                "menu/openLocal": "このデバイスから開く...",
+                "menu/openLocalFolder": "このデバイスのフォルダーを開く...",
+                "menu/openRecent": "最近使ったプロジェクト",
+                "menu/originCorner": "原点を基板の角に",
+                "menu/pan": "パン",
+                "menu/preferences": "環境設定...",
+                "menu/prevLayer": "前のレイヤー",
+                "menu/print": "印刷...",
+                "menu/properties": "プロパティ",
+                "menu/realistic": "リアルプレビュー",
+                "menu/redetect": "レイヤーの種類を再判定",
+                "menu/reload": "プロジェクトを再読み込み",
+                "menu/reloadLayer": "レイヤーを再読み込み",
+                "menu/removeLayer": "レイヤーを削除",
+                "menu/resetColours": "レイヤーの色をリセット",
+                "menu/resetOrder": "レイヤーの順序をリセット",
+                "menu/resetOrigin": "原点をリセット",
+                "menu/rotate": "90° 回転",
+                "menu/saveZip": "プロジェクトを ZIP で保存...",
+                "menu/select": "選択",
+                "menu/setOrigin": "原点を設定...",
+                "menu/shortcuts": "キーボードショートカット",
+                "menu/showAll": "すべてのレイヤーを表示",
+                "menu/showLayer": "レイヤーを表示",
+                "menu/showOnly": "このレイヤーのみ表示",
+                "menu/sidePanel": "サイドパネル",
+                "menu/snap": "形状にスナップ",
+                "menu/theme": "テーマ",
+                "menu/tips": "ヒント",
+                "menu/tools": "ツール",
+                "menu/topOnly": "トップ面のみ",
+                "menu/topSide": "トップ面",
+                "menu/units": "単位",
+                "menu/view": "表示",
+                "menu/viewBottom": "ボトム面を表示",
+                "menu/viewLayerSource": "レイヤーのソースを表示...",
+                "menu/viewSource": "ソースを表示...",
+                "menu/zoomArea": "範囲を拡大",
+                "menu/zoomFit": "全体を表示",
+                "menu/zoomIn": "拡大",
+                "menu/zoomLayer": "レイヤーを拡大",
+                "menu/zoomOut": "縮小",
+                "menu/zoomSelection": "選択範囲を拡大",
+                "mfg/delivery": "納品形態",
+                "mfg/enable": "プレビュー",
+                "mfg/enableTitle": "これらのオプションで基板を描画する",
+                "mfg/finish": "表面処理",
+                "mfg/layers": "層数",
+                "mfg/maker": "面付け",
+                "mfg/mask": "ソルダーレジストの色",
+                "mfg/material": "基板材料",
+                "mfg/noteFpc": "フレキシブル基板はカラーのソルダーレジストの代わりに琥珀色のポリイミドカバーレイで覆われます。",
+                "mfg/noteLarge": "面付けの一辺が 500 mm を超えており、多くのメーカーの対応範囲外です。",
+                "mfg/noteTent": "テンティングしたビアは穴が残り、レジストはランドだけを覆うため、小さなくぼみが見えます。",
+                "mfg/noteVcut": "V カットには直線の基板端が必要です。この外形は単純な長方形ではないため、ミシン目の方が安全です。",
+                "mfg/panel": "面付け",
+                "mfg/panelHint": "列 × 行",
+                "mfg/panelSize": "面付けサイズ",
+                "mfg/perPanel": "面付けあたりの基板数",
+                "mfg/railWidth": "捨て基板の幅",
+                "mfg/rails": "捨て基板",
+                "mfg/railsAll": "四辺",
+                "mfg/railsLR": "左右",
+                "mfg/railsNone": "なし",
+                "mfg/railsTB": "上下",
+                "mfg/reset": "オプションをリセット",
+                "mfg/separation": "分割方法",
+                "mfg/silk": "シルクの色",
+                "mfg/single": "単品",
+                "mfg/size": "基板サイズ",
+                "mfg/stack": "層構成",
+                "mfg/stackLayers": "{layers} 層、{t} mm",
+                "mfg/summary": "注文の概要",
+                "mfg/tab": "ミシン目",
+                "mfg/thickness": "基板厚",
+                "mfg/title": "製造プレビュー",
+                "mfg/vcut": "V カット",
+                "mfg/via": "ビアの処理",
+                "mfin/enig": "ENIG(無電解金めっき)",
+                "mfin/hasl": "有鉛はんだレベラー",
+                "mfin/haslLF": "鉛フリーはんだレベラー",
+                "mfin/osp": "鉛フリー OSP",
+                "pref/board": "基板",
+                "pref/canvasBg": "キャンバスの背景",
+                "pref/crosshair": "画面全体の十字カーソル",
+                "pref/cursor": "カーソル",
+                "pref/customColour": "カスタムカラー",
+                "pref/decimals": "小数点以下の桁数",
+                "pref/default": "既定",
+                "pref/display": "表示",
+                "pref/finish": "表面処理",
+                "pref/general": "一般",
+                "pref/grid": "グリッド",
+                "pref/layerColours": "レイヤーの色",
+                "pref/mask": "ソルダーレジスト",
+                "pref/measuring": "測定",
+                "pref/minimap": "ミニマップ",
+                "pref/opacity": "新しいレイヤーの不透明度",
+                "pref/recolour": "開いているプロジェクトのレイヤーにも適用する",
+                "pref/reset": "既定値に戻す",
+                "pref/resetDone": "環境設定を既定値に戻しました",
+                "pref/resetText": "すべての設定を既定値に戻しますか?",
+                "pref/resetTitle": "環境設定のリセット",
+                "pref/restoreLast": "前回のプロジェクトを開く",
+                "pref/showGrid": "グリッドを表示",
+                "pref/showMinimap": "ミニマップを表示",
+                "pref/showTips": "ヒントを表示",
+                "pref/sidePanel": "サイドパネル",
+                "pref/silk": "シルク",
+                "pref/snap": "パッド、穴、線の端点にスナップ",
+                "pref/startup": "起動時",
+                "pref/theme": "テーマ",
+                "pref/thickness": "基板厚",
+                "pref/title": "環境設定",
+                "pref/unitIn": "インチ(in)",
+                "pref/unitMil": "ミル(mil)",
+                "pref/unitMm": "ミリメートル(mm)",
+                "pref/units": "単位",
+                "print/actual": "実寸(1:1)",
+                "print/colour": "レイヤーの色",
+                "print/colours": "色",
+                "print/fit": "ページに合わせる",
+                "print/mono": "白地に黒",
+                "print/note": "実寸では基板を 1:1 で印刷するため、フットプリントを実際の部品と照合するのに便利です。ブラウザーの印刷ダイアログで「ページに合わせる」や拡大縮小をオフにしてください。",
+                "print/print": "印刷",
+                "print/scale": "倍率",
+                "print/title": "印刷",
+                "project/untitled": "無題のプロジェクト",
+                "prop/angle": "角度",
+                "prop/aperture": "アパーチャ",
+                "prop/area": "面積",
+                "prop/ccw": "反時計回り",
+                "prop/center": "中心",
+                "prop/component": "部品",
+                "prop/cw": "時計回り",
+                "prop/diameter": "径",
+                "prop/end": "終点",
+                "prop/extent": "範囲",
+                "prop/function": "機能",
+                "prop/layer": "レイヤー",
+                "prop/length": "長さ",
+                "prop/net": "ネット",
+                "prop/noNet": "(ネットなし)",
+                "prop/object": "オブジェクト",
+                "prop/pin": "ピン",
+                "prop/pinValue": "{ref} の {pin} 番ピン",
+                "prop/plated": "めっき",
+                "prop/position": "位置",
+                "prop/radius": "半径",
+                "prop/start": "始点",
+                "prop/sweep": "角度範囲",
+                "prop/tool": "ツール",
+                "prop/vertices": "頂点数",
+                "prop/width": "幅",
+                "props/apertures": "アパーチャ",
+                "props/aperturesBtn": "アパーチャ",
+                "props/arcsN": "円弧 {n} 個",
+                "props/area": "面積",
+                "props/board": "基板",
+                "props/colour": "色と不透明度",
+                "props/coordinates": "座標形式",
+                "props/copperLayers": "銅箔層",
+                "props/created": "作成日時",
+                "props/drillTable": "ドリル一覧",
+                "props/empty": "空",
+                "props/excellon": "Excellon ドリル",
+                "props/extent": "範囲",
+                "props/file": "ファイル",
+                "props/flashesN": "フラッシュ {n} 個",
+                "props/format": "フォーマット",
+                "props/function": "機能",
+                "props/generator": "作成ソフト",
+                "props/hint": "レイヤーを選択するか、基板上のオブジェクトをクリックすると詳細が表示されます。",
+                "props/holesLabel": "穴",
+                "props/holesN": "穴 {n} 個",
+                "props/holesValue": "{n}(めっき {p} 個、めっきなし {np} 個)",
+                "props/layerType": "レイヤーの種類",
+                "props/linesN": "線 {n} 本",
+                "props/measureFrom": "ここから測定",
+                "props/minHole": "最小穴径",
+                "props/minTrace": "最小配線幅",
+                "props/noOutline": "見つからないため、レイヤーの範囲を使用",
+                "props/noProject": "プロジェクトを開くとプロパティが表示されます。",
+                "props/objects": "オブジェクト",
+                "props/originOffset": "原点からのオフセット",
+                "props/outline": "外形",
+                "props/outlineClosed": "閉じた外形 {n} 個",
+                "props/outlineCut": "閉じた外形 {n} 個、切り欠き {c} 個",
+                "props/polarity": "極性",
+                "props/polarityBoth": "ダークとクリア",
+                "props/polarityDark": "ダーク",
+                "props/regionsN": "領域 {n} 個",
+                "props/remove": "削除",
+                "props/size": "サイズ",
+                "props/slotsLabel": "スロット",
+                "props/slotsN": "スロット {n} 個",
+                "props/source": "ソース",
+                "props/tools": "ツール",
+                "props/units": "単位",
+                "props/visible": "表示",
+                "props/warnings": "警告",
+                "props/zoomTo": "拡大",
+                "recent/device": "このデバイスから開いたプロジェクト",
+                "recent/none": "最近のプロジェクトはありません",
+                "recent/remove": "一覧から削除",
+                "report/area": "基板面積",
+                "report/file": "ファイル",
+                "report/ignored": "無視したファイル",
+                "report/layer": "レイヤー",
+                "report/layers": "レイヤー",
+                "report/noOutline": "閉じた外形が見つかりません",
+                "report/notes": "備考",
+                "report/objects": "オブジェクト",
+                "report/outline": "外形",
+                "report/outlines": "閉じた外形 {n} 個、切り欠き {c} 個",
+                "report/project": "プロジェクト",
+                "report/size": "基板サイズ",
+                "report/title": "基板レポート",
+                "report/units": "単位",
+                "report/warnings": "警告 {n} 件",
+                "save/aroz": "ArozOS...",
+                "save/device": "このデバイス",
+                "save/failed": "保存できません:{error}",
+                "save/saved": "{path} に保存しました",
+                "save/saving": "{name} を保存しています...",
+                "save/title": "{name} を保存",
+                "save/where": "このファイルをどこに保存しますか?",
+                "silk/auto": "自動",
+                "silk/black": "ブラック",
+                "silk/white": "ホワイト",
+                "silk/yellow": "イエロー",
+                "skip/archivetype": "対応していないアーカイブ形式です",
+                "skip/expanded": "アーカイブを展開すると CAM データが 512 MB を超えます",
+                "skip/none": "Gerber ファイルもドリルファイルも見つかりませんでした。",
+                "skip/notcam": "Gerber ファイルでもドリルファイルでもありません",
+                "skip/notfound": "ファイルが見つかりません",
+                "skip/parse": "解析できません:{error}",
+                "skip/projecttoolarge": "プロジェクトが 96 MB を超えています",
+                "skip/tartoolarge": "64 MB を超える tar アーカイブには対応していません",
+                "skip/title": "一部のファイルをスキップしました",
+                "skip/toolarge": "ファイルが 32 MB を超えています",
+                "skip/toomany": "ファイルが多すぎます",
+                "skip/ziponly": "このデバイスからは zip アーカイブのみ開けます",
+                "source/lines": "{n} 行",
+                "source/more": "...(残り {size})",
+                "source/title": "レイヤーのソース",
+                "status/layers": "{n} レイヤー",
+                "tab/layers": "レイヤー",
+                "tab/props": "プロパティ",
+                "theme/dark": "ダーク",
+                "theme/light": "ライト",
+                "theme/system": "ArozOS に合わせる",
+                "time/day": "{n} 日前",
+                "time/hour": "{n} 時間前",
+                "time/min": "{n} 分前",
+                "time/now": "たった今",
+                "tip/0": "一覧のレイヤーをドラッグすると描画順を変更できます。一覧の一番上のレイヤーが最前面に描画されます。",
+                "tip/1": "色見本をクリックすると、レイヤーの色と不透明度を変更できます。",
+                "tip/2": "M キーで測定します。測定点はパッド、ビア、線の端点にスナップします。",
+                "tip/3": "P キーでリアルな基板プレビュー、B キーでボトム面を表示します。",
+                "tip/4": "レイヤーを右クリックすると、そのレイヤーだけを表示するなどの操作ができます。",
+                "tip/5": "ツール > ドリル一覧で、すべての穴径と穴数、めっきの有無を確認できます。",
+                "tip/6": "スペースキーを押しながらドラッグすると、どのツールでもパンできます。何もない所をダブルクリックすると全体表示に戻ります。",
+                "tip/7": "3 キーで基板を 3D 表示します。基板厚と色は環境設定で変更できます。",
+                "tip/8": "最近のプロジェクトは、レイヤーの色、順序、表示状態を記憶します。",
+                "tip/9": "パッドや配線をクリックすると、プロパティにアパーチャ、サイズ、位置が表示されます。",
+                "title/offline": "このページからは ArozOS に接続できません",
+                "toast/3dFailed": "3D 表示を起動できません:{error}",
+                "toast/added": "{n} 個のレイヤーを追加しました",
+                "toast/addedOne": "1 個のレイヤーを追加しました",
+                "toast/copied": "{text} をコピーしました",
+                "toast/copyFailed": "コピーできません:{error}",
+                "toast/imageCopied": "表示をクリップボードにコピーしました",
+                "toast/loadAll": "すべて読み込む",
+                "toast/no3d": "3D 表示には基板外形または銅箔レイヤーが必要です。",
+                "toast/noClipboard": "ここでは画像をクリップボードにコピーできません",
+                "toast/noReload": "このプロジェクトは再読み込みできません。ファイルを開き直してください。",
+                "toast/notCached": "「{name}」はデバイスから開いたもので、このブラウザーにはキャッシュされていません。もう一度開いてください。",
+                "toast/offline": "ArozOS に接続できません。「このデバイスから開く」を使用してください。",
+                "toast/offlineShort": "ArozOS に接続できません。",
+                "toast/openFailed": "開けません:{error}",
+                "toast/originCorner": "原点を基板の左下の角に移動しました",
+                "toast/originSet": "選択した位置を原点に設定しました",
+                "toast/pickOrigin": "原点にする位置をクリックしてください。Esc でキャンセルします。",
+                "toast/readFailed": "ファイルを読み込めません:{error}",
+                "toast/reloadFailed": "再読み込みできません:{error}",
+                "toast/reloaded": "{name} を再読み込みしました",
+                "toast/sibling": "このフォルダーには Gerber/ドリルファイルがあと 1 個あります。",
+                "toast/siblings": "このフォルダーには Gerber/ドリルファイルがあと {n} 個あります。",
+                "type/bottom-assembly": "ボトム組立図",
+                "type/bottom-copper": "ボトム銅箔",
+                "type/bottom-mask": "ボトムソルダーレジスト",
+                "type/bottom-paste": "ボトムペースト",
+                "type/bottom-silk": "ボトムシルク",
+                "type/document": "ドキュメント",
+                "type/drill": "ドリル",
+                "type/drill-npth": "ドリル(NPTH)",
+                "type/drill-pth": "ドリル(PTH)",
+                "type/drill-via": "ドリル(ビア)",
+                "type/inner-copper": "内層銅箔",
+                "type/inner-n": "内層銅箔 {n}",
+                "type/keepout": "禁止領域",
+                "type/other": "不明なレイヤー",
+                "type/outline": "基板外形",
+                "type/top-assembly": "トップ組立図",
+                "type/top-copper": "トップ銅箔",
+                "type/top-mask": "トップソルダーレジスト",
+                "type/top-paste": "トップペースト",
+                "type/top-silk": "トップシルク",
+                "unit/inLong": "インチ",
+                "unit/inch": "インチ",
+                "unit/milLong": "ミル",
+                "unit/mm": "ミリメートル",
+                "unit/mmLong": "ミリメートル",
+                "via/open": "テンティングなし",
+                "via/paste": "銅ペースト充填+めっきキャップ",
+                "via/plug": "レジスト充填",
+                "via/resin": "樹脂充填+めっきキャップ",
+                "via/tent": "テンティング",
+                "view3d/bottom": "ボトム",
+                "view3d/front": "正面",
+                "view3d/iso": "等角",
+                "view3d/mfg": "製造",
+                "view3d/top": "トップ",
+                "warn/arcap": "円以外のアパーチャで描かれた円弧は近似で表示しています。",
+                "warn/blocktf": "ブロックアパーチャのミラー、回転、拡大縮小は適用されません。",
+                "warn/d3reg": "領域内のフラッシュは許可されていないため、スキップしました。",
+                "warn/deprecated": "廃止された %{cmd} のイメージ変換は無視しました。",
+                "warn/inc": "相対座標(廃止)が使われています。",
+                "warn/macro": "不明なアパーチャテンプレート「{name}」です。",
+                "warn/mo": "単位(%MO)の指定がないため、インチとみなしました。",
+                "warn/noapdraw": "アパーチャを選択せずに描画しています。",
+                "warn/noapflash": "アパーチャを選択せずにフラッシュしています。",
+                "warn/notool": "ツールを選択する前に穴あけが行われています。",
+                "warn/openreg": "G37 で閉じられていない領域があります。",
+                "warn/toolsize": "ツール {tool} のサイズが指定されていません。",
+                "warn/undef": "定義されていないアパーチャ D{d} が使われています。",
+                "warn/units": "METRIC/INCH の指定がないため、インチとみなしました。",
+                "zoom/actual": "1:1(実寸)"
+            },
+            "titles": {
+                "2D view": "2D 表示",
+                "3D view (3)": "3D 表示 (3)",
+                "Click or drag to move the view": "クリックまたはドラッグで表示範囲を移動",
+                "Crosshair cursor": "十字カーソル",
+                "Focus mode": "集中モード",
+                "Gerber Viewer": "Gerber ビューアー",
+                "Hide all layers": "すべてのレイヤーを非表示",
+                "Measure (M)": "測定 (M)",
+                "More": "その他",
+                "Next tip": "次のヒント",
+                "Pan (H)": "パン (H)",
+                "Render mode": "表示モード",
+                "Rotate 90 degrees (R)": "90 度回転 (R)",
+                "Select / inspect (V)": "選択/検査 (V)",
+                "Show / hide side panel": "サイドパネルの表示/非表示",
+                "Show all layers": "すべてのレイヤーを表示",
+                "View bottom side (B)": "ボトム面を表示 (B)",
+                "Zoom": "ズーム",
+                "Zoom to area (Z)": "範囲を拡大 (Z)",
+                "Zoom to fit (F)": "全体を表示 (F)"
+            },
+            "placeholder": {}
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fwtitle": "Gerber 뷰어",
+            "strings": {
+                "about/credits": "ArozOS의 압축 파일은 이 앱의 AGI 백엔드에서 압축을 풉니다. 3D 보기는 three.js(MIT 라이선스)를 사용합니다.",
+                "about/storedAroz": "설정과 최근 프로젝트는 사용자별로 ArozOS에 저장됩니다.",
+                "about/storedLocal": "설정과 최근 프로젝트는 이 브라우저에 저장됩니다.",
+                "about/text": "PCB 제조 데이터를 레이어, 실제와 같은 기판 미리보기 또는 3D로 확인합니다. 형상을 측정하고 애퍼처와 드릴을 검사하며 이미지를 내보낼 수 있습니다.",
+                "about/title": "정보",
+                "about/version": "버전 {v} (ArozOS용)",
+                "ap/block": "블록 애퍼처",
+                "ap/circle": "원형 Ø{d}",
+                "ap/circleHole": "원형 Ø{d}, 홀 {h}",
+                "ap/dcode": "D 코드",
+                "ap/function": "기능",
+                "ap/macro": "매크로 {name}",
+                "ap/none": "이 레이어에는 정의된 애퍼처가 없습니다.",
+                "ap/note": "표준 애퍼처 크기는 밀리미터로 표시되며, 매크로 매개변수는 파일의 단위({unit})를 사용합니다.",
+                "ap/obround": "장원형 {w} × {h}",
+                "ap/polygon": "다각형 Ø{d}, {n}각",
+                "ap/rect": "사각형 {w} × {h}",
+                "ap/shape": "모양",
+                "ap/title": "애퍼처 표",
+                "ap/uses": "사용 횟수",
+                "app/title": "Gerber 뷰어",
+                "badge/none": "프로젝트 없음",
+                "badge/project": "Gerber 프로젝트",
+                "bg/black": "검정",
+                "bg/graphite": "그래파이트",
+                "bg/midnight": "미드나이트",
+                "bg/paper": "종이",
+                "bg/slate": "슬레이트",
+                "card/fileInfo": "파일 정보",
+                "card/tip": "팁",
+                "card/visibility": "레이어 표시",
+                "chip/bottom": "하단면 (반전)",
+                "chip/layers": "레이어",
+                "chip/realistic": "실제",
+                "chip/top": "상단면",
+                "colour/custom": "사용자 지정",
+                "colour/opacity": "불투명도",
+                "common/cancel": "취소",
+                "common/clear": "지우기",
+                "common/close": "닫기",
+                "common/no": "아니요",
+                "common/ok": "확인",
+                "common/reset": "재설정",
+                "common/save": "저장",
+                "common/unknown": "알 수 없음",
+                "common/yes": "예",
+                "confirm/clearRecent": "최근 프로젝트 지우기",
+                "confirm/clearRecentText": "최근 목록에서 모든 프로젝트를 제거할까요?",
+                "ctx/center": "여기를 중심으로",
+                "ctx/copyCoords": "좌표 복사",
+                "ctx/measureFrom": "여기서 측정 시작",
+                "ctx/objectOn": "{layer}의 {object}",
+                "ctx/select": "선택",
+                "ctx/setOrigin": "여기를 원점으로 설정",
+                "ctx/showOnly": "{layer}만 표시",
+                "ctx/viewTop": "상단면 보기",
+                "ctx/zoomObject": "개체 확대",
+                "drill/csv": "CSV 내보내기...",
+                "drill/diameter": "지름",
+                "drill/diameterIn": "지름(인치)",
+                "drill/duplicates": "파일에 {n}개의 드릴 기록이 있으며, 파일 간에 중복된 홀은 한 번만 계산합니다.",
+                "drill/holes": "홀 수",
+                "drill/none": "이 프로젝트에는 드릴 파일이 없습니다.",
+                "drill/plated": "도금",
+                "drill/slots": "슬롯",
+                "drill/smallest": "최소 홀 지름 {d}.",
+                "drill/summary": "홀 {n}개(도금 {p}개, 비도금 {np}개), 슬롯 {s}개.",
+                "drill/title": "드릴 표",
+                "drill/tool": "공구",
+                "drill/total": "합계",
+                "drop/hint": "놓아서 열기",
+                "empty/drop": "또는 .zip, 폴더, Gerber 파일을 이 창 아무 곳에나 끌어다 놓으세요",
+                "empty/intro": "PCB 제조 패키지(zip) 또는 개별 Gerber 및 드릴 파일을 엽니다.",
+                "empty/openAroz": "ArozOS에서 열기",
+                "empty/openFolder": "폴더 열기",
+                "empty/openLocal": "이 기기에서 열기",
+                "empty/recent": "최근 프로젝트",
+                "error/baddata": "파일 데이터가 잘못되었거나 너무 큽니다",
+                "error/badentry": "잘못된 항목입니다",
+                "error/badop": "알 수 없는 작업입니다",
+                "error/badpath": "잘못된 저장 경로입니다",
+                "error/badpaths": "잘못된 경로 목록입니다",
+                "error/badsettings": "잘못된 설정입니다",
+                "error/badtype": "이 파일 형식은 Gerber 뷰어에서 저장할 수 없습니다",
+                "error/denied": "이 위치에 쓸 권한이 없습니다",
+                "error/nofolder": "저장할 폴더가 없습니다",
+                "error/notcam": "Gerber 또는 드릴 파일이 아닙니다",
+                "error/notfound": "파일을 찾을 수 없습니다",
+                "error/nothing": "열 항목이 없습니다",
+                "error/offline": "ArozOS에 연결할 수 없습니다",
+                "error/projecttoolarge": "프로젝트가 96MB를 초과합니다",
+                "error/read": "{name}을(를) 읽을 수 없습니다",
+                "error/readfail": "파일을 읽을 수 없습니다",
+                "error/reply": "서버에서 예상치 못한 응답을 받았습니다",
+                "error/server": "서버 오류 {status}",
+                "error/toolarge": "파일이 너무 큽니다",
+                "error/toomany": "파일이 너무 많습니다",
+                "error/writefail": "파일을 쓸 수 없습니다",
+                "error/zip64": "ZIP64 압축 파일은 지원하지 않습니다.",
+                "error/zipCorrupt": "손상된 zip 항목: {name}",
+                "error/zipEncrypted": "암호화된 zip 항목은 지원하지 않습니다: {name}",
+                "error/zipInvalid": "zip 압축 파일이 아닙니다.",
+                "error/zipMethod": "지원하지 않는 zip 압축 방식 {method} ({name})",
+                "error/zipNoInflate": "이 브라우저에서는 zip 파일의 압축을 풀 수 없습니다.",
+                "export/3d": "3D 스냅샷",
+                "export/area": "범위",
+                "export/background": "배경",
+                "export/bgCanvas": "캔버스 색상",
+                "export/bgNone": "투명",
+                "export/bgWhite": "흰색",
+                "export/board": "기판 전체",
+                "export/bottom": "실제 하단면",
+                "export/content": "내용",
+                "export/download": "다운로드",
+                "export/failed": "내보내기 실패: {error}",
+                "export/format": "형식",
+                "export/layers": "레이어 보기",
+                "export/noteSize": "이미지 크기는 약 {w} × {h} 픽셀입니다.",
+                "export/noteSizeCapped": "이미지 크기는 약 {w} × {h} 픽셀입니다(긴 변 최대 12000 픽셀).",
+                "export/noteSvg": "보이는 레이어를 밀리미터 단위의 벡터 경로로 내보냅니다.",
+                "export/noteView": "현재 보이는 화면을 화면 해상도의 두 배로 내보냅니다.",
+                "export/rendering": "{name} 렌더링 중...",
+                "export/resolution": "해상도",
+                "export/svg": "SVG(벡터)",
+                "export/title": "이미지 내보내기",
+                "export/toAroz": "ArozOS에 저장...",
+                "export/top": "실제 상단면",
+                "export/view": "현재 화면",
+                "finish/enig": "ENIG(무전해 금도금)",
+                "finish/hasl": "HASL(납 도금)",
+                "finish/osp": "OSP / 무도금 구리",
+                "fmt/absolute": "절대 좌표",
+                "fmt/decimal": "소수 / 기본값",
+                "fmt/incremental": "상대 좌표",
+                "fmt/leading": "앞자리 0 생략",
+                "fmt/lz": "앞자리 0 포함",
+                "fmt/trailing": "뒷자리 0 생략",
+                "fmt/tz": "뒷자리 0 포함",
+                "formats/detect": "레이어 종류는 X2 속성과 EasyEDA, KiCad, Altium, Eagle / Fusion, DipTrace, Proteus의 파일 이름 규칙으로 판별합니다. 레이어 메뉴에서 언제든 종류를 바꿀 수 있습니다.",
+                "formats/drill": "Excellon(.drl .xln .exc .txt .drd), 미터 또는 인치, 앞자리 또는 뒷자리 0 생략, 슬롯(G85)과 라우팅 외곽을 지원합니다.",
+                "formats/drillTitle": "드릴",
+                "formats/gerber": "RS-274X와 Gerber X2(속성, 네트, 부품). 애퍼처 매크로, 영역, 원호, 클리어 극성, 스텝 앤 리피트, 블록 애퍼처를 지원합니다. 주요 확장자: .gbr .ger .gtl .gbl .gto .gbo .gts .gbs .gtp .gbp .gko .gm1 .g1 .. .g30 .art .pho 및 Eagle / KiCad 이름.",
+                "formats/packages": "이 기기에서는 zip 압축 파일. ArozOS에서는 zip, 7z, tar, tar.gz 압축 파일(서버에서 압축 해제), 폴더 또는 여러 파일.",
+                "formats/packagesTitle": "패키지",
+                "formats/title": "지원 형식",
+                "goto/go": "이동",
+                "goto/missing": "두 좌표를 모두 입력하세요",
+                "goto/note": "좌표는 현재 원점을 기준으로 합니다.",
+                "goto/title": "좌표로 이동",
+                "info/created": "생성일",
+                "info/device": "이 기기",
+                "info/file1": "파일 1개",
+                "info/files": "파일 {n}개",
+                "info/filesIgnored": "{files}(무시 {n}개)",
+                "info/filesLabel": "파일",
+                "info/format": "형식",
+                "info/generator": "생성 프로그램",
+                "info/itemsOnAroz": "ArozOS의 항목 {n}개",
+                "info/layers": "레이어",
+                "info/layersCopper": "{n}(동박 {c}층)",
+                "info/name": "프로젝트 이름",
+                "info/noOutline": "{size}(외곽선 없음)",
+                "info/none": "없음",
+                "info/project": "프로젝트",
+                "info/size": "크기",
+                "info/source": "위치",
+                "keys/actual": "실제 크기",
+                "keys/addDrop": "끌어다 놓은 파일을 프로젝트에 추가",
+                "keys/arrows": "방향키",
+                "keys/bottom": "하단면",
+                "keys/cancel": "취소 / 지우기",
+                "keys/context": "컨텍스트 메뉴",
+                "keys/copyCursor": "커서 좌표 복사",
+                "keys/crosshair": "십자 커서",
+                "keys/cycle": "이전 / 다음 레이어",
+                "keys/dblclick": "개체 확대 / 화면에 맞춤",
+                "keys/dim": "2D / 3D",
+                "keys/doubleClick": "더블클릭",
+                "keys/drag": "드래그",
+                "keys/dragPan": "이동",
+                "keys/drop": "놓기",
+                "keys/export": "이미지 내보내기",
+                "keys/files": "파일",
+                "keys/fit": "화면에 맞춤",
+                "keys/focus": "집중 모드",
+                "keys/goto": "좌표로 이동",
+                "keys/grid": "격자",
+                "keys/measure": "측정",
+                "keys/mouse": "마우스",
+                "keys/openAroz": "ArozOS에서 열기",
+                "keys/openLocal": "이 기기에서 열기",
+                "keys/pan": "이동",
+                "keys/panAny": "모든 도구에서 이동",
+                "keys/panAnyMouse": "이동(모든 도구)",
+                "keys/panKeys": "이동",
+                "keys/panel": "사이드 패널",
+                "keys/preferences": "환경설정",
+                "keys/print": "인쇄",
+                "keys/realistic": "실제 미리보기",
+                "keys/reload": "프로젝트 다시 불러오기",
+                "keys/rightClick": "마우스 오른쪽 클릭",
+                "keys/rightDrag": "오른쪽 / 가운데 버튼 드래그",
+                "keys/rotate": "90° 회전",
+                "keys/saveZip": "프로젝트를 ZIP으로 저장",
+                "keys/select": "선택",
+                "keys/title": "키보드 단축키",
+                "keys/tools": "도구",
+                "keys/view": "보기",
+                "keys/wheel": "휠",
+                "keys/wheelZoom": "확대/축소",
+                "keys/zoom": "확대 / 축소",
+                "keys/zoomArea": "영역 확대",
+                "kind/arc": "원호",
+                "kind/flash": "패드(플래시)",
+                "kind/hole": "드릴 홀",
+                "kind/image": "이미지 채움",
+                "kind/object": "개체",
+                "kind/region": "영역 / 동박 채움",
+                "kind/slot": "슬롯",
+                "kind/trace": "트레이스",
+                "layer/colourTitle": "색상 및 불투명도",
+                "layer/hide": "레이어 숨기기",
+                "layer/show": "레이어 표시",
+                "layers/empty": "불러온 레이어가 없습니다.",
+                "loading/3dBuild": "3D 기판을 만드는 중...",
+                "loading/3dStart": "3D 보기를 시작하는 중...",
+                "loading/aroz": "ArozOS에서 읽는 중...",
+                "loading/default": "불러오는 중...",
+                "loading/files": "파일을 읽는 중...",
+                "loading/opening": "{name} 여는 중...",
+                "loading/reading": "{name} 읽는 중({i} / {n})",
+                "loading/reloading": "{name} 다시 불러오는 중",
+                "loading/unpack": "{name} 압축 해제 중",
+                "mask/black": "검정",
+                "mask/blue": "파랑",
+                "mask/green": "초록",
+                "mask/matte": "무광 검정",
+                "mask/none": "없음(무도포 기판)",
+                "mask/purple": "보라",
+                "mask/red": "빨강",
+                "mask/white": "흰색",
+                "mask/yellow": "노랑",
+                "mat/fpc": "FPC(연성)",
+                "mat/fr4": "FR-4",
+                "measure/angle": "각도",
+                "measure/clear": "측정 지우기",
+                "measure/from": "시작",
+                "measure/live": "측정 중...",
+                "measure/title": "측정",
+                "measure/to": "끝",
+                "menu/2d": "2D 보기",
+                "menu/3d": "3D 보기",
+                "menu/about": "Gerber 뷰어 정보",
+                "menu/actualSize": "실제 크기(1:1)",
+                "menu/addAroz": "ArozOS에서 레이어 추가...",
+                "menu/addLocal": "이 기기에서 레이어 추가...",
+                "menu/apertureTable": "애퍼처 표...",
+                "menu/boardReport": "기판 보고서...",
+                "menu/bottomOnly": "하단면만",
+                "menu/bottomSide": "하단면(반전)",
+                "menu/clearMeasure": "측정 지우기",
+                "menu/clearRecent": "최근 프로젝트 지우기",
+                "menu/clearSelection": "선택 해제",
+                "menu/close": "프로젝트 닫기",
+                "menu/colour": "색상 및 불투명도...",
+                "menu/copperOnly": "동박만",
+                "menu/copyCursor": "커서 좌표 복사",
+                "menu/copyImage": "화면을 이미지로 복사",
+                "menu/crosshair": "십자 커서",
+                "menu/drillTable": "드릴 표...",
+                "menu/edit": "편집",
+                "menu/empty": "항목 없음",
+                "menu/exit": "종료",
+                "menu/export": "이미지 내보내기...",
+                "menu/exportCsv": "드릴 표 내보내기(CSV)...",
+                "menu/file": "파일",
+                "menu/focus": "집중 모드",
+                "menu/formats": "지원 형식",
+                "menu/goto": "좌표로 이동...",
+                "menu/grid": "격자",
+                "menu/help": "도움말",
+                "menu/hideAll": "모든 레이어 숨기기",
+                "menu/hideLayer": "레이어 숨기기",
+                "menu/layerType": "레이어 종류",
+                "menu/layerView": "레이어 보기",
+                "menu/layers": "레이어",
+                "menu/measure": "측정",
+                "menu/mfg": "제조 사양 미리보기...",
+                "menu/minimap": "미니맵",
+                "menu/moveDown": "아래로 이동",
+                "menu/moveUp": "위로 이동",
+                "menu/nextLayer": "다음 레이어",
+                "menu/openAroz": "ArozOS에서 열기...",
+                "menu/openArozFolder": "ArozOS에서 폴더 열기...",
+                "menu/openLocal": "이 기기에서 열기...",
+                "menu/openLocalFolder": "이 기기에서 폴더 열기...",
+                "menu/openRecent": "최근 프로젝트 열기",
+                "menu/originCorner": "원점을 기판 모서리로",
+                "menu/pan": "이동",
+                "menu/preferences": "환경설정...",
+                "menu/prevLayer": "이전 레이어",
+                "menu/print": "인쇄...",
+                "menu/properties": "속성",
+                "menu/realistic": "실제 미리보기",
+                "menu/redetect": "레이어 종류 다시 판별",
+                "menu/reload": "프로젝트 다시 불러오기",
+                "menu/reloadLayer": "레이어 다시 불러오기",
+                "menu/removeLayer": "레이어 제거",
+                "menu/resetColours": "레이어 색상 재설정",
+                "menu/resetOrder": "레이어 순서 재설정",
+                "menu/resetOrigin": "원점 재설정",
+                "menu/rotate": "90° 회전",
+                "menu/saveZip": "프로젝트를 ZIP으로 저장...",
+                "menu/select": "선택",
+                "menu/setOrigin": "원점 설정...",
+                "menu/shortcuts": "키보드 단축키",
+                "menu/showAll": "모든 레이어 표시",
+                "menu/showLayer": "레이어 표시",
+                "menu/showOnly": "이 레이어만 표시",
+                "menu/sidePanel": "사이드 패널",
+                "menu/snap": "형상에 스냅",
+                "menu/theme": "테마",
+                "menu/tips": "팁",
+                "menu/tools": "도구",
+                "menu/topOnly": "상단면만",
+                "menu/topSide": "상단면",
+                "menu/units": "단위",
+                "menu/view": "보기",
+                "menu/viewBottom": "하단면 보기",
+                "menu/viewLayerSource": "레이어 소스 보기...",
+                "menu/viewSource": "소스 보기...",
+                "menu/zoomArea": "영역 확대",
+                "menu/zoomFit": "화면에 맞춤",
+                "menu/zoomIn": "확대",
+                "menu/zoomLayer": "레이어 확대",
+                "menu/zoomOut": "축소",
+                "menu/zoomSelection": "선택 항목 확대",
+                "mfg/delivery": "납품 형태",
+                "mfg/enable": "미리보기",
+                "mfg/enableTitle": "이 옵션으로 기판 렌더링",
+                "mfg/finish": "표면 처리",
+                "mfg/layers": "레이어 수",
+                "mfg/maker": "패널화",
+                "mfg/mask": "솔더 마스크 색상",
+                "mfg/material": "기판 재질",
+                "mfg/noteFpc": "연성 기판은 색상 솔더 마스크 대신 호박색 폴리이미드 커버레이로 덮입니다.",
+                "mfg/noteLarge": "패널의 한 변이 500mm를 넘어 대부분의 제조사가 받지 않는 크기입니다.",
+                "mfg/noteTent": "텐팅한 비아는 홀이 남아 있고 마스크가 링만 덮으므로 작은 홈이 보입니다.",
+                "mfg/noteVcut": "V컷에는 곧은 기판 가장자리가 필요합니다. 이 외곽은 단순한 사각형이 아니므로 스탬프 홀이 더 안전합니다.",
+                "mfg/panel": "패널",
+                "mfg/panelHint": "열 × 행",
+                "mfg/panelSize": "패널 크기",
+                "mfg/perPanel": "패널당 기판 수",
+                "mfg/railWidth": "레일 폭",
+                "mfg/rails": "공정 레일",
+                "mfg/railsAll": "네 변 모두",
+                "mfg/railsLR": "좌우",
+                "mfg/railsNone": "없음",
+                "mfg/railsTB": "상하",
+                "mfg/reset": "옵션 재설정",
+                "mfg/separation": "분리 방식",
+                "mfg/silk": "실크스크린 색상",
+                "mfg/single": "단품",
+                "mfg/size": "기판 크기",
+                "mfg/stack": "적층 구조",
+                "mfg/stackLayers": "{layers}층, {t}mm",
+                "mfg/summary": "주문 요약",
+                "mfg/tab": "스탬프 홀",
+                "mfg/thickness": "기판 두께",
+                "mfg/title": "제조 미리보기",
+                "mfg/vcut": "V컷",
+                "mfg/via": "비아 처리",
+                "mfin/enig": "ENIG(무전해 금도금)",
+                "mfin/hasl": "유연 HASL",
+                "mfin/haslLF": "무연 HASL",
+                "mfin/osp": "무연 OSP",
+                "pref/board": "기판",
+                "pref/canvasBg": "캔버스 배경",
+                "pref/crosshair": "전체 화면 십자 커서",
+                "pref/cursor": "커서",
+                "pref/customColour": "사용자 지정 색상",
+                "pref/decimals": "소수 자릿수",
+                "pref/default": "기본값",
+                "pref/display": "표시",
+                "pref/finish": "표면 처리",
+                "pref/general": "일반",
+                "pref/grid": "격자",
+                "pref/layerColours": "레이어 색상",
+                "pref/mask": "솔더 마스크",
+                "pref/measuring": "측정",
+                "pref/minimap": "미니맵",
+                "pref/opacity": "새 레이어 불투명도",
+                "pref/recolour": "열려 있는 프로젝트의 레이어에도 적용",
+                "pref/reset": "기본값으로 재설정",
+                "pref/resetDone": "환경설정을 기본값으로 되돌렸습니다",
+                "pref/resetText": "모든 환경설정을 기본값으로 되돌릴까요?",
+                "pref/resetTitle": "환경설정 재설정",
+                "pref/restoreLast": "마지막 프로젝트 다시 열기",
+                "pref/showGrid": "격자 표시",
+                "pref/showMinimap": "미니맵 표시",
+                "pref/showTips": "팁 표시",
+                "pref/sidePanel": "사이드 패널",
+                "pref/silk": "실크스크린",
+                "pref/snap": "패드, 홀, 선 끝점에 스냅",
+                "pref/startup": "시작할 때",
+                "pref/theme": "테마",
+                "pref/thickness": "기판 두께",
+                "pref/title": "환경설정",
+                "pref/unitIn": "인치(in)",
+                "pref/unitMil": "밀(mil)",
+                "pref/unitMm": "밀리미터(mm)",
+                "pref/units": "단위",
+                "print/actual": "실제 크기(1:1)",
+                "print/colour": "레이어 색상",
+                "print/colours": "색상",
+                "print/fit": "페이지에 맞춤",
+                "print/mono": "흰 바탕에 검정",
+                "print/note": "실제 크기는 기판을 1:1로 인쇄하므로 풋프린트를 실제 부품과 비교할 때 편리합니다. 브라우저 인쇄 대화상자에서 \"페이지에 맞춤\"이나 배율 조정을 끄세요.",
+                "print/print": "인쇄",
+                "print/scale": "배율",
+                "print/title": "인쇄",
+                "project/untitled": "제목 없는 프로젝트",
+                "prop/angle": "각도",
+                "prop/aperture": "애퍼처",
+                "prop/area": "면적",
+                "prop/ccw": "반시계 방향",
+                "prop/center": "중심",
+                "prop/component": "부품",
+                "prop/cw": "시계 방향",
+                "prop/diameter": "지름",
+                "prop/end": "끝점",
+                "prop/extent": "범위",
+                "prop/function": "기능",
+                "prop/layer": "레이어",
+                "prop/length": "길이",
+                "prop/net": "네트",
+                "prop/noNet": "(네트 없음)",
+                "prop/object": "개체",
+                "prop/pin": "핀",
+                "prop/pinValue": "{ref}의 {pin}번 핀",
+                "prop/plated": "도금",
+                "prop/position": "위치",
+                "prop/radius": "반지름",
+                "prop/start": "시작점",
+                "prop/sweep": "호 각도",
+                "prop/tool": "공구",
+                "prop/vertices": "꼭짓점 수",
+                "prop/width": "폭",
+                "props/apertures": "애퍼처",
+                "props/aperturesBtn": "애퍼처",
+                "props/arcsN": "원호 {n}개",
+                "props/area": "면적",
+                "props/board": "기판",
+                "props/colour": "색상 및 불투명도",
+                "props/coordinates": "좌표 형식",
+                "props/copperLayers": "동박 레이어",
+                "props/created": "생성일",
+                "props/drillTable": "드릴 표",
+                "props/empty": "비어 있음",
+                "props/excellon": "Excellon 드릴",
+                "props/extent": "범위",
+                "props/file": "파일",
+                "props/flashesN": "플래시 {n}개",
+                "props/format": "형식",
+                "props/function": "기능",
+                "props/generator": "생성 프로그램",
+                "props/hint": "레이어를 선택하거나 기판의 개체를 클릭하면 자세한 정보가 표시됩니다.",
+                "props/holesLabel": "홀",
+                "props/holesN": "홀 {n}개",
+                "props/holesValue": "{n}(도금 {p}개, 비도금 {np}개)",
+                "props/layerType": "레이어 종류",
+                "props/linesN": "선 {n}개",
+                "props/measureFrom": "여기서 측정",
+                "props/minHole": "최소 홀 지름",
+                "props/minTrace": "최소 트레이스 폭",
+                "props/noOutline": "찾을 수 없어 레이어 범위를 사용합니다",
+                "props/noProject": "프로젝트를 열면 속성이 표시됩니다.",
+                "props/objects": "개체",
+                "props/originOffset": "원점 오프셋",
+                "props/outline": "외곽선",
+                "props/outlineClosed": "닫힌 외곽선 {n}개",
+                "props/outlineCut": "닫힌 외곽선 {n}개, 컷아웃 {c}개",
+                "props/polarity": "극성",
+                "props/polarityBoth": "다크와 클리어",
+                "props/polarityDark": "다크",
+                "props/regionsN": "영역 {n}개",
+                "props/remove": "제거",
+                "props/size": "크기",
+                "props/slotsLabel": "슬롯",
+                "props/slotsN": "슬롯 {n}개",
+                "props/source": "소스",
+                "props/tools": "공구",
+                "props/units": "단위",
+                "props/visible": "표시",
+                "props/warnings": "경고",
+                "props/zoomTo": "확대",
+                "recent/device": "이 기기에서 연 프로젝트",
+                "recent/none": "최근 프로젝트가 없습니다",
+                "recent/remove": "목록에서 제거",
+                "report/area": "기판 면적",
+                "report/file": "파일",
+                "report/ignored": "무시한 파일",
+                "report/layer": "레이어",
+                "report/layers": "레이어",
+                "report/noOutline": "닫힌 외곽선을 찾을 수 없습니다",
+                "report/notes": "비고",
+                "report/objects": "개체",
+                "report/outline": "외곽선",
+                "report/outlines": "닫힌 외곽선 {n}개, 컷아웃 {c}개",
+                "report/project": "프로젝트",
+                "report/size": "기판 크기",
+                "report/title": "기판 보고서",
+                "report/units": "단위",
+                "report/warnings": "경고 {n}개",
+                "save/aroz": "ArozOS...",
+                "save/device": "이 기기",
+                "save/failed": "저장할 수 없습니다: {error}",
+                "save/saved": "{path}에 저장했습니다",
+                "save/saving": "{name} 저장 중...",
+                "save/title": "{name} 저장",
+                "save/where": "이 파일을 어디에 저장할까요?",
+                "silk/auto": "자동",
+                "silk/black": "검정",
+                "silk/white": "흰색",
+                "silk/yellow": "노랑",
+                "skip/archivetype": "지원하지 않는 압축 형식입니다",
+                "skip/expanded": "압축을 풀면 CAM 데이터가 512MB를 초과합니다",
+                "skip/none": "Gerber 또는 드릴 파일을 찾을 수 없습니다.",
+                "skip/notcam": "Gerber 또는 드릴 파일이 아닙니다",
+                "skip/notfound": "파일을 찾을 수 없습니다",
+                "skip/parse": "분석할 수 없습니다: {error}",
+                "skip/projecttoolarge": "프로젝트가 96MB를 초과합니다",
+                "skip/tartoolarge": "64MB를 초과하는 tar 압축 파일은 지원하지 않습니다",
+                "skip/title": "일부 파일을 건너뛰었습니다",
+                "skip/toolarge": "파일이 32MB를 초과합니다",
+                "skip/toomany": "파일이 너무 많습니다",
+                "skip/ziponly": "이 기기에서는 zip 압축 파일만 열 수 있습니다",
+                "source/lines": "{n}줄",
+                "source/more": "...({size} 더 있음)",
+                "source/title": "레이어 소스",
+                "status/layers": "레이어 {n}개",
+                "tab/layers": "레이어",
+                "tab/props": "속성",
+                "theme/dark": "어둡게",
+                "theme/light": "밝게",
+                "theme/system": "ArozOS 설정 따르기",
+                "time/day": "{n}일 전",
+                "time/hour": "{n}시간 전",
+                "time/min": "{n}분 전",
+                "time/now": "방금",
+                "tip/0": "목록에서 레이어를 드래그하면 그리는 순서를 바꿀 수 있습니다. 목록 맨 위의 레이어가 맨 위에 그려집니다.",
+                "tip/1": "색상 견본을 클릭하면 레이어의 색상과 불투명도를 바꿀 수 있습니다.",
+                "tip/2": "M 키로 측정합니다. 측정점은 패드, 비아, 선 끝점에 스냅됩니다.",
+                "tip/3": "P 키로 실제 기판 미리보기를, B 키로 하단면을 봅니다.",
+                "tip/4": "레이어를 마우스 오른쪽 버튼으로 클릭하면 해당 레이어만 표시하는 등 더 많은 옵션을 쓸 수 있습니다.",
+                "tip/5": "도구 > 드릴 표에서 모든 드릴 크기와 홀 수, 도금 여부를 볼 수 있습니다.",
+                "tip/6": "스페이스 키를 누른 채 드래그하면 어떤 도구에서든 이동할 수 있습니다. 빈 곳을 더블클릭하면 화면에 맞춥니다.",
+                "tip/7": "3 키를 누르면 기판을 3D로 봅니다. 기판 두께와 색상은 환경설정에서 바꿀 수 있습니다.",
+                "tip/8": "최근 프로젝트는 레이어 색상, 순서, 표시 상태를 기억합니다.",
+                "tip/9": "패드나 트레이스를 클릭하면 속성에서 애퍼처, 크기, 위치를 볼 수 있습니다.",
+                "title/offline": "이 페이지에서는 ArozOS에 연결할 수 없습니다",
+                "toast/3dFailed": "3D 보기를 시작할 수 없습니다: {error}",
+                "toast/added": "레이어 {n}개를 추가했습니다",
+                "toast/addedOne": "레이어 1개를 추가했습니다",
+                "toast/copied": "{text} 복사됨",
+                "toast/copyFailed": "복사할 수 없습니다: {error}",
+                "toast/imageCopied": "화면을 클립보드에 복사했습니다",
+                "toast/loadAll": "모두 불러오기",
+                "toast/no3d": "3D 보기에는 기판 외곽선이나 동박 레이어가 필요합니다.",
+                "toast/noClipboard": "여기에서는 이미지를 클립보드에 복사할 수 없습니다",
+                "toast/noReload": "이 프로젝트는 다시 불러올 수 없습니다. 파일을 다시 여세요.",
+                "toast/notCached": "\"{name}\"은(는) 기기에서 연 프로젝트로 이 브라우저에 저장되어 있지 않습니다. 다시 여세요.",
+                "toast/offline": "ArozOS에 연결할 수 없습니다. \"이 기기에서 열기\"를 사용하세요.",
+                "toast/offlineShort": "ArozOS에 연결할 수 없습니다.",
+                "toast/openFailed": "열 수 없습니다: {error}",
+                "toast/originCorner": "원점을 기판 왼쪽 아래 모서리로 옮겼습니다",
+                "toast/originSet": "선택한 위치를 원점으로 설정했습니다",
+                "toast/pickOrigin": "원점으로 쓸 위치를 클릭하세요. Esc로 취소합니다.",
+                "toast/readFailed": "파일을 읽을 수 없습니다: {error}",
+                "toast/reloadFailed": "다시 불러올 수 없습니다: {error}",
+                "toast/reloaded": "{name}을(를) 다시 불러왔습니다",
+                "toast/sibling": "이 폴더에 Gerber / 드릴 파일이 1개 더 있습니다.",
+                "toast/siblings": "이 폴더에 Gerber / 드릴 파일이 {n}개 더 있습니다.",
+                "type/bottom-assembly": "하단 조립도",
+                "type/bottom-copper": "하단 동박",
+                "type/bottom-mask": "하단 솔더 마스크",
+                "type/bottom-paste": "하단 솔더 페이스트",
+                "type/bottom-silk": "하단 실크스크린",
+                "type/document": "문서",
+                "type/drill": "드릴",
+                "type/drill-npth": "드릴(NPTH)",
+                "type/drill-pth": "드릴(PTH)",
+                "type/drill-via": "드릴(비아)",
+                "type/inner-copper": "내층 동박",
+                "type/inner-n": "내층 동박 {n}",
+                "type/keepout": "금지 영역",
+                "type/other": "알 수 없는 레이어",
+                "type/outline": "기판 외곽선",
+                "type/top-assembly": "상단 조립도",
+                "type/top-copper": "상단 동박",
+                "type/top-mask": "상단 솔더 마스크",
+                "type/top-paste": "상단 솔더 페이스트",
+                "type/top-silk": "상단 실크스크린",
+                "unit/inLong": "인치",
+                "unit/inch": "인치",
+                "unit/milLong": "밀",
+                "unit/mm": "밀리미터",
+                "unit/mmLong": "밀리미터",
+                "via/open": "텐팅 안 함",
+                "via/paste": "구리 페이스트 충전 + 도금 캡",
+                "via/plug": "마스크 충전",
+                "via/resin": "레진 충전 + 도금 캡",
+                "via/tent": "텐팅",
+                "view3d/bottom": "하단",
+                "view3d/front": "정면",
+                "view3d/iso": "등각",
+                "view3d/mfg": "제조",
+                "view3d/top": "상단",
+                "warn/arcap": "원형이 아닌 애퍼처로 그린 원호는 근사치로 표시됩니다.",
+                "warn/blocktf": "블록 애퍼처의 미러, 회전, 배율은 적용되지 않습니다.",
+                "warn/d3reg": "영역 안의 플래시는 허용되지 않아 건너뛰었습니다.",
+                "warn/deprecated": "더 이상 사용되지 않는 %{cmd} 이미지 변환을 무시했습니다.",
+                "warn/inc": "상대 좌표(더 이상 사용되지 않음)를 사용합니다.",
+                "warn/macro": "알 수 없는 애퍼처 템플릿 \"{name}\"입니다.",
+                "warn/mo": "단위(%MO) 명령이 없어 인치로 간주합니다.",
+                "warn/noapdraw": "애퍼처를 선택하지 않고 그렸습니다.",
+                "warn/noapflash": "애퍼처를 선택하지 않고 플래시했습니다.",
+                "warn/notool": "공구를 선택하기 전에 드릴 가공이 있습니다.",
+                "warn/openreg": "G37로 닫히지 않은 영역이 있습니다.",
+                "warn/toolsize": "공구 {tool}의 크기가 지정되지 않았습니다.",
+                "warn/undef": "정의되지 않은 애퍼처 D{d}를 사용합니다.",
+                "warn/units": "METRIC / INCH 지정이 없어 인치로 간주합니다.",
+                "zoom/actual": "1:1(실제 크기)"
+            },
+            "titles": {
+                "2D view": "2D 보기",
+                "3D view (3)": "3D 보기 (3)",
+                "Click or drag to move the view": "클릭하거나 드래그하여 보기 이동",
+                "Crosshair cursor": "십자 커서",
+                "Focus mode": "집중 모드",
+                "Gerber Viewer": "Gerber 뷰어",
+                "Hide all layers": "모든 레이어 숨기기",
+                "Measure (M)": "측정 (M)",
+                "More": "더 보기",
+                "Next tip": "다음 팁",
+                "Pan (H)": "이동 (H)",
+                "Render mode": "표시 모드",
+                "Rotate 90 degrees (R)": "90도 회전 (R)",
+                "Select / inspect (V)": "선택 / 검사 (V)",
+                "Show / hide side panel": "사이드 패널 표시 / 숨기기",
+                "Show all layers": "모든 레이어 표시",
+                "View bottom side (B)": "하단면 보기 (B)",
+                "Zoom": "확대/축소",
+                "Zoom to area (Z)": "영역 확대 (Z)",
+                "Zoom to fit (F)": "화면에 맞춤 (F)"
+            },
+            "placeholder": {}
+        }
+    }
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini