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&#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();
})();
"""