Skip to content

Grasshopper re-write: caps, placement, booleans...#99

Open
ofloveandhate wants to merge 44 commits into
developfrom
feature/grasshopper_frenzy
Open

Grasshopper re-write: caps, placement, booleans...#99
ofloveandhate wants to merge 44 commits into
developfrom
feature/grasshopper_frenzy

Conversation

@ofloveandhate

Copy link
Copy Markdown
Owner

Grasshopper pipeline for Bertini_real surfaces and curves

This branch (re)-builds a complete Grasshopper pipeline for working with Bertini_real decompositions
in Rhino, plus a Rhino-free Python pipeline for the same operations.

Architecture

  • One file, one reader. Python exports a single br_gh_export.json per decomposition; C#
    stays a thin reader. A unified vertex set is the spine — meshes and embedded curves all index
    the same point cloud, so joins, closed solids, and curve∩mesh intersections work exactly.
  • New br_gh_export.json folds in singularity/connector data (locations, directions, parities,
    per-piece assignments), retiring the old br_surf_piece_data.json.

New Grasshopper components

Readers / data: Surface Read GH JSON, Curve Read GH JSON (with Scale input), Untangle
Curves By Type, Mesh Mode selector

Capping / closing: Sphere Caps (radial rings slerped along sphere; caps the piece mesh's own
on-sphere boundary — not the sphere curve — to avoid T-junctions), Flat Caps (flat fan to loop
centroid, for cylinder ends), Close Piece (weld + UnifyNormals + IsClosed + Naked Edges
diagnostic)

Booleans: Boolean Piece (ordered fold: Solid + Features + signed Operations, +1 union /
−1 subtract), Connectors To Features (weaves plug/socket trees into an ordered Features +
Operations sequence)

Assembly / display: Spread Pieces (radial explosion, absolute distance), Spread By
Connectors (BFS the connector graph from the most-connected hub; each piece offset = parent's
offset + Distance along the connector axis — handles chains, cycles, and disconnected groups),
Color By Function (colors piece meshes by a scalar expression in x,y,z via
GH_ExpressionParser), Surface Place Components (rewired to per-piece trees; Size input removed —
connectors placed at true scale; accepts any subset of plug/socket geometries), Surface Group By
Piece (rewired to per-piece trees)

Utility subcategory: Centered Closed Cylinder (Sides: 1 = round, 3+ = N-gon prism)

Python additions (python/bertini_real/)

  • surface: sphere_cap_meshes, flat_cap_meshes, join_meshes, spread_pieces,
    mesh_boolean_fold (trimesh manifold backend), as_closed_mesh,
    SurfacePiece.as_mesh/sphere_caps/flat_caps/to_gh_dict, Surface.export_gh_json
  • curve: Curve.export_gh_json, CurvePiece.to_point_indices
  • decomposition: _sphere_dict
  • data: gather_and_export_gh()
  • Scripts: export_for_grasshopper.py, close_pieces.py, prep_surf_for_grasshopper.py
  • New dependencies: manifold3d (mesh booleans), bertini2>=3.0.0

Tutorial

python/docs/tutorials/capping_and_joining.rst — Ding Dong example showing sphere vs. flat
caps, then subtracting a square hole from one piece and adding a square rod to the other so
they snap together. Figures generated by make_images.py.

Bug fixes (pre-existing bugs)

  • curve/__init__.py — crash in to_points on unsampled curves: len(None) when
    self.sampled_points is None caused a crash. Fixed by guarding the sampled-points path.
  • surface/__init__.pyas_mesh_raw produced non-manifold geometry: Degenerate
    triangles from critical-slice curve edges (repeated vertex indices) were included, and
    keep_all_vertices was not honored. Fixed: skip triangles with a repeated vertex index;
    pass keep_all_vertices=True to extract_points. Raw pieces are now manifold and close
    watertight (verified 9/9 nordstrand pieces).

Pipeline diagram

