app
Adom Chip Thumbnailer
Public Made by Adomby adom
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.
← Commit history
Publish 1.1.8
11 files changed
+436−56
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.  +## 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):+++ ## 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. -+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:+ -+ ## 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) {