app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
0.1.69: sourcing recovery, component states guidance, and routing fidelity; preserve latest Fusion source
14 files changed
+274−18
Cargo.lock+10−10Cargo.toml+1−1README.md+6SKILL.md+1−1bin/adom-aiflowcrates/adom-aiflow/src/browser_access.rs+149crates/adom-aiflow/src/main.rs+8crates/aiflow-board/src/lib.rs+9−2crates/aiflow-router/src/board_copper.rs+27crates/aiflow-router/src/drive.rs+22−1docs/release-0.1.69.md+10package.json+2−2skills/adom-aiflow/SKILL.md+1−1skills/aiflow-sourcing/SKILL.md+28Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.68"+version = "0.1.69" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.68"+version = "0.1.69" dependencies = [ "roxmltree", "serde",@@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "serde",@@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "serde",@@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "serde",@@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.68"+version = "0.1.69" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.68"+version = "0.1.69" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.68"+version = "0.1.69" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+6@@ -189,3 +189,9 @@ Latest maintenance: [0.1.28 lifecycle and native IPC readiness](docs/release-0.1 Adom Fields 0.4.0 adds conducting heatsink blocks, copper thickness per layer and a Signals view for plane-overlap capacitance, differential-pair skew, reference coverage and microstrip estimates. AI Flow hints direct the AI to use explicit assembly and stackup data and keep unsupported cases visible. [Fields inputs, sources, limits and fixture](https://wiki.adom.inc/adom/adom-fields/files/docs/heatsinks-and-signals.md). Native pour integration: [owned-zone transactions and asynchronous refill/readback](docs/native-zone-landing.md).++## Browser access during sourcing++`adom-aiflow browser-check --lane pup --input page-observation.json` distinguishes access denial from missing or loading pages and recommends an owned native-browser session when appropriate. It checks saved page evidence and does not navigate or claim the product was verified. See [the sourcing skill](skills/aiflow-sourcing/SKILL.md#detect-browser-access-failures-before-abandoning-a-source).++[0.1.69: sourcing recovery, state-aware component assets and routing fidelity](docs/release-0.1.69.md).
SKILL.md+1−1@@ -244,7 +244,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched ## 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).+Every selected, identified, reusable manufacturer part needs an exact component wiki page: reuse and improve an existing page after duplicate checking, or create a missing one. This includes off-board fans, heatsinks, sensors and connectors, even when they are absent from the PCB footprint inventory. An unresolved identity or restricted asset must be recorded as an explicit incomplete item; never create a guessed component or claim complete coverage. Truthful per-file provenance is required when publishing or improving a page. 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.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/browser_access.rsadded+149@@ -0,0 +1,149 @@+//! Classifies a caller-supplied page observation, not bridge transport success.+use serde_json::{json, Value};++pub fn classify(v: &Value, lane: &str) -> Result<Value, String> {+ let url = v["url"]+ .as_str()+ .ok_or("page evidence needs url, title and text strings")?;+ let title = v["title"]+ .as_str()+ .ok_or("page evidence needs title string")?;+ let text = v["text"]+ .as_str()+ .ok_or("page evidence needs text string")?;+ if !url.starts_with("https://") && !url.starts_with("http://") {+ return Err("url must be HTTP(S)".into());+ }+ let title = title.to_lowercase();+ let text = text.to_lowercase();+ let denied = title.contains("access denied")+ || title.contains("access to this page has been denied")+ || text.trim_start().starts_with("access denied")+ || text.contains("using automation tools to browse")+ || v["httpStatus"] == 403;+ let challenge = title.contains("just a moment")+ || title.contains("verify you are human")+ || text.trim_start().starts_with("verify you are human");+ let missing = v["httpStatus"] == 404+ || title.contains("page not found")+ || text.contains("sorry, we can’t find the page")+ || text.contains("sorry, we can't find the page");+ let login = v["loginPage"] == true;+ let state = if challenge {+ "human_verification"+ } else if v["httpStatus"] == 429 {+ "rate_limited"+ } else if denied {+ "access_denied"+ } else if missing {+ "not_found"+ } else if login {+ "login_required"+ } else if text.trim().is_empty() {+ "loading_or_empty"+ } else {+ "content_present"+ };+ let (next, hint) = match (state, lane) {+ ("login_required", "http") => ("nb", "Use the normal authorized login in an owned native-browser window with the appropriate existing profile."),+ ("access_denied", "http") => ("pup", "After one HTTP failure, inspect the public page in an owned background Pup window; for login use nb-bridge with the appropriate existing profile."),+ ("access_denied" | "login_required", "pup") => ("nb", "Use adom-native-browser: nbrowser_readiness, nbrowser_profiles, then nbrowser_open_window with a new sessionId, thread, purpose and appropriate work profile. Keep it in the background. Never reuse a user tab, import cookies, change network identity or disable protections. Offer installation only if needed. Verify the resulting page before claiming success."),+ ("access_denied", "nb") => ("official_alternative", "Native browser is also denied. Stop retries; use the vendor Search API/BOM tool or manufacturer source, or request human assistance. Do not evade the denial."),+ ("rate_limited", _) => ("wait_or_official_api", "Respect the rate limit and Retry-After. Do not rotate browsers or profiles to evade it; use the documented vendor API when authorized."),+ ("human_verification", _) => ("human_assistance", "A human verification challenge is present. Preserve the owned window and request human assistance; do not solve or bypass it automatically."),+ ("not_found", _) => ("vendor_search", "This is a missing page, not an access denial. Use the site's normal part search once and verify the exact MPN and canonical product URL; do not invent a product URL."),+ ("loading_or_empty", _) => ("inspect_after_load", "Empty content is not success or denial. Wait for navigation completion and inspect again with a bounded wait."),+ ("login_required", "nb") => ("authorized_login", "Use the site's normal authorized sign-in flow in the owned native-browser window; do not expose credentials."),+ _ => ("verify_product", "Content exists, but this does not prove the requested product or CAD asset. Verify exact MPN, stock/date and CAD links; validate downloaded file type, hash, dimensions and provenance."),+ };+ Ok(+ json!({"schema":"adom/browser-access@1","state":state,"lane":lane,"next":next,"hint":hint,"productVerified":false,"automaticNavigation":false}),+ )+}++#[cfg(test)]+mod tests {+ use super::*;+ fn page(title: &str, text: &str) -> Value {+ json!({"url":"https://www.mouser.com/example","title":title,"text":text})+ }+ #[test]+ fn denied_pup_recommends_nb() {+ let r = classify(+ &page("Access to this page has been denied.", "Access Denied"),+ "pup",+ )+ .unwrap();+ assert_eq!(r["next"], "nb");+ }+ #[test]+ fn denied_nb_stops() {+ assert_eq!(+ classify(&page("Access Denied", ""), "nb").unwrap()["next"],+ "official_alternative"+ );+ }+ #[test]+ fn missing_is_not_denied() {+ assert_eq!(+ classify(+ &page(+ "Mouser",+ "Sorry, we can’t find the page you’re looking for."+ ),+ "nb"+ )+ .unwrap()["next"],+ "vendor_search"+ );+ }+ #[test]+ fn bridge_success_is_not_page_success() {+ assert!(classify(&json!({"success":true}), "pup").is_err());+ }+ #[test]+ fn blank_is_not_success() {+ assert_eq!(+ classify(&page("", ""), "nb").unwrap()["state"],+ "loading_or_empty"+ );+ }+ #[test]+ fn challenge_requires_human() {+ assert_eq!(+ classify(&page("Just a moment...", ""), "pup").unwrap()["next"],+ "human_assistance"+ );+ }+ #[test]+ fn product_text_not_auto_verified() {+ let r = classify(+ &page(+ "Fan | Mouser",+ "Product description includes access denied in a troubleshooting guide.",+ ),+ "nb",+ )+ .unwrap();+ assert_eq!(r["state"], "content_present");+ assert_eq!(r["productVerified"], false);+ }+ #[test]+ fn rate_limit_does_not_switch_browser() {+ let mut p = page("", "");+ p["httpStatus"] = json!(429);+ assert_eq!(classify(&p, "pup").unwrap()["next"], "wait_or_official_api");+ }+ #[test]+ fn challenge_takes_priority_over_403() {+ let mut p = page("Verify you are human", "");+ p["httpStatus"] = json!(403);+ assert_eq!(classify(&p, "pup").unwrap()["next"], "human_assistance");+ }+ #[test]+ fn http_status_detected() {+ let mut p = page("", "");+ p["httpStatus"] = json!(403);+ assert_eq!(classify(&p, "http").unwrap()["next"], "pup");+ }+}
crates/adom-aiflow/src/main.rs+8@@ -6,6 +6,7 @@ mod testplan; mod switchnode; mod thermalvias; mod outline;+mod browser_access; /// The timeline dashboard (tools/timeline/index.html): it polls timeline.json beside it. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html");@@ -48,6 +49,8 @@ struct Cli { #[derive(Subcommand)] enum Cmd {+ /// Inspect saved browser page evidence for access denial, login, missing page or usable content. Recommendations only; never drives a browser or changes profiles.+ BrowserCheck { #[arg(long)] input: PathBuf, #[arg(long, value_parser=["http", "pup", "nb"])] lane: String }, /// Measure external work as a real running command, including its output and exit status. Use: exec -- <program> <args> Exec { #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] command: Vec<String> }, SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },@@ -1970,6 +1973,11 @@ fn main() { } } match &cli.cmd {+ Cmd::BrowserCheck { input, lane } => {+ let data: Value = serde_json::from_slice(&std::fs::read(input).unwrap_or_else(|e| err(&e.to_string(), &[]))).unwrap_or_else(|e| err(&e.to_string(), &[]));+ let result = browser_access::classify(&data, lane).unwrap_or_else(|e| err(&e, &[]));+ println!("{}", serde_json::to_string_pretty(&result).unwrap());+ } Cmd::Exec { command } => { thread(&cli); let mut r=load_run(&cli); r.log("external-start",json!({"program":command[0],"args":&command[1..]}));
crates/aiflow-board/src/lib.rs+9−2@@ -254,7 +254,7 @@ impl Board { } } }- if !copper || name.is_empty() {+ if !copper { continue; } let pat = pn.find("at");@@ -284,8 +284,15 @@ impl Board { let (half_long, half_short, axis) = if lx >= ly { (lx, ly, geom::rot_kicad(1.0, 0.0, pang)) } else { (ly, lx, geom::rot_kicad(0.0, 1.0, pang)) }; let polys: Vec<Vec<Pt>> = local.iter().map(|poly| poly.iter().map(|&(a, b)| { let (rx, ry) = geom::rot_kicad(a, b, pang); (x + rx, y + ry) }).collect()).collect(); let (ccx, ccy) = geom::rot_kicad(lcx, lcy, pang);+ // Unnumbered copper-layer pads, including NPTH mounting holes,+ // are physical obstacles rather than electrical terminals.+ let key = if name.is_empty() {+ format!("{reference}.__physical_{}", self.pads.len())+ } else {+ format!("{reference}.{name}")+ }; self.pads.push(Pad {- key: format!("{reference}.{name}"), reference: reference.clone(), name, net, x, y, angle: pang, shape, size: (sx_, sy_), drill, thru,+ key, reference: reference.clone(), name, net, x, y, angle: pang, shape, size: (sx_, sy_), drill, thru, on_f: on_f && (thru || true) && on_f, on_b: on_b || thru, polys, fp_x: fx, fp_y: fy, long_axis: axis, half_long, half_short, uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(), polys_centre: (x + ccx, y + ccy), clearance: fp_clearance.max(pclr), });
crates/aiflow-router/src/board_copper.rs+27@@ -213,6 +213,33 @@ pub(crate) mod tests { format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width 0.25)\n\t\t(layer \"{layer}\")\n\t\t(net \"{net}\")\n\t)\n", a.0, a.1, b.0, b.1) } + #[test]+ fn unnamed_mounting_holes_block_vias_without_becoming_terminals() {+ let extra = r#"(footprint "T:Mount"+ (layer "F.Cu") (at 10 5)+ (property "Reference" "MH")+ (pad "" np_thru_hole circle (at 0 -2) (size 1 1)+ (drill 1) (layers "*.Cu" "*.Mask"))+ (pad "" np_thru_hole circle (at 0 2) (size 1 1)+ (drill 1) (layers "*.Cu" "*.Mask")))"#;+ let b = board(&[("J1", "A", 2.0, 5.0, true)], extra);+ let holes = b.pads_of("MH");+ assert_eq!(holes.len(), 2);+ assert_ne!(holes[0].key, holes[1].key);+ assert!(holes.iter().all(|p| p.name.is_empty() && p.net.is_empty() && p.thru));+ assert_eq!(b.nets.len(), 1);+ assert_eq!(b.nets["A"].len(), 1);+ let mut r = Router::new(b, Rules::default(), false);+ let net = r.net_ids["A"];+ for y in [3.0, 7.0] {+ let (i, j) = r.grid.to_cell(10.0, y);+ assert!(r.thru_cells.get(i as usize, j as usize));+ assert!(!r.via_clear_exact(net, 10.0, y));+ assert!(!r.via_clear_exact(net, 10.0, y - 0.31));+ }+ assert!(r.via_clear_exact(net, 13.0, 5.0));+ }+ const PADS: [(&str, &str, f64, f64, bool); 4] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "B", 10.0, 2.0, true), ("J4", "B", 10.0, 8.0, true)]; fn route(b: Board) -> (Router, Vec<serde_json::Value>, serde_json::Value, usize) {
crates/aiflow-router/src/drive.rs+22−1@@ -1022,7 +1022,11 @@ impl Router { let key = self.nets[net].clusters[*cl].key(&self.board); if term.layer() == B_CU { pts.push(json!({"pad": key, "layer": "B.Cu"})); } else { pts.push(json!(key)); } }- _ => pts.push(xy(&cells[0])),+ _ => {+ let first = &cells[0];+ let p = g.to_xy(first.i as i64, first.j as i64);+ pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[first.layer()]}));+ } } let n = cells.len(); let mut k = 1;@@ -1238,6 +1242,23 @@ fn _unused(_: HashMap<u8, u8>) {} #[cfg(test)] mod path_via_tests { use super::*;++ #[test]+ fn existing_copper_start_preserves_its_layer_in_export() {+ let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true)], "");+ let r = Router::new(b, crate::Rules::default(), false);+ let net = r.net_ids["A"];+ let (i, j) = r.grid.to_cell(10.0, 5.0);+ for layer in [F_CU, B_CU] {+ let cells = vec![Term::new(layer, i, j), Term::new(layer, i + 1, j)];+ let route = Route::new(cells, 0.25, End::Copper, End::Copper);+ let points = r.waypoints(net, &route);+ assert_eq!(points[0]["layer"], aiflow_board::LAYER_NAMES[layer]);+ let expected = r.grid.to_xy(i, j);+ assert_eq!(points[0]["x"], expected.0);+ assert_eq!(points[0]["y"], expected.1);+ }+ } #[test] fn nearby_transitions_in_one_path_are_rejected() { let mut parent=vec![usize::MAX;200];
docs/release-0.1.69.mdadded+10@@ -0,0 +1,10 @@+# 0.1.69 — sourcing recovery and routing fidelity++This release combines the current 0.1.68 source, including the concurrent Fusion work, with the electronic-load build's changes. It is a direct update requested by John.++- `browser-check` classifies observed vendor pages and recommends the appropriate next browser lane. A Pup access-denied page can recommend an owned native-browser session. Missing pages, rate limits and human challenges receive different guidance; the command never navigates or bypasses a challenge itself.+- Component coverage explicitly includes reusable off-board fans, heatsinks and sensors. Their wiki pages need traceable electrical, mechanical and digital assets. Fan-state guidance identifies named GLB rotor nodes, revolution clips, RPM scaling and unknown/stale telemetry. Simulator integration remains a separate consumer task.+- Unnumbered copper-layer mounting holes remain physical routing obstacles, without becoming electrical net terminals.+- Routes joining existing copper retain their starting layer when exported, preventing bottom-layer paths from being silently placed on the top layer.++The electronic-load native gate demonstrated why planner closure is not acceptance. After the router corrections, a remaining duplicate via needed a reviewed plan correction; the complete candidate then had zero new DRC findings but eleven open ground connections. This release does not declare that board finished, thermally qualified or fabrication-ready. Native gates remain mandatory.
package.json+2−2@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.68",+ "version": "0.1.69", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",@@ -86,4 +86,4 @@ "aiflow-simulate", "aiflow-test-plan" ]-}+}
skills/adom-aiflow/SKILL.md+1−1@@ -239,7 +239,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched ## 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).+Every selected, identified, reusable manufacturer part needs an exact component wiki page: reuse and improve an existing page after duplicate checking, or create a missing one. This includes off-board fans, heatsinks, sensors and connectors, even when they are absent from the PCB footprint inventory. An unresolved identity or restricted asset must be recorded as an explicit incomplete item; never create a guessed component or claim complete coverage. Truthful per-file provenance is required when publishing or improving a page. 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.
skills/aiflow-sourcing/SKILL.md+28@@ -122,3 +122,31 @@ Whenever a component page is created or its model changes (here, or through mole ## Processing STEP files locally The shared step2glb service comes first, but it is often busy. When a STEP needs local work (turning, merging, reading colours and names through XCAF), suggest the lightweight `cadquery-ocp-novtk` build of OpenCascade's Python bindings in its own venv: about 0.4 GB, against about 1.3 GB for the full `cadquery-ocp`, whose VTK visualisation library STEP processing never uses. Ask the user before installing it, and say how to remove it (delete the venv). `adom-aiflow-etch` no longer needs it: the planner, the etch, the gate and the outline work on the shared service with plain python3 (only `--legacy` uses this venv).++## Detect browser access failures before abandoning a source++Save a minimal page observation as JSON: `url`, `title`, `text` (visible body text), optionally `httpStatus` and `loginPage`. Exclude account details, cookies and credentials. Run:++```bash+adom-aiflow browser-check --lane pup --input page-observation.json+```++The command classifies the observed page and recommends a next step. It never opens a browser or claims that the product was verified. A successful bridge call or HTTP 200 can still contain an Access Denied page. Empty/loading, missing-page, login and human-verification states are distinct.++After Pup denial, read adom-native-browser and check `nbrowser_readiness` then `nbrowser_profiles`; use a new owned background window in the appropriate existing work profile. Do not touch user tabs or switch disabled extensions on. If native browsing also fails, stop retries and use official vendor APIs/BOM tools or the manufacturer. Human-verification challenges require human assistance.++Verified example, 2026-10-08: Pup showed Mouser Access Denied for MF40200V1-1000U-G99. Native Chrome reached a normal missing-page response for the same incomplete URL. Mouser's normal exact-MPN search then opened the actual product page and its manufacturer-hosted 3D ZIP. The recovered model is a separate provenance/dimensional check, not proof of a qualified cooling design. Record short clips of the observed states when the user requested a process video; label a later walkthrough as such rather than presenting it as the original discovery.++## Required component pages include off-board hardware++Before calling sourcing complete, reconcile the full assembly BOM (not only PCB references) against exact component wiki pages. Fans, heatsinks, cables, sensors and connectors count. Duplicate-check first and improve the exact existing page; create a missing reusable part page. Link every selected MPN in the assembly register. Bare copper, unidentified placeholders and project-only assemblies remain in the project rather than becoming false catalog parts.++For each page track symbol, footprint or mechanical mounting pattern, plain STEP, GLB, 3D chip/component thumbnail, source drawing, provenance and verification evidence. An externally wired fan needs a power/ground/tach symbol and documented lead mapping, plus its mechanical mounting pattern; do not invent PCB electrical pads or pretend a separately chosen connector is supplied with the fan. Keep connector and fan identities distinct. Label inapplicable fields with a reason and missing assets as incomplete. CAD existence is not geometric verification; compare dimensions, mounting holes, wire exit, axes and visible geometry with the exact part drawing and photos. Simplified models are not full exact digital twins.++Publish permitted assets with portable paths, an asset manifest and source/derived hashes, and inspect the rendered component page and native EDA binding. Restricted vendor files stay private references with source links; independently authored geometry must disclose its assumptions and simplifications. Component publication and verification are required workflow work, not an automatic certification conferred by `components` or `browser-check`.++## State-aware component assets++For a part whose appearance or motion changes in operation, create/reuse `states.json` and set `component.states` on its wiki page. Read wiki-component and component-widgets: LEDs use off/lit models; fans use off/on plus a typed RPM variable and the proposed `adom/component-motion@1` GLB binding. Name stationary and moving scene branches, animation clips, axes/pivots, cycle duration and revolutions per cycle; preserve static STEP and fallback GLB. One revolution/second maps to RPM/60 playback rate, with any slow-motion preview separately labeled.++Check actual GLB binding and playback before marking motion ready. Keep simulated and measured sources distinct; stale readings are UNKNOWN. Write a part-specific skill when necessary, and pass states.json, GLB hashes, instance transform and required capabilities to adom-aiflow-probing. An unsupported consumer must report that limitation rather than silently portraying a stopped fan as operating. See the [fan motion example](https://wiki.adom.inc/adom/mf40200v1-1000u-g99/files/docs/component-motion.md). This is a proposed extension until each owning app releases support.