| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """Self-contained "Metrics" explainer page for the Space. |
| |
| Builds one static, dependency-free HTML document explaining how a |
| candidate STEP is scored: the validity gate, the three orthogonal |
| axes (shape / topology / interface), and the editing renormalization. |
| |
| It is curated (a Space-tailored summary, deliberately a little |
| duplicated from the canonical ``docs/metrics*`` in the code repo) |
| rather than rendered from those markdown files, because the docs use |
| repo-relative links + local illustration images that don't resolve |
| when hosted. The page links out to the GitHub deep-dives for the full |
| derivations, so the canonical source of truth stays there. |
| |
| The page is served two ways from the same builder |
| (:func:`build_metrics_page`): |
| |
| - as a standalone route ``/metrics`` (so the per-submission report's |
| headline metric pills can deep-link to ``/metrics#<anchor>``), and |
| - embedded in the "Metrics" Gradio tab via an iframe. |
| |
| Formulas are plain monospace blocks (no MathJax / KaTeX), so the page |
| renders identically online and offline with no network dependency. The |
| anchor ids are a published contract the report links against; see |
| :data:`METRIC_ANCHORS`. |
| """ |
| from __future__ import annotations |
|
|
| |
| |
| |
| METRIC_ANCHORS = { |
| "cad_score": "cad-score", |
| "shape": "shape-similarity", |
| "interface": "interface-match", |
| "topology": "topology-match", |
| "validity": "validity", |
| "editing": "editing", |
| } |
|
|
| |
| |
| |
| _DOCS_BASE = "https://github.com/huggingface/cadgenbench/blob/main/docs" |
|
|
| |
| |
| |
| _MATING_GROUP_IMG = "/metrics-assets/mating_group.webp" |
|
|
| _CSS = """\ |
| * { box-sizing: border-box; } |
| body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; |
| background: #f8f9fa; color: #1f2430; line-height: 1.55; } |
| a { color: #1565c0; } |
| h1 { font-size: 1.7em; margin: 0 0 4px; } |
| .lede { color: #5b6170; margin: 0 0 20px; } |
| .card { background: #fff; border: 1px solid #e3e5ea; border-radius: 12px; |
| padding: 20px 24px; margin: 16px 0; box-shadow: 0 1px 3px rgba(0,0,0,0.05); |
| scroll-margin-top: 16px; } |
| .card h2 { margin: 0 0 10px; font-size: 1.2em; display: flex; align-items: baseline; |
| gap: 10px; } |
| .card h3 { font-size: 0.98em; margin: 16px 0 4px; color: #37474f; } |
| .axis-tag { font-family: monospace; font-size: 0.62em; font-weight: 700; |
| text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; |
| border-radius: 6px; } |
| .t-cad { border-left: 5px solid #37474f; } |
| .t-cad .axis-tag { background: #eceff1; color: #37474f; } |
| .t-shape { border-left: 5px solid #1565c0; } |
| .t-shape .axis-tag { background: #e3f2fd; color: #1565c0; } |
| .t-iface { border-left: 5px solid #4527a0; } |
| .t-iface .axis-tag { background: #ede7f6; color: #4527a0; } |
| .t-topo { border-left: 5px solid #006d77; } |
| .t-topo .axis-tag { background: #d8f3f4; color: #006d77; } |
| .t-gate { border-left: 5px solid #c62828; } |
| .t-gate .axis-tag { background: #ffebee; color: #c62828; } |
| .t-edit { border-left: 5px solid #9e7700; } |
| .t-edit .axis-tag { background: #fff9c4; color: #9e7700; } |
| pre.formula { background: #0f1525; color: #e7ecf5; border-radius: 8px; |
| padding: 14px 16px; overflow-x: auto; font-size: 0.86em; |
| line-height: 1.5; margin: 10px 0; } |
| code { background: #eef0f4; padding: 1px 5px; border-radius: 4px; |
| font-size: 0.88em; } |
| table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.92em; } |
| th, td { border: 1px solid #e3e5ea; padding: 7px 10px; text-align: left; } |
| th { background: #f5f7fa; } |
| .deep { font-size: 0.9em; color: #5b6170; margin: 20px 0 0; } |
| .endspace { height: 60vh; } |
| .toc { background: #fff; border: 1px solid #e3e5ea; border-radius: 12px; |
| padding: 14px 20px; margin: 16px 0; } |
| .toc ul { margin: 6px 0 0; padding-left: 18px; } |
| .note { color: #5b6170; font-size: 0.92em; } |
| figure.fig { margin: 14px 0; } |
| figure.fig img { display: block; width: 100%; max-width: 520px; height: auto; |
| border: 1px solid #e3e5ea; border-radius: 10px; background: #fff; } |
| figure.fig figcaption { font-size: 0.84em; color: #5b6170; margin-top: 6px; |
| max-width: 560px; } |
| .weight-pill { font-family: monospace; font-size: 0.8em; padding: 1px 7px; |
| border-radius: 6px; background: #eceff1; color: #37474f; } |
| @media (max-width: 760px) { |
| /* Tighten the reading frame for phones. The end-spacer stays (vh of the |
| bounded mobile iframe) so the last "On this page" target can scroll to the |
| top of the box. */ |
| body { padding: 16px 14px 28px; } |
| h1 { font-size: 1.5em; } |
| .card { padding: 16px 16px; } |
| .card h2 { font-size: 1.12em; } |
| pre.formula { font-size: 0.78em; padding: 12px 13px; } |
| table { font-size: 0.86em; } |
| th, td { padding: 6px 8px; } |
| } |
| """ |
|
|
|
|
| def _section( |
| *, anchor: str, css_class: str, tag: str, title: str, body: str, |
| ) -> str: |
| return ( |
| f'<section class="card {css_class}" id="{anchor}">' |
| f'<h2><span class="axis-tag">{tag}</span>{title}</h2>' |
| f"{body}" |
| "</section>" |
| ) |
|
|
|
|
| def build_metrics_page() -> str: |
| """Return the full self-contained Metrics explainer HTML document.""" |
| a = METRIC_ANCHORS |
|
|
| overview = _section( |
| anchor=a["cad_score"], |
| css_class="t-cad", |
| tag="CAD Score", |
| title="How one part is scored", |
| body=( |
| "<p>CADGenBench scores a generated part (a STEP file) against one " |
| "ground-truth STEP. First a hard <b>validity gate</b>; if it " |
| "passes, the <b>CAD Score</b> is a weighted mean of three " |
| "independent metrics, each in [0, 1].</p>" |
| '<pre class="formula">' |
| "cad_score = 0 if not valid\n" |
| " = 0.4*shape + 0.4*interface + 0.2*topology otherwise" |
| "</pre>" |
| "<p class='note'>(This is the <b>generation</b> composition. " |
| "<b>Editing</b> tasks renormalize the shape axis and reweight; " |
| f'see <a href="#{a["editing"]}">Editing tasks</a> below.)</p>' |
| "<table><thead><tr><th>Component</th><th>Range</th>" |
| "<th>What it asks</th></tr></thead><tbody>" |
| f'<tr><td><a href="#{a["validity"]}">CAD Validity</a> (gate)</td>' |
| "<td>{0, 1}</td><td>Is the geometry valid?</td></tr>" |
| f'<tr><td><a href="#{a["shape"]}">Shape Similarity</a></td>' |
| "<td>[0, 1]</td><td>Does the bulk geometry match?</td></tr>" |
| f'<tr><td><a href="#{a["topology"]}">Topology Match</a></td>' |
| "<td>[0, 1]</td><td>Same pieces / holes / voids?</td></tr>" |
| f'<tr><td><a href="#{a["interface"]}">Interface Match</a></td>' |
| "<td>[0, 1]</td><td>Does it bolt up to the same fixture?</td></tr>" |
| "</tbody></table>" |
| "<h3>Why three axes</h3>" |
| "<p>They are orthogonal by construction: each catches errors the " |
| "others are blind to:</p>" |
| "<ul>" |
| "<li><b>Shape</b> catches wrong bulk geometry; blind to topology.</li>" |
| "<li><b>Topology</b> catches wrong hole / piece / void counts; blind " |
| "to feature position.</li>" |
| "<li><b>Interface</b> catches a misplaced / mis-sized mating feature; " |
| "blind to overall shape.</li>" |
| "</ul>" |
| "<p class='note'>Outputs are rigidly aligned to the ground truth " |
| "(rotation + translation only, never scale) before scoring.</p>" |
| ), |
| ) |
|
|
| validity = _section( |
| anchor=a["validity"], |
| css_class="t-gate", |
| tag="Gate", |
| title="CAD Validity", |
| body=( |
| "<p>Runs before every other metric on the raw candidate STEP. Any " |
| "failure sets <code>is_valid = False</code> and forces " |
| "<code>cad_score = 0</code>, so an invalid solid never beats a worse " |
| "but valid one. Passing requires all of:</p>" |
| "<ol>" |
| "<li><b>Well-formed BREP</b>: no per-face / edge / vertex errors " |
| "(self-intersecting wires, edges off their surface, etc.).</li>" |
| "<li><b>Watertight</b>: every shell is closed; no naked or free " |
| "edges.</li>" |
| "<li><b>Meshable as a closed orientable manifold</b>: tessellates " |
| "to a manifold, closed (3F = 2E), orientation-consistent triangle " |
| "mesh.</li>" |
| "</ol>" |
| ), |
| ) |
|
|
| shape = _section( |
| anchor=a["shape"], |
| css_class="t-shape", |
| tag="Shape", |
| title="Shape Similarity", |
| body=( |
| "<p>Does the bulk geometry match? The mean of two complementary " |
| "sub-metrics, each in [0, 1]:</p>" |
| '<pre class="formula">' |
| "shape_similarity = 0.5 * (surface_distance_F1 + volume_IoU)" |
| "</pre>" |
| "<h3>Surface Distance F1</h3>" |
| "<p>Checks the candidate's surface sits where the GT's does and " |
| "faces the same way. Points are sampled across both surfaces with " |
| "their outward normals; a point matches when the closest point on " |
| "the other mesh's surface is within 0.5% of the GT bounding-box " |
| "diagonal <b>and</b> the normals agree to within 20°. Precision and " |
| "recall combine into F1.</p>" |
| "<h3>Volume IoU</h3>" |
| "<p>Shared volume of the two solids over their combined volume " |
| "(intersection over union).</p>" |
| "<p class='note'>Both use a tolerance proportional to part size, so " |
| "small features can move without shifting the score; those are " |
| f'covered by <a href="#{a["interface"]}">interface match</a>.</p>' |
| ), |
| ) |
|
|
| topology = _section( |
| anchor=a["topology"], |
| css_class="t-topo", |
| tag="Topo", |
| title="Topology Match", |
| body=( |
| "<p>Does the candidate have the same number of pieces, " |
| "through-holes, and internal voids? It compares the three " |
| "<b>Betti numbers</b> of the solid:</p>" |
| "<ul>" |
| "<li><b>b₀</b>: connected solid components.</li>" |
| "<li><b>b₁</b>: independent through-handles.</li>" |
| "<li><b>b₂</b>: enclosed internal voids (cavities).</li>" |
| "</ul>" |
| "<p>Each axis gets a fuzzy log-ratio against GT, sharpened by " |
| "α = 2, and the three are <b>multiplied</b>:</p>" |
| '<pre class="formula">' |
| "s_i = ((min(cand,gt) + 1) / (max(cand,gt) + 1)) ^ 2\n" |
| "topology_match = s_0 * s_1 * s_2" |
| "</pre>" |
| "<p>The product means one wrong count collapses the " |
| "score: topology is discrete, so two of three right is not a partial " |
| "match. Example: GT (1,2,0) vs candidate (1,4,0) scores " |
| "(3/5)² = 0.36. Blind features (blind pockets, fillets, " |
| "chamfers) are topologically trivial and covered by the other " |
| "axes.</p>" |
| ), |
| ) |
|
|
| interface = _section( |
| anchor=a["interface"], |
| css_class="t-iface", |
| tag="Interface", |
| title="Interface Match", |
| body=( |
| "<p>Would it bolt up to the same fixture? Each mating feature is a " |
| "region of space the candidate must match in shape, size, and " |
| "position:</p>" |
| "<ul>" |
| "<li><b>Keep-out (KOR)</b>: must be empty (a bolt hole, a slot).</li>" |
| "<li><b>Keep-in (KIR)</b>: must be solid (a locating boss, a " |
| "pin).</li>" |
| "</ul>" |
| "<h3>Mating groups</h3>" |
| "<p>The features that must seat together against a single fixture " |
| "form one <b>mating group</b>: here, two bolt holes and a slot that " |
| "one jig drops into. A part can have several independent groups (say " |
| "a bolt pattern on one face and a boss on another), and each group " |
| "is scored on its own.</p>" |
| '<figure class="fig">' |
| f'<img src="{_MATING_GROUP_IMG}" loading="lazy" ' |
| 'alt="A jig with two pins and a slot key seating into a part\'s two ' |
| 'bolt holes and slot">' |
| "<figcaption>A mating group: a jig with two pins and a slot key " |
| "seats into the part's two bolt holes and slot. The candidate has " |
| "to fit the same fixture.</figcaption>" |
| "</figure>" |
| "<h3>Scoring</h3>" |
| "<p>Per group:</p>" |
| "<ol>" |
| "<li><b>Per-feature fit</b>: volumetric IoU against the region " |
| "(with a thin shell of opposite material, so both oversize and " |
| "undersize lose points).</li>" |
| "<li><b>Bounded pose search</b>: ±1° and ±1% of part " |
| "size per axis, so a feature isn't penalized for the residual of " |
| "whole-part alignment.</li>" |
| "<li><b>Pass/fail ramp</b>: IoU ≥ 0.95 → 1, ≤ 0.80 " |
| "→ 0, linear between; a sloppy fit scores 0.</li>" |
| "</ol>" |
| "<p>A group scores as its <b>worst</b> feature (the minimum); the " |
| "fixture scores as the <b>mean</b> over its groups, so nailing one " |
| "interface and missing another still earns partial credit.</p>" |
| ), |
| ) |
|
|
| editing = _section( |
| anchor=a["editing"], |
| css_class="t-edit", |
| tag="Editing", |
| title="Editing tasks: no-op renormalization", |
| body=( |
| "<p>Editing fixtures ship an <code>input.step</code> plus an edit " |
| "request; the GT is a small change to that input. Since all three " |
| "axes measure global similarity, submitting the input unchanged " |
| "(the <b>no-op</b>) already scores high, so the raw composition " |
| "would reward doing nothing.</p>" |
| "<p>The fix renormalizes the <b>shape</b> axis against the no-op " |
| "baseline <code>b = shape_similarity(input, GT)</code>:</p>" |
| '<pre class="formula">' |
| "s_renorm = max(0, (shape_similarity - b) / (1 - b))\n" |
| "cad_score = 0.6*s_renorm + 0.3*interface + 0.1*topology (0 if not valid)" |
| "</pre>" |
| "<p>This maps the no-op to 0 and a perfect candidate to 1. Topology " |
| "and interface stay raw (most edits leave them unchanged). A no-op " |
| "therefore caps at 0.3 + 0.1 = 0.4, and any real shape improvement " |
| "clears it.</p>" |
| ), |
| ) |
|
|
| toc = ( |
| '<nav class="toc"><b>On this page</b><ul>' |
| f'<li><a href="#{a["cad_score"]}">CAD Score: how one part is scored</a></li>' |
| f'<li><a href="#{a["validity"]}">CAD Validity (gate)</a></li>' |
| f'<li><a href="#{a["shape"]}">Shape Similarity</a></li>' |
| f'<li><a href="#{a["topology"]}">Topology Match</a></li>' |
| f'<li><a href="#{a["interface"]}">Interface Match</a></li>' |
| f'<li><a href="#{a["editing"]}">Editing tasks</a></li>' |
| "</ul></nav>" |
| ) |
|
|
| footer = ( |
| '<p class="deep">For the full definitions and derivations, see the ' |
| f'metrics reference in the code: ' |
| f'<a href="{_DOCS_BASE}/metrics.md" target="_blank" rel="noopener">' |
| "docs/metrics.md</a>.</p>" |
| |
| |
| |
| '<div class="endspace" aria-hidden="true"></div>' |
| ) |
|
|
| return ( |
| "<!DOCTYPE html><html lang='en'><head>" |
| "<meta charset='utf-8'>" |
| "<meta name='viewport' content='width=device-width, initial-scale=1'>" |
| "<title>CADGenBench Metrics</title>" |
| f"<style>{_CSS}</style>" |
| "</head><body>" |
| "<h1>Metrics</h1>" |
| "<p class='lede'>How CADGenBench scores one generated CAD part against " |
| "the ground truth. These metrics are new, so this page explains each " |
| "one.</p>" |
| f"{toc}{overview}{validity}{shape}{topology}{interface}{editing}" |
| f"{footer}" |
| f"<script>{_JS}</script>" |
| "</body></html>" |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _JS = """ |
| (function () { |
| var narrow = (window.innerWidth || 1000) < 760; |
| function fit() { |
| if (!narrow) return; |
| try { |
| var fe = window.frameElement; |
| if (!fe) return; // standalone /metrics: window scrolls |
| var avail = (window.screen && window.screen.availHeight) || 740; |
| var h = Math.max(340, Math.min(640, Math.round(avail - 360))); |
| fe.style.height = h + 'px'; // bounded -> the iframe scrolls itself |
| } catch (e) { /* keep the CSS fallback height */ } |
| } |
| document.addEventListener('click', function (e) { |
| var a = e.target && e.target.closest ? e.target.closest('a[href^="#"]') : null; |
| if (!a) return; |
| var el = document.getElementById(a.getAttribute('href').slice(1)); |
| if (el) { e.preventDefault(); el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } |
| }); |
| window.addEventListener('load', fit); |
| window.addEventListener('resize', function () { narrow = (window.innerWidth || 1000) < 760; fit(); }); |
| fit(); |
| })(); |
| """ |
|
|