Michael Rabinovich commited on
Commit
0f9004d
·
1 Parent(s): 85bb21f

Accept mesh candidates in leaderboard submissions

Browse files
Files changed (5) hide show
  1. README.md +1 -1
  2. app.py +8 -5
  3. metrics_page.py +11 -5
  4. submit.py +52 -27
  5. tests/test_submit.py +20 -3
README.md CHANGED
@@ -30,6 +30,6 @@ textual or visual description of a mechanical part into a valid,
30
  geometrically correct 3D model?
31
 
32
  Browse the **Leaderboard** tab for ranked submissions, or upload your
33
- own STEP files on the **Submit** tab: the Space runs the eval and
34
  publishes a row with a per-submission report. The **About** tab covers
35
  the scoring axes and links to the benchmark code and datasets.
 
30
  geometrically correct 3D model?
31
 
32
  Browse the **Leaderboard** tab for ranked submissions, or upload your
33
+ own STEP/BREP or mesh candidate files on the **Submit** tab: the Space runs the eval and
34
  publishes a row with a per-submission report. The **About** tab covers
35
  the scoring axes and links to the benchmark code and datasets.
app.py CHANGED
@@ -107,8 +107,9 @@ ABOUT_MD = f"""## About
107
  turn a description of a mechanical part into a valid, geometrically
108
  correct 3D model.
109
 
110
- - **Reference baseline**: an iterative AI agent that writes build123d Python.
111
- - **Submission flow**: upload a zip of per-fixture STEP files; the Space
 
112
  runs the eval and appends a row to the submissions dataset.
113
  - **Datasets**: fixture inputs in
114
  [`{HF_DATA_REPO}`](https://huggingface.co/datasets/{HF_DATA_REPO});
@@ -1151,9 +1152,11 @@ with gr.Blocks(
1151
  f"""
1152
  **Submission format.** A single zip with:
1153
 
1154
- - one folder per sample in `{HF_DATA_REPO}`; include `output.step` for
1155
- samples where your system produced a candidate. Missing `output.step`
1156
- scores zero for that sample;
 
 
1157
  - a top-level `meta.json`:
1158
 
1159
  ```json
 
107
  turn a description of a mechanical part into a valid, geometrically
108
  correct 3D model.
109
 
110
+ - **Reference baseline**: an iterative AI agent that writes CAD code.
111
+ - **Submission flow**: upload a zip of per-fixture candidate files (STEP/BREP
112
+ or mesh); the Space
113
  runs the eval and appends a row to the submissions dataset.
114
  - **Datasets**: fixture inputs in
115
  [`{HF_DATA_REPO}`](https://huggingface.co/datasets/{HF_DATA_REPO});
 
1152
  f"""
1153
  **Submission format.** A single zip with:
1154
 
1155
+ - one folder per sample in `{HF_DATA_REPO}`; include one accepted candidate
1156
+ file for samples where your system produced a result. Accepted names:
1157
+ `output.step`, `output.stp`, `output.stl`, `output.obj`, `output.off`,
1158
+ `output.3mf`, or `output.ply`. Missing `output.*` scores zero for that
1159
+ sample;
1160
  - a top-level `meta.json`:
1161
 
1162
  ```json
metrics_page.py CHANGED
@@ -15,7 +15,7 @@
15
  """Self-contained "Metrics" explainer page for the Space.
16
 
17
  Builds one static, dependency-free HTML document explaining how a
18
- candidate STEP is scored: the validity gate, the three orthogonal
19
  axes (shape / topology / interface), and the editing renormalization.
20
 
21
  It is curated (a Space-tailored summary, deliberately a little
@@ -147,8 +147,8 @@ def build_metrics_page() -> str:
147
  tag="CAD Score",
148
  title="How one part is scored",
149
  body=(
150
- "<p>CADGenBench scores a generated part (a STEP file) against one "
151
- "ground-truth STEP. First a hard <b>validity gate</b>; if it "
152
  "passes, the <b>CAD Score</b> is a weighted mean of three "
153
  "independent metrics, each in [0, 1].</p>"
154
  '<pre class="formula">'
@@ -190,10 +190,11 @@ def build_metrics_page() -> str:
190
  tag="Gate",
191
  title="CAD Validity",
192
  body=(
193
- "<p>Runs before every other metric on the raw candidate STEP. Any "
194
  "failure sets <code>is_valid = False</code> and forces "
195
  "<code>cad_score = 0</code>, so an invalid solid never beats a worse "
196
- "but valid one. Passing requires all of:</p>"
 
197
  "<ol>"
198
  "<li><b>Well-formed BREP</b>: no per-face / edge / vertex errors "
199
  "(self-intersecting wires, edges off their surface, etc.).</li>"
@@ -203,6 +204,11 @@ def build_metrics_page() -> str:
203
  "to a manifold, closed (3F = 2E), orientation-consistent triangle "
204
  "mesh.</li>"
205
  "</ol>"
 
 
 
 
 
206
  ),
207
  )
208
 
 
15
  """Self-contained "Metrics" explainer page for the Space.
