History

The numbered sections first record what the pre-2026 implementation got right, then the defects found after the rewrite. Source comments cite them by number, so the numbers are stable.

The first five were recorded in Phase 0, while the reasoning was fresh, so that the understanding survives even though the code does not. The code itself is in reference/axford/ and nothing under src/ may import it.

1. The fillet arithmetic is correct (reference/axford/curvedPipe.scad:71-105)

ang = anglev(dir1, dir2) is the deflection angle, so the corner’s interior angle is 180 - ang. A circle of radius r inscribed tangent to both legs has tangent length

T = r / tan((180 - ang)/2) = r * cot(90 - ang/2) = r * tan(ang/2)

so inset = r * tan(ang/2) is the distance from the corner vertex back to the arc’s tangent point, and rStart = start + (l1 - inset) * dir1u is that point. preInset / postInset exist because a straight leg is trimmed at both ends — by the previous vertex’s tangent length at its start and this vertex’s at its end — so the drawn run is l1 - preInset - inset. The indexing is consistent: radii is indexed by leg, radii[point] is the bend at points[point+1].

This is the one unambiguously correct piece of the old library and it is carried into src/frontend/ut_polyline.scad verbatim — with the guards it never had (see §3).

2. The seam transport is real parallel transport (reference/axford/half_curvedPipe.scad:19,141)

function rotVec(v, ang, vRef) = v*cos(ang) + cross(vRef,v)*sin(ang) + vRef*dot(vRef,v)*(1-cos(ang));

That is Rodrigues’ rotation formula, correct, in one line. openTubeVec(v1, v2, rtVec) carries the previous station’s seam vector forward by rotating it about that bend’s own binormal by that bend’s own turn angle — discrete parallel transport along a circular arc, i.e. a rotation-minimizing frame.

The geometric problem it solves: a lengthwise-split tube needs a roll reference marking the cut plane. Derive it independently at each station from a fixed world axis and it rotates relative to the tube as the axis turns, so the split plane spirals. Transport fixes that.

The insight that generalises: transport along an arc has a closed form — rotate about the arc’s own axis by the arc’s own sweep angle. Exact, no integration, no accumulated error. This is the single strongest argument for keeping arcs in the IR rather than sampling to points, and it is why utPath has an ["A", c, u, v, r, ang] segment kind.

What was wrong was the plumbing, not the math. OpenSCAD cannot thread state through a for loop, so the scan was hand-unrolled into a nine-deep nested if (lines 167-264, 98 of the file’s 288), capping any path at 9 segments with no warning. The fix is to compute the frame list in a pure recursive function up front and then emit in a single flat loop — which is precisely the “compile the path to a canonical IR” architecture, arrived at from the other direction.

Note also that path_extrude.scad’s cumPreRots is a second, independent hand-rolled solution to the same missing datum, in the same repo. Two independent implementations of “carry a roll reference along the path” is the strongest evidence for what the IR must contain: stations, not points.

3. The three silent failures the guards must catch

All three were confirmed by rendering, not by reading. Each must now assert with a message naming the offending element.

input what happens today why it is invisible
preInset + inset > l1 (fillets overlap a short leg) The straight leg silently vanishes and the two bend tori interpenetrate. Verified: the same path at radii=[20,20] renders genus 1; at radii=[80,80] on a 100 mm leg it renders genus 2 — an extra through-hole. OpenSCAD emits nothing for linear_extrude(height <= 0), with no warning.
180° reversal tan(90) is literally inf, so rStart becomes [nan,nan,nan] and the segment disappears. No guard anywhere.
near-collinear waypoints anglev = acos(dot/(mod*mod)) returns NaN once float error pushes the argument past 1.0. Verified: acos(1.0000001) is nan. NaN propagates silently through inset and every frame.

Fix for the third is global: never use acos for an angle between vectors. Use atan2(norm(cross(a,b)), a*b), which never NaNs.

4. The junction fix already works, in the wrong file

reference/doommeister/Round_Ducting_V1.1.scad:168-228 (middle_tee_n, manifold_n) implements

difference() {
  union()  { sphere + N outer branches }
  union()  { inner sphere + N bores }
}

— the two-pass outer-union-minus-lumen-union fix — working, on real shipping geometry. It is hardcoded to straight radial branches, but it is a reference implementation and it predates the 2023 attempt that failed to build the same thing.

Why the 2023 attempt failed: pipeCurveCutout modelled the lumen as a separate subtraction, so it had to re-derive the bend geometry from scratch, and it called torusSlice_only_inner_pipes() — a module git log -S confirms was never defined in any commit. Making the lumen an inner boundary of the swept profile is what makes the bore pass free: same spine, same stations, same frame, different region.

5. What is being discarded, and why it costs nothing

  • libs/maths.scad — 677 lines, ~130 functions, 3 are reachable (quat, quat_to_mat4, vec4_mult_mat4), all only to implement pipeOrientate. The entire 280-line spline section has zero call sites.
  • libs/vector.scad — 358 lines, 6 reachable (mod, cross, dot, unitv, anglev, orientate). cross and norm are OpenSCAD builtins; the vendored versions were shadowing them. This is also the only GPL file in the old core.
  • pipeOrientate’s 25 lines of quaternion machinery — replaced by building the frame matrix directly from (n, cross(t,n), t).
  • torusSlice / torusSliceF / tube / halfTorusSlice / halfTube — all obsolete once a run is one swept polyhedron rather than a union of primitives.

