# 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.