16
 
17
  Builds one static, dependency-free HTML document explaining how a
18
+ candidate file is scored: the validity gate, the three orthogonal
19
  axes (shape / topology / interface), and the editing renormalization.
20
 
21
  It is curated (a Space-tailored summary, deliberately a little
 
147
  tag="CAD Score",
148
  title="How one part is scored",
149
  body=(
150
+ "<p>CADGenBench scores a generated part (STEP/BREP or mesh) "
151
+ "against one ground-truth STEP. First a hard <b>validity gate</b>; if it "
152
  "passes, the <b>CAD Score</b> is a weighted mean of three "
153
  "independent metrics, each in [0, 1].</p>"
154
  '<pre class="formula">'
 
190
  tag="Gate",
191
  title="CAD Validity",
192
  body=(
193
+ "<p>Runs before every other metric on the raw candidate. Any "
194
  "failure sets <code>is_valid = False</code> and forces "
195
  "<code>cad_score = 0</code>, so an invalid solid never beats a worse "
196
+ "but valid one.</p>"
197
+ "<p><b>STEP/BREP candidates</b> must pass all of:</p>"
198
  "<ol>"
199
  "<li><b>Well-formed BREP</b>: no per-face / edge / vertex errors "
200
  "(self-intersecting wires, edges off their surface, etc.).</li>"
 
204
  "to a manifold, closed (3F = 2E), orientation-consistent triangle "
205
  "mesh.</li>"
206
  "</ol>"
207
+ "<p><b>Mesh candidates</b> (<code>output.stl</code>, "
208
+ "<code>output.obj</code>, <code>output.off</code>, "
209
+ "<code>output.3mf</code>, or <code>output.ply</code>) skip BREP "
210
+ "checks and must directly satisfy the mesh gate: manifold, closed, "
211
+ "and orientation-consistent.</p>"
212
  ),
213
  )
214
 
submit.py CHANGED
@@ -44,11 +44,12 @@ Validation gates, in order:
44
  4. Fixture-set match: the set of folders inside the zip equals the
45
  set of fixture directories in :func:`cadgenbench.common.paths.data_inputs_dir`
46
  (no missing, no extras).
47
- 5. STEP parseability: any present ``<fixture>/output.step`` loads as STEP
48
- geometry. A missing ``output.step`` is allowed and scores zero via the
49
- evaluator's ``status="missing"`` path. Per-fixture validity (watertight,
50
- manifold, etc) is *not* checked here; this gate only rejects files that are
51
- present but not actually STEP.
 
52
 
53
  Hub-write ordering (after validation passes):
54
 
@@ -103,6 +104,7 @@ from typing import Any
103
  import cadgenbench
104
  import gradio as gr
105
  from cadgenbench.common.paths import data_inputs_dir
 
106
  from cadgenbench.common.validity import parse_step
