Skip to content

feat: add Loft primitive that morphs between two 2D cross-sections - #58

Open
hbehrensj wants to merge 4 commits into
Formsmith746:mainfrom
hbehrensj:feature/loft-shape
Open

feat: add Loft primitive that morphs between two 2D cross-sections#58
hbehrensj wants to merge 4 commits into
Formsmith746:mainfrom
hbehrensj:feature/loft-shape

Conversation

@hbehrensj

@hbehrensj hbehrensj commented Aug 12, 2026

Copy link
Copy Markdown

Summary

I built loftmorph as a standalone tool for lofting a
solid between two 2D cross-sections. Found SketchForge and wanted that as a real, parametric
primitive in the editor rather than an import-a-static-STL workaround — so this ports the
algorithm in as a new "Loft" shape next to Box/Cylinder/Gear.

Oval/Rectangle/Triangle/Pentagon/Hexagon on each end, independent size + rotation (top mirrors
how cone's Top Radius works independently of its base). Wired into every place "gear" touches,
including the MCP bridge's sketchforge_create_shape tool (kind: "loft" with its own params).

Corner-twist fix: the initial port sampled both ends at the same shared lab-frame angle, so
two same-cornered ends rotated relative to each other (e.g. two rectangles 25° apart) got walls
that twisted/bulged instead of connecting corner to corner. Replaced with an explicit best-corner
matching (ported from a fix in loftmorph itself, d91b3df) —
DP over a circular subsequence alignment that minimizes total angular mismatch, so each ring point
carries independent bottom/top angles instead of one shared angle. Went a bit further than the
literal reference in two spots, both found by a combinatorial smoke test across shape pairs and
rotations: the unequal-corner-count fallback (e.g. hexagon → rectangle) now interpolates between
the nearest matched neighbors instead of copying the other end's raw angle (which could land
outside the matched range and self-intersect), and the segment-filling interpolation advances each
angle stream by its own forward wraparound instead of assuming both streams wrap at the same seam.

Rigid-rotation fix (h/t @mhaeu): a corner-less end (e.g. an Oval,
which has no corners to match against) always took the corner-matching fallback path, which copied
the other end's raw angle verbatim — that angle already has the other end's rotation baked in,
so it didn't cancel out correctly unless both ends shared the same rotation. At low segment counts
this showed up as the corner-less end's ring reshaping as you rotated it instead of spinning as
a rigid body — reported
with a worked numeric example. Fixed by shifting that fallback (and the equivalent
both-ends-cornerless branch) by the two ends' relative rotation; no-op when both ends already share
a rotation.

Affects: storage (new loft* fields in .skf), export (STL/OBJ/STEP via the existing
boolean/export pipeline), and undo/redo (new fields added to the shape-equality check that backs
history dedup). No changes to grouping/import mechanics themselves.

Testing

  • npm run typecheck + test suite (loftGeometry.test.ts: closed/manifold + correct-winding
    checks, a rotational-symmetry invariant that caught the original centering bug, a corner-
    matching symmetry check — two squares 90° apart must reduce to an untwisted mesh — a
    rigid-rotation regression test built from @mhaeu's exact repro numbers, and a combinatorial
    smoke test across all shape pairs and a spread of relative rotations)
  • Manually tested: place, edit every property live, resize via gizmo, duplicate, undo/redo,
    use as a boolean hole, export STL and re-import it, save a project and reload the page,
    create a loft via the MCP bridge end to end (sketchforge_create_shape with kind: "loft")
  • Visual: rectangle → rectangle at 25° shows clean diagonal corner-to-corner edges instead of a
    bulged/twisted wall; an 8-segment Rectangle → Oval spins as a rigid octagon across 0–60°
    instead of reshaping

Notes

  • Known limitation: a strongly-rotated, non-round end whose top/bottom sizes differ can extend
    slightly past its nominal bounding box, so the resize gizmo won't always hug the mesh exactly
    in that combination. Cosmetic only — doesn't affect the generated geometry.
  • The toolbar icon is a placeholder.
  • I'm the author of both loftmorph and this port, so no licensing conflict contributing under AGPL.
  • Everything in my contribution was generated with Claude Code.