1035 vendored lines are replaced by roughly 25 in src/ut_math.scad.


The sections above were written before the rewrite. The ones below record defects found after it, moved here from the pages that now describe only the current behaviour. Each has a guard or a test that was watched to fail.

6. A fixed frame’s fallback was a discontinuity

ut_ref_fallback() swaps the reference vector when a run starts parallel to it. That is correct for what it was written for: choosing a starting reference once, at station 0, for a transport frame. Used once there is no discontinuity to create.

frame="fixed" derived every station’s normal independently, so the fallback became a hard switch in the middle of a run. Measured on the old examples/05: as the conduit turned to vertical the seam jumped 174° in a single 6° step. The mesh stayed manifold, CGAL reported Simple: yes, just check, just cgal and all 19 partspec assertions passed, and the C-section was simply wrong from that station on.

The projection is smooth to 1e-13 at every approach angle down to 1° from the axis and only breaks at it — a cliff, not gradual ill-conditioning. So ut_stations() now refuses the genuinely undefined ask rather than substituting a different reference for it: a vertical pipe has no upward-facing side.

Three tests asserted the wrong property.

tests/t_frame.scad and tests/t_split.scad both ended on a vertical leg on purpose, and both asserted that the result was not NaN. It is not NaN — the fallback guarantees that. Finiteness was never the requirement; continuity is, and nothing checked it. STATION-6 was documented from Phase 1 and enforced by nothing.

The third was worse. A caller-supplied frame=<list> parallel to the tangent does produce [nan,nan,nan], and the assert written to catch it — max([for (s = sts) abs(t * n)]) < 1e-9 — passed, reporting 2.22e-16, because OpenSCAD’s max() silently drops nan entries. Never test a frame for validity with max() alone; count the bad stations instead.

All three are now refused at the cause, and each refusal has a guard in tests/guards/ that is watched to fire.

7. Sampled paths: three first-order mistakes

End tangents. The ends used to be first-order — a two-point chord, error about half a sample’s rotation, measured 2.24° at n=160. Since the ends are exactly where port frames come from (STATION-3) and 2.2° across a 20 mm flange is ~0.8 mm of gap, that was the only error in a sampled path large enough to make a physical part wrong. _ut_central now uses a three-point one-sided difference, which is second order like the interior:

n two-point (old) three-point (now)
40 8.949° 0.460°
160 2.237° 0.0141°
320 1.119° 0.0032°

ut_at on a sampled segment. It used to return the tangent at the far end of the chord for every s, which made ut_at(path, 0) report the tangent one sample downstream: 8.94° out at the start of a 2-turn helix at n=40, exactly one sample’s rotation. The function had no test at all, which is how it survived. It has one now.

The turtle’s bend split. ut_turtle splits by replacing the oversized bend in its own command list with two halves and re-entering at the same index. Both slices around it must be guarded against being empty, because OpenSCAD silently reverses a descending range: [0:-1] is [-1, 0], not [], and --hardwarnings says nothing. Unguarded, a bend over 180° as the first or last command spliced undef into the program and aborted with unknown command "undef" — naming the wrong cause entirely. The only case covered had commands on both sides of the bend, so neither boundary was tested.

8. Networks: two measurements taken in the wrong place

NET-5 was an endpoint test. NET-5 compares the two centrelines by sampled path distance, not by endpoints. It was an endpoint test until 2026-08-21, and that miss was total: sliding a liner 70 mm along a jacket so the two shared no endpoint lost 3733.3 mm³ — exactly 100% of the liner — with ut_check_net returning true and CGAL reporting Simple: yes.

Radii where facets were needed. NET-4 compared the ball’s two radii, which treats both spheres as smooth; on two od=20 id=17 runs at $fn=5 the radii said a 3.1 mm wall and the facets said −0.33 mm, a hole through the junction. The same mistake sized the core sphere itself: ut_bore_inradius used to return min(norm(q)) over the bore’s vertices, which ut_ring() places exactly on the nominal circle — so it returned id/2 at every resolution while the real inscribed radius is id/2·cos(180/n). It uses ut_inradius() now, which measures to the edges.

9. Every swept solid was inside-out

ut_mesh_faces() winds its faces counter-clockwise seen from outside, which is what makes ut_volume() positive. polyhedron() wants clockwise. The faces were passed straight through, so every swept solid was inside-out from the first commit of the rewrite.

Nothing on OpenSCAD 2021.01 showed it, because CGAL repairs the orientation in any boolean: every network rendered, measured and passed its contracts. Only a plain ut_tube() gave it away, exporting −5865.23 mm³ for an elbow whose volume is +5865.23, and no gate measured an exported mesh.

On a Manifold-backend OpenSCAD the boolean does not repair it, and difference() subtracts nothing. Measured on 2026.10.15 before the fix:

example 2021.01 2026.10.15
10_tee_wall_landing +13716.23, 1834 triangles −43095.99, 12920 triangles
06_manifold_4into1 +24424.92, 7920 triangles −61109.14, 42400 triangles
08_coaxial_jacket +24106.02, 4980 triangles −174446.16, 9688 triangles

It was found by looking at a picture: the documentation images rendered on the newer version showed the open ends of all three networks capped. ut_sweep() now reverses the faces where it emits them, and just check measures the sign of every exported mesh. After the fix the two versions agree on all three.


This site uses Just the Docs, a documentation theme for Jekyll.