File size: 19,369 Bytes
28e0081 2a0d6fb 28e0081 f07f508 528723d f07f508 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 c8f3957 28e0081 c8f3957 28e0081 c8f3957 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb 28e0081 2a0d6fb f07f508 28e0081 f07f508 528723d f07f508 528723d f07f508 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | # 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₀</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>"
# 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();
})();
"""
|