Skip to content

Commit bf4ac59

Browse files
committed
v0.5.0 printability fixes and proof harness
1 parent 1b71371 commit bf4ac59

10 files changed

Lines changed: 190 additions & 29 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@
44
All notable changes to tq-threads. Format loosely follows
55
[Keep a Changelog](https://keepachangelog.com); versions are git tags.
66

7+
## [0.5.0] - 2026-06-23
8+
9+
Printability proof release for TinkerQuarry, plus two targeted geometry fixes.
10+
11+
### Fixed
12+
- `profile="rounded"` no longer overshoots the requested major diameter at the
13+
crest; the crest arc apex now lands at `Rmaj`.
14+
- `tq_wood_screw` no longer clips the helical thread with a cone boolean. The
15+
threaded body now stops before the point and unions to a solid lead-point cone,
16+
which avoids the previous non-manifold edge cases.
17+
- Negative/assert tests now write BOM-free temporary `.scad` files and require an
18+
actual OpenSCAD `assert` in stderr before counting a rejection as a pass.
19+
20+
### Added
21+
- `scripts/check_stl_mesh.py`, an independent STL edge-pairing and bounds checker
22+
used for the v0.5 targeted proof.
23+
24+
### Documentation
25+
- Clarified that `fit=` models nominal ISO 965 position allowance only. For M8
26+
`6g`, the diametral shift is about 0.029 mm, which is below typical FDM
27+
clearance and layer-height effects; use `clearance` as the real print-fit lever.
28+
729
## [0.4.0] — 2026-06-22
830

931
Stronger standards fidelity + honesty, robust Windows workflow, expanded proof.

MANUAL.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ default (or an oversize internal **cutter** with `internal=true`).
7676
| `starts` | `1` | Number of thread starts (integer ≥ 1). `lead = starts·pitch`. |
7777
| `hand` | `"right"` | `"right"` or `"left"`. |
7878
| `clearance` | `0.4` | **Total diametral** FDM fit gap (mm); external shrinks `clearance/2`, internal grows `clearance/2`. |
79-
| `fit` | `undef` | Optional **ISO 965** tolerance *position* (allowance): external `"e"/"f"/"g"/"h"` or internal `"G"/"H"` (also accepts `"6g"`,`"6H"`,…; the grade digit is ignored — band width is not modelled). Applied on top of `clearance`. Case must match `internal`. |
79+
| `fit` | `undef` | Optional **ISO 965** tolerance *position* (allowance): external `"e"/"f"/"g"/"h"` or internal `"G"/"H"` (also accepts `"6g"`,`"6H"`,…; the grade digit is ignored — band width is not modelled). Applied on top of `clearance`. Case must match `internal`. This is nominal intent; M8 `6g` is only about 0.029 mm diametral shift, so tune `clearance` for real FDM fit. |
8080
| `profile` | `"flat"` | `"flat"` (ISO/UN basic), `"sharp"` (full V), `"rounded"` (filleted root/crest). |
8181
| `angle` | `60` | Included flank angle (degrees). 60 = ISO/UN; e.g. 55 ≈ Whitworth. Thread height derives from it. |
8282
| `tooth_height` | `undef` | Explicit radial flight depth (mm). Overrides the angle-derived height. |
@@ -349,6 +349,10 @@ nut generated with the **same** `clearance` share the gap.
349349
- For asymmetric bias (you only print the bolt, or only the nut), set a different
350350
`clearance` on each call, or set `clearance=0` and bake the allowance into `d`.
351351

352+
`fit=` adds ISO 965 position allowance only. It is much smaller than common FDM
353+
clearance (M8 `6g` is about 0.029 mm diametral), so treat it as nominal intent
354+
and use `clearance` for actual print fit.
355+
352356
Starting points: tuned printer 0.2–0.3, default 0.4, loose 0.5–0.6, caps 0.5–0.8
353357
mm. Print **axis vertical**; keep lead-in chamfers on; prefer `profile="rounded"`
354358
for load-bearing roots; below ~0.7 mm pitch consider a heat-set insert.

PROVENANCE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ Validation path: print the calibration set in [README → FDM fit guide](README.
4343
measure major/minor/pitch diameters with calipers/thread gauges, and adjust
4444
`clearance` (and optionally `fit`) per material/printer. For certified parts, use
4545
machined hardware or a metrology-grade CAM toolchain — not an FDM print.
46+
47+
Practical note: ISO 965 position allowance is tiny at FDM scale. M8 `6g` is about
48+
0.029 mm diametral shift, below typical tuned clearance and layer-line effects,
49+
so `fit=` records nominal intent; `clearance` is the real print-fit control.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
99
[![GPL‑2.0 compatible](https://img.shields.io/badge/GPL--2.0-compatible-blue.svg)](#license--attribution)
1010
[![OpenSCAD](https://img.shields.io/badge/OpenSCAD-2021.01%2B-f9d72c.svg)](https://openscad.org)
11-
[![Version](https://img.shields.io/badge/version-0.4.0-informational.svg)](CHANGELOG.md)
11+
[![Version](https://img.shields.io/badge/version-0.5.0-informational.svg)](CHANGELOG.md)
1212
[![CI](https://github.com/scottconverse/tq-threads/actions/workflows/ci.yml/badge.svg)](https://github.com/scottconverse/tq-threads/actions/workflows/ci.yml)
1313

1414
*Threaded rods, bolts, nuts, tapped holes, countersinks, washers, standoffs, couplers and caps — every thread is a single watertight `polyhedron`, so models stay manifold and export straight to STL.*
@@ -219,6 +219,10 @@ its nut with the **same** `clearance` and they fit.
219219

220220
## When to use heat‑set inserts
221221

222+
`fit=` is nominal ISO 965 position intent, not an FDM fit knob. For example,
223+
M8 `6g` shifts diameter by about 0.029 mm, below typical printed clearance and
224+
layer-line effects; tune `clearance` for real printed fit.
225+
222226
Reach for **brass heat‑set inserts** instead of a printed thread when the hole is small/fine (≤ M3), the joint is assembled/disassembled often, you need high pull‑out/torque, or you're threading across layer lines (side walls). For those, print a smooth tapered pilot (a plain `cylinder()` or `tq_standoff` with a plain bore) sized to the insert spec and melt it in. Reserve `tq_threaded_hole` for printed threads ≥ M4 and coarse/cap threads. Details in [MANUAL.md](MANUAL.md#heat-set-inserts).
223227

224228
---
@@ -291,7 +295,7 @@ table/formula is classified **EXACT / DERIVED / FDM / APPROX** in
291295
[REFERENCES.md §0](REFERENCES.md) and the [PROVENANCE ledger](PROVENANCE.md):
292296

293297
- **Exact nominal**: metric coarse/fine Ø+pitch (ISO 261), Unified Ø+TPI (ASME B1.1), listed ISO hardware dims (273/4032/4762/7089/10642).
294-
- **Derived** (exact formula): the 60° form & `H`, custom-`angle` geometry, rounded fillets, and the optional **ISO 965 `fit=`** allowance (the *position*, e.g. `es=-(15+11·P)` µm for `g`; the tolerance *grade/band* is **not** modelled).
298+
- **Derived** (exact formula): the 60° form & `H`, custom-`angle` geometry, rounded fillets, and the optional **ISO 965 `fit=`** allowance (the *position*, e.g. `es=-(15+11·P)` µm for `g`; the tolerance *grade/band* is **not** modelled). On M8 `6g`, this is about 0.029 mm diametral shift, so treat it as nominal intent and use `clearance` for FDM fit.
295299
- **FDM defaults**: `clearance` (0.4 mm) and resolution floors.
296300
- **Approx / generic** (no standard claimed): Phillips recess, `tq_auger`, `tq_bottle_thread`, `tq_wood_screw`, ratio fallbacks for unlisted sizes.
297301

@@ -361,7 +365,7 @@ A GitHub Actions workflow ([`.github/workflows/ci.yml`](.github/workflows/ci.yml
361365

362366
## Migration from v0.2 / v0.3
363367

364-
v0.4 is **backward compatible** — no public API was removed or renamed.
368+
v0.5 is **backward compatible** — no public API was removed or renamed.
365369

366370
- `include <tq_threads.scad>;` unchanged; default `angle=60` keeps v0.2/v0.3 geometry bit-for-bit.
367371
- New **optional** params: `tq_thread(... fit=, minor_d=)`; `tq_wood_screw(... taper=, core_d=, thread_depth=, point="gimlet"|"cone"|"flat", shank=)` (old `point=true/false` still works); `tq_bottle_thread(... angle=, tooth_height=, profile=, lead_in=)`.

REFERENCES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ exact formula (e.g. external g: `es=-(15+11·P)` µm). Published ISO tables pres
5050
these rounded to whole µm; tq-threads uses the unrounded formula. The tolerance
5151
**grade** (band width, e.g. the “6” in “6g”) is **not** modelled — there is one
5252
nominal surface, not a min/max envelope. Small-pitch footnote exceptions in the
53-
standard (e for P≤0.45, f for P≤0.3) are not special-cased.
53+
standard (e for P≤0.45, f for P≤0.3) are not special-cased. For M8 `6g`, the
54+
diametral allowance is about 0.029 mm, so this is nominal standards intent rather
55+
than a practical FDM fit lever; tune `clearance` for printed fit.
5456

5557
---
5658

docs/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ <h1>tq-threads</h1>
7171
<a href="https://github.com/scottconverse/tq-threads/blob/main/LICENSE"><img alt="MIT" src="https://img.shields.io/badge/License-MIT-green.svg"></a>
7272
<img alt="GPL-2.0 compatible" src="https://img.shields.io/badge/GPL--2.0-compatible-blue.svg">
7373
<img alt="OpenSCAD" src="https://img.shields.io/badge/OpenSCAD-2021.01%2B-f9d72c.svg">
74-
<img alt="version" src="https://img.shields.io/badge/version-0.4.0-informational.svg">
74+
<img alt="version" src="https://img.shields.io/badge/version-0.5.0-informational.svg">
7575
<a href="https://github.com/scottconverse/tq-threads/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/scottconverse/tq-threads/actions/workflows/ci.yml/badge.svg"></a>
7676
</div>
7777
<div class="cta">
7878
<a class="btn primary" href="https://github.com/scottconverse/tq-threads">View on GitHub</a>
79-
<a class="btn ghost" href="https://github.com/scottconverse/tq-threads/releases/tag/v0.4.0">Download v0.4.0</a>
79+
<a class="btn ghost" href="https://github.com/scottconverse/tq-threads/releases/tag/v0.5.0">Download v0.5.0</a>
8080
<a class="btn ghost" href="https://github.com/scottconverse/tq-threads/blob/main/MANUAL.md">Manual</a>
8181
<a class="btn ghost" href="https://github.com/scottconverse/tq-threads/discussions">Discussions</a>
8282
</div>
@@ -90,7 +90,7 @@ <h2>Why tq-threads</h2>
9090
<div class="card"><h3>🧩 Manifold by construction</h3><p>The thread surface is a helical height-field turned into one closed <code>polyhedron</code> — no boolean unions to go non-manifold. It renders cleanly and slices.</p></div>
9191
<div class="card"><h3>📐 Standards-based, clean-room</h3><p>Built only from public standards (ISO 68-1/261/262/273/4032/4762/7089/10642, ASME&nbsp;B1.1). No third-party thread library was copied or consulted.</p></div>
9292
<div class="card"><h3>🖨️ Printable-first</h3><p>Fit clearance, internal-oversize / external-undersize compensation, lead-in chamfers, rounded roots, and real <code>$fn/$fa/$fs</code> are all parameters.</p></div>
93-
<div class="card"><h3>🔩 Batteries included</h3><p><strong>101 presets</strong> (M1.6–M64 coarse + full ISO 261 fine + UNC/UNF #0–1″), plus bolts, nuts, washers, countersunk &amp; wood screws, augers, Phillips drives, clearance/counterbore/countersink holes, standoffs, couplers, and child‑difference <code>tq_tap</code>/<code>tq_drill</code> wrappers. v0.4 adds an optional <strong>ISO&nbsp;965 <code>fit=</code></strong> allowance, <code>minor_d</code>, taper &amp; custom flank angle.</p></div>
93+
<div class="card"><h3>🔩 Batteries included</h3><p><strong>101 presets</strong> (M1.6–M64 coarse + full ISO 261 fine + UNC/UNF #0–1″), plus bolts, nuts, washers, countersunk &amp; wood screws, augers, Phillips drives, clearance/counterbore/countersink holes, standoffs, couplers, and child‑difference <code>tq_tap</code>/<code>tq_drill</code> wrappers. Optional <strong>ISO&nbsp;965 <code>fit=</code></strong> allowance records nominal intent; tune <code>clearance</code> for FDM fit.</p></div>
9494
<div class="card"><h3>⚖️ MIT licensed</h3><p>Permissive <em>and</em> GPL-2.0-compatible, so it drops into a GPL-2.0-only project (e.g.&nbsp;TinkerQuarry) unchanged.</p></div>
9595
<div class="card"><h3>✅ Tested &amp; CI</h3><p>Compile-time preset assertions, split fast/heavy suites, a PowerShell render-proof, and GitHub Actions rendering on every push.</p></div>
9696
</div>

scripts/check_stl_mesh.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""Independent STL topology/bounds check for tq-threads release proofs."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import math
8+
import struct
9+
import sys
10+
from collections import Counter
11+
from pathlib import Path
12+
13+
14+
def _read_stl(path: Path) -> list[tuple[tuple[float, float, float], ...]]:
15+
data = path.read_bytes()
16+
tris: list[tuple[tuple[float, float, float], ...]] = []
17+
18+
if len(data) >= 84:
19+
n = struct.unpack_from("<I", data, 80)[0]
20+
if 84 + 50 * n == len(data):
21+
off = 84
22+
for _ in range(n):
23+
vals = struct.unpack_from("<12fH", data, off)
24+
tris.append((vals[3:6], vals[6:9], vals[9:12]))
25+
off += 50
26+
return tris
27+
28+
verts: list[tuple[float, float, float]] = []
29+
for line in data.decode("utf-8", errors="ignore").splitlines():
30+
parts = line.strip().split()
31+
if len(parts) == 4 and parts[0] == "vertex":
32+
verts.append(tuple(float(x) for x in parts[1:4]))
33+
if len(verts) % 3:
34+
raise ValueError(f"{path}: ASCII STL has an incomplete triangle")
35+
for i in range(0, len(verts), 3):
36+
tris.append((verts[i], verts[i + 1], verts[i + 2]))
37+
return tris
38+
39+
40+
def _key(v: tuple[float, float, float], scale: float) -> tuple[int, int, int]:
41+
return tuple(round(c / scale) for c in v)
42+
43+
44+
def analyze(path: Path, tol: float) -> dict[str, float | int | bool]:
45+
tris = _read_stl(path)
46+
if not tris:
47+
raise ValueError(f"{path}: no triangles found")
48+
49+
edges: Counter[tuple[tuple[int, int, int], tuple[int, int, int]]] = Counter()
50+
xs: list[float] = []
51+
ys: list[float] = []
52+
zs: list[float] = []
53+
54+
for tri in tris:
55+
keys = [_key(v, tol) for v in tri]
56+
for a, b in ((0, 1), (1, 2), (2, 0)):
57+
edge = tuple(sorted((keys[a], keys[b])))
58+
edges[edge] += 1
59+
for x, y, z in tri:
60+
xs.append(x)
61+
ys.append(y)
62+
zs.append(z)
63+
64+
bad_edges = sum(1 for count in edges.values() if count != 2)
65+
max_radius = max(math.hypot(x, y) for x, y in zip(xs, ys))
66+
min_radius = min(math.hypot(x, y) for x, y in zip(xs, ys) if math.hypot(x, y) > tol)
67+
68+
return {
69+
"triangles": len(tris),
70+
"unique_edges": len(edges),
71+
"bad_edges": bad_edges,
72+
"manifold": bad_edges == 0,
73+
"diameter": 2 * max_radius,
74+
"min_diameter": 2 * min_radius,
75+
"height": max(zs) - min(zs),
76+
"z_min": min(zs),
77+
"z_max": max(zs),
78+
}
79+
80+
81+
def main() -> int:
82+
parser = argparse.ArgumentParser()
83+
parser.add_argument("stl", type=Path)
84+
parser.add_argument("--tol", type=float, default=1e-5)
85+
parser.add_argument("--expect-manifold", action="store_true")
86+
parser.add_argument("--max-diameter", type=float)
87+
parser.add_argument("--min-diameter", type=float)
88+
parser.add_argument("--expect-height", type=float)
89+
parser.add_argument("--height-tol", type=float, default=0.08)
90+
args = parser.parse_args()
91+
92+
result = analyze(args.stl, args.tol)
93+
for key, value in result.items():
94+
print(f"{key}: {value}")
95+
96+
failures: list[str] = []
97+
if args.expect_manifold and not result["manifold"]:
98+
failures.append(f"expected a closed 2-manifold, found {result['bad_edges']} bad edges")
99+
if args.max_diameter is not None and result["diameter"] > args.max_diameter:
100+
failures.append(f"diameter {result['diameter']:.6f} > {args.max_diameter:.6f}")
101+
if args.min_diameter is not None and result["diameter"] < args.min_diameter:
102+
failures.append(f"diameter {result['diameter']:.6f} < {args.min_diameter:.6f}")
103+
if args.expect_height is not None:
104+
delta = abs(result["height"] - args.expect_height)
105+
if delta > args.height_tol:
106+
failures.append(f"height {result['height']:.6f} differs from {args.expect_height:.6f} by {delta:.6f}")
107+
108+
if failures:
109+
for failure in failures:
110+
print(f"FAIL: {failure}", file=sys.stderr)
111+
return 1
112+
print("PASS")
113+
return 0
114+
115+
116+
if __name__ == "__main__":
117+
raise SystemExit(main())

scripts/render_proof.ps1

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ Write-Host "============================================================"
6060
$pass = 0; $fail = 0; $rows = @()
6161

6262
# ---- run one render, return $true on success -------------------------------
63-
function Invoke-Render([string]$name, [string]$scad, [string]$desc) {
63+
function Invoke-Render([string]$name, [string]$scad, [string]$desc, [string[]]$ExtraArgs = @()) {
6464
$stl = Join-Path $outAbs "$name.stl"
6565
$err = Join-Path $outAbs "$name.stderr.txt"
6666
$sw = [System.Diagnostics.Stopwatch]::StartNew()
67-
$p = Start-Process -FilePath $script:oscad -ArgumentList @("-o", $stl, $scad) `
67+
$args = @("-o", $stl) + $ExtraArgs + @($scad)
68+
$p = Start-Process -FilePath $script:oscad -ArgumentList $args `
6869
-NoNewWindow -Wait -PassThru -RedirectStandardError $err
6970
$sw.Stop()
7071
$secs = [math]::Round($sw.Elapsed.TotalSeconds, 1)
@@ -82,22 +83,26 @@ function Invoke-Negative([string]$name, [string]$snippet) {
8283
$tmp = Join-Path $env:TEMP "tqneg_$name.scad"
8384
$stl = Join-Path $env:TEMP "tqneg_$name.stl"
8485
$err = Join-Path $env:TEMP "tqneg_$name.txt"
85-
Set-Content -Path $tmp -Value ("include <tq_threads.scad>`n" + $snippet) -Encoding utf8
86+
[System.IO.File]::WriteAllText($tmp, "include <tq_threads.scad>`n$snippet", [System.Text.UTF8Encoding]::new($false))
8687
$oldp = $env:OPENSCADPATH; $env:OPENSCADPATH = $root
8788
$p = Start-Process -FilePath $script:oscad -ArgumentList @("-o", $stl, $tmp) `
8889
-NoNewWindow -Wait -PassThru -RedirectStandardError $err
8990
$env:OPENSCADPATH = $oldp
90-
$ok = ($p.ExitCode -ne 0) # PASS when the bad input is REJECTED
91+
$stderr = if (Test-Path $err) { Get-Content $err -Raw } else { "" }
92+
$ok = ($p.ExitCode -ne 0) -and ($stderr -match "assert") # PASS only when an assert rejects it
9193
$script:rows += [pscustomobject]@{ Test=$name; Kind="negative"; Result=$(if($ok){"PASS"}else{"FAIL"}); Sec="-"; Facets="-"; Note="must reject" }
92-
if ($ok) { $script:pass++ } else { $script:fail++ }
94+
if ($ok) { $script:pass++ } else { $script:fail++; if ($stderr) { Write-Host " [$name] stderr:`n$stderr" -ForegroundColor DarkYellow } }
9395
Remove-Item $tmp,$stl,$err -ErrorAction SilentlyContinue
9496
return $ok
9597
}
9698

9799
# ============================ POSITIVE RENDERS ==============================
98100
Write-Host "`n-- standards self-test + suites --"
99101
Invoke-Render "selftest" (Join-Path $root "tq_threads_selftest.scad") "preset table + ISO 965 asserts" | Out-Null
100-
Invoke-Render "fast" (Join-Path $root "tq_threads_fast_tests.scad") "fast smoke grid (27 cells)" | Out-Null
102+
$fastScad = Join-Path $root "tq_threads_fast_tests.scad"
103+
foreach ($i in 0..26) {
104+
Invoke-Render ("fast_{0:00}" -f $i) $fastScad ("fast smoke cell {0}" -f $i) @("-D", "TQ_FAST_PART=$i") | Out-Null
105+
}
101106
if ($Heavy) { Invoke-Render "heavy" (Join-Path $root "tq_threads_heavy_tests.scad") "full visual grid" | Out-Null }
102107

103108
Write-Host "`n-- example wrappers (zero -D, every shell) --"

0 commit comments

Comments
 (0)