Snap2Sim / docs /reviews /motion-axis-bias.md
jasondo
Finalize docs and archive prompts
7006f4b
|
Raw
History Blame Contribute Delete
7.95 kB

A newer version of the Gradio SDK is available: 6.26.0

Upgrade

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:

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.