Michael Rabinovich Cursor commited on
Commit
49e27be
·
1 Parent(s): ae4b377

Gallery: show GT "answer-key" edit-diff for editing fixtures

Browse files

The ground-truth row now renders the blue edit-diff turntable (the
correct change vs the input) for editing fixtures instead of a plain GT
turntable, so the reference reads the same way as the candidate
red/amber diff in the same column. Adds a gt_diff_resolver pointing at
the one-time GT-dataset webp (<fixture>/renders/edit_diff_gt.webp, via
the existing /gt proxy) and updates the compare modal caption/legend.

tools/generate_gt_edit_diff.py renders/uploads those webps for the 32
editing fixtures (one-time, per data revision).

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (3) hide show
  1. app.py +13 -0
  2. gallery.py +36 -12
  3. tools/generate_gt_edit_diff.py +384 -0
app.py CHANGED
@@ -788,6 +788,18 @@ def _gt_proxy_url(fixture: str) -> str | None:
788
  return f"/gt-render/{fixture}.webp"
789
 
790
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  def serve_gt_render(fixture: str) -> Response:
792
  """Stream a fixture's ground-truth render WebP with long-lived caching."""
793
  webp = _fetch_gt_render(fixture)
@@ -863,6 +875,7 @@ def _gallery_iframe_html() -> str:
863
  rows = []
864
  doc = render_gallery_page(
865
  rows, _render_proxy_url, _gt_proxy_url, _render_diff_proxy_url,
 
866
  )
867
  escaped = html.escape(doc, quote=True)
868
  # The gallery JS (`fitIframe`) sizes this iframe to be the single scroller:
 
788
  return f"/gt-render/{fixture}.webp"
789
 
790
 
791
+ def _gt_diff_proxy_url(fixture: str) -> str | None:
792
+ """Resolver for an editing fixture's GT "answer key" edit-diff WebP.
793
+
794
+ The one-time GT generation (``tools/generate_gt_edit_diff.py``) writes
795
+ ``<fixture>/renders/edit_diff_gt.webp`` into the private GT dataset, so it
796
+ rides the existing generic GT proxy (``serve_gt_file``) rather than needing
797
+ a route of its own. The gallery uses this for the ground-truth row on
798
+ editing fixtures; a missing file 404s and degrades to the dashed cell.
799
+ """
800
+ return f"/gt/{fixture}/renders/edit_diff_gt.webp"
801
+
802
+
803
  def serve_gt_render(fixture: str) -> Response:
804
  """Stream a fixture's ground-truth render WebP with long-lived caching."""
805
  webp = _fetch_gt_render(fixture)
 
875
  rows = []
876
  doc = render_gallery_page(
877
  rows, _render_proxy_url, _gt_proxy_url, _render_diff_proxy_url,
878
+ _gt_diff_proxy_url,
879
  )
880
  escaped = html.escape(doc, quote=True)
881
  # The gallery JS (`fitIframe`) sizes this iframe to be the single scroller:
gallery.py CHANGED
@@ -150,7 +150,9 @@ def _sub_payload(row: dict, fixture_ids: list[str], render_resolver, diff_resolv
150
  }
151
 
152
 
153
- def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver, diff_resolver) -> dict:
 
 
154
  """Shape live rows into the JSON the gallery page renders from.
155
 
156
  The fixture columns are the fixed :data:`FIXED_FIXTURES` set (no
@@ -162,11 +164,16 @@ def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver, diff_r
162
  candidate turntable; backs the modal and non-editing grid tiles)
163
  - ``diff_resolver(submission_id, fixture_id) -> str | None`` (edit-diff
164
  turntable; backs the grid tile for editing fixtures)
165
- - ``gt_resolver(fixture_id) -> str | None``
 
 
 
 
166
 
167
  Returns ``{"fixtures", "subs", "gtImg"}`` where ``fixtures`` carries
168
  the fixed columns (id + task + difficulty) and ``gtImg`` maps each
169
- fixture to its ground-truth image source.
 
170
  """
