← Commit history
README.md+24−10
@@ -34,11 +34,25 @@ face** in the same crisp single-stroke line-font the vector outlines use. It is not a flat label pasted over the render: the text is projected onto the top plane through the live 3D camera, so it lies flat on the chip and follows the isometric perspective. A clean engraved/laser look, not a noisy 3D extrusion.-It comes name-marked on the vector outlines too, at 180 (reads upright to the-camera) or a right-angle 90, each scaled to fit the chip's bounding box.+The text always **faces the viewer** (the rotation is chosen per up-axis, so it+reads forward rather than upside-down or backwards), and an optional **second+line** carries a variant/date marking. The same mark comes on the vector+outlines too.  ![Clean single-stroke laser-mark of the part number on the chip, plus the name-marked vector outline](screenshots/name-laser.png) +## How the part number lands on the top surface++Most parts have leads that splay past the body, a base flange, or bevels, so the+full bounding box is wider than the real top surface. chip-thumbnailer takes the+**top 10% slice** of the height and re-bounds just that geometry to get the true+top-face footprint, then insets the text 10% on every side. The mark lands on+the actual top surface on SOICs (leads excluded), QFNs, and domed/flanged+sensors alike. The dashboard shows this as a **placement-debug tile** (full+bounding box in red, top-slice box in cyan):++![How placement is derived: full bbox in red, top-slice bbox in cyan, then the lasered result with a 2-line variant](screenshots/placement.png)+ ## 11 outline styles  OCCT hidden-line removal gives a clean, complete isometric outline (body@@ -54,17 +68,17 @@ Each comes plain and name-marked. Every outline style also comes **name-marked**, the chip's line-art silhouette with the manufacturer part number on it. This is exactly what a schematic symbol wants in its body: the real chip shape and the part number, instead of a-featureless rectangle. adom-symbol drops one straight into the symbol body.--The mark comes in two orientations. **At 180** it reads left-to-right along the-chip's width, but a long part number can run past the chip's edges:+featureless rectangle. adom-symbol drops one straight into the symbol body. The+name uses the **same top-slice placement and viewer-facing rotation as the 3D+laser**, so the outline and the shaded icon agree. -![Name-marked outlines at 180 (along the width)](screenshots/named-outlines.png)+The mark comes two ways: the **facing** orientation (reads forward to the+camera) and a **right-angle** alternate, for a long part number on a narrow chip+that would otherwise spill: -**At 90** (the right angle) it runs along the other axis and stays inside the-chip, so it is usually the one to pick for a long part number or a narrow chip:+![Name-marked outlines, facing orientation](screenshots/named-outlines.png) -![Name-marked outlines at 90, the part number fits inside the chip](screenshots/named90-outlines.png)+![Name-marked outlines, right-angle alternate](screenshots/named90-outlines.png)  ## Works across every package type 
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.1.7",+  "version": "1.1.8",   "type": "app",   "description": "The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app + CLI.",   "tags": [
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-chip-thumbnailer",-  "version": "1.1.7",+  "version": "1.1.8",   "type": "app",   "description": "The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app + CLI.",   "dependencies": {},
screenshots/across-chip-types.png
⋯ 1 unchanged line ⋯
screenshots/name-laser.png
⋯ 1 unchanged line ⋯
screenshots/named-outlines.png
⋯ 1 unchanged line ⋯
screenshots/named90-outlines.png
⋯ 1 unchanged line ⋯
screenshots/placement.pngadded
⋯ 1 unchanged line ⋯
src/render_3d.rs+127−25
@@ -85,16 +85,9 @@ pub fn render(chip: &ChipPaths) -> Result<()> { /// The vector outlines + name-laser are a single-pose set. chip-fetcher records /// the picked up-axis in `info.json.chosen_up_axis`; we honor it so Y-up chips /// (Bosch / Espressif / Nordic WLCSP) pose correctly. Default = Z-up canonical.-fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {-    let tok = std::fs::read_to_string(chip.dir.join("info.json"))-        .ok()-        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())-        .and_then(|v| {-            v.get("chosen_up_axis")-                .and_then(|a| a.as_str())-                .map(String::from)-        })-        .unwrap_or_default();+/// Parse a chip-fetcher / UI up-axis token into an orientation. Accepts the+/// upstream forms ("y", "-z") and our json_key forms ("yUp", "zDown").+pub fn orient_from_token(tok: &str) -> ThreeDOrientation {     match tok.to_lowercase().as_str() {         "y" | "yup" | "+y" => ThreeDOrientation::YUp,         "-y" | "ydown" => ThreeDOrientation::YDown,@@ -105,6 +98,83 @@ fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {     } } +/// The up-axis decision that drives the extended icons (outlines + name-laser),+/// plus where it came from:+///   "override"     — set by the user here in chip-thumbnailer+///   "chip-fetcher" — from the upstream `chosen_up_axis` metadata+///   "unset"        — no metadata at all; defaulted to AsIs (z-up)+pub fn up_axis_decision(chip: &ChipPaths) -> (ThreeDOrientation, &'static str) {+    let info = std::fs::read_to_string(chip.dir.join("info.json"))+        .ok()+        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());+    let axis = info+        .as_ref()+        .and_then(|v| v.get("chosen_up_axis"))+        .and_then(|a| a.as_str())+        .filter(|s| !s.is_empty());+    match axis {+        None => (ThreeDOrientation::AsIs, "unset"),+        Some(tok) => {+            let src = info+                .as_ref()+                .and_then(|v| v.get("chosen_up_axis_source"))+                .and_then(|a| a.as_str());+            let source = if src == Some("chip-thumbnailer") {+                "override"+            } else {+                "chip-fetcher"+            };+            (orient_from_token(tok), source)+        }+    }+}++/// Persist the user's up-axis override into info.json, MERGING so we never+/// clobber the other fields chip-fetcher wrote. Tags the source so the UI can+/// show that this axis was chosen here, and so downstream tools (adom-symbol)+/// read the corrected orientation.+pub fn set_up_axis(chip: &ChipPaths, orient: ThreeDOrientation) -> Result<()> {+    let path = chip.dir.join("info.json");+    let mut v: serde_json::Value = std::fs::read_to_string(&path)+        .ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_else(|| serde_json::json!({}));+    if !v.is_object() {+        v = serde_json::json!({});+    }+    let obj = v.as_object_mut().unwrap();+    obj.insert(+        "chosen_up_axis".into(),+        serde_json::Value::String(orient.json_key().to_string()),+    );+    obj.insert(+        "chosen_up_axis_source".into(),+        serde_json::Value::String("chip-thumbnailer".into()),+    );+    let pretty = serde_json::to_string_pretty(&v).context("serialize info.json")?;+    std::fs::write(&path, pretty).with_context(|| format!("write {}", path.display()))?;+    Ok(())+}++fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {+    up_axis_decision(chip).0+}++/// The in-plane text rotation that makes the lasered / outline part number read+/// FACING the viewer at the iso camera, per up-axis. A fixed 180 only faces the+/// viewer on z-up chips; y-up chips need 0 (the text otherwise points away —+/// verified on BME688). Returns (primary, alternate); the alternate is a right+/// angle (facing − 90) for long names that would spill off the body.+fn facing_rotations(orient: ThreeDOrientation) -> (u32, u32) {+    let primary = match orient {+        // y-up family: text faces the viewer at 0 (verified), alternate 270.+        ThreeDOrientation::YUp | ThreeDOrientation::YDown => 0,+        // z-up family (asIs/zDown) verified at 180; x-* default to the same.+        _ => 180,+    };+    (primary, (primary + 270) % 360)+}+ /// Generate the vector-outline SVGs + name-lasered icons for the chip by /// calling service-step2glb's /outline and /name-emboss. Writes: ///   <MPN>-3d-outline-<style>.svg          (5 plain styles)@@ -117,21 +187,26 @@ pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {     let orient = chosen_orientation(chip);     let axis = orient.step2glb_arg(); // "asIs" / "yUp" / ... +    // The text rotation that faces the viewer depends on the up-axis (the fixed+    // 180 pointed away on y-up chips). `primary` = the default `-named`;+    // `alt` = the right-angle `-named90` for long names that would spill.+    let (primary, alt) = facing_rotations(orient);+     // Plain vector outlines (no text, so rotation is irrelevant).-    write_outlines(chip, &step_bytes, axis, None, "", 180)?;-    // Name-lasered set, two text orientations the user can pick from:-    //   180 → reads right-side-up to the iso camera (the default `-named`)-    //   90  → a right angle to that (-90 from 180), kept facing the viewer-    //         rather than tilted away, for chips taller than wide (`-named90`)-    if write_named(chip, &step_bytes, axis, 180, "named").is_ok() {-        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", 180);+    write_outlines(chip, &step_bytes, axis, None, "", primary)?;+    if write_named(chip, &step_bytes, axis, primary, "named").is_ok() {+        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", primary);+        // A debug tile showing the full + top-slice bboxes used for placement.+        let _ = write_named_debug(chip, &step_bytes, axis, primary);     }-    if write_named(chip, &step_bytes, axis, 90, "named90").is_ok() {-        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", 90);+    if write_named(chip, &step_bytes, axis, alt, "named90").is_ok() {+        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", alt);     }      // The canonical `<MPN>-3d-iso-icon.png` (no axis suffix) follows the chosen     // axis, matching chip-fetcher's convention (adom-symbol reads that name).+    // The TRUE as-is icon is preserved separately by render_icon_green+    // (`-asis-icon.png`), so the dashboard's as-is tile stays honest.     if orient != ThreeDOrientation::AsIs {         let src = chip.three_d_icon_path(orient);         if src.is_file() {@@ -141,6 +216,20 @@ pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {     Ok(()) } +/// Render a placement-debug tile: the chip on the dark canvas with the full+/// bbox (red) + top-slice bbox (cyan) overlaid, so the dashboard can explain how+/// the part-number placement was derived. Writes <MPN>-3d-iso-named-debug.png.+fn write_named_debug(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32) -> Result<()> {+    let tmp_step = chip.dir.join(format!(".dbg-src-{}.step", chip.mpn));+    std::fs::write(&tmp_step, step)?;+    let out = chip.dir.join(format!("{}-3d-iso-named-debug.png", chip.mpn));+    let res = service_thumbnail_named_ext(+        &tmp_step, &out, axis, 460, 350, &chip.mpn, "", &chip.mpn, rot, true,+    );+    let _ = std::fs::remove_file(&tmp_step);+    res+}+ /// Vector outline SVGs (5 styles) via service /outline. `suffix` e.g. "-named". fn write_outlines(     chip: &ChipPaths,@@ -180,12 +269,12 @@ fn write_outlines( /// (chip + light text are far from green), then composites onto the dark canvas. /// Writes:  <MPN>-3d-iso-<tag>.png   <MPN>-3d-iso-<tag>-icon.png fn write_named(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32, tag: &str) -> Result<()> {-    // service_thumbnail_named reads the STEP from a path, so stage the bytes.+    // service_thumbnail_named_ext reads the STEP from a path, so stage the bytes.     let tmp_step = chip.dir.join(format!(".named-src-{}.step", chip.mpn));     std::fs::write(&tmp_step, step).with_context(|| format!("write {}", tmp_step.display()))?;     let green = chip.dir.join(format!(".green-{tag}-{}.png", chip.mpn));-    let res = service_thumbnail_named(-        &tmp_step, &green, axis, 320, 240, &chip.mpn, "00ff00", &chip.mpn, rot,+    let res = service_thumbnail_named_ext(+        &tmp_step, &green, axis, 320, 240, &chip.mpn, "00ff00", &chip.mpn, rot, false,     );     let _ = std::fs::remove_file(&tmp_step);     res?;@@ -325,14 +414,15 @@ fn service_thumbnail(     mpn: &str,     bg: &str, ) -> Result<()> {-    service_thumbnail_named(step, out, up_axis, w, h, mpn, bg, "", 180)+    service_thumbnail_named_ext(step, out, up_axis, w, h, mpn, bg, "", 180, false) }  /// Like `service_thumbnail`, but optionally lasers `name` onto the chip top in /// the clean single-stroke Hershey outline font (the same line-font the vector /// outlines use) at in-plane rotation `name_rot`. Empty `name` = plain render.+/// `name_debug` overlays the full + top-slice bboxes used for placement. #[allow(clippy::too_many_arguments)]-fn service_thumbnail_named(+fn service_thumbnail_named_ext(     step: &Path,     out: &Path,     up_axis: &str,@@ -342,6 +432,7 @@ fn service_thumbnail_named(     bg: &str,     name: &str,     name_rot: u32,+    name_debug: bool, ) -> Result<()> {     let svc = service_url();     let body = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;@@ -353,6 +444,9 @@ fn service_thumbnail_named(     if !name.is_empty() {         url.push_str(&format!("&name={name}&rot={name_rot}"));     }+    if name_debug {+        url.push_str("&nameDebug=1");+    }      let resp = ureq::post(&url)         .set("Content-Type", "application/step")@@ -423,7 +517,15 @@ fn render_icon_green(chip: &ChipPaths, step: &Path, orient: ThreeDOrientation) -     service_thumbnail(step, &green, orient.step2glb_arg(), 320, 240, &chip.mpn, "00ff00")?;     let (w, h, rgba) = key_green_rgba(&green)?;     let _ = std::fs::remove_file(&green);-    write_rgba_png(&chip.three_d_icon_path(orient), w, h, &rgba)+    write_rgba_png(&chip.three_d_icon_path(orient), w, h, &rgba)?;+    // Keep a stable TRUE as-is icon for the dashboard's as-is tile: the+    // no-suffix `-icon.png` later gets clobbered with the chosen pose for+    // adom-symbol, so the as-is group would otherwise show the chosen pose.+    if orient == ThreeDOrientation::AsIs {+        let asis_true = chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn));+        let _ = write_rgba_png(&asis_true, w, h, &rgba);+    }+    Ok(()) }  /// Alpha-key a pure-green-background PNG. greenness = G - max(R,B): ~0 on the
src/serve.rs+125−5
@@ -42,6 +42,8 @@ struct Session {     started: u64,     running: bool,     slots: Vec<Slot>,+    up_axis: String,        // json_key of the orientation driving the extended icons+    up_axis_source: String, // "chip-fetcher" | "override" | "unset" }  #[derive(Clone)]@@ -119,6 +121,11 @@ pub fn run(port: u16) -> Result<()> {                 handle_generate(ctype, filename, body)             }             (Method::Post, "/api/regenerate") => handle_regenerate(),+            (Method::Post, "/api/up-axis") => {+                let mut body = String::new();+                let _ = request.as_reader().read_to_string(&mut body);+                handle_set_up_axis(body)+            }              (Method::Get, p) if p.starts_with("/thumb/") => serve_thumb(&p["/thumb/".len()..]), @@ -231,6 +238,65 @@ fn handle_regenerate() -> Response<std::io::Cursor<Vec<u8>>> {     json_response(state_json()) } +/// Override the up orientation that drives the extended icons. Persists the+/// choice to info.json (so adom-symbol and everything downstream read it too),+/// then re-renders ONLY the "Chip icons" tab (outlines + name-laser) from the+/// new axis — the six shaded poses don't depend on the choice, so they stay.+fn handle_set_up_axis(body: String) -> Response<std::io::Cursor<Vec<u8>>> {+    let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));+    let axis = v.get("axis").and_then(|a| a.as_str()).unwrap_or("");+    if axis.is_empty() {+        return error_response(400, "{\"error\":\"missing axis\"}".into());+    }+    let orient = render_3d::orient_from_token(axis);++    let (name, dir, gen) = {+        let g = STATE.lock().unwrap();+        match g.as_ref() {+            Some(s) => (s.name.clone(), s.dir.clone(), s.generation),+            None => return error_response(409, "{\"error\":\"no chip loaded\"}".into()),+        }+    };+    let chip = ChipPaths::for_dir(dir, &name);+    if let Err(e) = render_3d::set_up_axis(&chip, orient) {+        return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));+    }++    // Drop the stale derived slots, flip to running, and bust the image cache+    // (bump `started`) so the regenerated icons re-fetch instead of showing the+    // old axis from cache.+    {+        let mut g = STATE.lock().unwrap();+        if let Some(s) = g.as_mut() {+            if s.generation == gen {+                s.up_axis = orient.json_key().to_string();+                s.up_axis_source = "override".to_string();+                s.slots.retain(|x| x.kind != "named" && x.kind != "outline");+                s.started = now_epoch();+                s.running = true;+            }+        }+    }++    let chip2 = chip.clone();+    std::thread::spawn(move || {+        if let Some(step) = chip2.step.as_ref() {+            let _ = render_3d::render_family_extras(&chip2, step);+        }+        if is_current(gen) {+            append_family_slots(gen, &chip2);+        }+        let mut g = STATE.lock().unwrap();+        if let Some(s) = g.as_mut() {+            if s.generation == gen {+                s.running = false;+            }+        }+    });++    json_response(state_json())+}+ // ──────────────────────────────────────────────────────────────────────── // generation: build slots, spawn the render thread // ────────────────────────────────────────────────────────────────────────@@ -238,6 +304,7 @@ fn handle_regenerate() -> Response<std::io::Cursor<Vec<u8>>> { fn start_generation(source: &'static str, name: String, dir: PathBuf, chip: ChipPaths) {     let generation = GEN.fetch_add(1, Ordering::SeqCst) + 1;     let (slots, plans) = build_slots_and_plans(&chip);+    let (up_orient, up_source) = render_3d::up_axis_decision(&chip);      {         let mut g = STATE.lock().unwrap();@@ -249,6 +316,8 @@ fn start_generation(source: &'static str, name: String, dir: PathBuf, chip: Chip             started: now_epoch(),             running: !plans.is_empty(),             slots,+            up_axis: up_orient.json_key().to_string(),+            up_axis_source: up_source.to_string(),         });     } @@ -311,11 +380,24 @@ fn build_slots_and_plans(chip: &ChipPaths) -> (Vec<Slot>, Vec<Plan>) { /// Add read-only display slots for name-lasered + vector-outline icons that /// exist in the chip dir (produced by service-step2glb's emboss/HLR pass). fn push_existing_assets(chip: &ChipPaths, slots: &mut Vec<Slot>) {+    // Placement-debug tile: explains how the part-number placement was derived+    // (full bbox in red, top-slice bbox in cyan). Sits first in the icons tab.+    let dbg_file = format!("{}-3d-iso-named-debug.png", chip.mpn);+    if chip.dir.join(&dbg_file).is_file() {+        let mut s = pending_slot("named-debug", "named", "Placement", "full bbox (red) + top-slice bbox (cyan)");+        s.status = "done".into();+        s.file = Some(dbg_file.clone());+        if let Some(m) = inspect(&chip.dir.join(&dbg_file)) {+            s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);+            s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);+        }+        slots.push(s);+    }     let named = [-        ("named-icon", format!("{}-3d-iso-named-icon.png", chip.mpn), "name-lasered icon (180)"),-        ("named", format!("{}-3d-iso-named.png", chip.mpn), "name-lasered (180, opaque)"),-        ("named90-icon", format!("{}-3d-iso-named90-icon.png", chip.mpn), "name-lasered icon (90)"),-        ("named90", format!("{}-3d-iso-named90.png", chip.mpn), "name-lasered (90, opaque)"),+        ("named-icon", format!("{}-3d-iso-named-icon.png", chip.mpn), "name-lasered icon (facing)"),+        ("named", format!("{}-3d-iso-named.png", chip.mpn), "name-lasered (facing, opaque)"),+        ("named90-icon", format!("{}-3d-iso-named90-icon.png", chip.mpn), "name-lasered icon (right-angle)"),+        ("named90", format!("{}-3d-iso-named90.png", chip.mpn), "name-lasered (right-angle, opaque)"),     ];     for (id, file, label) in named {         if chip.dir.join(&file).is_file() {@@ -380,7 +462,14 @@ fn render_loop(generation: u64, chip: ChipPaths, plans: Vec<Plan>) {                 finish_slot(generation, &plan.id, &plan.out, meta);                 // The Md render also keys the transparent icon — finish its slot.                 if s == ThreeDSize::Md {-                    let icon_out = chip.three_d_icon_path(o);+                    // The as-is tile shows the stable true-as-is icon, not the+                    // no-suffix file (which gets clobbered with the chosen pose+                    // for adom-symbol — that's what made as-is look like Y-up).+                    let icon_out = if o == ThreeDOrientation::AsIs {+                        chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn))+                    } else {+                        chip.three_d_icon_path(o)+                    };                     let icon_meta = inspect(&icon_out);                     finish_slot(generation, &format!("icon-{}", o.json_key()), &icon_out, icon_meta);                 }@@ -652,9 +741,15 @@ fn state_json() -> String {                         "transparent": x.transparent,                         "lightTile": x.light_tile,                         "error": x.error,+                        // Which tab the box lives under, and (for orientation+                        // tiles) which axis it represents so the UI can drive+                        // the up-orientation picker.+                        "tab": slot_tab(x.kind),+                        "axis": slot_axis(&x.id, x.kind),                     })                 })                 .collect();+            let up_orient = render_3d::orient_from_token(&s.up_axis);             serde_json::json!({                 "hasSession": true,                 "name": s.name,@@ -664,6 +759,10 @@ fn state_json() -> String {                 "total": s.slots.len(),                 "done": s.slots.iter().filter(|x| x.status == "done").count(),                 "failed": s.slots.iter().filter(|x| x.status == "failed").count(),+                // The orientation decision that drives the "Chip icons" tab.+                "upAxis": s.up_axis,+                "upAxisLabel": short_orient(up_orient),+                "upAxisSource": s.up_axis_source,                 "slots": slots,             })             .to_string()@@ -742,6 +841,27 @@ fn sanitize_stem(name: &str) -> String {     } } +/// Which tab a slot's box lives under: the shaded poses + their transparent+/// icons are the orientation *picker* (Tab 1); the name-laser + vector outlines+/// are the icons *derived* from the chosen orientation (Tab 2).+fn slot_tab(kind: &str) -> &'static str {+    match kind {+        "3d" | "icon" => "orientation",+        _ => "icons",+    }+}++/// The orientation axis (json_key) an orientation-tile represents, parsed from+/// its slot id ("3d-yUp-md" -> "yUp", "icon-yUp" -> "yUp"). None for the+/// derived (icons-tab) slots, which aren't tied to a single pickable axis.+fn slot_axis(id: &str, kind: &str) -> Option<String> {+    match kind {+        "3d" => id.splitn(3, '-').nth(1).map(String::from),+        "icon" => id.strip_prefix("icon-").map(String::from),+        _ => None,+    }+}+ fn short_orient(o: ThreeDOrientation) -> &'static str {     match o {         ThreeDOrientation::AsIs => "as-is (Z-up)",
src/ui/app.html+158−14
@@ -46,6 +46,9 @@       radial-gradient(1100px 700px at 88% -12%, rgba(0,184,176,.10), transparent 60%),       radial-gradient(900px 700px at -8% 112%, rgba(140,107,247,.08), transparent 55%),       var(--bg);+    /* Pin the gradient to the viewport so it doesn't scroll with the content+       (otherwise the radial edges slide past and show a harsh seam). */+    background-attachment: fixed;     color: var(--text);     font-family: "Satoshi", system-ui, sans-serif;     font-size: 14px;@@ -225,6 +228,36 @@     opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; }   #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }   #toast b { color: var(--accent-bright); }++  /* ── up-orientation governing bar + the two-tab split ─────────────── */+  #upbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap;+    background:var(--panel-2); border:1px solid var(--line); border-radius:12px;+    padding:11px 15px; margin:0 0 14px; }+  #upbar .uplabel { color:var(--muted); font-size:13px; text-transform:uppercase; letter-spacing:.4px; }+  #upbar .upaxis { display:inline-flex; align-items:center; gap:8px; font-weight:600; color:var(--text); font-size:15px; }+  #upbar .upaxis .dot { width:8px; height:8px; border-radius:50%; background:var(--accent-bright); box-shadow:0 0 8px var(--accent); }+  #upbar .upsrc { font-size:12px; padding:2px 9px; border-radius:999px; border:1px solid var(--line); color:var(--muted); }+  #upbar .upsrc.override { color:var(--accent-bright); border-color:var(--accent); }+  #upbar .upsrc.unset { color:#ffcf5a; border-color:#7a5e18; }+  #upbar .uphint { color:var(--muted); font-size:12.5px; margin-left:auto; }+  #upbar .uphint b { color:var(--text); font-weight:600; }++  #tabbar { display:flex; gap:4px; margin:0 0 16px; border-bottom:1px solid var(--line); }+  .tab { appearance:none; background:none; border:none; cursor:pointer; color:var(--muted);+    font:inherit; font-size:14px; font-weight:600; padding:9px 15px 11px;+    border-bottom:2px solid transparent; margin-bottom:-1px; }+  .tab:hover { color:var(--text); }+  .tab.active { color:var(--accent-bright); border-bottom-color:var(--accent); }+  .tab .tcount { color:var(--muted); font-weight:400; margin-left:6px; font-size:12px; }++  .builtfrom { color:var(--muted); font-size:13px; margin:-4px 0 14px; }+  .builtfrom b { color:var(--text); }++  .group h3 .curup { margin-left:10px; font-size:11px; font-weight:600; color:var(--accent-bright);+    border:1px solid var(--accent); border-radius:999px; padding:2px 9px; white-space:nowrap; }+  .setup-btn { margin-left:10px; font-size:11.5px; font-weight:600; cursor:pointer; color:var(--muted);+    background:var(--panel-2); border:1px solid var(--line); border-radius:7px; padding:3px 10px; white-space:nowrap; }+  .setup-btn:hover { border-color:var(--accent); color:var(--accent-bright); } </style> </head> <body>@@ -285,6 +318,9 @@    <div class="prov">Icons rendered by chip-thumbnailer (OCCT via service-step2glb). Not extracted from the datasheet.</div> +  <div id="upbar" style="display:none"></div>+  <div id="tabbar" style="display:none"></div>+   <div id="content">     <div class="empty">       <div class="big">No chip loaded yet</div>@@ -467,6 +503,8 @@ async function loadLibrary() { let lastSig = "";        // structural signature (ids) to know when to rebuild let lastStarted = 0;     // cache-bust key for <img> after a regenerate let lastRunning = true;  // poll fast while a render is in flight, slow when idle+let activeTab = "orientation"; // "orientation" (the up-axis picker) | "icons" (derived)+let lastState = null;    // last /api/state, so a tab switch re-renders without refetching const cellEls = {};      // id -> { box, capEls... }  async function poll() {@@ -474,9 +512,16 @@ async function poll() {   try { s = await (await fetch("api/state")).json(); }   catch (e) { return; } -  if (!s.hasSession) { lastRunning = false; return; }+  if (!s.hasSession) {+    lastRunning = false;+    $("#upbar").style.display = "none";+    $("#tabbar").style.display = "none";+    return;+  }   lastRunning = !!s.running;+  lastState = s;   $("#regenBtn").disabled = false;+  renderUpbar(s);    // subject zone (what's loaded right now) + progress   const renderable = s.slots.filter(x => x.status !== "skipped").length;@@ -496,42 +541,141 @@ async function poll() {   else { s.slots.forEach(updateCell); } } +const CHEV =+  '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';++// The top bar: which orientation drives the derived ("Chip icons") tab, and+// where that decision came from (chip-fetcher default / your override / unset).+function renderUpbar(s) {+  const bar = $("#upbar");+  bar.style.display = "flex";+  const src = s.upAxisSource;+  const srcText = src === "override" ? "your override"+    : src === "chip-fetcher" ? "default from chip-fetcher"+    : "no metadata — pick one";+  const srcClass = src === "override" ? "override" : src === "unset" ? "unset" : "";+  const hint = src === "unset"+    ? 'no orientation chosen — pick a pose in <b>Orientation</b>'+    : 'drives every icon in <b>Chip icons</b>';+  bar.innerHTML =+    '<span class="uplabel">Up orientation</span>' ++    '<span class="upaxis"><span class="dot"></span>' + esc(s.upAxisLabel || s.upAxis) + '</span>' ++    '<span class="upsrc ' + srcClass + '">' + esc(srcText) + '</span>' ++    '<span class="uphint">' + hint + '</span>';+}++function switchTab(tab) {+  if (tab === activeTab) return;+  activeTab = tab;+  if (lastState) rebuild(lastState);+}++async function setUpAxis(axis) {+  try {+    const r = await fetch("api/up-axis", {+      method: "POST", headers: { "Content-Type": "application/json" },+      body: JSON.stringify({ axis }),+    });+    if (!r.ok) { const j = await r.json().catch(() => ({})); alert(j.error || "failed"); return; }+    activeTab = "icons";   // jump to the consequence of the choice+    lastSig = "";          // force a rebuild on the next poll+    poll();+  } catch (e) { console.error(e); }+}+ function rebuild(s) {   for (const k in cellEls) delete cellEls[k];   content.innerHTML = "";-  // group slots preserving order-  const groups = [];++  // Group slots (preserving order) and split into the two tabs by slot.tab.+  const order = [];   const byGroup = {};   s.slots.forEach(slot => {-    if (!byGroup[slot.group]) { byGroup[slot.group] = []; groups.push(slot.group); }-    byGroup[slot.group].push(slot);+    if (!byGroup[slot.group]) {+      byGroup[slot.group] = { group: slot.group, tab: slot.tab || "orientation", slots: [] };+      order.push(byGroup[slot.group]);+    }+    byGroup[slot.group].slots.push(slot);   });-  groups.forEach(g => {+  const orientGroups = order.filter(g => g.tab === "orientation");+  const iconGroups = order.filter(g => g.tab === "icons");++  // Tab bar.+  const tb = $("#tabbar");+  tb.style.display = "flex";+  const oCount = orientGroups.reduce((n, g) => n + g.slots.length, 0);+  const iCount = iconGroups.reduce((n, g) => n + g.slots.length, 0);+  tb.innerHTML =+    '<button class="tab' + (activeTab === "orientation" ? " active" : "") + '" data-tab="orientation">Orientation<span class="tcount">' + oCount + '</span></button>' ++    '<button class="tab' + (activeTab === "icons" ? " active" : "") + '" data-tab="icons">Chip icons<span class="tcount">' + iCount + '</span></button>';+  tb.querySelectorAll(".tab").forEach(b => b.addEventListener("click", () => switchTab(b.dataset.tab)));++  const groups = activeTab === "icons" ? iconGroups : orientGroups;++  // The "Chip icons" tab is governed by the up-axis: say so, and show a+  // placeholder while it's being (re)built.+  if (activeTab === "icons") {+    const bf = document.createElement("div"); bf.className = "builtfrom";+    bf.innerHTML = 'Built from the <b>' + esc(s.upAxisLabel || s.upAxis) ++      '</b> orientation. Change it in the <b>Orientation</b> tab to re-render these.';+    content.appendChild(bf);+    if (groups.length === 0) {+      const ph = document.createElement("div"); ph.className = "empty";+      ph.innerHTML = s.running+        ? '<div class="big">Rebuilding chip icons…</div><div>Rendering outlines + name-laser from the ' + esc(s.upAxisLabel || s.upAxis) + ' pose.</div>'+        : '<div class="big">No chip icons yet</div><div>They generate after the orientations finish.</div>';+      content.appendChild(ph);+    }+  }++  groups.forEach(gr => {+    const g = gr.group;     const wrap = document.createElement("div"); wrap.className = "group";     if (isCollapsed(g)) wrap.classList.add("collapsed");     const h = document.createElement("h3");-    h.innerHTML =-      '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' ++    h.innerHTML = CHEV +       '<span class="gname">' + esc(g) + '</span>' +-      '<span class="gcount">' + byGroup[g].length + '</span>' ++      '<span class="gcount">' + gr.slots.length + '</span>' +       '<span class="rule"></span>';+    // Orientation header: the active up gets a badge; the others get a+    // one-click "Set as up" that re-drives the Chip icons tab.+    if (gr.tab === "orientation") {+      const axisSlot = gr.slots.find(x => x.axis);+      const axis = axisSlot && axisSlot.axis;+      if (axis) {+        // Sit the control on the LEFT, right after the group name, so it's a+        // short cursor trip from the title — not stranded at the far right.+        const rule = h.querySelector(".rule");+        if (axis === s.upAxis) {+          const badge = document.createElement("span");+          badge.className = "curup";+          badge.textContent = "▲ current up — drives the icons";+          h.insertBefore(badge, rule);+        } else {+          const btn = document.createElement("button");+          btn.className = "setup-btn";+          btn.textContent = "Set as up";+          btn.addEventListener("click", e => { e.stopPropagation(); setUpAxis(axis); });+          h.insertBefore(btn, rule);+        }+      }+    }     h.addEventListener("click", () => {       groupCollapsed[g] = !wrap.classList.contains("collapsed") ? true : false;       wrap.classList.toggle("collapsed");     });     wrap.appendChild(h);     const grid = document.createElement("div"); grid.className = "grid";-    byGroup[g].forEach(slot => grid.appendChild(makeCell(slot)));+    gr.slots.forEach(slot => grid.appendChild(makeCell(slot)));     wrap.appendChild(grid);     content.appendChild(wrap);   }); } -// Collapse the big secondary sections (vector outlines + name-lasered) by-// default so the chip orientations + transparent icons stay at the top. The-// user's per-group toggle persists across re-renders via groupCollapsed.+// Nothing is collapsed by default — the outline icons are too important to+// hide. The user can still collapse any group via its chevron (persisted). const groupCollapsed = {};-function defaultCollapsed(name) { return /outline|name-lasered/i.test(name); }+function defaultCollapsed(_name) { return false; } function isCollapsed(name) { return name in groupCollapsed ? groupCollapsed[name] : defaultCollapsed(name); }  function makeCell(slot) {