feat: add Loft primitive that morphs between two 2D cross-sections - #58
feat: add Loft primitive that morphs between two 2D cross-sections#58hbehrensj wants to merge 4 commits into
Conversation
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>
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>
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>
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>
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>
…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
|
Picked this up in a fork and hit an edge case in the corner matching, in A corner-less end sampled through With the default Rectangle/Oval pairing that makes the Oval's ring Measured on width 20, depth 10, segments 8, bottom Rectangle at 0 deg, topRotation 0 -> (10.000, 0.000) (8.944, 2.236) (8.944, -2.236) ... A rigid 45 deg turn would put (10, 0) at (7.071, 7.071) and (8.944, 2.236) What worked for us was shifting that fallback by how far the two ends are The delta is zero whenever both ends share a rotation, so the case the After that the ring matches the rigid rotation exactly: (8.944, 2.236) -> Happy to open a PR against your branch if you'd like it in that form. |
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>
|
@mhaeu Thanks for the careful writeup — verified independently (my numbers matched yours almost exactly) and fixed as you described: Pushed as 🤖 Generated with Claude Code |
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>
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_shapetool (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 existingboolean/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-windingchecks, 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)
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_shapewithkind: "loft")bulged/twisted wall; an 8-segment Rectangle → Oval spins as a rigid octagon across 0–60°
instead of reshaping
Notes
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.
🤖 Generated with Claude Code