Skip to content

fix: stop shrinking CAD models rotated off-axis - #961

Merged
seveibar merged 2 commits into
tscircuit:mainfrom
addibble:fix/rotated-model-fit-scale
Aug 5, 2026
Merged

fix: stop shrinking CAD models rotated off-axis#961
seveibar merged 2 commits into
tscircuit:mainfrom
addibble:fix/rotated-model-fit-scale

Conversation

@addibble

@addibble addibble commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

A CAD model that declares a size matching its own mesh is shrunk whenever its component sits at a rotation that is not a multiple of 90°.

This 6×6mm tactile switch (9.5mm lead span) is the same part four times, at 0°, 30°, 45° and 90°. Only the right-angle ones meet their pads:

rotation rendered scale
1.00
15° 0.56
30° 0.42
45° 0.39
90°, 180° 1.00

Cause

getObjectBoundsRelativeToParent measured each mesh by transforming its bounding box into world space and then back into the parent's frame, as two separate calls:

transformedBounds.applyMatrix4(node.matrixWorld)
transformedBounds.applyMatrix4(parentInverseMatrix)

Box3.applyMatrix4 re-derives an axis-aligned box around the eight transformed corners, so it is not invertible. Applying a rotation and then its inverse doesn't cancel — each call grows the box:

9.50 × 6.05  ──rotate +30°──►  11.25 × 9.99  ──rotate −30°──►  14.74 × 14.28

getCadModelFitScale then divides the declared size by that inflated measurement and shrinks the model to 0.42 to make it "fit".

Right angles are unaffected because an axis-aligned box is rotation-invariant there.

Fix

Compose the two matrices and apply them once, so the ancestor transform genuinely cancels:

const localMatrix = parentInverseMatrix.clone().multiply(node.matrixWorld)
transformedBounds.applyMatrix4(localMatrix)

Rotations at or below the measured object are still included — the existing fit includes loader transform group rotation in measured bounds test covers that and still passes.

Why this went unnoticed

Two things hid it:

  • size is optional, and the fit is skipped without it (if (!targetSize) return [1,1,1]). Parts that don't declare one were never affected.
  • The headless pipeline is correct. circuit-json-to-gltf applies component rotation as a scene-graph property after fitMeshToCadBounds, so its fit always measures an unrotated mesh. I confirmed it returns 9.500 × 15.000 × 6.050 at 0°, 30° and 45°. Snapshot tests, tsci build and poppygl renders are all correct — so no existing test could have caught this. It is visible only in the browser viewer.

Tests

  • tests/cad-model-fit.test.ts — asserts scale 1.0 at 0/15/30/45/90/180°. Verified it fails without the fix.
  • stories/Bugs/RotatedModelFitScale.stories.tsx — visual regression. Pads use rotated_rect with ccw_rotation and the silkscreen outline is rotated point-by-point, so both turn exactly with the part and a shrunken model visibly pulls away from them. Each instance declares size deliberately, since the fit is skipped without it.

Full suite matches main exactly: the 4 pre-existing failures (faux-board z defaults, Atari SVG outline) are unchanged, and the fit tests go 4 → 5 passing.

No new Circuit JSON properties are used; everything is in the pinned circuit-json@0.0.446.

addibble and others added 2 commits August 4, 2026 19:34
getObjectBoundsRelativeToParent transformed each mesh's bounding box into
world space and then back into the parent's frame using two separate
Box3.applyMatrix4 calls. Box3.applyMatrix4 re-derives an axis-aligned box
around the transformed corners, so it is not invertible: for any rotation
that is not a multiple of 90 degrees the round trip widened the box twice
rather than cancelling.

A 9.5 x 6.05 model on a component rotated 30 degrees measured 14.74 x
14.28, and getCadModelFitScale then shrank the model to 0.42 to fit that
phantom size into its declared `size`. Worst case was 45 degrees at 0.39.
Right angles were unaffected, which is why this survived: an AABB is
rotation-invariant there, and the headless circuit-json-to-gltf pipeline
applies rotation after fitting, so no snapshot could catch it.

Compose the two matrices and apply them once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The same switch at 0, 30, 45 and 90 degrees. Its declared size matches its
mesh, so every instance must fill its silkscreen outline and meet its pads
at every angle; before the fit fix the 30 and 45 degree parts rendered at
0.42 and 0.39.

Each instance declares `size` deliberately -- both renderers skip the fit
when it is absent, which is why core's own fixture for this part never
showed the defect.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
3d-viewer Error Error Aug 5, 2026 1:34am

Request Review

@seveibar
seveibar merged commit 95e4e73 into tscircuit:main Aug 5, 2026
4 of 5 checks passed
@tscircuitbot

Copy link
Copy Markdown

Thank you for your contribution! 🎉

PR Rating: ⭐⭐
Impact: Minor

Track your contributions and see the leaderboard at: tscircuit Contribution Tracker


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants