The path IR
Two levels, one compiler function between them, and a hard rule about who may
look at which. See ADR 0002 for why, and src/ut_path.scad for the code.
Level 1 — utPath, the spine
An ordered chain of exact curve segments describing ONE strand of centreline.
Sparse (a five-bend run is eleven segments regardless of $fn), exact (arc
length, tangents and bend radii are closed-form), and carrying no roll and no
resolution.
path = ["utpath", segs, closed, opts]
seg = ["L", p0, p1] // straight
| ["A", c, u, v, r, ang] // circular arc
| ["P", pts, tans] // sampled chain, one unit tangent per point
Arcs are parameterised exactly as STEP/IGES axis2_placement_3d:
point(x) = c + r*(cos(ang*x)*u + sin(ang*x)*v)
tangent(x) = -sin(ang*x)*u + cos(ang*x)*v (already unit)
axis = cross(u, v) (the transport axis)
arclen = ang/360 * 2*PI*r
Sweep direction lives in the (u, v) basis, never in the sign of ang, so ang
is always positive. That removes an entire class of tangent-sign bug.
P carries per-sample tangents deliberately: a bare point list reintroduces the
chord-tangent error this IR exists to avoid. Frontends with an analytic
derivative must supply it.
Level 2 — utStations
st = [p, t, n, s] // position, unit tangent, unit roll normal (n·t = 0), arclength
Produced by exactly one function, ut_stations(path, opts). Roll is decided,
resolution is decided, duplicates are gone, closed-loop holonomy is absorbed.
ut_st_mat(st) converts a station to a 4×4 frame — columns (n, cross(t,n), t)
plus the position. No scale, no shear, ever. A tapered run that baked scale
into its frames would hand every attached termination a silently shrunken port.
ut_at(path, s) — a point on the spine, without tessellating
ut_at returns [position, unit tangent] at an arclength, read exactly from
the spine: it walks segments, never stations, so the answer does not depend on
$fa/$fs and does not interpolate between samples. Joints use it for mid-run
landings, which is the case where a branch meets a trunk somewhere along its
length rather than at an end.
On L and A segments it is exact. On a P segment the position is exact on
the chord and the tangent is interpolated along it, so it is exactly the
stored tangent at every sample and second order in between — measured worst case
0.021° anywhere on a 2-turn helix at n=40.
The rule
Frontends produce a spine and never touch frames. Exactly one function lowers spine → stations. The backend consumes stations and never infers, guesses, corrects or re-derives orientation.
Every historical geometry bug in this repository, and both of the bugs found in
reference/gringer/path_extrude.scad, is a violation of that rule.
Invariants
SPINE-1 |
every segment is exactly L, A or P; arc bases are orthonormal |
SPINE-2 |
contiguity — each segment ends where the next begins, to 1e-6 |
SPINE-3 |
tangent continuity everywhere. There is no G0 / mitre case (ADR 0004) |
SPINE-4 |
non-degenerate: lines have length, arcs have 0 < ang ≤ 180 |
SPINE-5 |
no roll. No resolution for L and A — see the caveat below |
SPINE-6 |
a spine is a STRAND, not a graph. Branching lives in the network |
STATION-1 |
no two consecutive positions coincide — the unconditional dedupe |
STATION-2 |
orthonormal frames, right-handed, det +1, no scale or shear |
STATION-3 |
terminal and arc-tangency tangents are EXACT, never smoothed |
STATION-4 |
s[0] = 0, strictly increasing, s[last] = ut_length(path) |
STATION-5 |
bounded turn between consecutive tangents |
STATION-6 |
continuous roll — the frame never flips. Enforced in ut_stations(): the roll step may not exceed the tangent step by more than 45°. Transport measures 0 by construction, a fixed frame 1e-13; a flip measures 84–174, so there is nothing in between to tune against |
STATION-7 |
a closed station list carries no duplicated terminal station |
SPINE-5 and sampled segments — a stated limit
SPINE-5 holds fully for L and A segments: they are exact, and $fa/$fs
decide the station count at compile time.
It does not hold for P. ut_curve bakes its n into the spine, and the
resolution knobs are inert on the result:
| path | $fa=24, $fs=4 |
$fa=0.5, $fs=0.1 |
|---|---|---|
arc-based (ut_polyline, ut_turtle) |
7 stations | 183 stations |
sampled (ut_curve) |
161 | 161 |
This is recorded rather than fixed. Closing it means fitting arcs to the samples
— see spikes/biarc_fitter.scad, which exists and works — and that is deferred
(issue #17). Resolution independence is the one argument that genuinely
justifies building it; the arclength and transport arguments do not survive
measurement.
Closed paths
Reachable through ut_spine(segs, closed = true) only. ut_polyline refuses
them explicitly; ut_turtle and ut_curve have no closed parameter, so
--hardwarnings turns passing one into an error. Wiring closed through the
other frontends raises a real question each — what is a closed turtle program?
— and is deliberately unanswered.
The frame is transported all the way round, the loop holonomy is measured against
the seed, the nearest multiple of 360/symmetry is absorbed, and the remainder is
distributed by arclength. Measured on a four-arc torus: residual 0°.
symmetry comes from the options, not from the profile, and twist is not
required to close
(#34).
A closed tube’s lumen is a sealed internal cavity. CGAL reports Volumes: 3
— outside, material, trapped bore. That is correct topology, not a defect, and it
means the part is useless until something breaches it.
Frame policy
How roll is chosen — frame, normal, twist, and what each asserts — is in
usage/frames.md. The compiler side of it is one fact:
transport across an arc is closed form, a rotation of the roll reference about
the arc’s own axis by the arc’s own sweep angle. That is the strongest argument
for keeping arcs in the spine rather than sampling them (ADR 0002).
Worked example
ut_polyline([[0,0,0],[50,0,0],[50,50,0]], r=15) lowers to:
["utpath",
[ ["L", [0,0,0], [35,0,0] ], // trimmed by inset = r*tan(ang/2) = 15
["A", [35,15,0], [0,-1,0], [1,0,0], 15, 90],
["L", [50,15,0], [50,50,0] ] ],
false, [["frame","transport"], ["twist",0]]]
Verifiable by hand, no rendering:
c + r*u = [35,15,0] + 15*[0,-1,0] = [35,0,0] arc start == leg 1 end
c + r*(cos90*u+sin90*v) = [35,15,0] + 15*[1,0,0] = [50,15,0] arc end == leg 2 start
tangent(0) = v = [1,0,0] == leg 1 direction
tangent(1) = -u = [0,1,0] == leg 2 direction
ut_length(path) = 35 + (90/360)*2*PI*15 + 35 = 93.5619449019 exact