kwabs22 Claude Opus 5.5 commited on
Commit
af80e6b
·
1 Parent(s): 65f605e

Add a rings rung to the ladder: planar sections sampled at a fixed count

Browse files

A second simplification path aimed at papercraft and easy debugging:
N horizontal sections of the mesh, each outline sampled at S angles from
its centre, joined into bands with flat caps. Each band is one paper strip
and each vertex is (ring, angle); the report lists every ring's height,
centre and radii. simplify_item takes rings (default 8) and samples
(default 16), also in the UI.

Checked by hand: a 0.1 m cylinder gives radius 0.1 at every sample, a cone
gives linearly shrinking radii. Limit: one stack per object, so branching
shapes like a lamp merge into one envelope; it sits beside decimation,
not in place of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (3) hide show
  1. SPEC.md +9 -1
  2. app.py +30 -6
  3. vli/simplify.py +62 -1
SPEC.md CHANGED
@@ -99,7 +99,7 @@ Verbs: `sat/stood/lay/rested` → `on` (ground implied), `shone/glowed` → `lig
99
  | `check_words` | sentence, primitives → per-word status, missing words, completions |
100
  | `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
101
  | `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
102
- | `simplify_item` | ledger, item id, html → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
103
 
104
  `check_words` also returns `maps_to` per noun (the asset it would get), so a word
105
  that exists but maps to the wrong thing shows up before submitting.
@@ -113,6 +113,14 @@ thin parts. Rungs are
113
  labelled with the faces they actually have, and the ladder stops descending when
114
  a rung doesn't reduce, so a stalled mesh never shows as several identical rungs.
115
 
 
 
 
 
 
 
 
 
116
  ### Mini HTML
117
 
118
  A self-contained page: Three.js (`three@0.160.0` from unpkg) plus one inline `D`
 
99
  | `check_words` | sentence, primitives → per-word status, missing words, completions |
100
  | `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
