Spaces:
Running on Zero
Running on Zero
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 filesA 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>
- SPEC.md +9 -1
- app.py +30 -6
- 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"
|
| 172 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)))
|