Spaces:
Sleeping
Sleeping
| # Review: Motion Axis Bias Toward [0, 1, 0] | |
| Date: 2026-06-15 | |
| Scope: Does the current prompt (and surrounding pipeline) bias part motion | |
| toward the world Y axis `[0, 1, 0]`? | |
| ## Short answer | |
| Yes. The system prompt nudges the model toward `[0, 1, 0]`, and two | |
| non-prompt layers (the renderer default and the coercion fallback) push the | |
| *rendered* result toward Y even harder. The net effect is that uncertain or | |
| under-specified parts tend to spin/oscillate/slide about world Y regardless of | |
| their actual geometry orientation. | |
| ## Where the bias comes from | |
| ### 1. Prompt anchoring (`snap2sim/prompts.py`, `VISION_SYSTEM_PROMPT`) | |
| `[0, 1, 0]` is the *only* concrete axis literal the model ever sees, and it | |
| appears twice with no counterexample: | |
| - Line 29: `Motions, with axis as a numeric vector like [0, 1, 0]:` | |
| - Line 40: `Every motion axis must be a numeric vector such as [0, 1, 0], never a string like x, y, or z.` | |
| The compact example payload (lines 52β68) uses a `pulse` motion, which has no | |
| `axis` field, so it adds no balancing example. With a single repeated exemplar | |
| and no contrast cases, an LLM under uncertainty defaults to the most available | |
| pattern β here, `[0, 1, 0]`. This is textbook few-shot anchoring. | |
| Note: `EXAMPLE_ANALYSIS` in `snap2sim/schema.py` is *not* injected into the | |
| prompt (the prompt is self-contained), so it does not directly steer the model. | |
| But it reflects the same lean: of its three parts that carry an axis, two are | |
| `[0, 1, 0]` (`ratchet_gear` rotate, `pawl` oscillate) and one is `[1, 0, 0]` | |
| (`selector_pin` translate). It is also the local sample-mode render, so the | |
| Y-lean is visible there too. | |
| ### 2. Renderer hard default (`index.html`) | |
| `applyMotion`, line 1418: | |
| ```js | |
| const axis = new THREE.Vector3(...(motion.axis || [0, 1, 0])).normalize(); | |
| ``` | |
| If the model omits `axis` entirely (valid β `axis` is optional in the schema | |
| and coercion never injects one), the renderer animates about Y. So "model is | |
| biased to emit `[0, 1, 0]`" and "model omits axis" produce the *same* on-screen | |
| result: Y-axis motion. This makes the bias look even stronger than the model's | |
| raw output. | |
| ### 3. Coercion fallback (`snap2sim/model_io.py`) | |
| - `_axis_vector` (lines 297β309) returns `None` when no usable axis is present; | |
| `_coerce_part` only adds `axis` if truthy (lines 173β175). So omitted axes | |
| stay omitted β renderer default (Y) applies. | |
| - `_generic_analysis` (line 232) hard-codes `"axis": [0, 1, 0]` for its rotor | |
| part. Any fully-degraded response that falls back to the generic payload is | |
| pure Y. | |
| ## Why this is a real visual problem, not just cosmetics | |
| Geometry orientation and motion axis are decided **independently**, and they can | |
| disagree: | |
| - Geometry: `axisProfile` + `orientYAxisGeometry` / `orientZAxisGeometry` | |
| (`index.html` lines 1319β1367) bake the primitive's long axis into the | |
| geometry buffer using the **odd-one-out `size` dimension**. A cylinder with | |
| `size [3, 0.5, 3]`... e.g. `size [2.0, 0.4, 0.4]` lays the shaft along world X. | |
| - Motion: `applyMotion` calls `mesh.rotateOnAxis(axis, β¦)` with the raw motion | |
| axis (object space, and with no `baseRotation` that equals world space). | |
| So a shaft whose geometry was oriented along **X** but whose motion axis is the | |
| biased **`[0, 1, 0]`** will spin about Y β i.e. it sweeps end-over-end instead | |
| of rotating about its own length. The size-driven geometry inference added in | |
| `docs/reviews/scene-geometry-review.md` made geometry orientation smart, but | |
| motion axis was left to the model/default, so the two can visibly contradict. | |
| ## Conclusion | |
| The prompt is a genuine contributor to the `[0, 1, 0]` lean, but fixing only the | |
| prompt will not fully resolve the on-screen behavior because the renderer | |
| default and generic fallback also resolve to Y. Address the prompt *and* make | |
| motion axis follow the geometry's inferred primitive axis when the model is | |
| silent or low-signal. | |
| ## Implementation plan (completed) | |
| 1. **De-anchor the prompt** (`snap2sim/prompts.py`): | |
| - In the motions section, show axis examples across all three world axes, | |
| e.g. spin about a shaft's long axis `[1, 0, 0]` / `[0, 0, 1]` as well as | |
| `[0, 1, 0]`, so no single axis is the lone exemplar. | |
| - Add one sentence: the motion axis should match the part's real axis of | |
| rotation/translation (usually the geometry's long/odd-one-out dimension), | |
| not default to vertical. | |
| - Optionally add a second `parts` exemplar in the compact shape that carries | |
| an explicit non-`[0,1,0]` axis, to balance the `pulse` example. | |
| 2. **Make the renderer default geometry-aware** (`index.html`): | |
| - When `motion.axis` is absent, derive the default from the same | |
| `axisProfile` result used to orient the geometry (map the inferred | |
| `profile.axis` 0/1/2 to `[1,0,0]`/`[0,1,0]`/`[0,0,1]`) instead of the hard | |
| `[0, 1, 0]`. This aligns spin with the part's long axis for free. | |
| - Keep `[0, 1, 0]` only as the final fallback when no geometry/profile is | |
| available. | |
| 3. **Align the coercion fallback** (`snap2sim/model_io.py`): | |
| - Consider deriving `_generic_analysis` rotor axis from its own geometry, or | |
| at minimum document that the generic part is intentionally Y. | |
| - Decide whether `_coerce_part` should inject a geometry-derived axis when the | |
| model omits one (vs. leaving it to the renderer). Prefer leaving it to the | |
| renderer so there is a single source of truth (option 2). | |
| 4. **Verification after changes:** | |
| - `EXAMPLE_ANALYSIS` and `docs/fixtures/mini-fan-analysis.json` render with | |
| each part rotating about a sensible axis (shafts about their length). | |
| - Schema/parser/coercion checks still pass; FastAPI `TestClient` smoke for | |
| `/`, `/analyze_image`, `/generate_scene`. | |
| - Re-run a representative Modal analysis and inspect the distribution of | |
| emitted motion axes β confirm it is no longer almost entirely `[0, 1, 0]`. | |
| ## Implementation status | |
| Completed locally on 2026-06-15: | |
| - Prompt examples now show X, Y, and Z axes and explicitly tell the model to | |
| match motion axes to the real rotation or translation axis. | |
| - The deterministic renderer stores the geometry-inferred axis on each mesh | |
| and uses it when `motion.axis` is omitted, keeping `[0, 1, 0]` only as the | |
| final no-profile fallback. | |
| - `_generic_analysis` no longer hard-codes the rotor motion axis; its disk-like | |
| geometry lets the renderer derive the Y axis from size. | |
| - Local schema/parser/coercion checks, FastAPI smoke checks, and Playwright | |
| renderer probes passed. | |
| - Modal dev `run_analysis_endpoint_check` returned a rotating `dial` part with | |
| `axis: [1, 0, 0]`, confirming the prompt no longer collapses the synthetic | |
| check to `[0, 1, 0]`. | |
| - Stable Modal deployment completed after the prompt/renderer update, and an | |
| unauthenticated deployed `runtime_probe` request returned `401 Unauthorized`. | |
| - GitHub-to-Hugging Face sync run `27544185461` deployed commit `e7369eb` to the | |
| then-private Space after shortening the README `short_description` metadata to meet | |
| Hugging Face's 60-character limit. | |
| - Authenticated Space verification passed: the root served the trusted | |
| shell, a synthetic target image returned `rotating shaft assembly` at `0.65` | |
| confidence with an emitted `[1.0, 0.0, 0.0]` motion axis, `/generate_scene` | |
| returned `renderer: three` and `render_mode: three`, and no HTML field was | |
| present. | |
| - Final submission note, 2026-06-15: this pass shipped before the public | |
| `build-small-hackathon/Snap2Sim` submission. | |
| ## Files referenced | |
| - `snap2sim/prompts.py` β `VISION_SYSTEM_PROMPT` (lines 29, 40), example payload. | |
| - `index.html` β `applyMotion` (line 1418), `axisProfile` (1319β1344), | |
| `orientYAxisGeometry` / `orientZAxisGeometry` (1354β1367), `buildPartMesh`. | |
| - `snap2sim/model_io.py` β `_axis_vector` (297β309), `_coerce_part` (145β204), | |
| `_generic_analysis` (~232). | |
| - `snap2sim/schema.py` β `EXAMPLE_ANALYSIS` axes, motion schema. | |