107
  from huggingface_hub import (
108
  CommitOperationAdd,
@@ -443,13 +445,13 @@ def handle_submit(
443
  yield _submit_status(
444
  "working",
445
  "Validating submission (unpacking the zip, checking the sample set "
446
- "and STEP files)…",
447
  )
448
 
449
  zip_path = Path(zip_file.name)
450
 
451
  # The tempdir lives only for the cheap-sync validation pass
452
- # (unpack zip, validate meta + fixture set + STEP parseability).
453
  # The Job downloads the zip itself from the Hub, so the
454
  # Space-local unpack is throwaway and the tempdir gets cleaned
455
  # up unconditionally in the outer finally.
@@ -461,7 +463,7 @@ def handle_submit(
461
  _extract_zip(zip_path, run_dir)
462
  meta = _load_and_validate_meta(run_dir)
463
  fixture_names = _validate_fixture_set(run_dir)
464
- _validate_steps_parseable(run_dir, fixture_names)
465
  except _ValidationError as e:
466
  msg = f"Submission rejected: {e}"
467
  yield _submit_status("error", msg)
@@ -691,9 +693,10 @@ def _validate_fixture_set(unpacked: Path) -> set[str]:
691
  return expected
692
 
693
 
694
- def _validate_steps_parseable(unpacked: Path, fixture_names: set[str]) -> None:
695
  # Threads (not processes): OCC's parse_step releases the GIL during
696
- # the C++ STEP read, and this gate doesn't touch the VTK renderer
 
697
  # (which is the only piece in the eval pipeline that needs the
698
  # ProcessPoolExecutor + spawn dance). Per-fixture I/O + OCC load is
699
  # 1-5s, so fanning out a 5+ fixture set across cpu-upgrade vCPUs
@@ -701,36 +704,58 @@ def _validate_steps_parseable(unpacked: Path, fixture_names: set[str]) -> None:
701
  # exception when its iterator is consumed, so wrapping in list()
702
  # preserves the same `Sample <name>` rejection text as the
703
  # sequential loop did.
704
- def _check_one_step(name: str) -> None:
705
- step = _candidate_step_path(unpacked / name)
706
- if step is None:
707
  # Missing output is a valid benchmark outcome: the evaluator writes
708
  # status="missing" and the fixture contributes cad_score=0.
709
  return
710
- if step.stat().st_size == 0:
711
  raise _ValidationError(
712
- f"Sample `{name}` has an empty `{step.name}`."
713
  )
714
- try:
715
- parse_step(step)
716
- except RuntimeError as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  raise _ValidationError(
718
- f"Sample `{name}` has an `{step.name}` that is not loadable "
719
- f"as STEP geometry: {e}"
720
- ) from e
721
 
722
  with ThreadPoolExecutor(
723
  max_workers=min(8, os.cpu_count() or 1),
724
  ) as ex:
725
- list(ex.map(_check_one_step, sorted(fixture_names)))
 
726
 
 
 
727
 
728
- def _candidate_step_path(fixture_dir: Path) -> Path | None:
729
- """Return the submitted candidate STEP for *fixture_dir*, if present."""
 
730
  for name in ("output.step", "output.stp"):
731
- step = fixture_dir / name
732
- if step.is_file():
733
- return step
 
 
 
 
734
  return None
735
 
736
 
 
44
  4. Fixture-set match: the set of folders inside the zip equals the
45
  set of fixture directories in :func:`cadgenbench.common.paths.data_inputs_dir`
46
  (no missing, no extras).
47
+ 5. Candidate parseability: any present ``<fixture>/output.*`` candidate
48
+ (STEP/BREP or accepted mesh format) can be cheaply loaded. A missing
49
+ candidate is allowed and scores zero via the evaluator's
50
+ ``status="missing"`` path. Per-fixture validity (watertight, manifold,
51
+ etc.) is *not* checked here; this gate only rejects files that are present
52
+ but not actually loadable as their declared candidate kind.
53
 
54
  Hub-write ordering (after validation passes):
55
 
 
104
  import cadgenbench
105
  import gradio as gr
106
  from cadgenbench.common.paths import data_inputs_dir
107
+ from cadgenbench.common.mesh import MESH_FILE_SUFFIXES, mesh_from_file
108
  from cadgenbench.common.validity import parse_step
109
  from huggingface_hub import (
110
  CommitOperationAdd,
 
445
  yield _submit_status(
446
  "working",
447
  "Validating submission (unpacking the zip, checking the sample set "
448
+ "and candidate files)…",
449
  )
450
 
451
  zip_path = Path(zip_file.name)
452
 
453
  # The tempdir lives only for the cheap-sync validation pass
454
+ # (unpack zip, validate meta + fixture set + candidate parseability).
455
  # The Job downloads the zip itself from the Hub, so the
456
  # Space-local unpack is throwaway and the tempdir gets cleaned
457
  # up unconditionally in the outer finally.
 
463
  _extract_zip(zip_path, run_dir)
464
  meta = _load_and_validate_meta(run_dir)
465
  fixture_names = _validate_fixture_set(run_dir)
466
+ _validate_candidates_parseable(run_dir, fixture_names)
467
  except _ValidationError as e:
468
  msg = f"Submission rejected: {e}"
469
  yield _submit_status("error", msg)
 
693
  return expected
694
 
695
 
696
+ def _validate_candidates_parseable(unpacked: Path, fixture_names: set[str]) -> None:
697
  # Threads (not processes): OCC's parse_step releases the GIL during
698
+ # the C++ STEP read, and mesh loading is I/O + numpy/trimesh work. This
699
+ # gate doesn't touch the VTK renderer
700
  # (which is the only piece in the eval pipeline that needs the
701
  # ProcessPoolExecutor + spawn dance). Per-fixture I/O + OCC load is
702
  # 1-5s, so fanning out a 5+ fixture set across cpu-upgrade vCPUs
 
704
  # exception when its iterator is consumed, so wrapping in list()
705
  # preserves the same `Sample <name>` rejection text as the
706
  # sequential loop did.
707
+ def _check_one_candidate(name: str) -> None:
708
+ candidate = _candidate_path(unpacked / name)
709
+ if candidate is None:
710
  # Missing output is a valid benchmark outcome: the evaluator writes
711
  # status="missing" and the fixture contributes cad_score=0.
712
  return
713
+ if candidate.stat().st_size == 0:
714
  raise _ValidationError(
715
+ f"Sample `{name}` has an empty `{candidate.name}`."
716
  )
717
+ suffix = candidate.suffix.lower()
718
+ if suffix in {".step", ".stp"}:
719
+ try:
720
+ parse_step(candidate)
721
+ except RuntimeError as e:
722
+ raise _ValidationError(
723
+ f"Sample `{name}` has an `{candidate.name}` that is not "
724
+ f"loadable as STEP geometry: {e}"
725
+ ) from e
726
+ elif suffix in MESH_FILE_SUFFIXES:
727
+ try:
728
+ mesh_from_file(candidate)
729
+ except Exception as e: # noqa: BLE001 - normalize user-facing error
730
+ raise _ValidationError(
731
+ f"Sample `{name}` has an `{candidate.name}` that is not "
732
+ f"loadable as a triangle mesh: {e}"
733
+ ) from e
734
+ else: # pragma: no cover - _candidate_path constrains suffixes
735
  raise _ValidationError(
736
+ f"Sample `{name}` uses unsupported candidate file `{candidate.name}`."
737
+ )
 
738
 
739
  with ThreadPoolExecutor(
740
  max_workers=min(8, os.cpu_count() or 1),
741
  ) as ex:
742
+ list(ex.map(_check_one_candidate, sorted(fixture_names)))
743
+
744
 
745
+ def _candidate_path(fixture_dir: Path) -> Path | None:
746
+ """Return the submitted candidate for *fixture_dir*, if present.
747
 
748
+ STEP/BREP wins when both a STEP and a mesh are present, matching the
749
+ evaluator's candidate discovery policy.
750
+ """
751
  for name in ("output.step", "output.stp"):
752
+ candidate = fixture_dir / name
753
+ if candidate.is_file():
754
+ return candidate
755
+ for suffix in sorted(MESH_FILE_SUFFIXES):
756
+ candidate = fixture_dir / f"output{suffix}"
757
+ if candidate.is_file():
758
+ return candidate
759
  return None
760
 
761
 
tests/test_submit.py CHANGED
@@ -392,14 +392,14 @@ def test_pending_row_preserves_existing_metadata(monkeypatch):
392
  assert row[k] is None
393
 
394
 
395
- def test_validate_steps_allows_missing_output_step(tmp_path: Path, monkeypatch):
396
  """Missing fixture outputs are accepted; evaluator scores them as missing."""
397
  (tmp_path / "101").mkdir()
398
  (tmp_path / "102").mkdir()
399
  calls: list[Path] = []
400
  monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
401
 
402
- submit._validate_steps_parseable(tmp_path, {"101", "102"})
403
 
404
  assert calls == []
405
 
@@ -413,6 +413,23 @@ def test_validate_steps_checks_present_output_stp(tmp_path: Path, monkeypatch):
413
  calls: list[Path] = []
414
  monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
415
 
416
- submit._validate_steps_parseable(tmp_path, {"101"})
417
 
418
  assert calls == [candidate]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  assert row[k] is None
393
 
394
 
395
+ def test_validate_steps_allows_missing_output_candidate(tmp_path: Path, monkeypatch):
396
  """Missing fixture outputs are accepted; evaluator scores them as missing."""
397
  (tmp_path / "101").mkdir()
398
  (tmp_path / "102").mkdir()
399
  calls: list[Path] = []
400
  monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
401
 
402
+ submit._validate_candidates_parseable(tmp_path, {"101", "102"})
403
 
404
  assert calls == []
405
 
 
413
  calls: list[Path] = []
414
  monkeypatch.setattr(submit, "parse_step", lambda p: calls.append(p))
415
 
416
+ submit._validate_candidates_parseable(tmp_path, {"101"})
417
 
418
  assert calls == [candidate]
419
+
420
+
421
+ def test_validate_steps_checks_present_output_mesh(tmp_path: Path, monkeypatch):
422
+ """Mesh candidates are cheap-load checked instead of STEP-parsed."""
423
+ fixture = tmp_path / "101"
424
+ fixture.mkdir()
425
+ candidate = fixture / "output.stl"
426
+ candidate.write_text("solid empty\nendsolid empty\n")
427
+ step_calls: list[Path] = []
428
+ mesh_calls: list[Path] = []
429
+ monkeypatch.setattr(submit, "parse_step", lambda p: step_calls.append(p))
430
+ monkeypatch.setattr(submit, "mesh_from_file", lambda p: mesh_calls.append(p))
431
+
432
+ submit._validate_candidates_parseable(tmp_path, {"101"})
433
+
434
+ assert step_calls == []
435
+ assert mesh_calls == [candidate]