101
  | `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
102
+ | `simplify_item` | ledger, item id, html, rings, samples → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
103
 
104
  `check_words` also returns `maps_to` per noun (the asset it would get), so a word
105
  that exists but maps to the wrong thing shows up before submitting.
 
113
  labelled with the faces they actually have, and the ladder stops descending when
114
  a rung doesn't reduce, so a stalled mesh never shows as several identical rungs.
115
 
116
+ **Rings rung (second path, for papercraft).** `rings` horizontal sections, each
117
+ outline sampled at `samples` evenly spaced angles from the section's centre (the
118
+ farthest crossing per ray), joined into bands with flat caps. Every band is one
119
+ paper strip and every vertex is (ring, angle); the report lists each ring's height,
120
+ centre and radii, so it can be checked by hand. One stack per object: branching or
121
+ overhanging parts (a lamp's arm and shade) merge into one envelope, and heights with
122
+ no section are skipped.
123
+
124
  ### Mini HTML
125
 
126
  A self-contained page: Three.js (`three@0.160.0` from unpkg) plus one inline `D`
app.py CHANGED
@@ -48,6 +48,17 @@ def _seed(value):
48
  raise ValueError(f"seed must be a whole number, got {value!r}")
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
51
  HTML_MODES = ["url", "inline", "skip"]
52
 
53
 
@@ -168,18 +179,25 @@ def refine_scene(ledger_json: str, changes_json: str = "[]", seed: str = "0",
168
  _fail(e)
169
 
170
 
171
- def simplify_item(ledger_json: str, item_id: str, html: str = "url") -> tuple[str, list, list, str, str]:
172
- """Build the simplification ladder for one object: full asset -> fewer faces -> convex hull -> box.
 
173
 
174
  Look at where the object stops reading as its word: that rung is the least geometry
175
  that still carries the meaning, and the low rungs are the ones that can be made
176
  physically (fewer faces = fewer papercraft pieces).
177
 
 
 
 
 
178
  Args:
179
  ledger_json: Ledger JSON from translate_to_scene or refine_scene.
180
  item_id: The object item to simplify, e.g. "n1".
181
  html: "url" (default), "inline" or "skip", as in translate_to_scene. The ladder page
182
  holds several rungs of up to 1000 faces each, so inline is especially large.
 
 
183
 
184
  Returns:
185
  (report JSON string, gallery of rung pictures, GLB file per rung,
@@ -192,14 +210,17 @@ def simplify_item(ledger_json: str, item_id: str, html: str = "url") -> tuple[st
192
  raise ValueError(f"{item_id!r} is not an object item in this ledger")
193
  mesh = assets.settle(item)
194
  work = tempfile.mkdtemp(prefix="vli_ladder_")
195
- rungs = simplify.ladder(mesh)
196
  report, gallery, files = {"item": item_id, "word": item["word"], "rungs": []}, [], []
197
  for k, (label, rung) in enumerate(rungs):
198
  png = scene.render({item_id: rung}, os.path.join(work, f"{k}.png"), title=label)
199
  slug = re.sub(r"\W+", "_", label)
200
  glb = os.path.join(work, f"{k}_{slug}.glb")
201
  rung.export(glb)
202
- report["rungs"].append({"rung": label, **simplify.stats(rung)})
 
 
 
203
  gallery.append((png, label))
204
  files.append(glb)
205
  return (json.dumps(report, indent=1), gallery, files,
@@ -299,7 +320,10 @@ with gr.Blocks(title="VisualLearningIdea") as demo:
299
  changes = gr.Code(label="Changes", language="json", value=EXAMPLE_CHANGES, interactive=True)
300
  refine = gr.Button("Refine")
301
  with gr.Accordion("Simplification ladder", open=False):
302
- item_id = gr.Textbox(label="Object item id", value="n1")
 
 
 
303
  simp = gr.Button("Simplify")
304
  ladder_pics = gr.Gallery(label="Rungs", columns=4)
305
  ladder_preview = gr.HTML()
@@ -319,7 +343,7 @@ with gr.Blocks(title="VisualLearningIdea") as demo:
319
  refine.click(refine_scene, [ledger, changes, seed, html_mode],
320
  [ledger, picture, model, scene_file, scene_html], api_name="refine_scene") \
321
  .then(_html_preview, scene_file, scene_preview, api_visibility="private")
322
- simp.click(simplify_item, [ledger, item_id, html_mode],
323
  [ladder_report, ladder_pics, ladder_files, ladder_file, ladder_html], api_name="simplify_item") \
324
  .then(_html_preview, ladder_file, ladder_preview, api_visibility="private")
325
 
 
48
  raise ValueError(f"seed must be a whole number, got {value!r}")
49
 
50
 
51
+ def _count(value, name, lo, hi):
52
+ """A whole number in [lo, hi], sent as a string like seeds."""
53
+ try:
54
+ n = int(float(str(value).strip()))
55
+ except ValueError:
56
+ raise ValueError(f"{name} must be a whole number, got {value!r}")
57
+ if not lo <= n <= hi:
58
+ raise ValueError(f"{name} must be between {lo} and {hi}, got {n}")
59
+ return n
60
+
61
+
62
  HTML_MODES = ["url", "inline", "skip"]
63
 
64
 
 
179
  _fail(e)
180
 
181
 
182
+ def simplify_item(ledger_json: str, item_id: str, html: str = "url", rings: str = "8",
183
+ samples: str = "16") -> tuple[str, list, list, str, str]:
184
+ """Build the simplification ladder for one object: full asset -> fewer faces -> rings -> convex hull -> box.
185
 
186
  Look at where the object stops reading as its word: that rung is the least geometry
187
  that still carries the meaning, and the low rungs are the ones that can be made
188
  physically (fewer faces = fewer papercraft pieces).
189
 
190
+ The rings rung is a second path: `rings` horizontal sections, each outline sampled at
191
+ `samples` angles, joined into bands (one paper strip per band). The report lists every
192
+ ring's height, centre and radii, so each vertex can be checked by hand.
193
+
194
  Args:
195
  ledger_json: Ledger JSON from translate_to_scene or refine_scene.
196
  item_id: The object item to simplify, e.g. "n1".
197
  html: "url" (default), "inline" or "skip", as in translate_to_scene. The ladder page
198
  holds several rungs of up to 1000 faces each, so inline is especially large.
199
+ rings: Number of horizontal sections for the rings rung (2-64).
200
+ samples: Points sampled around each section (3-128).
201
 
202
  Returns:
203
  (report JSON string, gallery of rung pictures, GLB file per rung,
 
210
  raise ValueError(f"{item_id!r} is not an object item in this ledger")
211
  mesh = assets.settle(item)
212
  work = tempfile.mkdtemp(prefix="vli_ladder_")
213
+ rungs = simplify.ladder(mesh, _count(rings, "rings", 2, 64), _count(samples, "samples", 3, 128))
214
  report, gallery, files = {"item": item_id, "word": item["word"], "rungs": []}, [], []
215
  for k, (label, rung) in enumerate(rungs):
216
  png = scene.render({item_id: rung}, os.path.join(work, f"{k}.png"), title=label)
217
  slug = re.sub(r"\W+", "_", label)
218
  glb = os.path.join(work, f"{k}_{slug}.glb")
219
  rung.export(glb)
220
+ entry = {"rung": label, **simplify.stats(rung)}
221
+ if "rings" in rung.metadata:
222
+ entry["rings"] = rung.metadata["rings"]
223
+ report["rungs"].append(entry)
224
  gallery.append((png, label))
225
  files.append(glb)
226
  return (json.dumps(report, indent=1), gallery, files,
 
320
  changes = gr.Code(label="Changes", language="json", value=EXAMPLE_CHANGES, interactive=True)
321
  refine = gr.Button("Refine")
322
  with gr.Accordion("Simplification ladder", open=False):
323
+ with gr.Row():
324
+ item_id = gr.Textbox(label="Object item id", value="n1")
325
+ ring_count = gr.Textbox(label="Rings", value="8")
326
+ ring_samples = gr.Textbox(label="Samples per ring", value="16")
327
  simp = gr.Button("Simplify")
328
  ladder_pics = gr.Gallery(label="Rungs", columns=4)
329
  ladder_preview = gr.HTML()
 
343
  refine.click(refine_scene, [ledger, changes, seed, html_mode],
344
  [ledger, picture, model, scene_file, scene_html], api_name="refine_scene") \
345
  .then(_html_preview, scene_file, scene_preview, api_visibility="private")
346
+ simp.click(simplify_item, [ledger, item_id, html_mode, ring_count, ring_samples],
347
  [ladder_report, ladder_pics, ladder_files, ladder_file, ladder_html], api_name="simplify_item") \
348
  .then(_html_preview, ladder_file, ladder_preview, api_visibility="private")
349
 
vli/simplify.py CHANGED
@@ -85,7 +85,64 @@ def _recolor(mesh, color):
85
  return mesh
86
 
87
 
88
- def ladder(mesh):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  color = base_color(mesh)
90
  rungs = [("original", mesh)]
91
  for n in FACE_LEVELS:
@@ -96,6 +153,10 @@ def ladder(mesh):
96
  if len(rung.faces) > 0.8 * prev:
97
  break # decimation stalled: lower levels would repeat this mesh
98
  rungs.append((f"{len(rung.faces)} faces", rung))
 
 
 
 
99
  rungs.append(("convex hull", _recolor(mesh.convex_hull, color)))
100
  box = mesh.bounding_box_oriented
101
  rungs.append(("bounding box", _recolor(trimesh.Trimesh(box.vertices, box.faces), color)))
 
85
  return mesh
86
 
87
 
88
+ def _ray_hits(center, dirs, a, b):
89
+ """Farthest hit of each 2D ray (center + t*dir) on segments a->b; nan where a ray misses."""
90
+ e, w = b - a, a - center
91
+ denom = dirs[:, None, 0] * e[None, :, 1] - dirs[:, None, 1] * e[None, :, 0]
92
+ with np.errstate(divide="ignore", invalid="ignore"):
93
+ t = (w[:, 0] * e[:, 1] - w[:, 1] * e[:, 0])[None] / denom
94
+ s = (w[:, 0] * dirs[:, None, 1] - w[:, 1] * dirs[:, None, 0]) / denom
95
+ far = np.where((np.abs(denom) > 1e-12) & (t >= 0) & (s >= 0) & (s <= 1), t, -np.inf).max(axis=1)
96
+ return np.where(np.isfinite(far), far, np.nan)
97
+
98
+
99
+ def rings(mesh, count=8, samples=16):
100
+ """Planar circumferences: `count` horizontal sections, each sampled at `samples` angles.
101
+
102
+ Each ring is the section's outer outline seen from its own centre: the farthest
103
+ crossing along `samples` evenly spaced rays. Neighbouring rings join into bands,
104
+ so every band is one paper strip and every vertex is (ring, angle). Cruder than
105
+ decimation, but each number can be checked by hand. Returns (mesh, profile).
106
+ """
107
+ lo, hi = mesh.bounds[:, 1]
108
+ inset = 0.01 * (hi - lo)
109
+ ang = np.linspace(0, 2 * np.pi, samples, endpoint=False)
110
+ dirs = np.c_[np.cos(ang), np.sin(ang)] # in the (x, z) plane
111
+ profile = []
112
+ for y in np.linspace(lo + inset, hi - inset, count):
113
+ seg = trimesh.intersections.mesh_plane(mesh, [0, 1, 0], [0, y, 0])
114
+ if len(seg) == 0:
115
+ continue # a gap between parts at this height
116
+ a, b = seg[:, 0][:, [0, 2]], seg[:, 1][:, [0, 2]]
117
+ pts = np.r_[a, b]
118
+ center = (pts.min(axis=0) + pts.max(axis=0)) / 2
119
+ r = _ray_hits(center, dirs, a, b)
120
+ hit = ~np.isnan(r)
121
+ if not hit.any():
122
+ continue
123
+ r = np.interp(ang, ang[hit], r[hit], period=2 * np.pi) # fill rays that found nothing
124
+ profile.append({"y": round(float(y), 3), "center": [round(float(c), 3) for c in center],
125
+ "radii": [round(float(x), 3) for x in r]})
126
+ if len(profile) < 2:
127
+ return None, profile
128
+ verts = [[p["center"][0] + rr * dx, p["y"], p["center"][1] + rr * dz]
129
+ for p in profile for rr, (dx, dz) in zip(p["radii"], dirs)]
130
+ n, faces = len(profile), []
131
+ for i in range(n - 1):
132
+ for j in range(samples):
133
+ v00, v01 = i * samples + j, i * samples + (j + 1) % samples
134
+ faces += [[v00, v00 + samples, v01 + samples], [v00, v01 + samples, v01]]
135
+ for k, p in ((0, profile[0]), (n - 1, profile[-1])): # flat caps: fan to the ring centre
136
+ c = len(verts)
137
+ verts.append([p["center"][0], p["y"], p["center"][1]])
138
+ faces += [[c, k * samples + (j + 1) % samples, k * samples + j] for j in range(samples)]
139
+ out = trimesh.Trimesh(np.array(verts), np.array(faces), process=False)
140
+ if out.volume < 0:
141
+ out.invert()
142
+ return out, profile
143
+
144
+
145
+ def ladder(mesh, ring_count=8, ring_samples=16):
146
  color = base_color(mesh)
147
  rungs = [("original", mesh)]
148
  for n in FACE_LEVELS:
 
153
  if len(rung.faces) > 0.8 * prev:
154
  break # decimation stalled: lower levels would repeat this mesh
155
  rungs.append((f"{len(rung.faces)} faces", rung))
156
+ ringed, profile = rings(mesh, ring_count, ring_samples)
157
+ if ringed is not None:
158
+ ringed.metadata["rings"] = profile
159
+ rungs.append((f"rings {len(profile)}x{ring_samples}", _recolor(ringed, color)))
160
  rungs.append(("convex hull", _recolor(mesh.convex_hull, color)))
161
  box = mesh.bounding_box_oriented
162
  rungs.append(("bounding box", _recolor(trimesh.Trimesh(box.vertices, box.faces), color)))