🤖 Generated with Claude Code

hbehrensj and others added 2 commits August 12, 2026 11:33
Ports the loft/morph algorithm from hbehrensj/loftmorph as a native
parametric shape, mirroring the existing "gear" shape's integration
pattern (toolbar entry, geometry builder, viewport render + boolean/
export dispatch, inspector panel, .skf format validation).

Bottom and top ends are each independently configurable: shape (Oval/
Rectangle/Triangle/Pentagon/Hexagon), size (bottom follows the shape's
own width/depth like every other primitive; top has its own absolute
width/depth, mirroring how cone's topRadius works independently of its
base), and rotation. Corner-anchored angle sampling keeps polygon
corners crisp regardless of segment count or which two shapes are
morphing between each other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gearGeometry.ts (the direct template for this file) has zero comments
across 240 lines; keep only the ones that prevent a real regression
(axis-swap/winding, why polygon ends aren't bbox-centered, and the
buildAngles corner-anchoring summary) and tighten those to a line or
two, per CONTRIBUTING.md's "keep comments short and useful".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@hbehrensj hbehrensj changed the title feat: add Loft primitive that morphs between two 2D cross-sectionsFeature/loft shape feat: add Loft primitive that morphs between two 2D cross-sections Aug 12, 2026
Ported from hbehrensj/loftmorph (commit d91b3df). The bottom/top ring
correspondence used to sample both ends at the same absolute angle, so
two same-cornered ends rotated relative to each other (e.g. two
rectangles 25° apart) got walls that twisted/bulged instead of
connecting corner to corner -- a bottom corner would land wherever the
top happened to be smooth. Replaces it with an explicit best-matching
correspondence (DP over a circular subsequence alignment, minimizing
total angular mismatch), so each ring point carries independent
aBottom/aTop angles instead of one shared angle.

Two robustness fixes beyond the literal reference port, both found via
a combinatorial smoke test across shape pairs and rotations:
- Unequal corner counts (e.g. hexagon -> rectangle) leave some corners
  unmatched; falling back to the other end's raw angle (as in the
  reference) can land far outside the matched neighbors' range and
  break monotonicity. Interpolating between the nearest matched
  neighbors instead keeps it in range.
- The segment-filling interpolation assumed both angle streams only
  wrap around at the same point (last anchor -> first). They can wrap
  independently, so each stream now advances by its own forward
  angular delta per segment instead of a single shared wrap check.

Also wires "loft" into MCP: the sketchforge_create_shape tool schema
and the create_shape command handler both recognize kind: "loft" with
its own parameters now, matching every other native shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mhaeu pushed a commit to mhaeu/SketchForge-3D that referenced this pull request Sep 9, 2026
Ports the loft/morph algorithm from hbehrensj/loftmorph as a native
parametric shape, mirroring the existing "gear" shape's integration
pattern (toolbar entry, geometry builder, viewport render + boolean/
export dispatch, inspector panel, .skf format validation).

Bottom and top ends are each independently configurable: shape (Oval/
Rectangle/Triangle/Pentagon/Hexagon), size (bottom follows the shape's
own width/depth like every other primitive; top has its own absolute
width/depth, mirroring how cone's topRadius works independently of its
base), and rotation. Corner-anchored angle sampling keeps polygon
corners crisp regardless of segment count or which two shapes are
morphing between each other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applied-from: Formsmith746#58 (upstream nicht gemergt)
Applied-by: Claude Opus 5 <noreply@anthropic.com>
mhaeu pushed a commit to mhaeu/SketchForge-3D that referenced this pull request Sep 9, 2026
gearGeometry.ts (the direct template for this file) has zero comments
across 240 lines; keep only the ones that prevent a real regression
(axis-swap/winding, why polygon ends aren't bbox-centered, and the
buildAngles corner-anchoring summary) and tighten those to a line or
two, per CONTRIBUTING.md's "keep comments short and useful".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applied-from: Formsmith746#58 (upstream nicht gemergt)
Applied-by: Claude Opus 5 <noreply@anthropic.com>
mhaeu pushed a commit to mhaeu/SketchForge-3D that referenced this pull request Sep 9, 2026
Ported from hbehrensj/loftmorph (commit d91b3df). The bottom/top ring
correspondence used to sample both ends at the same absolute angle, so
two same-cornered ends rotated relative to each other (e.g. two
rectangles 25° apart) got walls that twisted/bulged instead of
connecting corner to corner -- a bottom corner would land wherever the
top happened to be smooth. Replaces it with an explicit best-matching
correspondence (DP over a circular subsequence alignment, minimizing
total angular mismatch), so each ring point carries independent
aBottom/aTop angles instead of one shared angle.