171
  verified = _verified_rows(rows)
172
  fixtures = [
@@ -174,7 +181,12 @@ def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver, diff_r
174
  for f in FIXED_FIXTURES
175
  ]
176
  fixture_ids = [f["id"] for f in fixtures]
177
- gt_img = {fid: gt_resolver(fid) for fid in fixture_ids}
 
 
 
 
 
178
  return {
179
  "fixtures": fixtures,
180
  "subs": [
@@ -185,18 +197,23 @@ def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver, diff_r
185
  }
186
 
187
 
188
- def render_gallery_page(rows: list[dict], render_resolver, gt_resolver, diff_resolver) -> str:
 
 
189
  """Build the full standalone gallery HTML document from live rows.
190
 
191
- ``render_resolver`` / ``gt_resolver`` / ``diff_resolver`` supply the
192
- cached render-proxy URLs (see :func:`build_gallery_payload`); the
193
- browser lazy-loads only the on-screen turntables.
 
194
 
195
  The document is self-contained and uses **system font stacks only**
196
  (no external font CDN fetch) so it never errors inside a sandboxed
197
  iframe.
198
  """
199
- payload = build_gallery_payload(rows, render_resolver, gt_resolver, diff_resolver)
 
 
200
  data_json = json.dumps(payload, ensure_ascii=False)
201
  return (
202
  "<!DOCTYPE html><html lang='en'><head>"
@@ -459,7 +476,7 @@ _BODY = """
459
  <h4 id="modalTitle"></h4>
460
  <div class="msub" id="modalSub"></div>
461
  <div class="modal-compare">
462
- <figure><figcaption>Ground truth</figcaption><div class="mthumb" id="modalGt"></div></figure>
463
  <figure><figcaption id="modalOutCap">Output (aligned)</figcaption><div class="mthumb" id="modalOut"></div></figure>
464
  </div>
465
  <div class="modal-note" id="modalNote"></div>
@@ -625,6 +642,10 @@ function openModal(fxId, sub) {
625
  const cell = cellOf(sub, fxId);
626
  document.getElementById('modalGt').innerHTML = gt
627
  ? '<img src="' + gt + '" alt="ground truth">' : '<span>no GT render</span>';
 
 
 
 
628
  document.getElementById('modalOutCap').textContent =
629
  isEditing ? 'Output vs ground truth (edit diff)' : 'Output (aligned)';
630
  const outEl = document.getElementById('modalOut');
@@ -638,6 +659,7 @@ function openModal(fxId, sub) {
638
  const cad = (cell.cad === null || cell.cad === undefined) ? '-' : Number(cell.cad).toFixed(3);
639
  const legend = isEditing
640
  ? '<div class="modal-legend">'
 
641
  + '<span class="mlc" style="background:#bdc4d1"></span>your output'
642
  + '<span class="mlc" style="background:#e62929"></span>extra material (too much)'
643
  + '<span class="mlc" style="background:#f5991a"></span>missing material (too little)'
@@ -646,8 +668,10 @@ function openModal(fxId, sub) {
646
  document.getElementById('modalNote').innerHTML =
647
  'CAD score for this sample: <b>' + cad + '</b>. '
648
  + (isEditing
649
- ? 'The right view is the edit diff: red is material your output added that the '
650
- + 'GT lacks, amber is GT material your output is missing. '
 
 
651
  : '')
652
  + 'The full per-sample report (shape similarity, interface, topology + 3D view) '
653
  + 'opens from the report viewer.' + legend;
 
150
  }
151
 
152
 
153
+ def build_gallery_payload(
154
+ rows: list[dict], render_resolver, gt_resolver, diff_resolver, gt_diff_resolver,
155
+ ) -> dict:
156
  """Shape live rows into the JSON the gallery page renders from.
157
 
158
  The fixture columns are the fixed :data:`FIXED_FIXTURES` set (no
 
164
  candidate turntable; backs the modal and non-editing grid tiles)
165
  - ``diff_resolver(submission_id, fixture_id) -> str | None`` (edit-diff
166
  turntable; backs the grid tile for editing fixtures)
167
+ - ``gt_resolver(fixture_id) -> str | None`` (plain GT turntable)
168
+ - ``gt_diff_resolver(fixture_id) -> str | None`` (GT "answer key"
169
+ edit-diff turntable; used for the ground-truth row on **editing**
170
+ fixtures so the reference also shows the correct change in blue,
171
+ mirroring the candidate's red/amber diff in the same column)
172
 
173
  Returns ``{"fixtures", "subs", "gtImg"}`` where ``fixtures`` carries
174
  the fixed columns (id + task + difficulty) and ``gtImg`` maps each
175
+ fixture to its ground-truth image source (the answer-key diff for
176
+ editing fixtures, the plain turntable otherwise).
177
  """
178
  verified = _verified_rows(rows)
179
  fixtures = [
 
181
  for f in FIXED_FIXTURES
182
  ]
183
  fixture_ids = [f["id"] for f in fixtures]
184
+ gt_img = {
185
+ f["id"]: (
186
+ gt_diff_resolver(f["id"]) if f["task"] == "editing" else gt_resolver(f["id"])
187
+ )
188
+ for f in fixtures
189
+ }
190
  return {
191
  "fixtures": fixtures,
192
  "subs": [
 
197
  }
198
 
199
 
200
+ def render_gallery_page(
201
+ rows: list[dict], render_resolver, gt_resolver, diff_resolver, gt_diff_resolver,
202
+ ) -> str:
203
  """Build the full standalone gallery HTML document from live rows.
204
 
205
+ ``render_resolver`` / ``gt_resolver`` / ``diff_resolver`` /
206
+ ``gt_diff_resolver`` supply the cached render-proxy URLs (see
207
+ :func:`build_gallery_payload`); the browser lazy-loads only the
208
+ on-screen turntables.
209
 
210
  The document is self-contained and uses **system font stacks only**
211
  (no external font CDN fetch) so it never errors inside a sandboxed
212
  iframe.
213
  """
214
+ payload = build_gallery_payload(
215
+ rows, render_resolver, gt_resolver, diff_resolver, gt_diff_resolver,
216
+ )
217
  data_json = json.dumps(payload, ensure_ascii=False)
218
  return (
219
  "<!DOCTYPE html><html lang='en'><head>"
 
476
  <h4 id="modalTitle"></h4>
477
  <div class="msub" id="modalSub"></div>
478
  <div class="modal-compare">
479
+ <figure><figcaption id="modalGtCap">Ground truth</figcaption><div class="mthumb" id="modalGt"></div></figure>
480
  <figure><figcaption id="modalOutCap">Output (aligned)</figcaption><div class="mthumb" id="modalOut"></div></figure>
481
  </div>
482
  <div class="modal-note" id="modalNote"></div>
 
642
  const cell = cellOf(sub, fxId);
643
  document.getElementById('modalGt').innerHTML = gt
644
  ? '<img src="' + gt + '" alt="ground truth">' : '<span>no GT render</span>';
645
+ // For editing fixtures the GT side is the "answer key" edit diff (blue = the
646
+ // correct change), so it pairs with the output's red/amber diff; label both.
647
+ document.getElementById('modalGtCap').textContent =
648
+ isEditing ? 'Ground truth (correct change)' : 'Ground truth';
649
  document.getElementById('modalOutCap').textContent =
650
  isEditing ? 'Output vs ground truth (edit diff)' : 'Output (aligned)';
651
  const outEl = document.getElementById('modalOut');
 
659
  const cad = (cell.cad === null || cell.cad === undefined) ? '-' : Number(cell.cad).toFixed(3);
660
  const legend = isEditing
661
  ? '<div class="modal-legend">'
662
+ + '<span class="mlc" style="background:#2173f5"></span>correct change (ground truth)'
663
  + '<span class="mlc" style="background:#bdc4d1"></span>your output'
664
  + '<span class="mlc" style="background:#e62929"></span>extra material (too much)'
665
  + '<span class="mlc" style="background:#f5991a"></span>missing material (too little)'
 
668
  document.getElementById('modalNote').innerHTML =
669
  'CAD score for this sample: <b>' + cad + '</b>. '
670
  + (isEditing
671
+ ? 'Left is the ground-truth answer key: blue is the change the edit should '
672
+ + 'make (vs the starting shape). Right is your output\\u2019s edit diff: red '
673
+ + 'is material your output added that the GT lacks, amber is GT material your '
674
+ + 'output is missing. '
675
  : '')
676
  + 'The full per-sample report (shape similarity, interface, topology + 3D view) '
677
  + 'opens from the report viewer.' + legend;
tools/generate_gt_edit_diff.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate the ground-truth "answer key" edit-diff turntables (editing fixtures).
3
+
4
+ For each *editing* fixture (one that ships an ``input.step`` seed) this renders
5
+ the reference companion to the per-submission edit diff: the GT drawn as a
6
+ translucent ghost with the **correct change painted blue** (added material on the
7
+ GT body, removed material as a blue phantom of the input). See
8
+ :func:`cadgenbench.common.edit_diff.build_gt_edit_diff_shapes`.
9
+
10
+ Like :mod:`generate_gt_turntables`, the result is a property of the **data
11
+ revision** (GT vs input), not of any submission, so this runs once per data
12
+ revision and both the gallery's ground-truth row and every per-submission report
13
+ reference the same webp via the GT proxy. One clip is written per fixture:
14
+
15
+ - ``<fixture>/renders/edit_diff_gt.webp`` -- full turntable.
16
+
17
+ The GT mesh comes from the trusted sidecar (no tessellation); the input mesh is
18
+ tessellated once at the GT's deflection so the GT-vs-input edit region is found
19
+ at one consistent scale (mirrors the eval's ``_editing_input_mesh``).
20
+
21
+ Run locally (against checkouts), render only::
22
+
23
+ python tools/generate_gt_edit_diff.py \
24
+ --gt-root ../cadgenbench-data-gt --inputs-root ../cadgenbench-data \
25
+ --out-dir ../out/gt_edit_diff --no-upload
26
+
27
+ Add ``--upload`` (and an ``HF_TOKEN`` with **write** scope on the private GT
28
+ dataset) to commit the webps, or run it on an HF GPU job exactly like
29
+ ``generate_gt_turntables.py``.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ from pathlib import Path
39
+
40
+ from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
41
+
42
+ # Allow running straight from the repo without installing the leaderboard pkg;
43
+ # cadgenbench itself must be importable (installed in the env / eval-gpu image).
44
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
45
+ _SRC = _REPO_ROOT / "cadgenbench" / "src"
46
+ if _SRC.is_dir():
47
+ sys.path.insert(0, str(_SRC))
48
+
49
+ from cadgenbench.common.artifacts import StepArtifacts # noqa: E402
50
+ from cadgenbench.common.edit_diff import render_gt_edit_diff_turntable # noqa: E402
51
+
52
+ GT_STEP_NAME = "ground_truth.step"
53
+ GT_SIDECAR_NAME = "ground_truth.mesh.npz"
54
+ INPUT_STEP_NAME = "input.step"
55
+ FULL_NAME = "renders/edit_diff_gt.webp"
56
+ # One commit per this many files: keeps an individual commit small and
57
+ # rate-limit friendly.
58
+ COMMIT_CHUNK = 60
59
+
60
+
61
+ def _default_repo_id() -> str:
62
+ return os.getenv(
63
+ "HF_DATA_GT_REPO",
64
+ f"{os.getenv('HF_ORG', 'HuggingAI4Engineering')}/cadgenbench-data-gt",
65
+ )
66
+
67
+
68
+ def _default_inputs_repo_id() -> str:
69
+ return os.getenv(
70
+ "HF_DATA_REPO",
71
+ f"{os.getenv('HF_ORG', 'HuggingAI4Engineering')}/cadgenbench-data",
72
+ )
73
+
74
+
75
+ def _editing_fixture_ids(
76
+ api: HfApi,
77
+ gt_repo: str,
78
+ inputs_repo: str,
79
+ gt_root: Path | None,
80
+ inputs_root: Path | None,
81
+ ) -> list[str]:
82
+ """Fixture ids with BOTH a ``ground_truth.step`` and an ``input.step``.
83
+
84
+ The ``input.step`` is what defines an editing fixture, so the intersection
85
+ of the two repos (or two checkouts) is exactly the editing set.
86
+ """
87
+ if gt_root is not None:
88
+ gt_ids = {
89
+ p.name for p in gt_root.iterdir()
90
+ if p.is_dir() and (p / GT_STEP_NAME).is_file()
91
+ }
92
+ else:
93
+ files = api.list_repo_files(gt_repo, repo_type="dataset")
94
+ gt_ids = {f.split("/", 1)[0] for f in files if f.endswith("/" + GT_STEP_NAME)}
95
+
96
+ if inputs_root is not None:
97
+ in_ids = {
98
+ p.name for p in inputs_root.iterdir()
99
+ if p.is_dir() and (p / INPUT_STEP_NAME).is_file()
100
+ }
101
+ else:
102
+ files = api.list_repo_files(inputs_repo, repo_type="dataset")
103
+ in_ids = {f.split("/", 1)[0] for f in files if f.endswith("/" + INPUT_STEP_NAME)}
104
+
105
+ return sorted(gt_ids & in_ids, key=lambda s: (len(s), s))
106
+
107
+
108
+ def _materialize_gt(
109
+ api: HfApi, repo_id: str, fixture: str, gt_root: Path | None,
110
+ cache_dir: Path, token: str | None,
111
+ ) -> Path:
112
+ """Local dir holding this fixture's GT STEP + trusted mesh sidecar.
113
+
114
+ The sidecar must sit next to the STEP so ``StepArtifacts`` takes the
115
+ trusted-mesh path (no tessellation, no validation).
116
+ """
117
+ if gt_root is not None:
118
+ return gt_root / fixture
119
+ dest = cache_dir / "gt" / fixture
120
+ dest.mkdir(parents=True, exist_ok=True)
121
+ for name in (GT_STEP_NAME, GT_SIDECAR_NAME):
122
+ local = hf_hub_download(
123
+ repo_id=repo_id, filename=f"{fixture}/{name}",
124
+ repo_type="dataset", token=token,
125
+ )
126
+ target = dest / name
127
+ if not target.exists():
128
+ target.write_bytes(Path(local).read_bytes())
129
+ return dest
130
+
131
+
132
+ def _materialize_input(
133
+ api: HfApi, repo_id: str, fixture: str, inputs_root: Path | None,
134
+ cache_dir: Path, token: str | None,
135
+ ) -> Path:
136
+ """Local path to this fixture's ``input.step`` (checkout or Hub download)."""
137
+ if inputs_root is not None:
138
+ return inputs_root / fixture / INPUT_STEP_NAME
139
+ local = hf_hub_download(
140
+ repo_id=repo_id, filename=f"{fixture}/{INPUT_STEP_NAME}",
141
+ repo_type="dataset", token=token,
142
+ )
143
+ return Path(local)
144
+
145
+
146
+ def _render_fixture(gt_dir: Path, input_step: Path) -> bytes:
147
+ """Render the full answer-key turntable WebP for one editing fixture."""
148
+ gt_mesh = StepArtifacts(gt_dir / GT_STEP_NAME, is_ground_truth=True).mesh()
149
+ input_mesh = StepArtifacts(
150
+ input_step, deflection_override=gt_mesh.linear_deflection_mm,
151
+ ).mesh()
152
+ return render_gt_edit_diff_turntable(gt_mesh, input_mesh)
153
+
154
+
155
+ def _commit_in_chunks(api: HfApi, repo_id: str, ops: list[CommitOperationAdd]) -> None:
156
+ for i in range(0, len(ops), COMMIT_CHUNK):
157
+ chunk = ops[i:i + COMMIT_CHUNK]
158
+ api.create_commit(
159
+ repo_id=repo_id, repo_type="dataset", operations=chunk,
160
+ commit_message=f"add GT edit-diff answer-key webp(s) [{i + 1}-{i + len(chunk)}]",
161
+ )
162
+ print(f" committed {len(chunk)} file(s)", flush=True)
163
+
164
+
165
+ def _resolved_fixtures(
166
+ parser: argparse.ArgumentParser, args: argparse.Namespace,
167
+ api: HfApi, gt_root: Path | None, inputs_root: Path | None,
168
+ ) -> list[str]:
169
+ fixtures = _editing_fixture_ids(
170
+ api, args.repo_id, args.inputs_repo_id, gt_root, inputs_root,
171
+ )
172
+ if args.fixtures:
173
+ wanted = {f.strip() for f in args.fixtures.split(",") if f.strip()}
174
+ fixtures = [f for f in fixtures if f in wanted]
175
+ if args.limit is not None:
176
+ fixtures = fixtures[: args.limit]
177
+ if not fixtures:
178
+ parser.error("No editing fixtures matched.")
179
+ return fixtures
180
+
181
+
182
+ def _upload_from_out_dir(api: HfApi, repo_id: str, out_dir: Path, fixtures: list[str]) -> None:
183
+ """Commit already-rendered webps/pngs under *out_dir* to the GT dataset."""
184
+ ops: list[CommitOperationAdd] = []
185
+ for fixture in fixtures:
186
+ local = out_dir / fixture / "renders" / "edit_diff_gt.webp"
187
+ if local.exists():
188
+ ops.append(CommitOperationAdd(f"{fixture}/{FULL_NAME}", local.read_bytes()))
189
+ if not ops:
190
+ print("Nothing to upload (no rendered files found in --out-dir).", flush=True)
191
+ return
192
+ print(f"Uploading {len(ops)} file(s) to {repo_id} ...", flush=True)
193
+ _commit_in_chunks(api, repo_id, ops)
194
+
195
+
196
+ def _run_upload_only(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
197
+ """Commit already-rendered ``edit_diff_gt.webp`` files from --out-dir."""
198
+ if args.out_dir is None or not args.out_dir.is_dir():
199
+ parser.error("--upload-only requires an existing --out-dir.")
200
+ token = os.environ.get("HF_TOKEN")
201
+ api = HfApi(token=token) # falls back to the stored CLI token when env unset
202
+ out_dir = args.out_dir.resolve()
203
+ fixtures = sorted(
204
+ (p.parent.parent.name for p in out_dir.glob("*/renders/edit_diff_gt.webp")),
205
+ key=lambda s: (len(s), s),
206
+ )
207
+ if not fixtures:
208
+ parser.error(f"No edit_diff_gt.webp found under {out_dir}")
209
+ print(f"Uploading {len(fixtures)} fixture webp(s) from {out_dir} -> {args.repo_id}", flush=True)
210
+ print(f"FIXTURES: {' '.join(fixtures)}", flush=True)
211
+ _upload_from_out_dir(api, args.repo_id, out_dir, fixtures)
212
+ print("Done.", flush=True)
213
+ return 0
214
+
215
+
216
+ def _run_isolated(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
217
+ """Render each fixture in a fresh subprocess (one fixture == ~240 plotters).
218
+
219
+ Spawns this same tool with ``--fixtures <id> --no-upload`` per fixture so the
220
+ GL context is fully released between fixtures, then (optionally) uploads once
221
+ from ``--out-dir``. Worker stdout/stderr inherit the parent's, so progress
222
+ and the VTK noise land in the same streams the non-isolated path uses.
223
+ """
224
+ if args.out_dir is None:
225
+ parser.error("--isolate requires --out-dir (workers render to disk).")
226
+ token = os.environ.get("HF_TOKEN")
227
+ if not args.no_upload and not token:
228
+ parser.error("HF_TOKEN required to upload (or pass --no-upload).")
229
+ api = HfApi(token=token)
230
+ gt_root = args.gt_root.resolve() if args.gt_root else None
231
+ inputs_root = args.inputs_root.resolve() if args.inputs_root else None
232
+ for label, root in (("--gt-root", gt_root), ("--inputs-root", inputs_root)):
233
+ if root is not None and not root.is_dir():
234
+ parser.error(f"{label} does not exist: {root}")
235
+
236
+ fixtures = _resolved_fixtures(parser, args, api, gt_root, inputs_root)
237
+ print(f"Isolated render of {len(fixtures)} editing fixture(s) (one subprocess each).", flush=True)
238
+ print(f"FIXTURES: {' '.join(fixtures)}", flush=True)
239
+
240
+ base_cmd = [sys.executable, str(Path(__file__).resolve()),
241
+ "--out-dir", str(args.out_dir), "--no-upload",
242
+ "--repo-id", args.repo_id, "--inputs-repo-id", args.inputs_repo_id]
243
+ if gt_root is not None:
244
+ base_cmd += ["--gt-root", str(gt_root)]
245
+ if inputs_root is not None:
246
+ base_cmd += ["--inputs-root", str(inputs_root)]
247
+
248
+ failures: list[str] = []
249
+ for i, fixture in enumerate(fixtures, start=1):
250
+ print(f"=== [{i}/{len(fixtures)}] {fixture} ===", flush=True)
251
+ proc = subprocess.run([*base_cmd, "--fixtures", fixture]) # noqa: S603, PLW1510
252
+ if proc.returncode != 0:
253
+ failures.append(fixture)
254
+
255
+ done = len(fixtures) - len(failures)
256
+ print(f"Isolated render complete: {done}/{len(fixtures)} ok, {len(failures)} failed.", flush=True)
257
+ if failures:
258
+ print(f"FAILED: {' '.join(failures)}", flush=True)
259
+ if not args.no_upload:
260
+ _upload_from_out_dir(api, args.repo_id, args.out_dir, fixtures)
261
+ print("Done.", flush=True)
262
+ return 1 if failures else 0
263
+
264
+
265
+ def main() -> int:
266
+ parser = argparse.ArgumentParser(description=__doc__)
267
+ parser.add_argument(
268
+ "--gt-root", type=Path, default=None,
269
+ help="Local cadgenbench-data-gt checkout. Omit to download from the Hub.",
270
+ )
271
+ parser.add_argument(
272
+ "--inputs-root", type=Path, default=None,
273
+ help="Local cadgenbench-data checkout (holds input.step). Omit for Hub.",
274
+ )
275
+ parser.add_argument("--repo-id", default=_default_repo_id())
276
+ parser.add_argument("--inputs-repo-id", default=_default_inputs_repo_id())
277
+ parser.add_argument("--fixtures", help="Comma-separated fixture ids. Omit for all editing fixtures.")
278
+ parser.add_argument("--limit", type=int, default=None)
279
+ parser.add_argument(
280
+ "--out-dir", type=Path, default=None,
281
+ help="Also write each webp/png here (e.g. for local inspection).",
282
+ )
283
+ parser.add_argument(
284
+ "--no-upload", action="store_true",
285
+ help="Render only; do not commit to the GT dataset.",
286
+ )
287
+ parser.add_argument(
288
+ "--upload-only", action="store_true",
289
+ help=(
290
+ "Skip rendering; commit the ``edit_diff_gt.webp`` files already under "
291
+ "--out-dir to the GT dataset. Use after an isolated render run."
292
+ ),
293
+ )
294
+ parser.add_argument(
295
+ "--isolate", action="store_true",
296
+ help=(
297
+ "Render each fixture in its own subprocess. Works around macOS "
298
+ "offscreen VTK losing its GL context after many sequential Plotter "
299
+ "create/close cycles (not needed on the Linux EGL eval job). Implies "
300
+ "render-to-out-dir; upload, if requested, runs once from --out-dir."
301
+ ),
302
+ )
303
+ args = parser.parse_args()
304
+
305
+ if args.upload_only:
306
+ return _run_upload_only(parser, args)
307
+ if args.isolate:
308
+ return _run_isolated(parser, args)
309
+
310
+ token = os.environ.get("HF_TOKEN")
311
+ api = HfApi(token=token)
312
+ gt_root = args.gt_root.resolve() if args.gt_root else None
313
+ inputs_root = args.inputs_root.resolve() if args.inputs_root else None
314
+ for label, root in (("--gt-root", gt_root), ("--inputs-root", inputs_root)):
315
+ if root is not None and not root.is_dir():
316
+ parser.error(f"{label} does not exist: {root}")
317
+
318
+ fixtures = _editing_fixture_ids(
319
+ api, args.repo_id, args.inputs_repo_id, gt_root, inputs_root,
320
+ )
321
+ if args.fixtures:
322
+ wanted = {f.strip() for f in args.fixtures.split(",") if f.strip()}
323
+ fixtures = [f for f in fixtures if f in wanted]
324
+ if args.limit is not None:
325
+ fixtures = fixtures[: args.limit]
326
+ if not fixtures:
327
+ parser.error("No editing fixtures matched.")
328
+
329
+ if not args.no_upload and not token:
330
+ parser.error("HF_TOKEN required to upload (or pass --no-upload).")
331
+
332
+ print(
333
+ f"Rendering {len(fixtures)} editing GT answer-key turntable(s)"
334
+ + ("" if args.no_upload else f" -> {args.repo_id} (will upload)"),
335
+ flush=True,
336
+ )
337
+ print(f"FIXTURES: {' '.join(fixtures)}", flush=True)
338
+
339
+ ops: list[CommitOperationAdd] = []
340
+ failures: list[str] = []
341
+ with tempfile.TemporaryDirectory(prefix="gt-edit-diff-") as tmp:
342
+ cache_dir = Path(tmp)
343
+ for i, fixture in enumerate(fixtures, start=1):
344
+ print(f"[{i}/{len(fixtures)}] {fixture} ...", flush=True)
345
+ try:
346
+ gt_dir = _materialize_gt(
347
+ api, args.repo_id, fixture, gt_root, cache_dir, token,
348
+ )
349
+ input_step = _materialize_input(
350
+ api, args.inputs_repo_id, fixture, inputs_root, cache_dir, token,
351
+ )
352
+ full = _render_fixture(gt_dir, input_step)
353
+ except Exception as e: # noqa: BLE001 - log and keep going
354
+ print(f" FAILED {type(e).__name__}: {e}", flush=True)
355
+ failures.append(fixture)
356
+ continue
357
+
358
+ print(f" ok: full={len(full) // 1024}KB", flush=True)
359
+
360
+ if args.out_dir is not None:
361
+ fx_out = args.out_dir / fixture / "renders"
362
+ fx_out.mkdir(parents=True, exist_ok=True)
363
+ (fx_out / "edit_diff_gt.webp").write_bytes(full)
364
+
365
+ ops.append(CommitOperationAdd(f"{fixture}/{FULL_NAME}", full))
366
+
367
+ done = len(fixtures) - len(failures)
368
+ print(
369
+ f"Rendered {done}/{len(fixtures)} fixture(s) ({len(failures)} failed).",
370
+ flush=True,
371
+ )
372
+ if failures:
373
+ print(f"FAILED: {' '.join(failures)}", flush=True)
374
+ if args.no_upload:
375
+ print("Upload skipped (--no-upload).", flush=True)
376
+ return 1 if failures else 0
377
+ print(f"Uploading {len(ops)} file(s) to {args.repo_id} ...", flush=True)
378
+ _commit_in_chunks(api, args.repo_id, ops)
379
+ print("Done.", flush=True)
380
+ return 1 if failures else 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ raise SystemExit(main())