CADGenBench / metrics_page.py
Michael Rabinovich
metrics page: rename Point Cloud F1 -> Surface Distance F1
c8f3957
# Copyright 2026 Hugging Face
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""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
# Section anchor ids. The per-submission report's headline pills link to
# ``/metrics#<anchor>``; keep these stable (and in sync with the
# report's pill links in cadgenbench's single_run.py).
METRIC_ANCHORS = {
"cad_score": "cad-score",
"shape": "shape-similarity",
"interface": "interface-match",
"topology": "topology-match",
"validity": "validity",
"editing": "editing",
}
# Canonical deep-dive docs live in the code repo; linked from each
# section so the Space page stays a summary and the full derivations
# have one source of truth.
_DOCS_BASE = "https://github.com/huggingface/cadgenbench/blob/main/docs"
# Bundled illustration served by the Space (see app.py's /metrics-assets
# route). Relative so it resolves same-origin whether the page is the
# standalone /metrics route or the iframe in the Metrics tab.
_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&#8320;</b>: connected solid components.</li>"
"<li><b>b&#8321;</b>: independent through-handles.</li>"
"<li><b>b&#8322;</b>: enclosed internal voids (cavities).</li>"
"</ul>"
"<p>Each axis gets a fuzzy log-ratio against GT, sharpened by "
"&#945; = 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)&#178; = 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>: &#177;1&#176; and &#177;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 &#8805; 0.95 &#8594; 1, &#8804; 0.80 "
"&#8594; 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>"
# Trailing space so the last section can scroll to the top of the
# viewport when reached via an in-page anchor (e.g. the "Editing
# tasks" link); without it a near-bottom target lands mid-screen.
'<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>"
)
# Mobile sizing for the embedding iframe.
#
# We deliberately give the phone iframe its OWN bounded height (an internal
# scroll) rather than auto-sizing it to the full content. Reason: when embedded
# on huggingface.co the Space sits in a cross-origin outer frame that owns the
# real scroll, so a content-sized iframe can't be scrolled from inside -- which
# silently breaks the "On this page" anchor links (scrollIntoView has nothing it
# is allowed to move). A bounded iframe scrolls its own document, so the anchors
# work again. Desktop keeps its fixed CSS height (reads well there).
#
# Anchor clicks use scrollIntoView (smooth, scrolls whichever ancestor actually
# scrolls -- the bounded iframe when embedded, the window on the standalone
# /metrics route). All wrapped in try/catch: frameElement access is fine for
# this same-origin iframe but must never throw if that ever changes.
_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();
})();
"""