Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.pyc
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"python-envs.defaultEnvManager": "ms-python.python:conda",
"python-envs.defaultPackageManager": "ms-python.python:conda"
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.defaultPackageManager": "ms-python.python:pip"
}
118 changes: 118 additions & 0 deletions DOCS-insert-v4-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# openUC2 V4 module inserts — design description & measured interface spec

*Prepared for docs.openuc2.com (extends https://docs.openuc2.com/dev/hw/module-inserts/v4/).
Every number below was extracted from the Inventor master models through the
COM API and cross-checked against exact STEP sections of the released,
injection-molded parts. Sources: `MAS - 2003 - Master Insert - B.ipt`,
`MAS - 2013 - Square Inserts - V04.ipt`, released parts PRT-2123 (MASLCK)
and PRT-2027 (INSLEND43F-50).*

## 1. System concept

The openUC2 toolbox is built on a **50 mm cube grid** (`Grid = 50 mm`).
Hollow injection-molded cubes snap onto baseplates; **square inserts** slide
into the cubes and carry the optics. Because cubes, baseplates and the
generic inserts are injection molded, the mechanical interface is highly
reproducible — every custom holder only has to match the insert interface,
not the cube itself. Custom holders are then either 3D-printed inner parts
clamped by molded inserts, or (with the parametric CAD below) complete
insert-shaped parts.

## 2. The square-insert interface (common to all V4 inserts)

Top view: a square envelope with stepped corners and four flexure springs.

| feature | value | Inventor parameter |
| --- | --- | --- |
| envelope (square) | 49.4 × 49.4 mm | `Grid − 0.6` |
| shoulder width (slides in the cube tracks) | 33.9 mm (lens inserts) / 33.8 mm (master insert) | `CubeClearWidth − 0.1 / − 0.2` |
| 45° corner flats, across corners | 53.54 mm / 53.34 mm | `CubeClearDiagonalV04 − 0.2 / − 0.4` |
| corner-edge fillet | r 0.4 / r 0.5 | — |
| outer chamfer on all top/bottom outline edges | 0.4 × 45° | `OuterChamfer` |
| plate thickness | 4.0 mm (master insert), 17 mm (lens insert), variant-specific | `HightMasterInsert`, `d167`, … |

**Corner steps.** Each corner is cut back to the 45° flat (clears the cube's
corner posts) with a ledge back to the shoulder plane. On the master insert
the two ledges on the ±X sides additionally carry a **raised rib** (crest
0.8586 mm proud of the ledge, flat over z ±0.14, 45° flanks ending at
z ±1.0, plan end rounded r 0.5): it rides in the center groove of the cube
track and gives the 4 mm plate its lateral seating.

**Flexure springs.** Two per side on two opposite sides (±Y), molded into
the plate by a slot cut. Each spring is a ~7°-tapered finger whose tip
carries a rounded hook nose (r 0.5). In the **sliding** variants the nose
tip stays ≈ 0.1 mm below the 49.4 envelope: the springs only preload the
insert against the cube track so it can be repositioned continuously along
the optical axis (focusing). The **locking** variants add a tongue bump
(a suppressed feature pair in the master model) that engages one of the
**7 notches** molded into each cube side, giving discrete, repeatable
positions perpendicular to the optical axis.

## 3. The master insert (MAS-2003 → PRT-2100 MASINS / PRT-2123 MASLCK)

A 4 mm plate on the interface above, with a conic center opening:

- **Cone**: Ø40.0 at the bottom face → Ø38.876 at the top face
(`InsertDiam = 40`, wall at **82°** to the face), both rims chamfered
0.2 mm equal-leg.
- **8 nose grooves** ("teeth") every **45°** (phase 22.5°): each groove is
the full revolution of a Ø1.6 cylinder about an axis parallel to the cone
wall, inset 0.3 mm perpendicular from it, ending in an **R 1.3 spherical
pocket** at the top face (the visible dimples). Groove rims are blended
r 0.5.
- **Base holder** (the round carrier all round inserts derive from): the
complementary conic disk, offset **+0.05 mm** (`OffsetDiameterBaseHolder`)
for snap interference, with 8 matching noses. Result: any round insert
clicks into the master insert in **45° indexed orientations**, or is held
purely by friction between the teeth.
- **Self-mating screw pattern**: at (±21.4, ±13.6) each half carries one
diagonal pair of Ø1.9 thread-forming pilot holes (3.5 deep from the top,
45° relief cone breaking through the bottom) and one diagonal pair of
Ø2.8 clearance holes with Ø5.0 × 2.0 head counterbores. Two *identical*
halves screwed face-to-face therefore clamp an optic between them —
one mold, no left/right parts (screws: TP 2.5 × 6 Torx).

## 4. The square lens insert (MAS-2013 → PRT-2027 INSLEND family)

A 17 mm tall block on the same interface whose interior is the lens cavity
(all Ø parametric in `LensDiam`, INSLEND43F-50 shown):

| z (mid-plane = 0) | feature |
| --- | --- |
| +8.5 … +8.1 | top face, 0.4 chamfer into the bore |
| +8.1 … +2.9 | internal clamping thread: root Ø`LensDiam+2.2` (45.2), crest Ø`LensDiam+0.8` (43.8), **pitch 1.7 mm** (`ThreadPitch`), 2 turns, 45° trapezoidal flanks |
| +2.9 … +2.0 | 45° lead-in cone |
| +2.0 … −6.3 | lens pocket Ø`LensDiam+0.4` (43.4) |
| −6.3 | lens seat shoulder |
| −6.7 … −8.1 | clear aperture Ø`LensDiam−4` (39.0), chamfered both ends |

The **knurled pre-screw ring** (Ø49.6 = `LensDiam+6.6`, height
`3·ThreadPitch+0.8`, 12-flute knurl) screws into the thread and clamps the
lens onto the seat — lens exchange without tools, preload without glue.
The naming encodes the optic: `INSLEND43F-50` = insert, lens end,
Ø43 mm, f = 50 mm.

## 5. Parametric CAD layer (this repo + optikit-core)

- `uc2v4/` (CadQuery): `build_master_insert()` and `build_lens_insert()`
reproduce PRT-2123 / PRT-2027 from the extracted parameters and are
verified against the released STEPs by `build_uc2v4.py`
(mesh deviation p99 ≤ 0.1 mm; the only knowing deviations are the
omitted engraved labels, the notch rim blends and thread run-out ends).
Everything is a dataclass parameter: grid, shoulder, thickness, springs
on/off, notch count/phase, hole pattern, `lens_diam`, thread…
- `optikit-core/generators/square_insert_v4.py`: the same lens insert as a
standalone WP-10 T3 generator (`openuc2.tpl.square_insert_v4`), so
optikit can generate correct-interface holders for arbitrary round optics
directly inside cube slots.

### Deliberate omissions of the CAD reconstruction

- engraved label text (0.2 mm deep top-face labels on the master insert,
0.7 mm deep side-face labels on the lens insert; ≈ 60 mm³),
- r 0.5 blend fillets around the notch grooves and the 0.2 chamfer wrap at
their rims,
- thread run-out feathering at the two coil ends,
- sub-0.1 mm blend nuances where the corner rib meets the side face.

None of these affect the cube-facing interface or the optic seating.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

Programmatic generation of an **openUC2 cube insert** and **component cutouts** using **CadQuery**.

> **New: `uc2v4/` — exact parametric reconstructions of the molded V4 inserts.**
> Built from the Inventor master models (COM extraction, see
> [`extracted/README.md`](extracted/README.md)) and verified against the
> released STEP geometry:
>
> - `uc2v4.build_master_insert()` → PRT - 2123 - MASLCK - V04 - B
> (4 mm master-insert plate: springs, corner ribs, 82° cone opening,
> 8×45° nose grooves, self-mating screw pattern)
> - `uc2v4.build_lens_insert()` → PRT - 2027 - INSLEND43F-50 - V04
> (17 mm lens insert: parametric `lens_diam`, pocket/seat/aperture,
> 2-turn pitch-1.7 clamping thread for the pre-screw ring)
>
> ```bash
> uv run --with cadquery --with trimesh --with rtree --with scipy python build_uc2v4.py
> ```
> writes STEP/STL into `generated/` and prints the mesh-deviation report
> against `extracted/*.step`. Design write-up: [`DOCS-insert-v4-design.md`](DOCS-insert-v4-design.md).
> The same lens insert is available to optikit-core as the standalone T3
> generator `openuc2.tpl.square_insert_v4`.
>
> The scripts below predate the extraction and approximate the outline from
> drawings — still useful as simple starting points, but `uc2v4/` is the
> measured reference.

![](./IMAGES/insert.png)

*Python-generated generic insert that can e.g. host a lens or something*
Expand Down
125 changes: 125 additions & 0 deletions build_uc2v4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Build the parametric uc2v4 inserts and verify them against the Inventor
ground truth STEP exports in ./extracted/.

Usage:
uv run --with cadquery --with trimesh --with rtree --with scipy \
python build_uc2v4.py [--no-verify]

The Inventor-written STEP files silently break OCC booleans (tolerance
abuse), so verification happens in the mesh domain: both b-reps are
tessellated and compared by surface-deviation sampling in both directions.
Known, intentional model omissions (engraved label text, notch rim fillets)
show up as attributable deviation clusters and are listed per part.
"""

from __future__ import annotations

import sys
import time
from pathlib import Path

import cadquery as cq
import numpy as np
import trimesh

from uc2v4 import build_lens_insert, build_master_insert

HERE = Path(__file__).parent
OUT = HERE / "generated"
GT = {
"uc2v4_master_insert": HERE / "extracted" / "PRT_-_2123_-_MASLCK_-_V04_-_B.step",
"uc2v4_lens_insert": HERE / "extracted" / "PRT_-_2027_-_INSLEND43F-50_-_V04.step",
}

SAMPLES = 60000
TESS_TOL = 0.01


def fused_solid(wp: cq.Workplane) -> cq.Solid:
solids = wp.solids().vals()
s = solids[0]
for extra in solids[1:]:
s = s.fuse(extra)
return s


def to_mesh(shape: cq.Shape) -> trimesh.Trimesh:
verts, tris = shape.tessellate(TESS_TOL)
m = trimesh.Trimesh(vertices=[v.toTuple() for v in verts], faces=tris)
m.merge_vertices()
return m


def deviation(src: trimesh.Trimesh, dst: trimesh.Trimesh, n: int):
pts, _ = trimesh.sample.sample_surface(src, n)
_, dist, _ = trimesh.proximity.closest_point(dst, pts)
return pts, dist


def cluster_report(pts, dist, threshold: float, label: str):
"""Group offending samples into coarse spatial clusters and print them."""
bad = dist > threshold
if not bad.any():
print(f" {label}: no deviations > {threshold} mm")
return
p, d = pts[bad], dist[bad]
cells = np.round(p / 4.0).astype(int) # 4 mm grid
seen = {}
for c, (pt, dv) in zip(map(tuple, cells), zip(p, d)):
best = seen.get(c)
if best is None or dv > best[1]:
seen[c] = (pt, dv)
# merge to the N worst distinct cells
worst = sorted(seen.values(), key=lambda t: -t[1])[:6]
print(f" {label}: {bad.sum()}/{len(dist)} samples > {threshold} mm, "
f"max {d.max():.3f} mm; worst spots:")
for pt, dv in worst:
print(f" {dv:6.3f} mm at ({pt[0]:7.2f}, {pt[1]:7.2f}, {pt[2]:7.2f})")


def report(name: str, mine: cq.Workplane, gt_path: Path) -> None:
a = fused_solid(mine)
b = fused_solid(cq.importers.importStep(str(gt_path)))

va, vb = a.Volume(), b.Volume()
aa, ab = a.Area(), b.Area()
print(f" volume mine {va:10.2f} gt {vb:10.2f} delta {va - vb:+8.2f} mm^3"
f" ({(va - vb) / vb * 100:+.3f} %)")
print(f" area mine {aa:10.2f} gt {ab:10.2f} delta {aa - ab:+8.2f} mm^2")

t0 = time.time()
ma, mb = to_mesh(a), to_mesh(b)
for label, m in (("mine", ma), ("gt", mb)):
lo, hi = m.bounds
print(f" bbox {label:4s} [{lo[0]:.3f},{lo[1]:.3f},{lo[2]:.3f}]"
f" .. [{hi[0]:.3f},{hi[1]:.3f},{hi[2]:.3f}]")

pts_a, d_a = deviation(ma, mb, SAMPLES) # my surface vs gt
pts_b, d_b = deviation(mb, ma, SAMPLES) # gt surface vs mine
print(f" deviation mine->gt: mean {d_a.mean():.4f} p99 {np.percentile(d_a, 99):.4f}"
f" max {d_a.max():.3f} mm")
print(f" deviation gt->mine: mean {d_b.mean():.4f} p99 {np.percentile(d_b, 99):.4f}"
f" max {d_b.max():.3f} mm ({time.time() - t0:.0f}s)")
cluster_report(pts_a, d_a, 0.1, "mine->gt > 0.1")
cluster_report(pts_b, d_b, 0.1, "gt->mine > 0.1")


def main() -> None:
OUT.mkdir(exist_ok=True)
verify = "--no-verify" not in sys.argv

for name, builder in (("uc2v4_master_insert", build_master_insert),
("uc2v4_lens_insert", build_lens_insert)):
print(f"=== {name}")
t0 = time.time()
part = builder()
print(f" built in {time.time() - t0:.1f}s")
cq.exporters.export(part, str(OUT / f"{name}.step"))
cq.exporters.export(part, str(OUT / f"{name}.stl"), tolerance=0.02)
print(f" wrote {OUT / (name + '.step')}")
if verify and GT[name].exists():
report(name, part, GT[name])


if __name__ == "__main__":
main()
Loading