← Commit history
SKILL.md+2−34
@@ -82,38 +82,6 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper. -## Component coverage before placement+## Silkscreen placement, complete coverage and observable decisions -The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.--Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.--Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.--## Early component quality and optional MPN marking--Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.--Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.--Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.--## Provenance is required for shared component assets--Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).--For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.--For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.--Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.--## Optional live progress--Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.--## Keep both native views current after every update--After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.--Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.+Read `docs/silkscreen.md` for complete coverage, font/locality search, both-face contact/test-point labels, values and leader rules. Read `tools/silkscreen-dashboard/SKILL.md` for optional live/replay observation and recordings. Native geometry, edit/undo and 2D/3D refresh remain bridge responsibilities. `silkscreen-layout` proposes; `silkscreen-audit` checks an independent whole-board inventory; neither claims native acceptance. The dashboard makes no AI/provider calls. Fusion and Altium data adapters still need implementation and native tests.
crates/adom-aiflow/src/main.rs+33−58
@@ -26,12 +26,12 @@ struct Cli {  #[derive(Subcommand)] enum Cmd {+    SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },+    SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },+    SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+     /// Start a run: copies the board, stamps the prompt time     Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },-    /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.-    Components { #[arg(long, value_parser = ["ask", "off", "on"])] etch: Option<String> },-    /// Optional Hydrogen progress widget: enable, disable, status, or publish an existing thumbnail.-    Widget { #[arg(value_parser = ["enable", "disable", "status", "event"])] action: String, #[arg(long)] file: Option<PathBuf>, #[arg(long)] label: Option<String>, #[arg(long)] step: Option<String>, #[arg(long)] progress: Option<f64> },     /// The stages this board needs and who can take each     Plan,     /// Record who takes a stage: route=ai pour=binary@@ -474,6 +474,35 @@ fn main() {         }     }     match &cli.cmd {+        Cmd::SilkscreenAudit { input, out } => {+            thread(&cli); let _r = load_run(&cli);+            let result = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));+            if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);+        }+        Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {+            let t = thread(&cli); let _r = load_run(&cli);+            let mut command = std::process::Command::new("adom-aiflow-silkscreen");+            command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);+            if let Some(v)=url { command.arg("--url").arg(v); }+            if let Some(v)=target { command.arg("--target").arg(v); }+            if let Some(v)=json { command.arg("--json").arg(v); }+            if let Some(v)=reason { command.arg("--reason").arg(v); }+            let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));+            if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}+            ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);+        }+        Cmd::SilkscreenLayout { input, out, events } => {+            thread(&cli);+            let _r = load_run(&cli);+            let source = include_str!("../../../tools/silkscreen-layout.py");+            let mut command = std::process::Command::new("python3");+            command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);+            if let Some(v)=events {command.arg("--events").arg(v);}+            let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));+            if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+            ok(&String::from_utf8_lossy(&result.stdout), &["Candidate rectangles only: verify native text bounds, component-body visibility and DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);+        }         Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {             let t = thread(&cli);             if !Path::new(board).is_file() {@@ -497,55 +526,6 @@ fn main() {             let _ = TURN.set((dir.clone(), idx));             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);         }-        Cmd::Widget { action, file, label, step, progress } => {-            let t = thread(&cli);-            let _r = load_run(&cli);-            let mut cmd = std::process::Command::new("adom-aiflow-widget");-            cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);-            if let Some(v) = file { cmd.arg("--file").arg(v); }-            if let Some(v) = label { cmd.arg("--label").arg(v); }-            if let Some(v) = step { cmd.arg("--step").arg(v); }-            if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }-            let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));-            if !status.success() { err("widget helper failed", &[]); }-            ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);-        }-        Cmd::Components { etch } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let b = board_of(&r);-            let path = dir.join("components.json");-            let old: Value = if path.exists() {-                let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));-                serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))-            } else { json!({}) };-            let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("ask");-            let mut entries = Vec::new();-            for fp in b.root.find_all("footprint") {-                let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();-                let Some(reference) = props.get("Reference") else { continue; };-                let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));-                let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());-                if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {-                    entry["review"]["status"] = json!("stale");-                    entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");-                }-                entry["properties"] = json!(props);-                entry["footprint"] = json!(fp.value());-                entry["modelReferences"] = models;-                entries.push(entry);-            }-            let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,-                "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});-            std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));-            r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});-            r.save().unwrap_or_else(|e| err(&e, &[]));-            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();-            let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();-            hints.push(format!("MPN marking preference: {preference}. 'ask' means offer the option, not consent; 'off' preserves plain models; 'on' requests additional reviewed marked variants. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));-            ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);-        }         Cmd::Plan => {             let mut r = load_run(&cli);             // the flow is a file: the steps in order, who does each, what the binary offers, what comes later@@ -572,11 +552,6 @@ fn main() {             lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));             for st in flow["steps"].as_array().cloned().unwrap_or_default() {                 lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));-                if let Some(workflow) = st["workflow"].as_array() {-                    for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {-                        lines.push(format!("    {}. {}", i + 1, action));-                    }-                }             }             lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));             lines.push("stages the binary offers:".into());
docs/silkscreen-dashboard.mdadded+9
@@ -0,0 +1,9 @@+# Watch silkscreen placement++The optional AI Flow silkscreen dashboard displays live solver events or replays their saved history. Users can inspect sampled candidates, selected reference/value pairs and unresolved groups in 2D, then inspect fitted component geometry in 3D. It makes no AI/provider calls. Rendering and recording have CPU/GPU/storage costs; no token multiplier is assumed.++Start/show with `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --run <run>`. Prepare the explicit manifest described in `tools/silkscreen-dashboard/SKILL.md`; the ESC's exporter is currently a task adapter, not an automatic cross-EDA importer. Native bridge ownership is documented in KiCad Bridge issue #104.++Use `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` to emit real timestamped solver decisions. The dashboard samples candidates and explains constraints; it does not expose private reasoning or fabricate intermediate edits. Opt out by omitting `--events` and not starting the dashboard. Solver correctness does not depend on visualization.++Live/replay status and native-review-pending status remain visible. Record replay produces a labelled 2D WebM; retain the detailed walkthrough separately from the short final-video segment. No replay is presented as live native CAD footage. KiCad, Fusion and Altium bridges should supply native geometry/metrics, edits, undo, validation and linked view refresh; the dashboard and search stay shared. Only the KiCad ESC data adapter has been exercised so far.
docs/silkscreen.md+32
@@ -59,3 +59,35 @@ For the requested InstaPCB profile, attempt a nearby value for EVERY resistor an After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.  Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.++## Search space before shrinking text++Treat 0.2 mm as an exceptional fallback, not a successful default. Start with the largest sensible font for the information hierarchy and search nearby positions, rotations and both sides of the component. Use the native EDA's stroke-font bounds, stroke width, justification, rotation and mirrored bottom-face transform. If only estimated bounds are available, identify them as estimates and validate every accepted batch with native plots/DRC and assembled 3D visibility.++Build obstacles from fitted component bodies, individual pads and solder-mask openings, vias/holes, board edges, existing silk and text. Do not replace a collection of separated obstacles with one enormous bounding box that discards useful gaps. Keep reference/value pairs associated but retain separate text rectangles so empty space between them remains usable. Solve each face with its own obstacles; a copied top layout is not a validated bottom layout.++Search alternative positions at each useful font size, then jointly repack neighboring label groups. Use a bounded search with backtracking or beam alternatives so an early label does not permanently consume the best space. Favor complete coverage, readable font sizes and short unmistakable association; preserve connector pin-row order. Reserve and validate curved leader paths too. Report unresolved labels rather than hiding or silently omitting them. Never move copper or components merely to fit text without a design return.++Use `adom-aiflow silkscreen-layout --input candidates.json --out layout.json --ai-thread <thread> --run <run>` for the shared bounding-box candidate search. Input carries board bounds, fixed obstacle rectangles and label groups with alternative native-derived rectangles, font sizes, positions and anchors. A candidate may provide several `boxes` for an intact ref/value pair or leader segments. This is a bounded weighted search, not proof of a global optimum; it does not edit the board. Native text measurement/insertion/refresh remains the EDA bridge's job. Apply by stable item identity, never by text alone: duplicate values and repeated pinout tables are common.++Run native clearance/overlap checks, compare findings against the inherited baseline, then inspect actual-size legibility and close-up association on both faces. Revise the candidates when validation rejects them; do not just call the search successful. Refresh both the 2D editor and its linked 3D viewer after an accepted batch. Record genuine placement and rework in the silkscreen step clip.++### Candidate geometry evidence++`tools/native-text-bounds.py --svg front.svg --labels labels.json --layer F.SilkS --out bounds.json` measures native stroke paths from a single-face KiCad SVG export. Export each face separately through the bridge. The label manifest contains `labels` with `text`, `x`, `y`, `size`, `angle` and `layer`. The helper reports its nearest-text matching method and SVG hash; inspect ambiguous duplicate/multiline mappings. It is a fallback geometry reader, not a native live text-metrics API. Preserve stable item IDs for application.++A `silkscreen-layout` input has `bounds: [xmin,ymin,xmax,ymax]`, `obstacles: [{box: [...]}]`, and `labels: [{id,anchor:[x,y],candidates:[{font,position:[x,y],box:[...]}]}]`. Optional candidate `boxes` describes separate rectangles for grouped text/leader segments; every rectangle must be clear. Optional `orderGroup` and numeric `order` preserve increasing connector-row Y. `beamWidth` and `candidatesPerLabel` bound the search. The score weights font size, distance and movement; it cannot promise the global optimum. All outputs retain `nativeVerified: false` until separate native review evidence is recorded.++For tightly coupled regions, set `solver: "milp"` to use SciPy/HiGHS mixed-integer selection with `solverSeconds`, `milpPerFont`, `preferredMinimumFont`, and `smallFontPenalty`. This optional engine needs SciPy; the default beam engine uses Python's standard library. Candidate pruning must preserve alternatives at every font size. Solver optimality only concerns the finite supplied candidate set, never all possible board artwork.++`tools/silkscreen-leaders.py --input candidates.json --out routed-candidates.json` accepts per-label `leaderTargets` and `routeBounds`, uses a bounded grid search around fixed rectangles, and appends leader segment bounds to each candidate. The final selection prevents those reserved paths crossing other selected text/routes. It retains alternatives per font size. Native application may gently round corners inside the cleared envelope; validate the actual resulting curves. Failure to route a leader must trigger repacking or an explicit unresolved finding.++### Complete obstacle coverage is required++Before placing or accepting silkscreen, enumerate every fitted model, via and drilled hole across the entire board, on both faces. Record coverage counts and stable IDs. A local search region does not excuse leaving the rest of the board unchecked. Project the actual fitted 3D model into the board plane with its scale, offset, rotation and footprint transform; include overhangs. Conservative transformed model bounds are acceptable when a tighter silhouette is unavailable. F.Fab, courtyard or pad envelopes are fallbacks, not proof of fitted-body coverage: a footprint can have no Fab drawing, and the space between separated pads can still be occupied by the body. Missing model geometry must be reported and supplied a documented conservative envelope, never a tiny placeholder that silently allows text beneath the component.++Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result.++## Optional live silkscreen dashboard++Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.
flows/board.json+24−77
@@ -1,82 +1,27 @@ {   "name": "board",   "title": "The board flow: parts placement to a qualified board",-  "scope": "Starts with component identity, reusable CAD review and native library assembly for an existing schematic/board. Reviews the selected model variants on camera before placement, then routes, pours, analyzes and delivers a bounded final video.",+  "scope": "Starts at the 3D models and parts placement on a board whose schematic, libraries and part choices already exist. Ends when the board is 100 percent routed, DRC-clean, poured, its copper measured, its current and thermal analyses passed, its nets and its 3D view walked through on camera, and the AI has delivered the video.",   "steps": [     {       "name": "intake",       "who": "ai",-      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan; offer the optional Hydrogen progress widget (default off; enable when requested), reusing saved milestone images without extra AI calls",-      "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read",-      "workflow": [-        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."-      ]-    },-    {-      "name": "components",-      "who": "ai",-      "does": "Audit wiki component identity, reusable CAD quality and redistribution evidence before placement; offer cached optional MPN-marked variants.",-      "binary": [-        "components"-      ],-      "workflow": [-        "Inventory every reference from the actual board and schematic: manufacturer, MPN, supplier code, package, value and ratings; group repeated exact parts and record their references. Do not invent an MPN or silently substitute a similar value/package. Distinguish populated parts, DNP parts and bare copper/mechanical features; a bare test pad needs no purchased component or fictitious 3D body.",-        "Search the wiki FIRST for each exact manufacturer/MPN and supplier code; reuse the matching component page and inspect its symbol, footprint and STEP/WRL assets. A page existing is not proof its CAD bundle is complete. Record the page URL and missing assets per reference.",-        "For missing identities or assets, use adom-parts-search next, then original manufacturer and distributor websites. Use Pup to navigate and download through the automated browser when curl/fetch is blocked or the site requires JavaScript; do not treat a blocked fetch as proof the part is unavailable. Read the relevant parts-search and Pup skills for current commands.",-        "Verify manufacturer, exact ordering code, package dimensions, pin numbering and required electrical ratings against source evidence before accepting CAD or a candidate part. Preserve source URLs and provenance. Label generated or approximate models as such; never present an approximate body as a verified vendor model.",-        "Global component pages are shared resources for ALL ADOM USERS, never a board-specific BOM dump. Create one only for a distinct reusable manufacturer part or independently specified reusable custom component, after searching for duplicates. Write a part-focused page with portable verified assets and provenance, and improve existing pages. Keep board references, unresolved identities, one-off land patterns, bare copper test pads and project-only assemblies inside the board project wiki page; do not create catalog placeholders or rename a one-off feature to make it appear global.",-        "Use portable project/library model paths, preserve placement and routing, rerun kicad_model_check and inspect the saved board in native KiCad 3D. Report reference coverage, reused/created page URLs, unresolved identities and missing CAD separately. A successful download or wiki publication alone does not close a missing-model finding.",-        "Judge quality per component, not per board screenshot: publish a linked visual register with top, bottom and oblique views. Check dimensions, units, terminal count and pitch, pin-1/polarity, body/pad alignment, standoff, materials and visible details. File resolution alone is not quality. Record pass, needs-work, reference-only or unknown with evidence and limits.",-        "Prefer manufacturer CAD when the source permits the intended redistribution. Keep third-party/Ultra Librarian downloads as private reference-only inputs unless redistribution is explicitly permitted. Compare independently generated models against manufacturer drawings and permitted reference views; retain dimensional deviations and source hashes. Converting, extracting, recoloring, or etching a restricted model does not make it independently authored or license-cleared.",-        "Offer optional MPN marking using adom/adom-chip-laser or the current adom-step2glb laser-etch service. Ask whether to enable it; default to unmarked models until the user chooses. Check the shared page cache first. Preserve the plain STEP and publish an additional marked STEP, derived GLB and reviewed thumbnails only for assets eligible for redistribution. Keep the mark clear of pin-1, polarity, terminals and optical/mechanical features. Marking is an identification aid, not evidence of actual factory top-marking, especially on tiny passives.",-        "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.",-        "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",-        "For optional MPN marking, fit the text along the longest usable top-face direction with the largest legible size, respecting pin-1 and mechanical features. Compare both orientations and retain the package coordinate frame. Cache and identify the marked variant explicitly.",-        "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately.",-        "Before accepting a wiki component, read its actual native model_3d and STEP bindings and hash the referenced downloads against the reviewed variants. Wait for the native viewer to finish painting, then inspect the visible body and readable mark; nonzero meshes or a successful HTTP request do not prove a painted model. Check the static hero too. Use a descriptive manufacturer/MPN/function title in both page.json and package.json; follow wiki-component and preserve other contributors and original files.",-        "A resolved 3D file may still be hidden by native EDA appearance filters (for example KiCad excludes models not in position files). Check those filters before inventing replacement parts or changing BOM/placement flags. Keep bridge-specific visibility, binding and native audit operations in the owning bridge; report missing capabilities there. Explicit rights-holder permission may authorize a derivative despite a restrictive package default; record who authorized what and its scope, without extending it to third-party assets."-      ]-    },-    {-      "name": "libraries",-      "who": "ai",-      "does": "Assemble and validate the reviewed symbols, footprints and selected model variants in the chosen EDA before placement.",-      "workflow": [-        "Use the components register to build a portable project library for the actual EDA (KiCad, Altium or Fusion). Verify symbol pin numbers against footprint pads, package dimensions and pin-1/polarity; resolve model transforms and selected plain or marked STEP paths. Record native tool/version and evidence per part.",-        "Keep board reference mappings and native project transforms in the board project, while reusable source CAD and provenance remain on their existing global component pages. Do not claim native EDA support from a GLB gallery or a file extension alone.",-        "Return to components with --back --why when identity, rights or geometry cannot be resolved; preserve honest unknowns and report the affected references."-      ],-      "record": "Native library review with the selected symbol, footprint and model; saved verification evidence belongs with the library register."+      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan",+      "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read"     },     {       "name": "models",       "who": "ai",-      "does": "Check native board model resolution and inspect the selected library variants in the chosen EDA; use the component register for unresolved assets.",+      "does": "every footprint on the board has its 3D model resolved (kicad_model_check): fetch the vendor STEP, build one, or fix the path, so the board renders as it will be built; a bare footprint in the 3D view is a missing model",       "binary": [         "models"       ],-      "record": "Native board 3D inspection and model-check evidence; the separate library-tour step reviews each selected component.",-      "workflow": [-        "Use the components register and its reviewed plain or explicitly selected marked variants; fix portable model paths, run kicad_model_check, then inspect the native board render. Do not substitute a model merely to make the missing-file gate pass.",-        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."-      ]-    },-    {-      "name": "library-tour",-      "who": "ai",-      "does": "Review each selected component model in motion and produce a separate short library overview for the final video.",-      "workflow": [-        "Use tools/library-tour.py with the reviewed library.json, --run and --ai-thread. The manifest must identify the same plain or explicitly chosen marked variants and hashes that the native board uses. A marking choice does not authorize silent plain-model fallback.",-        "Inspect the detailed orbit tour and the moving overview in a rendered browser. Reject blank, clipped, wrong-variant or static clips; retain raw recordings and contact sheets. The detailed tour stays separately linked for component review.",-        "Budget approximately five seconds for the moving overview within the entire final video limit of 120 seconds. Do not insert the whole BOM walkthrough or one wiki-page shot per part into the final video. Optional scrolling wiki-page clips belong in the separate detailed review.",-        "Automatic overview selection in released compose requires the complete maintained release source (issue #16); until integrated, report that limitation explicitly and keep the reviewed clips and selection manifest available for integration."-      ],-      "record": "Two separate raw clips: a detailed component orbit tour and a roughly five-second moving overview. No title pages or baked captions; the final composer supplies its own overlays."+      "record": "nothing to film: the model check's list and the fixes; the 3D walkthrough later is the proof"     },     {       "name": "placement",       "who": "ai",-      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves; reserve visible space for connector labels and the two-sided service silkscreen",+      "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves",       "binary": [         "place pack",         "place check",@@ -140,19 +85,10 @@       ],       "record": "the Adom Fields window: the board in 3D drifting gently through each beat, the heat on the top copper, each pour isolated on its own, the board turned over to the bottom, the temperature, then an issue or two; slow on purpose, the final video plays it at ten times speed"     },-    {-      "name": "nets",-      "who": "binary",-      "does": "walk the key nets on camera: each loaded, wide or Kelvin net selected as a whole so the editor highlights its pours, tracks, vias and pads together, framed with Zoom to Selection",-      "binary": [-        "tour nets"-      ],-      "record": "the editor with one net lit at a time, framed; the markers say which net"-    },     {       "name": "silkscreen",       "who": "ai",-      "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility.",+      "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility. Offer the optional shared silkscreen dashboard: real solver events, candidate reasons, live/replay separation, fitted-model inspection and separately recorded detailed/5s replays. Audit every fitted body and every via/hole across the whole board, both faces, with coverage counts. Missing Fab geometry is not free space; native DRC does not establish fitted visibility. Keep unresolved labels explicit and do not apply an incomplete layout. Native geometry/edit/undo/refresh belongs to the EDA bridge.",       "workflow": [         "Read the InstaPCB silkscreen skill for the selected process; reserve label space during placement and finalize after copper and analysis stabilize. Use both faces, a clear font-size hierarchy, and the approved process profile for small secondary text.",         "Build a source-backed label manifest from actual schematic, pad numbers, nets and approved requirements. Include reference/value pairs, connector pinouts, power polarity, test points, switch/LED functions, revision and documentation link. Never infer voltage/current ratings from net names or component absolute maxima.",@@ -164,10 +100,20 @@         "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3\u20135 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",         "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",         "For the requested InstaPCB profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",-        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."+        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",+        "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."       ],       "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3\u20135 second final-video excerpt. Clearly label reconstructed replay."     },+    {+      "name": "nets",+      "who": "binary",+      "does": "walk the key nets on camera: each loaded, wide or Kelvin net selected as a whole so the editor highlights its pours, tracks, vias and pads together, framed with Zoom to Selection",+      "binary": [+        "tour nets"+      ],+      "record": "the editor with one net lit at a time, framed; the markers say which net"+    },     {       "name": "3d",       "who": "binary",@@ -175,10 +121,7 @@       "binary": [         "tour 3d"       ],-      "record": "the 3D Viewer window itself: the board turning, the components up close; full of motion, so the action cut keeps most of it",-      "workflow": [-        "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."-      ]+      "record": "the 3D Viewer window itself: the board turning, the components up close; full of motion, so the action cut keeps most of it"     },     {       "name": "capture",@@ -206,9 +149,13 @@   "returns": "any step may send the AI back to any earlier step: step <name> --back --why \"...\"",   "later": [     {-      "name": "part-selection",+      "name": "components",       "does": "choose the parts from the requirements; SPICE may send the AI back here"     },+    {+      "name": "libraries",+      "does": "symbols, footprints, 3D chips for every part"+    },     {       "name": "schematic",       "does": "the schematic, and the spec that falls out of it"
install.sh+2−3
@@ -12,6 +12,5 @@ command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs s command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop" echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'." -# Retain the helper beside its package so the scoped stop can verify its PID.-chmod +x "$HERE/tools/aiflow-widget.py"-ln -sf "$HERE/tools/aiflow-widget.py" "$HOME/.local/bin/adom-aiflow-widget"+chmod +x "$HERE/tools/silkscreen-dashboard/server.py"+ln -sf "$HERE/tools/silkscreen-dashboard/server.py" "$HOME/.local/bin/adom-aiflow-silkscreen"
skills/adom-aiflow/SKILL.md+2−28
@@ -82,32 +82,6 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper. -## Component coverage before placement+## Silkscreen placement, complete coverage and observable decisions -The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.--Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.--Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.--## Early component quality and optional MPN marking--Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.--Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.--Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.--## Provenance is required for shared component assets--Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).--For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.--For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.--Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.--## Optional live progress--Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.+Read `docs/silkscreen.md` for complete coverage, font/locality search, both-face contact/test-point labels, values and leader rules. Read `tools/silkscreen-dashboard/SKILL.md` for optional live/replay observation and recordings. Native geometry, edit/undo and 2D/3D refresh remain bridge responsibilities. `silkscreen-layout` proposes; `silkscreen-audit` checks an independent whole-board inventory; neither claims native acceptance. The dashboard makes no AI/provider calls. Fusion and Altium data adapters still need implementation and native tests.
tools/native-text-bounds.pyadded+25
@@ -0,0 +1,25 @@+from pathlib import Path+import xml.etree.ElementTree as E,re,json,math,argparse,hashlib+p=argparse.ArgumentParser(description='Measure text from a single-face native KiCad stroke SVG; layout evidence only.');p.add_argument('--svg',required=True,type=Path);p.add_argument('--labels',required=True,type=Path);p.add_argument('--out',required=True,type=Path);p.add_argument('--layer',default='F.SilkS');a=p.parse_args();root=E.parse(a.svg).getroot();groups=[]+def walk(e,sw=0):+ st=dict(re.findall(r'([\w-]+):([^;]+)',e.get('style','')));sw=float(st.get('stroke-width',e.get('stroke-width',sw)))+ if e.get('class')=='stroked-text':+  desc=next((x.text for x in e if x.tag.endswith('desc')),None);points=[]+  for p in e.iter():+   if p.tag.endswith('path'):+    vals=list(map(float,re.findall(r'[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',p.get('d',''))));points+=list(zip(vals[::2],vals[1::2]))+  if points:+   xs,ys=zip(*points);groups.append(dict(text=desc,box=[min(xs)-sw/2,min(ys)-sw/2,max(xs)+sw/2,max(ys)+sw/2]))+ for c in e:walk(c,sw)+walk(root);m=json.loads(a.labels.read_text());out=[]+for i,l in enumerate(m['labels']):+ if l['layer']!=a.layer:continue+ boxes=[]+ for line in l['text'].split('\n'):+  gs=[g for g in groups if g['text']==line]+  if not gs:raise ValueError((i,line))+  def dist(g):b=g['box'];return math.hypot((b[0]+b[2])/2-l['x'],(b[1]+b[3])/2-l['y'])+  g=min(gs,key=dist);boxes.append(g['box'])+ b=[min(v[0] for v in boxes),min(v[1] for v in boxes),max(v[2] for v in boxes),max(v[3] for v in boxes)]+ out.append(dict(index=i,text=l['text'],box=b,offset=[(b[k]-(l['x'] if k%2==0 else l['y']))/l['size'] for k in range(4)]))+a.out.write_text(json.dumps({'sourceSvgSha256':hashlib.sha256(a.svg.read_bytes()).hexdigest(),'layer':a.layer,'method':'nearest matching native stroked-text group; inspect mapping before layout','bounds':out},indent=2));print('Native SVG text bounds:',len(out))
tools/silkscreen-dashboard/SKILL.mdadded+23
@@ -0,0 +1,23 @@+# Silkscreen dashboard++An optional, EDA-neutral observer of placement candidates and recorded solver decisions. It makes no AI/provider calls and never edits a native board. Native geometry, text metrics, undoable edits and 2D/3D refresh belong to each EDA bridge. The ESC integration currently exports KiCad geometry; Fusion and Altium adapters still need implementation and native tests. Do not claim those integrations work from schema compatibility alone.++## Displaying this app (REQUIRED — read before showing anything)++Use `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --run <run>` to open the named Hydrogen webview. Never use VS Code Simple Browser. `--surface pup` requires an explicit desktop `--target` and desktop-reachable `--url`. Keep standard workspace navigation. Reuse the same run/thread instance; `ls` shows instances and version drift.++## Inputs and live observation++Place `board.json` and an append-only `events.jsonl` in `<run>/silkscreen-dashboard/`. These are separate artifacts; never edit the measurement ledger `run.jsonl`. The board manifest has `bounds` in mm, `labels`, `obstacles`, `viaCount`, source-board identity and validation state. Each label carries text, x/y, size, angle, face and an optional existing-collision flag. Each obstacle carries a stable reference, kind, layer and conservative world-space box. Model obstacles may additionally supply a relative GLB URL, footprint `at` in KiCad XY/degrees, model offset and height. This initial 3D adapter supports top-mounted, unit-scale models without separate model rotations; bake other transforms into the supplied GLB or refuse the preview. The viewport's board slab is an envelope, not a manufactured-board export; pads, routed copper and drilled geometry are not yet native-equivalent. Keep that distinction visible.++`adom-aiflow silkscreen-layout --input candidates.json --out layout.json --events <run>/silkscreen-dashboard/events.jsonl --run <run> --ai-thread "<name>"` emits timestamped start, sampled candidate, selected proposal, unresolved and completion events with a solve ID. Events are generated by real computation, not invented for a film. Candidate samples are bounded: this is not every internal solver iteration. Pre-filtered inputs cannot reconstruct prior rejected candidates. The event reason describes the observable constraint/result, not private internal reasoning.++SSE updates do not reload the DOM. Live follows new events; replay has shared play/pause, scrub and speed controls. Playback is independent of solver speed. A selected proposal is not a native edit. Native application/DRC/assembled-visibility evidence must be recorded separately; the dashboard never promotes proposal success into native acceptance.++## Recording++Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and the current recorded decision. Detailed review and a short final-video segment are separate artifacts. Preserve original event timestamps and raw native per-step clips. Do not call replay footage a live native edit or a KiCad recording. Verify the exported video actually plays. The browser-font preview does not replace KiCad's measured stroke-font geometry. Final-video pacing should not force the solver to sleep or trigger extra AI calls for every label.++## Acceptance++Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list.
tools/silkscreen-dashboard/index.htmladded+28
@@ -0,0 +1,28 @@+<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>AI Flow · Silkscreen</title><style>+:root{color-scheme:dark;font:14px system-ui;color:#e7edef;background:#141a20}*{box-sizing:border-box}body{margin:0;height:100vh;display:grid;grid-template-rows:48px 1fr 66px}header,footer{display:flex;align-items:center;gap:12px;padding:8px 18px;background:#1d252d;border-bottom:1px solid #35424d}header b{font-size:17px}button,select,input{font:inherit;color:inherit;background:#26333e;border:1px solid #536371;border-radius:5px;padding:7px}button{cursor:pointer}button:hover{border-color:#51cdc2}button.active{background:#24504f}main{display:grid;grid-template-columns:1fr 300px;min-height:0}#stage{position:relative;overflow:hidden;background:#111a1f}canvas{display:block;width:100%;height:100%;touch-action:none}#three{position:absolute;inset:0;display:none}aside{padding:18px;overflow:auto;background:#1a222a}h2{font-size:23px;margin:8px 0}p{line-height:1.5;color:#b5c4cd}.badge{border:1px solid #648076;border-radius:12px;padding:3px 10px;color:#7be0c7;font-size:12px}#native{color:#ffd293}.muted{color:#93a5b1;font-size:12px}#scrub{flex:1}#progress{height:5px;background:#33444f;margin:14px 0}#progress span{display:block;background:#48cdbb;height:100%;width:0}#legend{display:grid;gap:8px;margin-top:24px}.swatch{display:inline-block;width:12px;height:12px;margin-right:8px;border-radius:2px}#settings{position:absolute;right:20px;top:55px;z-index:8;background:#26313b;border:1px solid #566676;padding:18px;border-radius:8px}#settings label{display:block;margin:12px 0}#hud{position:absolute;left:16px;top:14px;pointer-events:none;background:#142028de;border:1px solid #41535e;padding:10px 14px;border-radius:6px}#error{color:#ff9999}a{color:#67dacc}footer{border-top:1px solid #35424d}#fit{margin-left:auto}@media(max-width:800px){main{grid-template-columns:1fr 220px}header{gap:5px;padding:7px}header b{font-size:14px}}+</style></head><body><header><b>Adom AI Flow · Silkscreen</b><span id="mode" class="badge">Connecting</span><select id="face" aria-label="Board face"><option value="F.SilkS">Top</option><option value="B.SilkS">Bottom</option></select><button id="two" class="active">2D layout</button><button id="threeBtn">3D inspection</button><button id="fit">Fit board</button><button id="gear" aria-label="Settings">⚙</button></header><main><div id="stage"><canvas id="board"></canvas><div id="three"></div><div id="hud"><div id="headline">Loading board geometry</div><div class="muted" id="geometry"></div></div></div><aside><div class="muted">Placement decision</div><h2 id="part">Waiting</h2><p id="reason">No solver events yet.</p><div id="progress"><span></span></div><div id="count"></div><p id="native">Native CAD review pending</p><label>Inspect component <select id="pick"><option value="">Whole board</option></select></label><div id="legend"><div><i class="swatch" style="background:#516a78"></i>Fitted body / reserved envelope</div><div><i class="swatch" style="background:#cbad4c"></i>Via / mask opening</div><div><i class="swatch" style="background:#ffb963"></i>Sampled candidate</div><div><i class="swatch" style="background:#57dcc4"></i>Selected proposal</div><div><i class="swatch" style="background:#f18389"></i>Existing collision</div></div><p class="muted">Selection is a proposal, not an edit to the EDA board. Native text bounds drive collision checks. Preview text uses the browser font.</p><div id="error"></div></aside></main><footer><button id="live">Follow live</button><button id="play">Replay</button><input type="range" id="scrub" min="-1" max="0" value="-1" aria-label="Event timeline"><span id="stamp" class="muted">0 / 0</span><button id="record">Record walkthrough</button><button id="short">Record 5s overview</button></footer><div id="settings" hidden><b>Playback & display</b><label>Events per second <input id="speed" type="number" min="0.5" max="60" step="0.5"></label><label><input id="obstacles" type="checkbox"> Show obstacles</label><label><input id="follow" type="checkbox" checked> Follow selected component</label><label>Idle shutdown, hours <input id="ttl" type="number" min="1" max="168"></label><p class="muted">No AI/provider calls. Solver runs independently of playback.</p></div>+<script type="module">+const $=s=>document.querySelector(s),canvas=$('#board'),ctx=canvas.getContext('2d');let data,history=[],state={},selected='',bounds,view,drag=null,viewer=null,scene=null,labelTexture=null,threeGroups=[],recording=false;const api=async(path,body)=>{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...body,aiThread:state.aiThread||'Silkscreen dashboard user'})});const j=await r.json();if(!r.ok)throw Error(j.error);if(j.state)await apply(j.state);return j};window.silkscreen={ready:false};+let cameraAnimation=0;function frame(b){cancelAnimationFrame(cameraAnimation);if(recording&&!state.summary&&view){const from=[...view],start=performance.now();const tick=now=>{const t=Math.min(1,(now-start)/220),ease=t*t*(3-2*t);view=from.map((v,i)=>v+(b[i]-v)*ease);draw();if(t<1)cameraAnimation=requestAnimationFrame(tick)};cameraAnimation=requestAnimationFrame(tick)}else{view=[...b];draw()}}+function fitPart(ref){const os=data.obstacles.filter(o=>o.reference===ref&&o.layer===state.layer);if(!os.length)return;const b=[Math.min(...os.map(o=>o.box[0]))-3,Math.min(...os.map(o=>o.box[1]))-3,Math.max(...os.map(o=>o.box[2]))+3,Math.max(...os.map(o=>o.box[3]))+3];frame(b);if(viewer){const cam=viewer.getCamera();cam.setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));cam.radius=Math.max(b[2]-b[0],b[3]-b[1])*1.8}}+function proposals(){const map=new Map();let start=0;for(let k=Math.min(state.cursor,history.length-1);k>=0;k--)if(history[k].kind==='start'){start=k;break}for(let k=start;k<=state.cursor&&k<history.length;k++){const e=history[k];if(e.kind==='placement')map.set(e.id,e.candidate)}return map}+function paint(c,w,h,extent,texture=false){c.clearRect(0,0,w,h);if(!texture){c.fillStyle='#122128';c.fillRect(0,0,w,h)}const bw=extent[2]-extent[0],bh=extent[3]-extent[1],scale=Math.min(w/bw,h/bh)*.93,sx=texture?w/bw:scale,sy=texture?h/bh:scale,ox=texture?-extent[0]*sx:(w-bw*scale)/2-extent[0]*scale,oy=texture?-extent[1]*sy:(h-bh*scale)/2-extent[1]*scale;c.save();c.translate(ox,oy);c.scale(sx,sy);c.lineWidth=1/scale;+function rect(b,color,fill=true){c.fillStyle=color;c.strokeStyle=color;if(fill)c.fillRect(b[0],b[1],b[2]-b[0],b[3]-b[1]);else c.strokeRect(b[0],b[1],b[2]-b[0],b[3]-b[1])}+if(!texture){rect(data.bounds,'#233b32');if(state.settings?.showObstacles)for(const o of data.obstacles){if(o.layer!==state.layer)continue;const col=o.kind.startsWith('fitted')?(o.reference===selected?'#607b87':'#394d58'):o.kind.startsWith('via')?'#bd9f43':'#889081';if(o.kind.startsWith('via')){const b=o.box;c.beginPath();c.arc((b[0]+b[2])/2,(b[1]+b[3])/2,(b[2]-b[0])/2,0,Math.PI*2);c.strokeStyle=col;c.stroke()}else rect(o.box,col,true)}}+const placed=proposals();const replaced=new Set([...placed.values()].flatMap(p=>(p.children||[]).map(x=>x.index)));+function label(l,color,b){c.save();c.translate(l.x??l.position?.[0]??0,l.y??l.position?.[1]??0);c.rotate(-(l.angle||0)*Math.PI/180);c.font=`${l.size||l.font||.5}px sans-serif`;c.textAlign='center';c.textBaseline='middle';c.fillStyle=color;(l.text||'').split('\n').forEach((s,i,all)=>c.fillText(s,0,(i-(all.length-1)/2)*(l.size||l.font||.5)*1.5));c.restore();if(!texture&&b)rect(b,color,false)}+for(let i=0;i<data.labels.length;i++){const l=data.labels[i];if(l.layer!==state.layer||replaced.has(i))continue;label(l,l.collision?'#f1838999':'#bfccc666')}+for(const p of placed.values()){if(p.layer&&p.layer!==state.layer)continue;for(const child of p.children||[p]){const original=child.index!=null?data.labels[child.index]:null;if(original&&original.layer!==state.layer)continue;label(child,'#79f5d9',texture?null:child.box)}}+const e=history[state.cursor];if(!texture&&e?.kind==='candidate'){for(const b of e.candidate.boxes||[e.candidate.box])rect(b,'#ffbd70',false);for(const child of e.candidate.children||[e.candidate])label(child,'#ffbd70')}c.restore();if(!texture&&recording){c.fillStyle='#101820df';c.fillRect(12,h-62,w-24,48);c.fillStyle='#dfece8';c.font='16px system-ui';c.fillText('REPLAY · '+(e?.id||'Silkscreen')+' · '+(e?.reason||e?.kind||''),24,h-33)} }+function draw(){if(!data||!view)return;const r=canvas.getBoundingClientRect();canvas.width=Math.max(1,r.width*devicePixelRatio);canvas.height=Math.max(1,r.height*devicePixelRatio);paint(ctx,canvas.width,canvas.height,view);if(labelTexture){const t=labelTexture.getContext();paint(t,2048,2048,data.bounds,true);labelTexture.update(false)}}+async function show3d(){if(viewer)return;$('#headline').textContent='Loading 3D geometry';try{await import('https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260915a');const B=window.BABYLON;viewer=window.Adom3DViewerBabylon9.init($('#three'),{zUp:true,showGround:false,showViewCube:false});scene=viewer.getScene();const b=data.bounds,cx=(b[0]+b[2])/2,cy=(b[1]+b[3])/2;const board=B.MeshBuilder.CreateBox('Board envelope',{width:b[2]-b[0],height:b[3]-b[1],depth:1.6},scene);board.position.set(cx,-cy,-.8);const bm=new B.PBRMaterial('board',scene);bm.albedoColor=B.Color3.FromHexString('#24563b');bm.metallic=0;bm.roughness=.85;board.material=bm;+for(const o of data.obstacles.filter(o=>o.kind.startsWith('fitted'))){const b=o.box;const mesh=B.MeshBuilder.CreateBox(o.reference,{width:b[2]-b[0],height:b[3]-b[1],depth:o.height||1},scene);mesh.position.set((b[0]+b[2])/2,-(b[1]+b[3])/2,(o.height||1)/2+.03);const mat=new B.StandardMaterial('reserved envelope',scene);mat.diffuseColor=B.Color3.FromHexString('#5b707d');mat.alpha=.72;mesh.material=mat;threeGroups.push({mesh,o});if(o.glb){try{const imported=await B.SceneLoader.ImportMeshAsync('',o.glb,'',scene),group=new B.TransformNode('fitted_'+o.reference,scene);for(const m of imported.meshes)if(!m.parent)m.parent=group;group.scaling.setAll(1000);const theta=(o.at[2]||0)*Math.PI/180,dx=o.offset?.[0]||0,dy=o.offset?.[1]||0;group.position.set(o.at[0]+dx*Math.cos(theta)-dy*Math.sin(theta),-o.at[1]+dx*Math.sin(theta)+dy*Math.cos(theta),o.offset?.[2]||0);group.rotation.z=theta;mesh.setEnabled(false);const buckets=new Map();for(const m of imported.meshes){if(!m.getTotalVertices())continue;m.computeWorldMatrix(true);const mat=m.material,key=JSON.stringify([mat?.albedoColor?.asArray(),mat?.diffuseColor?.asArray(),mat?.emissiveColor?.asArray(),mat?.alpha,mat?.metallic,mat?.roughness]);if(!buckets.has(key))buckets.set(key,[]);buckets.get(key).push(m)}for(const ms of buckets.values()){const mat=ms[0].material;for(const m of ms)m.material=mat;if(ms.length>1)B.Mesh.MergeMeshes(ms,true,true,undefined,false,false)}}catch(error){$('#error').textContent='Model fallback for '+o.reference+': '+error.message}}}+labelTexture=new B.DynamicTexture('silkscreen proposals',{width:2048,height:2048},scene,false);labelTexture.hasAlpha=true;const plane=B.MeshBuilder.CreatePlane('Silkscreen preview',{width:b[2]-b[0],height:b[3]-b[1]},scene);plane.position.set(cx,-cy,.015);const pm=new B.StandardMaterial('silkscreen',scene);pm.diffuseTexture=labelTexture;pm.opacityTexture=labelTexture;pm.useAlphaFromDiffuseTexture=true;pm.emissiveColor=B.Color3.White();pm.disableLighting=true;pm.backFaceCulling=false;plane.material=pm;viewer.getCamera().setTarget(new B.Vector3(cx,-cy,0));viewer.getCamera().radius=100;viewer.getCamera().beta=.65;viewer.getCamera().alpha=-1.6;viewer.getCamera().minZ=.01;viewer.getCamera().upperRadiusLimit=300;draw();$('#geometry').textContent='Fitted component models · rectangular board preview · native CAD review still required';}catch(e){$('#error').textContent=e.message}}+async function apply(s){const oldTotal=history.length;if(s.events)history=s.events;else if(s.total!==oldTotal){const full=await(await fetch('state')).json();history=full.events}state={...state,...s};window.silkscreen.state=state;const e=history[state.cursor];$('#mode').textContent=state.mode==='live'?'Live solver feed':'Recorded-event replay';$('#part').textContent=e?.id||'Whole board';$('#reason').textContent=e?.reason|| (e?.kind==='complete'?`Search complete; ${e.unresolved.length} unresolved groups.`:'Waiting for the next solver event.');$('#headline').textContent=e?.kind==='complete'?'Candidate layout · review pending':e?.id?`${e.id} · ${e.kind}`:'ESC silkscreen placement';$('#face').value=state.layer;$('#stamp').textContent=`${Math.max(0,state.cursor+1)} / ${history.length}`;$('#scrub').max=Math.max(0,history.length-1);$('#scrub').value=state.cursor;$('#count').textContent=`${proposals().size} selected groups`;$('#progress span').style.width=(history.length?(state.cursor+1)/history.length*100:0)+'%';$('#play').textContent=state.playing?'Pause':'Replay';$('#speed').value=state.settings.speed;$('#ttl').value=state.settings.ttlHours;$('#obstacles').checked=state.settings.showObstacles;if(e?.id&&selected!==e.id&&$('#follow').checked){selected=e.id;fitPart(selected)}$('#three').style.display=state.view==='3d'?'block':'none';$('#board').style.visibility=state.view==='3d'?'hidden':'visible';if(state.view==='3d'){await show3d();if(scene){const plane=scene.getMeshByName('Silkscreen preview');if(plane)plane.position.z=state.layer==='B.SilkS'?-1.615:.015;if(s.layer)viewer.getCamera().beta=state.layer==='B.SilkS'?Math.PI-.65:.65}}draw()}+try{data=await(await fetch('board.json')).json();bounds=data.bounds;view=[...bounds];const refs=[...new Set(data.obstacles.filter(o=>o.kind.startsWith('fitted')).map(o=>o.reference))].sort();for(const r of refs){const o=document.createElement('option');o.value=r;o.textContent=r;$('#pick').append(o)}$('#geometry').textContent=`${refs.length} fitted bodies · ${data.viaCount} vias · native measured text bounds`;+await apply(await(await fetch('state')).json());const stream=new EventSource('stream');stream.addEventListener('state',e=>apply(JSON.parse(e.data)).catch(e=>$('#error').textContent=e.message));stream.onerror=()=>$('#mode').textContent='Reconnecting';window.silkscreen.ready=true;+$('#live').onclick=()=>api('control',{mode:'live'});$('#play').onclick=()=>api('control',{mode:'replay',summary:false,playing:!state.playing,cursor:state.cursor>=history.length-1?-1:state.cursor});$('#scrub').oninput=e=>api('control',{mode:'replay',playing:false,cursor:+e.target.value});$('#face').onchange=e=>api('control',{layer:e.target.value});$('#two').onclick=()=>api('control',{view:'2d'});$('#threeBtn').onclick=()=>api('control',{view:'3d'});$('#fit').onclick=()=>{selected='';frame(bounds);if(viewer){const b=bounds;viewer.getCamera().setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));viewer.getCamera().radius=100}};$('#pick').onchange=e=>{selected=e.target.value;$('#follow').checked=false;selected?fitPart(selected):frame(bounds)};$('#gear').onclick=()=>$('#settings').hidden=!$('#settings').hidden;$('#speed').onchange=e=>api('settings',{speed:+e.target.value});$('#ttl').onchange=e=>api('settings',{ttlHours:+e.target.value});$('#obstacles').onchange=e=>api('settings',{showObstacles:e.target.checked});+canvas.onwheel=e=>{e.preventDefault();const f=e.deltaY>0?1.15:.87;const r=canvas.getBoundingClientRect(),px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height,w=view[2]-view[0],h=view[3]-view[1];view=[view[0]+w*px*(1-f),view[1]+h*py*(1-f),view[0]+w*px*(1-f)+w*f,view[1]+h*py*(1-f)+h*f];draw()};canvas.onpointerdown=e=>{drag={x:e.clientX,y:e.clientY,v:[...view]};canvas.setPointerCapture(e.pointerId)};canvas.onpointermove=e=>{if(!drag)return;const r=canvas.getBoundingClientRect(),dx=(e.clientX-drag.x)/r.width*(drag.v[2]-drag.v[0]),dy=(e.clientY-drag.y)/r.height*(drag.v[3]-drag.v[1]);view=[drag.v[0]-dx,drag.v[1]-dy,drag.v[2]-dx,drag.v[3]-dy];draw()};canvas.onpointerup=()=>drag=null;+async function recordReplay(summary=false){if(recording)return;const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});$('#follow').checked=!summary;frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(canvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);rec.stop()}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true);+new ResizeObserver(draw).observe($('#stage'));draw();}catch(e){$('#error').textContent=e.stack}+</script></body></html>
tools/silkscreen-dashboard/server.pyadded+146
@@ -0,0 +1,146 @@+#!/usr/bin/env python3+"""AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""+import argparse,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal+P=pathlib.Path;ROOT=P(__file__).resolve().parent;REG=P.home()/'.adom/instances/aiflow-silkscreen';REG.mkdir(parents=True,exist_ok=True)+def atomic(p,d):+ q=p.with_suffix('.tmp');q.write_text(json.dumps(d,indent=2));q.replace(p)+p=argparse.ArgumentParser();p.add_argument('action',choices=['start','serve','show','ls','state','control','stop']);p.add_argument('--run',required=True,type=P);p.add_argument('--ai-thread',required=True);p.add_argument('--port',default='auto');p.add_argument('--surface',choices=['wv','pup'],default='wv');p.add_argument('--target');p.add_argument('--url');p.add_argument('--json',default='{}');p.add_argument('--reason');a=p.parse_args();run=a.run.resolve();key=hashlib.sha256((a.ai_thread+str(run)).encode()).hexdigest()[:16];reg=REG/(key+'.json');snap=REG/(key+'-snapshot.json');folder=run/'silkscreen-dashboard';folder.mkdir(exist_ok=True);events=folder/'events.jsonl';manifest=folder/'board.json';version=hashlib.sha256((ROOT/'index.html').read_bytes()+P(__file__).read_bytes()).hexdigest()[:12]+def entry():+ try:+  e=json.loads(reg.read_text());r=json.load(urllib.request.urlopen('http://127.0.0.1:'+str(e['port'])+'/health',timeout=2));return e if r.get('key')==key else None+ except Exception:return None+def request(e,path,data=None):+ body=None if data is None else json.dumps(dict(data,aiThread=a.ai_thread)).encode();return json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+str(e['port'])+path,data=body,headers={'Content-Type':'application/json'}),timeout=5))+if a.action=='ls':+ for f in REG.glob('*.json'):+  if f.name.endswith('-snapshot.json'):continue+  e=json.loads(f.read_text())+  try:os.kill(e['pid'],0)+  except ProcessLookupError:f.unlink();continue+  print(json.dumps(dict(e,versionDrift=e.get('version')!=version)))+ sys.exit()+e=entry()+if a.action in ['start','show']:+ if not e:+  log=open(folder/'server.log','ab');subprocess.Popen([sys.executable,str(P(__file__).resolve()),'serve','--run',str(run),'--ai-thread',a.ai_thread,'--port',a.port],stdout=log,stderr=log,start_new_session=True)+  for _ in range(80):+   time.sleep(.1);e=entry()+   if e:break+  if not e:raise SystemExit('ERROR: server did not become healthy; see '+str(folder/'server.log'))+ url=a.url or ('http://'+str(e['port'])+'.localhost:'+urllib.parse.urlparse((P.home()/'.adom/hd-proxy-url').read_text().strip()).port.__str__()+'/' if (P.home()/'.adom/hd-proxy-url').exists() else 'http://127.0.0.1:'+str(e['port'])+'/')+ if a.action=='show':+  if a.surface=='wv':opened=subprocess.run(['adom-cli','hydrogen','webview','open-or-refresh','--name','AI Flow silkscreen','--url',url],check=True,capture_output=True,text=True);print(opened.stdout);e['tabId']=json.loads(opened.stdout).get('tabId');atomic(reg,e)+  else:+   if not a.target or not a.url:raise SystemExit('ERROR: pup needs --target and a desktop-reachable --url')+   subprocess.run(['adom-bridge','pup_open_window',json.dumps({'url':url,'background':True}),'--target',a.target,'--ai-thread',a.ai_thread],check=True)+ print(json.dumps(dict(e,url=url,reused=True)));sys.exit()+if a.action in ['state','control','stop']:+ if not e:raise SystemExit('ERROR: not running; use start')+ if a.action=='stop' and not a.reason:raise SystemExit('ERROR reason_required: explain why this task-owned server should stop')+ print(json.dumps(request(e,'/shutdown' if a.action=='stop' else '/control' if a.action=='control' else '/state',dict(json.loads(a.json),reason=a.reason) if a.action!='state' else None)));sys.exit()+if e:print('OK: already running');sys.exit()+settingspath=REG/'settings.json';settings=json.loads(settingspath.read_text()) if settingspath.exists() else dict(ttlHours=24,speed=4,showObstacles=True)+state=json.loads(snap.read_text()) if snap.exists() else dict(mode='live',cursor=-1,playing=False,layer='F.SilkS',view='2d');clients=set();lock=threading.RLock();last=time.time();revision=0;history=[];offset=0++def payload():return dict(state,settings=settings,events=history,aiThread=a.ai_thread,version=version)+def save():atomic(snap,state)+def broadcast():+ global revision+ revision+=1+ # Bounded state messages contain the current event; history is fetched separately.+ data=dict(state,settings=settings,event=history[state['cursor']] if 0<=state['cursor']<len(history) else None,total=len(history),revision=revision)+ for q in list(clients):+  try:q.write(('event: state\ndata: '+json.dumps(data)+'\n\n').encode());q.flush()+  except Exception:clients.discard(q)+class Handler(http.server.BaseHTTPRequestHandler):+ def log_message(self,*args):pass+ def response(self,data,code=200,ctype='application/json'):+  b=data if isinstance(data,bytes) else json.dumps(data).encode();self.send_response(code);self.send_header('Content-Type',ctype);self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)+ def do_GET(self):+  global last+  last=time.time();path=urllib.parse.urlparse(self.path).path+  if path=='/health':return self.response(dict(key=key,version=version))+  if path=='/state':return self.response(payload())+  if path=='/settings':return self.response(dict(settings=settings,enums={'ttlHours':'1..168','speed':'0.5..60','showObstacles':'boolean'}))+  if path=='/stream':+   self.send_response(200);self.send_header('Content-Type','text/event-stream');self.send_header('Cache-Control','no-cache');self.send_header('X-Accel-Buffering','no');self.end_headers();self.wfile.write((':'+(' '*2048)+'\n\n').encode());clients.add(self.wfile);broadcast()+   try:+    while True:time.sleep(15);self.wfile.write(b': heartbeat\n\n');self.wfile.flush()+   except Exception:clients.discard(self.wfile)+   return+  if path in ['/','/index.html']:return self.response((ROOT/'index.html').read_bytes(),ctype='text/html')+  if path=='/board.json':return self.response(manifest.read_bytes() if manifest.exists() else b'{"obstacles":[],"labels":[],"bounds":[0,0,100,100]}')+  if path.startswith('/models/'):+   f=(folder/path.lstrip('/')).resolve()+   if not f.is_relative_to((folder/'models').resolve()) or not f.is_file():return self.response({'error':'not found'},404)+   return self.response(f.read_bytes(),ctype='model/gltf-binary')+  self.response({'error':'not found'},404)+ def do_POST(self):+  global last+  last=time.time()+  try:+   if int(self.headers.get('Content-Length','0'))>65536:raise ValueError('request too large')+   body=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))+   if not body.get('aiThread'):return self.response(dict(errorCode='caller_identity_required',hint='Pass aiThread or CLI --ai-thread.'),400)+   with lock:+    if self.path=='/shutdown':+     if not body.get('reason'):raise ValueError('reason_required')+     self.response({'ok':True});threading.Thread(target=shutdown,daemon=True).start();return+    if self.path=='/settings':+     for k,v in body.items():+      if k=='aiThread':continue+      if k=='ttlHours' and isinstance(v,(int,float)) and 1<=v<=168:settings[k]=v+      elif k=='speed' and isinstance(v,(int,float)) and .5<=v<=60:settings[k]=v+      elif k=='showObstacles' and isinstance(v,bool):settings[k]=v+      else:raise ValueError('Allowed settings: ttlHours 1..168, speed .5..60, showObstacles boolean')+     atomic(settingspath,settings)+    elif self.path=='/control':+     for k,v in body.items():+      if k in ['aiThread','reason']:continue+      if k=='mode' and v in ['live','replay']:state[k]=v+      elif k=='layer' and v in ['F.SilkS','B.SilkS']:state[k]=v+      elif k=='view' and v in ['2d','3d']:state[k]=v+      elif k in ['playing','summary'] and isinstance(v,bool):state[k]=v+      elif k=='cursor' and isinstance(v,int) and -1<=v<len(history):state[k]=v+      else:raise ValueError('Allowed controls: mode live/replay, layer F.SilkS/B.SilkS, view 2d/3d, playing boolean, cursor within event history')+     if state['mode']=='live':state['cursor']=len(history)-1;state['playing']=False+    else:return self.response({'error':'not found'},404)+    save();broadcast();self.response({'ok':True,'state':state})+  except (ValueError,TypeError) as ex:self.response({'error':str(ex)},400)+http.server.ThreadingHTTPServer.allow_reuse_address=True+server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);server.daemon_threads=True+atomic(reg,dict(pid=os.getpid(),port=server.server_port,thread=a.ai_thread,run=str(run),version=version,startedAt=time.time()))+def shutdown():+ save();current=json.loads(reg.read_text()) if reg.exists() else {};reg.unlink(missing_ok=True)+ if current.get('tabId'):+  subprocess.run(['adom-cli','hydrogen','workspace','remove-tab','--tab-id',current['tabId']],capture_output=True)+ server.shutdown()+def watch():+ global offset,history+ previous=time.time()+ while True:+  time.sleep(.05)+  with lock:+   if events.exists():+    with events.open() as f:+     f.seek(offset)+     while True:+      pos=f.tell();line=f.readline()+      if not line or not line.endswith('\n'):break+      try:history.append(json.loads(line))+      except json.JSONDecodeError:pass+      offset=f.tell()+    if state['mode']=='live' and state['cursor']!=len(history)-1:state['cursor']=len(history)-1;broadcast()+   if state['mode']=='replay' and state['playing']:+    start=max([i for i,e in enumerate(history) if e.get('kind')=='start'] or [0])+    eligible=[i for i in range(start,len(history)) if not state.get('summary') or history[i].get('kind') in ['placement','unresolved']]+    speed=max(1,len(eligible)/5) if state.get('summary') else settings['speed']+    elapsed=time.time()-previous+    if elapsed>=1/speed:+     remaining=[i for i in eligible if i>state['cursor']];count=max(1,int(elapsed*speed));previous+=count/speed+     if remaining:state['cursor']=remaining[min(count,len(remaining))-1]+     if len(remaining)<=count:state['cursor']=len(history)-1;state['playing']=False+     broadcast()+   else:previous=time.time()+   if time.time()-last>settings['ttlHours']*3600:shutdown();return+threading.Thread(target=watch,daemon=True).start();signal.signal(signal.SIGTERM,lambda *_:threading.Thread(target=shutdown,daemon=True).start());print(json.dumps({'port':server.server_port}),flush=True);server.serve_forever();save();reg.unlink(missing_ok=True)
tools/silkscreen-layout.pyadded+81
@@ -0,0 +1,81 @@+#!/usr/bin/env python3+"""Bounded beam search for rectangular label candidates. EDA supplies conservative+visible-body/mask/text obstacles and measured text bounds; no native edits here.+Objective: place all labels, maximize font size, then minimize distance/motion.+Input/output mm. Every unresolved group remains explicit, never a silent omission.+"""+import argparse,json,math,time,uuid+EVENT_FILE=None+EVENT_ID=None+def emit(kind,**data):+ if EVENT_FILE:+  with open(EVENT_FILE,"a") as f:f.write(json.dumps(dict(kind=kind,at=time.time(),solveId=EVENT_ID,**data))+"\n")+from pathlib import Path++def overlap(a,b,gap=0):+ return not(a[2]+gap<=b[0] or b[2]+gap<=a[0] or a[3]+gap<=b[1] or b[3]+gap<=a[1])+def inside(a,b):return a[0]>=b[0] and a[1]>=b[1] and a[2]<=b[2] and a[3]<=b[3]+def solve_milp(spec,groups):+ import numpy as np+ from scipy.optimize import milp,Bounds,LinearConstraint+ from scipy.sparse import coo_matrix+ candidates=[];group_indices=[]+ for g,cs in groups:+  # Preserve alternatives at every font size; score truncation alone loses useful fallbacks.+  buckets={}+  for c in cs:buckets.setdefault(c['font'],[]).append(c)+  selected=[c for bucket in buckets.values() for c in bucket[:spec.get('milpPerFont',24)]]+  ix=[]+  for c in selected:ix.append(len(candidates));candidates.append((g,c))+  ix.append(len(candidates));candidates.append((g,dict(unresolved=True,score=-1e6)));group_indices.append(ix)+ rr=[];cc=[];vv=[];lo=[];hi=[]+ def row(indices,lower,upper):+  k=len(lo);rr.extend([k]*len(indices));cc.extend(indices);vv.extend([1.]*len(indices));lo.append(lower);hi.append(upper)+ for ix in group_indices:row(ix,1,1)+ gap=spec.get('clearance',.12)+ for i,(g,a) in enumerate(candidates):+  if a.get('unresolved'):continue+  for j in range(i):+   h,b=candidates[j]+   if h['id']==g['id'] or b.get('unresolved'):continue+   bad_order=g.get('orderGroup') and g.get('orderGroup')==h.get('orderGroup') and (g['order']-h['order'])*(a['position'][1]-b['position'][1])<=0+   conflict=bad_order or any(overlap(x,y,gap) for x in a.get('boxes',[a['box']]) for y in b.get('boxes',[b['box']]))+   if conflict:row([i,j],-np.inf,1)+ mat=coo_matrix((vv,(rr,cc)),shape=(len(lo),len(candidates))).tocsc();result=milp(c=-np.array([c['score'] for g,c in candidates]),integrality=np.ones(len(candidates)),bounds=Bounds(0,1),constraints=LinearConstraint(mat,lo,hi),options={'time_limit':spec.get('solverSeconds',45),'mip_rel_gap':.01})+ if result.x is None:raise ValueError('No candidate layout found within MILP budget: '+result.message)+ chosen=[dict(id=g['id'],orderGroup=g.get('orderGroup'),order=g.get('order'),**c) for x,(g,c) in zip(result.x,candidates) if x>.5]+ return dict(schemaVersion=1,placements=chosen,unresolved=[x['id'] for x in chosen if x.get('unresolved')],score=-float(result.fun),method='bounded mixed-integer candidate search; finite candidate set only',solverStatus=result.message,boundsSource=spec.get('boundsSource'),nativeVerified=False)+def solve(spec):+ emit('start',total=len(spec['labels']),nativeVerified=False)+ bounds=spec['bounds'];fixed=[x['box'] for x in spec.get('obstacles',[])];gap=spec.get('clearance',.12);width=int(spec.get('beamWidth',96));groups=[]+ for g in spec['labels']:+  cs=[];sampled=0+  for c in g['candidates']:+   if not all(math.isfinite(v) for v in c['box']) or c['box'][0]>=c['box'][2] or c['box'][1]>=c['box'][3]:raise ValueError('invalid bounds '+g['id'])+   if math.dist(c['position'],g['anchor'])>g.get('maxDistance',math.inf):continue+   rects=c.get('boxes',[c['box']])+   reason='outside board' if not all(inside(a,bounds) for a in rects) else 'intersects a reserved obstacle' if any(overlap(a,b,gap) for a in rects for b in fixed) else 'clears fixed obstacles; competing labels still to check'+   if sampled<3:emit('candidate',id=g['id'],candidate=c,reason=reason);sampled+=1+   if reason.startswith('clears'):cs.append(c)+  # Prefer large legible text, locality next, and stability last. Caller can tune units.+  for c in cs:+   distance=math.dist(c['position'],g['anchor']);motion=math.dist(c['position'],g.get('previous',c['position']));c['score']=c['font']*spec.get('fontWeight',100)-distance*spec.get('distanceWeight',1)-motion*.1-(abs(c['position'][1]-g['anchor'][1])*spec.get('rowWeight',0) if g.get('orderGroup') else 0)-max(0,spec.get('preferredMinimumFont',0)-c['font'])*spec.get('smallFontPenalty',1000)+  cs.sort(key=lambda c:-c['score']);groups.append((g,cs[:spec.get('candidatesPerLabel',100)]))+ if spec.get('solver')=='milp':return solve_milp(spec,groups)+ # Most constrained first; beam alternatives allow earlier placements to be revised.+ groups.sort(key=lambda x:(len(x[1]),x[0]['id']));beam=[(0,[],[])]+ for g,cs in groups:+  next_states=[]+  for score,chosen,boxes in beam:+   for c in cs:+    if g.get('orderGroup') and any(not x.get('unresolved') and x.get('orderGroup')==g['orderGroup'] and (x['order']-g['order'])*(x['position'][1]-c['position'][1])<=0 for x in chosen):continue+    rects=c.get('boxes',[c['box']])+    if any(overlap(a,b,gap) for a in rects for b in boxes):continue+    next_states.append((score+c['score'],chosen+[dict(id=g['id'],orderGroup=g.get('orderGroup'),order=g.get('order'),**c)],boxes+rects))+   next_states.append((score-1e6,chosen+[dict(id=g['id'],unresolved=True)],boxes))+  next_states.sort(key=lambda x:-x[0]);beam=next_states[:width]+ score,chosen,_=beam[0];return dict(schemaVersion=1,placements=chosen,unresolved=[x['id'] for x in chosen if x.get('unresolved')],score=score,method='bounded beam search; not a proof of global optimum',boundsSource=spec.get('boundsSource','caller supplied; native verification required'),nativeVerified=False)+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);p.add_argument('--events');a=p.parse_args();EVENT_FILE=a.events;EVENT_ID=uuid.uuid4().hex;r=solve(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(r,indent=2));+ for item in r['placements']:emit('unresolved' if item.get('unresolved') else 'placement',id=item['id'],candidate=item,reason='No feasible candidate in the supplied search set' if item.get('unresolved') else 'Selected by font, locality and collision constraints; native review pending')+ emit('complete',unresolved=r['unresolved'],nativeVerified=False,method=r['method']);print('OK: silkscreen candidate layout; '+str(len(r['placements'])-len(r['unresolved']))+' placed, '+str(len(r['unresolved']))+' unresolved');print('Hint: Inspect fitted-body visibility, verify native text extents/DRC, and refresh BOTH native views before acceptance. This planner does not mutate the board.');raise SystemExit(2 if r['unresolved'] else 0)
tools/silkscreen-leaders.pyadded+65
@@ -0,0 +1,65 @@+#!/usr/bin/env python3+"""Optional bounded leader candidate routing for the rectangle planner.+Caller supplies per-label leaderTargets and routeBounds in board millimetres.+Routes are candidate geometry, requiring native clearance and visual review.+"""+import argparse,json,math,heapq+from pathlib import Path++def prepare(spec):+ gap=spec.get('clearance',.15);step=spec.get('leaderGrid',.1);fixed=[o['box'] for o in spec['obstacles']]+ def hit(a,b,g=0):return not(a[2]+g<=b[0] or b[2]+g<=a[0] or a[3]+g<=b[1] or b[3]+g<=a[1])+ for group in spec['labels']:+  if not group.get('leaderTargets'):continue+  rb=group['routeBounds'];nx=round((rb[2]-rb[0])/step)+1;ny=round((rb[3]-rb[1])/step)+1+  def point(n):return rb[0]+n[0]*step,rb[1]+n[1]*step+  def cell(p):return round((p[0]-rb[0])/step),round((p[1]-rb[1])/step)+  blocked=set()+  def occupy(b):+   x0=max(0,math.ceil((b[0]-gap-.03-rb[0])/step));x1=min(nx-1,math.floor((b[2]+gap+.03-rb[0])/step));y0=max(0,math.ceil((b[1]-gap-.03-rb[1])/step));y1=min(ny-1,math.floor((b[3]+gap+.03-rb[1])/step))+   return {(x,y) for x in range(x0,x1+1) for y in range(y0,y1+1)}+  for b in fixed:blocked.update(occupy(b))+  target_list=[cell(p) for p in group['leaderTargets']];target_list=[n for n in target_list if n not in blocked];targets=set(target_list[:1]);accepted=[]+  if not targets:group['candidates']=[];continue+  candidates=[c for c in group['candidates'] if not any(hit(c['box'],b,gap) for b in fixed)]+  candidates.sort(key=lambda c:-(c['font']*spec.get('fontWeight',70)-math.dist(c['position'],group['anchor'])*spec.get('distanceWeight',7)))+  bands={}+  for c in candidates:bands.setdefault(c['font'],[]).append(c)+  candidates=[c for band in bands.values() for c in band[:spec.get('leaderAttemptsPerFont',180)]]+  for c in candidates:+   if sum(q['font']==c['font'] for q in accepted)>=spec.get('leaderCandidatesPerFont',40):continue+   if c.get('angle',0)==0 and abs(c['position'][1]-group['anchor'][1])<=.3 and math.dist(c['position'],group['anchor'])<3.5:accepted.append(c);continue+   b=c['box'];occupied=blocked|occupy(b)+   def free(n):return 0<=n[0]<nx and 0<=n[1]<ny and n not in occupied+   starts=set();pad=gap+.08+   for x in range(math.floor((b[0]-pad-rb[0])/step),math.ceil((b[2]+pad-rb[0])/step)+1):starts.update([(x,cell((0,b[1]-pad))[1]),(x,cell((0,b[3]+pad))[1])])+   for y in range(math.floor((b[1]-pad-rb[1])/step),math.ceil((b[3]+pad-rb[1])/step)+1):starts.update([(cell((b[0]-pad,0))[0],y),(cell((b[2]+pad,0))[0],y)])+   starts={n for n in starts if free(n)};ends={n for n in targets if free(n)}+   if not ends or not starts:continue+   def h(n):return min(math.dist(n,z) for z in ends)+   costs={n:0 for n in starts};prev={n:None for n in starts};heap=[(h(n),0,n) for n in starts];heapq.heapify(heap);found=None+   while heap:+    _,g,n=heapq.heappop(heap)+    if g!=costs[n]:continue+    if n in ends:found=n;break+    for dx,dy in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)]:+     z=(n[0]+dx,n[1]+dy)+     if not free(z) or (dx and dy and (not free((n[0]+dx,n[1])) or not free((n[0],n[1]+dy)))):continue+     ng=g+math.hypot(dx,dy)+     if ng<costs.get(z,1e99):costs[z]=ng;prev[z]=n;heapq.heappush(heap,(ng+h(z),ng,z))+   if found is None:continue+   path=[]+   while found is not None:path.append(point(found));found=prev[found]+   path.reverse()+   if len(path)<2:accepted.append(c);continue+   # Keep only changes in direction. Native EDA may round corners inside this envelope.+   reduced=[path[0]]+   for a,q,z in zip(path,path[1:],path[2:]):+    if abs((q[0]-a[0])*(z[1]-q[1])-(q[1]-a[1])*(z[0]-q[0]))>1e-8:reduced.append(q)+   reduced.append(path[-1]);rects=[b]+[[min(a[0],z[0])-.025,min(a[1],z[1])-.025,max(a[0],z[0])+.025,max(a[1],z[1])+.025] for a,z in zip(reduced,reduced[1:])]+   if any(hit(a,z,gap) for a in rects[1:] for z in fixed):continue+   accepted.append(dict(c,boxes=rects,leaderPoints=reduced))+  group['candidates']=accepted;print(group['id'],len(accepted),'routable candidates',flush=True)+ return spec+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();s=prepare(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(s,indent=2))
tools/silkscreen-obstacle-check.pyadded+22
@@ -0,0 +1,22 @@+#!/usr/bin/env python3+"""Fail-closed whole-board rectangular obstacle audit. Native CAD supplies the+transformed fitted-body/mask/hole envelopes and rendered text/leader bounds.+The inventory is independent of the obstacle export: missing geometry is an error.+"""+import argparse,json+from pathlib import Path++def overlaps(a,b):+ return a[0]<b[2] and b[0]<a[2] and a[1]<b[3] and b[1]<a[3]+def check(data):+ obs=data['obstacles'];inventory=data['inventory'];missing=[];collisions=[]+ present={(o['id'],o['kind'],o['layer']) for o in obs}+ for item in inventory:+  for layer in item['layers']:+   if (item['id'],item['kind'],layer) not in present:missing.append(dict(id=item['id'],kind=item['kind'],layer=layer))+ for label in data['marks']:+  for o in obs:+   if label['layer']==o['layer'] and any(overlaps(a,b) for a in label.get('boxes',[label['box']]) for b in o.get('boxes',[o['box']])):collisions.append(dict(mark=label['id'],obstacle=o['id'],kind=o['kind'],layer=o['layer']))+ return dict(schemaVersion=1,passed=bool(inventory) and not missing and not collisions,coverageErrors=[] if inventory else ['Whole-board inventory is empty'],missingObstacles=missing,collisions=collisions,inventoryItems=len(inventory),obstacles=len(obs),marks=len(data['marks']),nativeVerified=False,note='Conservative rectangle audit only; geometry provenance, fitted transforms, label association and native 2D/3D review remain required.')+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True,type=Path);p.add_argument('--out',required=True,type=Path);a=p.parse_args();result=check(json.loads(a.input.read_text()));a.out.write_text(json.dumps(result,indent=2));print(('OK' if result['passed'] else 'ERROR')+f": {len(result['coverageErrors'])} coverage errors; {len(result['missingObstacles'])} missing obstacles; {len(result['collisions'])} collisions");raise SystemExit(0 if result['passed'] else 2)
tools/test-silkscreen-events.pyadded+6
@@ -0,0 +1,6 @@+import importlib.util,pathlib,tempfile,json+p=pathlib.Path(__file__).with_name('silkscreen-layout.py');s=importlib.util.spec_from_file_location('layout',p);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)+spec=dict(bounds=[0,0,10,10],obstacles=[dict(box=[0,0,2,2])],labels=[dict(id='C1',anchor=[4,4],candidates=[dict(font=.5,position=[1,1],box=[.5,.5,1.5,1.5]),dict(font=.5,position=[4,4],box=[3,3,5,5])]),dict(id='C2',anchor=[1,1],candidates=[])],solver='milp')+with tempfile.TemporaryDirectory() as td:+ m.EVENT_FILE=str(pathlib.Path(td)/'events.jsonl');m.EVENT_ID='test';r=m.solve(spec);assert r['unresolved']==['C2'];events=[json.loads(l) for l in pathlib.Path(m.EVENT_FILE).read_text().splitlines()];assert any(e.get('reason')=='intersects a reserved obstacle' for e in events);assert any(e.get('reason','').startswith('clears') for e in events);assert all(e['solveId']=='test' for e in events);assert r['nativeVerified'] is False+print('PASS: real rejection/feasible events, solve identity, unresolved MILP result and native-review boundary')
tools/test-silkscreen-layout.pyadded+20
@@ -0,0 +1,20 @@+import importlib.util+from pathlib import Path+sp=importlib.util.spec_from_file_location('silk',Path(__file__).with_name('silkscreen-layout.py'));m=importlib.util.module_from_spec(sp);sp.loader.exec_module(m)+def c(x,font):return dict(box=[x,0,x+1,1],position=[x+.5,.5],font=font)+# The first label's locally best choice blocks the next; beam must revisit it.+s={'bounds':[-1,-1,6,3],'clearance':0,'labels':[{'id':'a','anchor':[.5,.5],'candidates':[c(0,.8),c(2,.7)]},{'id':'b','anchor':[.5,.5],'candidates':[c(0,.8),c(0,.7)]}]}+r=m.solve(s);assert not r['unresolved'];assert next(x for x in r['placements'] if x['id']=='a')['position'][0]==2.5+s['obstacles']=[{'box':[-1,-1,6,3]}];assert len(m.solve(s)['unresolved'])==2+print('PASS: revises earlier choices, prefers larger text and reports blocked labels')++# A grouped reference/value must not reserve the empty middle between their boxes.+s={'bounds':[-1,-1,6,3],'clearance':0,'obstacles':[{'box':[1.5,0,2.5,1]}],'labels':[{'id':'pair','anchor':[1.5,.5],'candidates':[dict(box=[0,0,4,1],boxes=[[0,0,1,1],[3,0,4,1]],position=[1.5,.5],font=.6)]}]}+assert not m.solve(s)['unresolved']+# Preserve connector row order even if a larger candidate would swap labels.+s={'bounds':[-2,-2,6,6],'clearance':0,'labels':[{'id':'p1','orderGroup':'left','order':1,'anchor':[0,0],'candidates':[dict(box=[0,0,1,1],position=[.5,.5],font=.6)]},{'id':'p3','orderGroup':'left','order':3,'anchor':[0,2],'candidates':[dict(box=[0,-2,1,-1],position=[.5,-1.5],font=.8),dict(box=[0,2,1,3],position=[.5,2.5],font=.5)]}]}+r=m.solve(s);assert next(x for x in r['placements'] if x['id']=='p3')['font']==.5+print('PASS: compound bounds retain empty space and pin rows stay ordered')+# Optional MILP must trade a larger label for two readable labels rather than a tiny fallback.+s={'solver':'milp','bounds':[-1,-1,6,3],'clearance':0,'preferredMinimumFont':.3,'smallFontPenalty':1000,'distanceWeight':0,'labels':[{'id':'a','anchor':[0,0],'candidates':[c(0,.6),c(2,.4)]},{'id':'b','anchor':[0,0],'candidates':[c(0,.4),c(4,.2)]}]}+r=m.solve(s);assert not r['unresolved'];assert all(x['font']>=.3 for x in r['placements']);print('PASS: MILP avoids a tiny fallback when repacking fits both labels')
tools/test-silkscreen-obstacle-check.pyadded+16
@@ -0,0 +1,16 @@+import importlib.util,pathlib,unittest+p=pathlib.Path(__file__).with_name('silkscreen-obstacle-check.py');s=importlib.util.spec_from_file_location('audit',p);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)+class Audit(unittest.TestCase):+ def test_missing_body_between_pads(self):+  d=dict(inventory=[dict(id='C11',kind='body',layers=['F.SilkS'])],obstacles=[],marks=[])+  self.assertFalse(m.check(d)['passed']);self.assertEqual(len(m.check(d)['missingObstacles']),1)+ def test_via_requires_both_faces(self):+  d=dict(inventory=[dict(id='v1',kind='via',layers=['F.SilkS','B.SilkS'])],obstacles=[dict(id='v1',kind='via',layer='F.SilkS',box=[0,0,1,1])],marks=[])+  self.assertEqual(m.check(d)['missingObstacles'][0]['layer'],'B.SilkS')+ def test_text_anchor_outside_but_letters_cross_body(self):+  d=dict(inventory=[dict(id='C4',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='C4',kind='body',layer='F.SilkS',box=[0,0,2,1])],marks=[dict(id='C11-label',layer='F.SilkS',box=[-1,.2,.3,.6])])+  self.assertEqual(len(m.check(d)['collisions']),1)+ def test_bottom_text_does_not_hit_top_body(self):+  d=dict(inventory=[dict(id='C4',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='C4',kind='body',layer='F.SilkS',box=[0,0,2,1])],marks=[dict(id='label',layer='B.SilkS',box=[.1,.2,.3,.6])])+  self.assertTrue(m.check(d)['passed'])+unittest.main()