grasshopper/docs/usage.puml (PlantUML) documents the full single-source pipeline.
Render with plantuml grasshopper/docs/usage.puml.

Gratitude and thanks

This PR builds off of the excellent work and contributions from my students at UWEC: Caden Jorgens @StellarRaccoon, Danya Morman, Morgan Fiebig, Briar Weston, Foong Min Wong @foongminwong. You have my gratitude, at helping make this possible. Caden, I finally got the plugs and sockets to go in the correct direction -- we were looking for the tangent cone! Danya, we did great work on skeletons and made that fun rainbow Whitney Umbrella (sadly the penetrant on the piece melted to some styrofoam and i had to discard the object). Morgan, we did so much work on triangulations, and now it's really paying off. And Foong Min, we printed so many things, and you really wrote most of the foundational Python code that now really gets to shine. Thank you all, for the things I listed, and the things I didn't.

ofloveandhate and others added 30 commits June 10, 2026 23:51
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… JSON

Add an additive Python exporter and thin Grasshopper components so surfaces
(nonsingular pieces as meshes + embedded curves) and standalone curves can be
loaded into Rhino/Grasshopper.

The export ships ONE unified vertex set; meshes carry only triangle indices into
it and curves only ordered vertex-index lists into it, so meshes and embedded
curves refer to the same points in Rhino (enabling joins, closed-solid detection,
and exact curve/mesh intersections).

Python (python/bertini_real/), all additive:
- Curve.export_gh_json + CurvePiece.to_point_indices (index-space twin of to_points)
- Surface.export_gh_json, SurfacePiece.to_gh_dict, Surface._curve_type_for_name,
  module-level _mesh_triangles
- data.gather_and_export_gh convenience
- fix latent crash in CurvePiece.to_points on unsampled curves (len(None))
- pytest suite under python/tests/ (pure-logic + fixture-based invariants)

C# (grasshopper/bertini_real/):
- GhExport/GhPiece/GhMesh/GhEmbeddedCurve/GhCurvePiece DTOs
- GhJsonIO helpers (build meshes on the shared cloud, no cull/compact)
- SurfaceReadGhJson and CurveReadGhJson components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
python/scripts/export_for_grasshopper.py wraps gather_and_export_gh / export_gh_json
into a runnable CLI. Runs from a decomposition's working directory (repo idiom) or
takes an explicit output_dim_X_comp_Y folder; auto-detects curve (dim 1) vs surface
(dim 2) and writes br_gh_export.json for the Read GH JSON components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Splits the parallel Curves + Curve Types trees from "Surface Read GH JSON" into one
output per type (Critical, Sphere, Singular, Midslice, Critslice, Other), preserving
per-piece tree paths, so a given curve type can be grabbed off a single wire without
manual tree filtering / get-item juggling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Decomposition._sphere_dict (center + radius) to the curve and surface JSON
exports, a GhSphere DTO, GhJsonIO.ToSphereBrep, and a new Sphere (Brep) output on
the "Surface Read GH JSON" component (appended last so existing wirings are
unaffected). Extend the export tests to cover the sphere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sphere Caps builds the faceted spherical cap(s) that close a surface piece where it
meets the bounding sphere. It caps the piece mesh's OWN on-sphere naked boundary loop
(not the separately-sampled sphere curve), so the cap shares the piece's boundary
vertices and respects raw-with-raw / sampled-with-sampled. It works in mesh topology
space (merging coincident/nodal vertices), skips degenerate edges and zero-area
triangles, keeps the smaller-area cap, and warns on open/non-manifold boundaries.

Close Piece joins a piece with its cap(s), merges coincident vertices to weld, unifies
normals, and reports whether each result is a closed solid.

