Spaces:
Running
A newer version of the Gradio SDK is available: 6.20.0
Renderer Vocabulary: Richer Geometry, Motion, and Part Counts
Review date: 2026-06-14. Reviewer: Claude (Opus 4.8), for OpenAI Codex to implement. Scope: one user goal β the vision prompt should be more concise and descriptive, and the pipeline should give the model more freedom over the built-in Three.js geometry/motion vocabulary and a larger range of parts.
This document is findings + next steps. It does not authorize any deployment
or Hugging Face changes on its own; follow the normal GitHub β HF sync and
verification flow in AGENTS.md. File/line references are to the repo state at
review time.
User decisions captured for this spec (2026-06-14):
- Shapes: add the high-value set β
cone,capsule,torus, and aspring(helix) builder β on top of the existing 5. - Motions: add
screw(helical: rotate + translate together),orbit(revolve around a pivot point), andpulse(scale breathing) on top of the existing 4. - Parts: raise the usable cap to 6 (the renderer already allows 6).
TL;DR β the one architectural fact that drives everything
The prompt cannot grant freedom the renderer doesn't already have. Because of
the hard security invariant β no model-authored HTML; render deterministically
from validated JSON (AGENTS.md Runtime Notes, SECURITY.md Agent Guidance,
docs/reviews/security-hardening.md Β§3) β the only things that can appear on
screen are shapes/motions
that the browser renderer explicitly builds in buildPartMesh
(index.html:1141) and applyMotion (index.html:1195).
So today the vocabulary is capped identically in four places, and any expansion must change all four in lockstep (renderer is the source of truth):
| Layer | Shapes | Motions | Parts cap |
|---|---|---|---|
Prompt (snap2sim/prompts.py:26-27, :32) |
box, cylinder, sphere, gear, rod | rotate, translate, oscillate, static | "2 to 4" |
Schema enum (snap2sim/schema.py:136-139, :165-169, :214-215) |
same 5 | same 4 | no max |
Coercion (snap2sim/model_io.py:166, :169, :117) |
same 5 (elseβbox) | same 4 (elseβstatic) | parts[:4] |
Renderer (index.html:842, :1145-1156, :1202-1210, :929) |
same 5 (elseβbox) | same 4 | slice(0,6) |
Two consequences:
- Parts is already inconsistent: renderer renders 6, but the prompt asks for β€4 and coercion truncates to 4. Raising the usable count to 6 is mostly removing an artificial cap (see Β§3).
- Adding shapes/motions is a 4-layer change, not a prompt edit. The order that avoids a broken intermediate state is: renderer β schema β coercion β prompt (build the capability, allow it, coerce toward it, then ask for it).
Finding 1 β The prompt is verbose and under-describes the vocabulary it already has
build_vision_prompt (snap2sim/prompts.py:13-56) is ~40 lines and embeds a
full JSON skeleton, but:
- It lists shapes/motions as bare enums (
prompts.py:26-27) with zero guidance on when to use each. The model getsbox, cylinder, sphere, gear, rodand must guess mapping. In practice the example payload (EXAMPLE_ANALYSIS,schema.py:11-110) only ever uses box/cylinder/gear, sosphereandrodare effectively invisible to the model. The vocabulary is underused before we even expand it. - It repeats constraints (e.g. "compact", "physically plausible", "short" appear
multiple times across
:15-18,:31, and the example) and spends many lines re-stating field rules already enforced byschema.py/ coerced bymodel_io.py. VISION_SYSTEM_PROMPT(prompts.py:5-10) is terse and doesn't frame the model's strengths (it's a Reasoning GGUF perAGENTS.md) or the "annotated cutaway" product goal.
Net: the prompt is long on rules and short on descriptive guidance, which is the opposite of what helps a reasoning model pick good primitives.
A cheap win exists independent of any renderer change: add a terse "shape β use for" and "motion β use for" guide so the model exploits the existing 5/4 vocabulary. That guide then grows naturally when we add the new primitives.
Finding 2 β High-value Three.js primitives are unused; the cutaway aesthetic wants them
The renderer hand-builds geometry in buildPartMesh (index.html:1141-1167)
and gearGeometry (index.html:1169-1187). Three.js ships many more primitives
that map cleanly onto real mechanism elements. The user picked the high-value
set:
| New shape | Three.js primitive | Real mechanism elements it covers |
|---|---|---|
cone |
ConeGeometry(radius, height, seg) |
valve cones, drill/screw tips, springs' seats, nozzles, pawl points |
capsule |
CapsuleGeometry(radius, length, β¦) |
pistons, dowel pins, rollers, bearings, plungers, shafts with rounded ends |
torus |
TorusGeometry(radius, tube, β¦) |
o-rings, retaining/snap rings, coils, washers (thick), seals |
spring |
custom helix via TubeGeometry + CatmullRomCurve3 (like gearGeometry is custom) |
compression/extension springs, coils, helical elements β very common and currently impossible to depict |
Note on a latent inconsistency to clean up while here: the current
cylinderbranch (index.html:1146) already secretly supports a cone/taper by usingsize[0]/2as top radius andsize[2]/2as bottom radius β butsizeis documented everywhere else as[x, y, z]extents, and nothing tells the model this. Introducing an explicitconeshape (with its ownsizesemantics) removes the need for that undocumented trick. Decide whethercylindershould revert to a true cylinder (radiusTop == radiusBottom) onceconeexists; recommended yes, to keepsizesemantics consistent.
size semantics for the new shapes (must be documented in the prompt)
Keep the existing convention: size: [x, y, z] are bounding extents, and the
builder derives radii/lengths from them so the model never has to send
radius/height (which the prompt already forbids, prompts.py:28-30). Proposed:
cone: base diameter frommax(x, z), height fromy. Apex +y.capsule: diameter frommax(x, z), total length fromy(long axis +y; reuse therodpattern if a horizontal default reads better β matchrod'srotateXconvention atindex.html:1151for consistency).torus: outer diameter frommax(x, z), tube thickness fromy(clamp tube to a fraction of radius so it stays a ring, not a sphere).spring: outer diameter frommax(x, z), length/height fromy; exposecoils(int, liketeethfor gear,schema.py:158) and an optionalwirethickness with sane defaults.
All four must also be reachable through the same color, rotation, and
position handling that already wraps buildPartMesh
(index.html:929-940, color at index.html:1158/colorFor :1228).
Finding 3 β Parts cap is artificially low and inconsistent
- Prompt says "Use 2 to 4 parts" (
prompts.py:32). - Coercion truncates with
parts[:4](model_io.py:117). - Renderer already maps
slice(0, 6)(index.html:929) and labels project per mesh (updateLabels,index.html:1213). - Schema imposes no max on
parts(schema.py:123-125).
Decision: usable cap = 6. This is mostly removing a cap:
- Prompt: change "Use 2 to 4 parts" β "Use 2 to 6 parts; prefer the fewest
that explain the mechanism" (
prompts.py:32). - Coercion:
parts[:4]βparts[:6](model_io.py:117). - Renderer: already 6 β no change, but double-check label legibility / overlap at
6 parts (
updateLabelsclamps atindex.html:1221-1222). - Schema: optionally add
"maxItems": 6to thepartsarray (schema.py:123) so over-long payloads fail validation predictably instead of being silently truncated only on the coercion path (note:model_io.pyruns only on the Modal path, perdocs/reviews/interaction-and-fallback-review.md; the Spacevalidate_analysispath does not truncate, so a schemamaxItemskeeps both paths consistent).
Finding 4 β Motions are hand-coded, not "built-in"; expand the custom set
applyMotion (index.html:1195-1211) implements all motion by hand: rotate
(continuous spin on axis), oscillate (sinusoidal rotation), translate
(sinusoidal slide along axis within range), static. Three.js has no
"motion" concept of its own, so "more built-in motions" = add more primitives
to applyMotion. User picked:
| New motion | Behavior | Mechanism examples | Params (extend schema) |
|---|---|---|---|
screw |
rotate and translate along the same axis together (helical) |
screws, lead screws, drill bits, augers, twist mechanisms | reuse axis, speed, phase, range (translate extent) + e.g. pitch |
orbit |
revolve the part around a pivot point (not its own center) | planetary/idler gears, cranks, eccentrics, governor weights | axis, speed, phase + a pivot: [x,y,z] |
pulse |
sinusoidal scale breathing | diaphragms, bladders, pumps, bellows, valves opening/closing | speed, phase, amplitude |
Implementation notes for applyMotion:
- It already resets to base each frame (
mesh.position.copy(basePosition),mesh.rotation.copy(baseRotation),index.html:1200-1201) β good;screwandorbitcan compose translate+rotate cleanly on top of that. orbitneeds the pivot in world space; store it onmesh.userDataat build time likebasePosition(index.html:936). Offset = position β pivot, rotate the offset byaxis, re-add pivot.pulseshould multiply the reveal scale (revealMesh,index.html:1189) rather than fight it β guard so the staggered reveal still plays first.- Keep the existing param defaults pattern (
speed||1,phase||0,amplitude||0.25,range||[-0.25,0.25],index.html:1197-1207).
Schema additions: extend the motion enum (schema.py:165-169, _MOTIONS
:215) and add optional pitch (number) and pivot (3-number list) to the
motion properties (schema.py:162-186), validated with the existing
_require_number_list helpers (schema.py:326-329). Coercion: extend the
allowed set (model_io.py:169) and carry the new params through (mirror
_axis_vector, model_io.py:173-175, :290).
Finding 5 β The renderability gate and validators must learn the new vocabulary too
Three guards currently encode the old vocabulary and will silently reject or downgrade the new shapes if missed:
- Browser renderability check (
index.html:840-844): the["box","cylinder","sphere","gear","rod"].includes(geometry.shape)test decides whether a part counts as renderable (feedsrender_mode/ annotate-fallback selection,schema.py:select_render_mode). Add the new shapes here or a part withshape:"spring"will be treated as non-renderable and fall back to annotate-only. - Schema validator (
schema.py:_SHAPES:214,_MOTIONS:215, enums:136-139/:165-169) β extend both sets/enums. - Coercion fallbacks (
model_io.py:166-167,:169-170) β extend the allowed sets so a valid new shape isn't rewritten tobox/static.
Also update EXAMPLE_ANALYSIS (schema.py:11-110) to exercise at least a couple
of the new primitives (e.g. a spring and a capsule) so the in-prompt example
actually demonstrates the wider vocabulary β the example is the single biggest
lever on what the model emits.
Proposed prompt rewrite (concise + descriptive)
Goal: shorter scaffolding, richer guidance. Two concrete changes.
A) VISION_SYSTEM_PROMPT (prompts.py:5-10) β frame the task and the model
Keep it ~4 lines but add: this is a reasoning model building an annotated technical cutaway; reason briefly, then emit one JSON object; prefer the simplest set of primitives that truthfully explains the mechanism; lower confidence + annotate when unsure (already the policy β keep it).
B) build_vision_prompt (prompts.py:13-56) β trim rules, add a vocabulary guide
Cut redundancy: state "final answer = one JSON object, no markdown" once; drop repeated "compact/short/plausible" restatements.
Replace bare enums with a terse guide the model can act on, e.g.:
Shapes (pick the closest; size = [x,y,z] extents): box plates, housings, blocks, levers, selectors cylinder shafts, sleeves, bushings, drums, pins cone valve cones, tips, nozzles, tapers capsule pistons, rollers, dowel pins, plungers, bearings sphere balls, detents, ball bearings, nodes rod links, tie rods, thin axles, connecting rods gear toothed wheels (set teeth); ratchets, cogs torus o-rings, snap/retaining rings, seals, coils (single) spring helical springs, coils (set coils) Motions (axis is a numeric vector like [0,1,0]): static fixed structure / housing rotate continuous spin (speed) oscillate sinusoidal twist (amplitude, speed) translate slide along axis (range [min,max]) screw spin + advance along axis together (pitch) β screws, drills orbit revolve around a pivot point (pivot [x,y,z]) β planetary/cranks pulse scale breathing (amplitude) β diaphragms, pumpsUpdate the part-count line to "Use 2 to 6 parts; prefer the fewest that explain the mechanism."
Keep the hard rules that protect parsing/rendering: numeric
size/position/axisvectors only, noradius/height/string-axis (prompts.py:28-30), normalized[0,1]top-left annotation coords (prompts.py:31-34), and "reason first, final answer is one JSON object" (prompts.py:14-17, required by the<think>-stripping parser,docs/reviews/interaction-and-fallback-review.mdQ3 /model_io.py).Keep the example but shrink it to a 2β3 part skeleton that now includes a new primitive, and rely on
EXAMPLE_ANALYSIS(the schema sample) to show the full shape.
Security / invariants to preserve (do not regress)
- Deterministic rendering only. All new shapes/motions are built in
buildPartMesh/applyMotionfrom validated JSON. Never inject model-authored HTML/JS/markup (AGENTS.md,SECURITY.md,docs/reviews/security-hardening.mdΒ§3).label/notestaytextContent(index.html:1223). - Validate before render. New enum values must be added to
schema.pysovalidate_analysis(schema.py:263) still runs before scene generation; do not loosen validation to "accept anything." - Coercion stays conservative. Unknown shapes/motions must still fall back to
box/static(model_io.py:166-170), never crash. - No change required to rate limiting, upload caps, decompression-bomb guards
(
app.py), Modal bearer auth, or the GitHubβHF sync.
Suggested implementation order for Codex
- Renderer first (
index.html): addcone,capsule,torus, and aspring(helix) builder tobuildPartMesh/a new helper next togearGeometry; addscrew,orbit,pulsetoapplyMotion(storepivotonuserData). Update the renderability gate atindex.html:840-844. Verify each renders before touching Python. - Schema (
snap2sim/schema.py): extend shape/motion enums +_SHAPES/_MOTIONS; addpitch/pivot/coilsproperties; optionalparts.maxItems: 6; updateEXAMPLE_ANALYSISto use a couple of new primitives. - Coercion (
snap2sim/model_io.py): extend allowed shape/motion sets; carry new params; changeparts[:4]βparts[:6]. - Prompt (
snap2sim/prompts.py): apply the concise rewrite (system + user), the shape/motion guide, and "2 to 6 parts". - Local verification (per
AGENTS.md): schema/parser checks, FastAPITestClientfor/,/analyze_image,/generate_scene; and a real browser check that each new shape and motion renders and that 5β6-part scenes don't have unreadable overlapping labels. - Optional Modal re-measure
(
docs/reviews/interaction-and-fallback-review.mdQ3 tuning note): the richer prompt may change token usage; confirmrun_analysis_endpoint_checkstill returns valid JSON within the current4096/8192/300sbudgets before deploy. - Standard PR -> GitHub Actions HF sync -> Space verification. The final
public submission is under
build-small-hackathon/Snap2Sim.
Archived considerations
Final submission note, 2026-06-15: the renderer vocabulary pass shipped before
the public build-small-hackathon/Snap2Sim submission.
- Whether
cylindershould revert to a true (untapered) cylinder once explicitconeexists (recommended yes β see Β§2 note). - Whether
spring/torusneed extra params (coils,wire/tubethickness) exposed to the model or just sensible fixed defaults (recommend defaults first, expose only if visual quality needs it).