Two robustness fixes beyond the literal reference port, both found via
a combinatorial smoke test across shape pairs and rotations:
- Unequal corner counts (e.g. hexagon -> rectangle) leave some corners
  unmatched; falling back to the other end's raw angle (as in the
  reference) can land far outside the matched neighbors' range and
  break monotonicity. Interpolating between the nearest matched
  neighbors instead keeps it in range.
- The segment-filling interpolation assumed both angle streams only
  wrap around at the same point (last anchor -> first). They can wrap
  independently, so each stream now advances by its own forward
  angular delta per segment instead of a single shared wrap check.

Also wires "loft" into MCP: the sketchforge_create_shape tool schema
and the create_shape command handler both recognize kind: "loft" with
its own parameters now, matching every other native shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applied-from: Formsmith746#58 (upstream nicht gemergt)
Applied-by: Claude Opus 5 <noreply@anthropic.com>
mhaeu added a commit to mhaeu/SketchForge-3D that referenced this pull request Sep 9, 2026
…hieben

Ein Oval hat keine Ecken, an denen sich die Ringabtastung ausrichten
koennte, also uebernimmt fallbackSmallAngle die Eckwinkel des anderen
Endes - bisher woertlich, samt dessen Drehframe. Beim Standard-Loft
(Rechteck unten, Oval oben) blieben die Stuetzpunkte des Ovals damit an
den unveraenderten Rechteck-Ecken haengen, waehrend sich seine Ellipse
drehte: bei wenigen Segmenten aenderte das sichtbare Achteck seine Form,
statt sich zu drehen.

Der Rueckfallwinkel wird jetzt um die Differenz der beiden Drehungen
verschoben. Sind beide Enden gleich gedreht, ist die Verschiebung null -
der von der Vorlage (hbehrensj/loftmorph) abgedeckte Fall bleibt exakt
unveraendert. Der eckenlose Fallback in buildAngles tastet zudem jedes
Ende in seinem eigenen Drehframe ab, ebenfalls ohne Wirkung bei gleicher
Drehung.

Nachgemessen bei 45 Grad: (8.944, 2.236) -> (4.743, 7.906) und
(10, 0) -> (7.071, 7.071), also die starre Drehung. Der Test schlaegt
ohne die Korrektur fehl.

Betrifft fremden Code aus PR Formsmith746#58; sollte dort zurueckgemeldet werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ES9bVPmFsQNJjBvgjaCG84
@mhaeu

mhaeu commented Sep 9, 2026

Copy link
Copy Markdown

Picked this up in a fork and hit an edge case in the corner matching, in
case it's useful.

A corner-less end sampled through fallbackSmallAngle's no-match branch
takes the other end's corner angles verbatim, including that end's
rotation frame:

if (matchedIndices.length === 0) return (k) => large[k];

With the default Rectangle/Oval pairing that makes the Oval's ring
vertices sit on the rectangle's corner angles. Rotating only the Oval end
then turns its ellipse while its sample parameters stay behind, so the
result is not a rigid rotation. At a high segment count nobody notices; at
8 segments the visible octagon changes shape instead of spinning, which is
how a user in our fork found it.

Measured on width 20, depth 10, segments 8, bottom Rectangle at 0 deg,
top Oval:

topRotation 0 -> (10.000, 0.000) (8.944, 2.236) (8.944, -2.236) ...
topRotation 45 -> (7.826, 5.590) (7.500, 2.500) (5.590, -1.118) ...