Also verify in export_gh_json that each piece's sphere curves are closed loops, warning
otherwise (surfaces a decomposition problem before Grasshopper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cap was a single fan to one apex, which looks coarse and pinches to a point on a
small (e.g. unit) sphere. Add a Resolution input (radial rings, default 4): the cap is
now subdivided into concentric rings slerped along the sphere from the boundary toward
the pole, so it follows the sphere surface. Ring 0 keeps the exact shared boundary
vertices, so the watertight weld with the piece is unaffected (validated: euler 2,
watertight at R=4 on whitney/dingdong).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sphere Caps culled triangles by area (< tol*tol), which at higher Resolution killed
legitimate thin triangles in the closely-spaced rings near the boundary, punching tiny
holes so the joined piece was no longer closed (observed at resolution >= 8). Cull only
triangles with an actually-collapsed edge (coincident vertices) instead; thin-but-valid
triangles are kept. Validated watertight at R=7..20 on whitney/dingdong.

Close Piece now also outputs the result's Naked Edges and reports the loop count, to
make any future non-closed case diagnosable (seam vs interior crack vs uncapped boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spreads per-piece meshes apart so they can be seen separated: each piece (tree branch)
is translated radially by Factor x (its bounding-box center - the overall center), so
Factor 0 leaves them in place and larger values separate them. Optional Center override;
outputs the moved meshes, per-piece translations, and the center used.

(Named "Spread" rather than "Explode" since in Grasshopper explode means decomposing a
thing into its constituents.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rrect direction for a plug/socket

it took me forever to understand this.  wish i'd known years ago.  i think some people told me -- Melody Chan, particularly, in Toronto in 2023 -- but i didn't get it.  now i do.

the committed code is a bit messy, but enough to get it integrated in a workflow.
…rities' into feature/grasshopper_frenzy

# Conflicts:
#	python/bertini_real/surface/__init__.py
Extract the nodal-singularity connector computation (locations, tangent-cone
directions from the Hessian, per-piece parities, singularities-on-pieces) out of
write_piece_data into a reusable Surface.singularity_connector_data(); write_piece_data
now delegates to it. Remove the dead centroid direction method (tangent-cone only).

export_gh_json embeds this under a "singularities" key, and Surface Read GH JSON exposes
Sing Locations / Sing Directions / Sing Parities / Sing On Pieces. This removes the need
for the separate br_surf_piece_data.json -- one file, one reader.

Add bertini2>=3.0.0 to install_requires (the tangent-cone direction needs the parser).
Verified on raw nordstrands_weird: 9 pieces, 11 singularities, each joining two pieces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eces

Promote the prototype geometry (validated all along as sanity checks) into supported
Python so users without a Rhino/Grasshopper license get the same pipeline via trimesh:

- module functions in surface: sphere_cap_meshes (cap a mesh's own on-sphere boundary
  loops, smaller-area side, radial-ring subdivision), join_meshes (weld via vertex merge),
  spread_pieces (exploded view).
- SurfacePiece methods: as_mesh, sphere_caps, as_closed_mesh (cap + join).
- python/scripts/close_pieces.py drives it end to end: split -> cap -> close -> optional
  spread -> export STLs, and reports watertightness + singularity connector count.

Watertight solids require a sampled decomposition (the raw blocky mesh is non-manifold);
the tools report this honestly rather than faking it. Verified: whitney pieces watertight,
nordstrands_weird (raw) caps all sphere boundaries and yields its 11 connectors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_mesh_raw fanned every face boundary edge to the face midpoint without skipping
degenerate curve edges. Those degenerate edges (left==mid or mid==right) come from the
critical point slices -- at a critical value the fiber degenerates, so some 1-cells
collapse to a point and are stored with a repeated vertex (the codebase already flags
these via is_edge_degenerate, and to_points skips them). Fanning one produced a zero-area
triangle like (a,a,mid) whose self-edge {a,mid} was counted twice, making the mesh
non-manifold (edges shared by 4 faces) and adding duplicate faces, so raw pieces could not
close into watertight solids.

Skip only the degenerate half of each edge's fan (len(set)==3), keeping the valid triangle
so no hole is introduced. Also honor keep_all_vertices (process=False) like as_mesh_smooth,
so the raw faces index the global/unified vertex set instead of a merged reindexed one.

Result: raw pieces are now clean manifolds that cap+join to watertight solids -- verified
9/9 on raw nordstrands_weird and on whitney. Adds tests/test_raw_mesh.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface Place Components no longer parses its own br_surf_piece_data.json. It now takes
the singularity data as inputs directly from Surface Read GH JSON -- Sing Locations,
Sing Directions, Sing Parities (per singularity, per piece) and Sing On Pieces (per
piece) -- so the whole Grasshopper graph is fed by one file and one reader instead of a
second JSON path.

Same placement behavior and per-piece connector/diagnostic outputs; geometry inputs are
now item access, and the pieceID user-string is the piece index. ComponentGuid unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface Group By Piece no longer reads br_surf_piece_data.json or routes connectors by a
pieceID user-string. Since meshes (Surface Read GH JSON) and connectors (Surface Place
Components) are already per-piece trees keyed by {piece_index}, it now just merges those
trees branch-by-branch: each output branch is the piece's mesh(es) followed by its
connectors. No file input; outputs the grouped tree plus the piece indices.

This makes the whole Grasshopper graph single-source (br_gh_export.json + Surface Read GH
JSON). The Data/PieceData DTOs are now unused, so remove them.

Consistency pass toward uniform component I/O: per-piece data is a DataTree keyed
{piece_index}; Meshes nickname is "M" everywhere (Group By Piece was "Ms"); the reader's
Mesh Mode nickname is "MM" to stop colliding with the Meshes output's "M".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
grasshopper/docs/usage.puml: br_gh_export.json -> Surface Read GH JSON, the three
fan-out branches (Untangle Curves; Sphere Caps -> Close Piece -> Spread Pieces; Surface
Place Components), and the Group By Piece join, with notes for the one-file/one-reader
rule and the per-piece {piece} DataTree convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SolveInstance read DA.GetData(22) for socketLength but the component registers only 7
inputs (0-6), so dropping the component on the canvas threw IndexOutOfRangeException
('Input parameter index [22] too high'). It should read index 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spread Pieces took a Mesh tree, so it could not consume Surface Group By Piece's output
(a tree whose branches mix a piece's mesh with its connector Breps). Change the input and
output to a generic geometry tree (IGH_GeometricGoo): each branch's bounding box gives the
piece center, and every item in the branch is translated together, so a piece and its
connectors stay assembled while spreading. Still accepts bare mesh trees (Surface Read GH
JSON / Close Piece). Input/output renamed Meshes -> Geometry (M -> G); GUID unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spread separates one piece per tree branch; if the per-piece tree is flattened upstream
to a single branch, the lone center equals the overall center and every translation is
zero -- so nothing moves regardless of Factor, with no feedback. Emit a warning in that
case pointing at the likely flatten, instead of silently passing the geometry through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s helper

The boolean step is an ordered fold over a list of signed operations, not two buckets:
start from a piece solid and apply each feature in order, +1 union / -1 subtract, each
acting on the running result (order matters). Operations default to subtract, so feeding
only negatives just subtracts them (the short-term need); the same machinery does the
alternating pos/neg/pos/neg case later.

- Python: surface.mesh_boolean_fold(solid, features, signs) via trimesh's exact 'manifold'
  backend; manifold3d added to install_requires (soft-imported). tests/test_booleans.py
  (incl. order-matters).
- Grasshopper: Boolean Piece folds Solid + Features (meshes or Breps, auto-meshed) + signed
  Operations per piece, warns on non-closed solids, reports failed steps. Connectors To
  Features weaves the plug/socket trees into one ordered Features tree + signs (pos,neg per
  connector) so no manual Merge/Weave/sign wiring.

"Feature" in the solid-modeling sense: an ordered additive/subtractive op on a body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
usage.puml now shows Connectors To Features (weave plug/socket trees -> ordered Features +
Operations) and Boolean Piece (fold Solid + Features + signed Operations -> Result), the
Close Piece -> Boolean Piece solid wire, and a note that Boolean Piece is an ordered fold.
Also reflects Spread's Geometry port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mesh.CreateFromBrep returns one mesh per Brep face, so appending a capped cylinder's
lateral + cap meshes left the seams unwelded -> an open cutter -> mesh boolean difference
silently did nothing. Weld coincident vertices (CombineIdentical) so a capped Brep becomes
a closed cutter. Also warn when a feature mesh isn't closed, since an open feature is the
usual reason a boolean appears to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface/Curve Read GH JSON now take a Scale (default 1) applied about the world origin on
import. Scaling the unified vertex set means meshes and curves come out scaled
automatically; the sphere and singularity locations are scaled too (directions stay unit),
so the whole model lands in one consistent, larger frame -- no downstream Scale component,
and connectors still place correctly at the scaled singularities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Features is now optional; an empty feature list is an identity fold, so the Solid is output
unchanged instead of the component returning nothing. Makes it safe to leave Features
unconnected (or feed empty branches) while wiring up the graph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Meshing a Brep cutter (Mesh.CreateFromBrep, one mesh per face) can yield an open mesh --
an uncapped cylinder tube, or a non-conforming seam between the lateral face and the caps
that doesn't weld. An open cutter makes the mesh boolean no-op. After welding, if the
cutter is still not closed, FillHoles() to cap it into a closed solid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connectors are now sized upstream (and the reader's Scale sets the model frame), so Place
Components scaling them by Size (default 0.01) only shrank them. Drop the Size input and
the scale step in moveComponents; connectors are oriented to the singularity direction and
translated to the location at their true size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the "at least one required" error and the "only positive geometry" remark. Any
combination (none through all of plug/socket +/-) is valid -- place whatever is supplied,
leave the rest empty. Positives-only (union bodies, no holes) is a normal workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New "Utility" subcategory alongside Curve/Surface. Centered Closed Cylinder makes a capped
cylinder centered on a plane origin along its Z axis (-Length/2..+Length/2), so it's ready
to use as a connector / boolean cutter (centered to straddle its placement point, closed so
mesh booleans bite) without wiring circle + cylinder + cap each time. Inputs Radius, Length,
Plane (default WorldXY); output a closed Brep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ofloveandhate and others added 14 commits June 20, 2026 08:32
Surface-subcategory helper mapping an integer to a Surface Read GH JSON Mesh Mode string
(0 = auto, 1 = smooth, 2 = raw), so a numeric slider can drive the reader's Mesh Mode
input. Out-of-range indices clamp with a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grasshopper documents: .gh is GH's binary format (no useful diff/merge), .ghx is XML text
(diffable). Prefer saving docs as .ghx for version control.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an integer Sides input. 1 keeps the true round cylinder; 3/4/5/... build a capped,
centered regular-polygon prism (triangle/square/pentagon/...) via an extruded n-gon
profile. Radius is the circumradius. Sides < 1 or == 2 (degenerate) error out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A sibling to Sphere Caps that fills each on-sphere boundary loop with a FLAT fan to the
loop centroid instead of a cap hugging the sphere -- so a Bertini-computed cylinder gets
flat disk ends, and blocky decompositions get faceted flat caps. The sphere still shows the
cut. Cap is built on the piece's own boundary vertices (welds watertight in Close Piece);
Resolution adds concentric linearly-interpolated rings. Verified watertight on whitney/
dingdong.

Factor the shared on-sphere boundary-loop detection and degenerate-skip triangle add out of
Sphere Caps into a Capping helper, used by both cap components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
flat_cap_meshes (fan to each on-sphere loop's centroid), SurfacePiece.flat_caps, and a
flat= flag on SurfacePiece.as_closed_mesh (resolution defaults 4 spherical / 1 flat). The
Rhino-free pipeline now offers both cap styles. Test: whitney pieces close watertight with
flat caps too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- python/docs/tutorials/capping_and_joining.rst: tutorial on the Python pieces ->
  caps -> close -> boolean flow, using Ding Dong. Shows spherical vs flat caps, and
  subtracting a square hole from one piece while adding a slightly smaller square rod to
  the other so they snap together. Rendered figures + the make_images.py generator.
- close_pieces.py: add --flat (flat caps) and let resolution default per cap style.
- join_meshes: fix_normals so a watertight result is a proper "volume" (consistent
  winding); manifold3d booleans require this, and it fixes inverted/negative-volume pieces.
- usage.puml: note Flat Caps as the drop-in alternative to Sphere Caps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a note pointing users without Rhino to the pure-Python pipeline tutorial
(python/docs/tutorials/capping_and_joining.rst) and scripts/close_pieces.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colors piece meshes by a scalar expression in x,y,z evaluated at each vertex (via
Grasshopper's GH_ExpressionParser), normalized over all meshes (or an explicit Domain)
and mapped through a color gradient onto mesh vertex colors. Default blue->red spectrum;
optional Colours stops. Outputs colored meshes, per-vertex Values, and the Domain used.

Rhino meshes carry vertex colors and Spread Pieces preserves them, so this works before or
after spreading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A directional alternative to Spread Pieces: each piece is translated by Factor times the
sum of unit vectors along its incident singularities' connector directions, oriented away
from each singularity (the way the piece slides off its rod). Opposite-side connectors
cancel, so a central hub barely moves while leaf pieces slide out along their rods --
showing how the pieces would assemble. Takes Sing Locations/Directions/On Pieces from the
reader. May replace the radial Spread if it proves out. Validated on nordstrand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ectors)

Spread Pieces used Factor as a proportional multiplier of each piece's offset from the
center, while Spread By Connectors uses Factor as an absolute model-unit distance. Make
Spread Pieces absolute too: move each piece Factor units along its outward unit direction.
Default Factor 1.0. Now F means the same thing in both spreads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ltiplier)

Both Spread Pieces and Spread By Connectors now expose the input as Distance (D) instead
of Factor (F), since it is an absolute model-unit distance rather than a multiplier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion)

Replace the local per-piece sum with a breadth-first traversal of the connector graph
(singularities are edges joining exactly two pieces). Each piece's displacement is its
parent's displacement plus a Distance step along the connecting axis, oriented so the child
slides off its rod away from the parent -- so chains telescope outward, a hub stays put,
cycles are spanning-treed, and each piece moves exactly one step per graph edge regardless
of its other connections. Root defaults to the most-connected piece; a Root input fixes a
chosen piece; disconnected groups are each rooted independently (with a remark). Validated
on nordstrand: hub fixed, all leaves slide out uniformly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ylinder

Reflect the newer components in usage.puml: Color By Function (vertex coloring before
spread), Spread Pieces vs Spread By Connectors (radial vs assembly explosion, Distance),
the Utility Centered Closed Cylinder feeding connectors/features, and Place Components
without the removed Size input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cap winding followed the arbitrary boundary-loop traversal direction, so
roughly half the caps faced inward (toward the sphere center). The welded
solid then had a seam fold where piece and cap disagreed, and UnifyNormals
(seeded from an arbitrary face) could leave the whole piece inside-out.

- SurfaceSphereCaps: CapWindsInward() tests the cone-fan normals against the
  outward radial direction and reverses the loop when inward, so caps are
  outward by construction and agree with the (outward) piece.
- SurfaceClosePiece: after UnifyNormals, flip the solid when IsClosed and
  Mesh.Volume() < 0, guaranteeing outward normals (mirrors trimesh.fix_normals).

Verified on nordstrands_weird_1: all 18 caps (9 pieces x smooth+raw) now
wind outward; previously 9 were flipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

1 participant