Snap2Sim / docs /reviews /motion-axis-bias.md
jasondo
Finalize docs and archive prompts
7006f4b
|
Raw
History Blame Contribute Delete
7.95 kB
# 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.