A rigid 45 deg turn would put (10, 0) at (7.071, 7.071) and (8.944, 2.236)
at (4.743, 7.906); neither is in the ring.

What worked for us was shifting that fallback by how far the two ends are
rotated against each other:

const rotationDelta =
  (swap ? topRotation : bottomRotation) - (swap ? bottomRotation : topRotation);
...
if (matchedIndices.length === 0) return (k) => large[k] + rotationDelta;

The delta is zero whenever both ends share a rotation, so the case the
reference was tuned for is bit-for-bit unchanged. Same idea for the
both-ends-corner-less branch in buildAngles, where each end can sample in
its own frame (aBottom: a + bottomRotation, aTop: a + topRotation) --
also a no-op at equal rotations.

After that the ring matches the rigid rotation exactly: (8.944, 2.236) ->
(4.743, 7.906), (10, 0) -> (7.071, 7.071).

Happy to open a PR against your branch if you'd like it in that form.
Thanks for the primitive -- the corner-matching DP is doing real work, this
is just the one spot where the two ends' frames drift apart.

Reported by github.com/mhaeu against PR Formsmith746#58: when one end has no
corners to match (e.g. a Rectangle bottom + Oval top), every ring
point took fallbackSmallAngle's zero-match branch, which copied the
other end's raw corner angle verbatim. That angle already has the
*other* end's rotation baked in (shapeCorners applies it before this
point), so sampling the corner-less end at it -- then letting
shapePoint apply that end's own rotation on top -- doesn't cancel out
correctly unless both ends share the same rotation. At low segment
counts (mhaeu's repro: width 20, depth 10, segments 8) this was
visible as the sampled polygon changing shape as you rotated just one
end, instead of spinning as a rigid body.

Fixed by shifting the fallback by the two ends' relative rotation
(smallRotation - largeRotation) so it's expressed in the corner-less
end's own frame, and applying the same idea to buildAngles' other
corner-less branch (both ends cornerless, e.g. Oval -> Oval). Both
fixes are no-ops when the two ends share a rotation, matching every
existing test and the 150-combo stress test unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@hbehrensj

Copy link
Copy Markdown
Author

@mhaeu Thanks for the careful writeup — verified independently (my numbers matched yours almost exactly) and fixed as you described: fallbackSmallAngle's zero-match branch now shifts by smallRotation - largeRotation, and the equivalent both-ends-cornerless branch in buildAngles now adds each end's own rotation. Both are no-ops when the two ends share a rotation, so the existing tests were untouched, and I added a regression test built directly from your repro numbers (width 20, depth 10, segments 8, Rectangle → Oval, 0° vs 45°) plus reran a 150-combo randomized stress test to be sure nothing else drifted.

Pushed as 86196e4. Appreciate you taking the time to dig into someone else's fork and write it up this clearly — happy to take a PR from you directly next time if you'd rather land the fix yourself.

🤖 Generated with Claude Code

mhaeu pushed a commit to mhaeu/SketchForge-3D that referenced this pull request Sep 10, 2026
Reported by github.com/mhaeu against PR Formsmith746#58: when one end has no
corners to match (e.g. a Rectangle bottom + Oval top), every ring
point took fallbackSmallAngle's zero-match branch, which copied the
other end's raw corner angle verbatim. That angle already has the
*other* end's rotation baked in (shapeCorners applies it before this
point), so sampling the corner-less end at it -- then letting
shapePoint apply that end's own rotation on top -- doesn't cancel out
correctly unless both ends share the same rotation. At low segment
counts (mhaeu's repro: width 20, depth 10, segments 8) this was
visible as the sampled polygon changing shape as you rotated just one
end, instead of spinning as a rigid body.

Fixed by shifting the fallback by the two ends' relative rotation
(smallRotation - largeRotation) so it's expressed in the corner-less
end's own frame, and applying the same idea to buildAngles' other
corner-less branch (both ends cornerless, e.g. Oval -> Oval). Both
fixes are no-ops when the two ends share a rotation, matching every
existing test and the 150-combo stress test unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Applied-from: hbehrensj@86196e4 (PR Formsmith746#58)
Applied-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants