rhino: review fixes -- mini-racer pinned to 0.12.4 (Rhino 8's Python is 3.9; 0.13+ needs 3.10; fins byte-identical to 0.14.1 on every fixture STL) and CI on 3.9; rerunning deletes the old overhangs and hidden fins (Delete with ignoreModes: a plain one skips locked layers); a fin mesh Rhino refuses is named in the report; Brep meshes in double precision, min edge in mm; custom / no units refused; Draw meshes the part once per session; smoke checks compare after the weld, units by piece boxes, a surface runs

This commit is contained in:
matthewtrahan authored and Matthew Trahan committed 2026-10-18 15:34:39 -05:00
1 parent 68c5f271fe
commit a5a0866421
8 files changed
+77 -35

No files matched your search

+2 -2
View File
@@ -226,12 +226,12 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.9' # Rhino 8's CPython
- uses: actions/setup-node@v4 # npx fetches the pinned esbuild (plugins/shared/bundle.py)
with:
node-version: '22'
- name: Install test dependencies
run: pip install pytest numpy mini-racer==0.14.1
run: pip install pytest numpy mini-racer==0.12.4 # the commands' # r: pin (0.13+ needs 3.10)
- name: Test
run: python -m pytest -q plugins/rhino/tests/
- name: Stage the project
+4 -2
View File
@@ -38,14 +38,16 @@ From a `.yak` file (until it's on the Package Manager): drag it onto Rhino's win
(Windows: `"C:\Program Files\Rhino 8\System\Yak.exe" install …`), then restart Rhino.
The first command run takes a minute: Rhino pip-installs numpy and mini-racer into the
plugin's own `support-fins` Python environment (the commands' `# r:` lines). Later runs
plugin's own `support-fins` Python environment (the commands' `# r:` lines). mini-racer
is pinned to 0.12.4, the last release for Rhino 8's Python 3.9 (the other plugins use
0.14.1; the fins are byte-identical on every `tests/fixtures` STL). Later runs
start the engine in under a second.
## Build and test
```
python3 plugins/rhino/build.py # stage build/SupportFins/ (+ .yak if Rhino 8 is installed)
python3 -m pytest -q plugins/rhino/tests/ # needs numpy (+ mini-racer==0.14.1 for the engine test)
python3 -m pytest -q plugins/rhino/tests/ # needs numpy (+ mini-racer==0.12.4 for the engine test)
```
`build.py` stages a Rhino script project (`SupportFins.rhproj`: the three commands, and
@@ -1,7 +1,7 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
# r: mini-racer==0.12.4
"""SupportFins: breakaway support fins for the selected part, as it sits on the XY bed.
Runs again on the same part to replace its fins (drawn walls stay). Settings:
@@ -1,7 +1,7 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
# r: mini-racer==0.12.4
"""SupportFinsDraw: one wall by hand, as on the site's Draw mode. Pick the part, then
the wall's two ends on its underside; the wall stands from the bed up to that line.
Repeats until Enter / Esc. SupportFins leaves drawn walls alone; delete one like any
@@ -44,6 +44,7 @@ def RunCommand():
doc.Objects.UnselectAll()
try:
mesh = sf.part_mesh(part, doc)
soup = sf.mesh_soup(mesh, sf.mm_per_unit(doc))
except Exception as e: # noqa: BLE001
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
@@ -56,7 +57,7 @@ def RunCommand():
if b is None:
break
try:
wall, info = sf.draw_wall(doc, part, a, b)
wall, info = sf.draw_wall(doc, part, a, b, soup)
except Exception as e: # noqa: BLE001
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
@@ -1,7 +1,7 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
# r: mini-racer==0.12.4
"""SupportFinsSettings: the site's settings, saved in this document. Built from
options.json: the site's labels, defaults and ranges; ones the engine would ignore
are hidden, as on the site. OK keeps them for the next SupportFins / SupportFinsDraw
@@ -37,6 +37,7 @@ SECTION, ENTRY = "SupportFins", "settings"
LAYER, OVER_LAYER = "Support Fins", "Overhangs"
# the site's STEP tessellation (web/step.js STEP_PARAMS): 0.01 mm chord, 0.1 rad
CHORD_MM, ANGLE_RAD = 0.01, 0.1
MIN_EDGE_MM = 0.0001 # Rhino's default, but in mm whatever the document's units
FIN_COLOR = (38, 115, 242)
DRAWN_COLOR = (242, 140, 26)
OVER_COLOR = (230, 10, 5) # the site's red: overhangs the fins hold
@@ -93,7 +94,12 @@ def visible(key, values):
# ---- the part --------------------------------------------------------------
def mm_per_unit(doc):
return Rhino.RhinoMath.UnitScale(doc.ModelUnitSystem, Rhino.UnitSystem.Millimeters)
units = doc.ModelUnitSystem
# (UnitSystem.None is a Python keyword: by name)
if units in (getattr(Rhino.UnitSystem, "None"), Rhino.UnitSystem.CustomUnits, Rhino.UnitSystem.Unset):
raise ValueError(f"the document's units are {units}: set real units (Document Properties > Units) "
"so the part has a size in mm")
return Rhino.RhinoMath.UnitScale(units, Rhino.UnitSystem.Millimeters)
def is_fin(obj):
@@ -123,8 +129,10 @@ def part_mesh(obj, doc):
if not isinstance(brep, Rhino.Geometry.Brep):
raise ValueError(f"{name} is a {geo.ObjectType}: pick a solid, a surface or a mesh"
+ (" (explode the block first)" if isinstance(geo, Rhino.Geometry.InstanceReferenceGeometry) else ""))
k = mm_per_unit(doc)
mp = MeshingParameters()
mp.Tolerance = CHORD_MM / mm_per_unit(doc)
mp.Tolerance = CHORD_MM / k
mp.MinimumEdgeLength = MIN_EDGE_MM / k
mp.RelativeTolerance = 0.0
mp.GridAngle = ANGLE_RAD
mp.RefineAngle = ANGLE_RAD
@@ -132,6 +140,9 @@ def part_mesh(obj, doc):
mp.SimplePlanes = False
mp.JaggedSeams = False
mesh = Mesh()
# float64 like the site's STEP path: a float32 vertex 500 mm out is ~3e-5 mm of
# noise, enough to move a tine (plugins/shared/ENGINE-SENSITIVITY.md)
mesh.Vertices.UseDoublePrecisionVertices = True
for m in Mesh.CreateFromBrep(brep, mp) or []:
mesh.Append(m)
if mesh is None or mesh.Faces.Count == 0:
@@ -207,19 +218,20 @@ def add_mesh(doc, tris_mm, name, layer, color, role, part):
attr.ObjectColor = _color(color)
attr.SetUserString("sf_role", role)
attr.SetUserString("sf_part", str(part.Id))
return doc.Objects.AddMesh(rhino_mesh(tris_mm, mm_per_unit(doc)), attr)
oid = doc.Objects.AddMesh(rhino_mesh(tris_mm, mm_per_unit(doc)), attr)
return None if oid == System.Guid.Empty else oid
def fins_of(doc, part, roles):
return [o for o in doc.Objects.FindByUserString("sf_part", str(part.Id), True)
return [o for o in (doc.Objects.FindByUserString("sf_part", str(part.Id), True) or [])
if o.Attributes.GetUserString("sf_role") in roles and not o.IsDeleted]
def remove(doc, objs):
# ignoreModes: a plain Delete skips objects that are locked, hidden or on a locked
# or hidden layer -- the overhangs' layer is locked, and a user may hide a fin
for o in objs:
if o.IsLocked or doc.Layers[o.Attributes.LayerIndex].IsLocked:
doc.Objects.Unlock(o.Id, True)
doc.Objects.Delete(o.Id, True)
doc.Objects.Delete(Rhino.DocObjects.ObjRef(o.Id), True, True)
def label(part):
@@ -238,24 +250,32 @@ def generate(doc, part):
remove(doc, fins_of(doc, part, {"fin", "overhangs"}))
layer = fins_layer(doc)
dropped = [] # Rhino refused the mesh: say so, never leave a fin out quietly
for p in pieces:
tris = np.concatenate([fins[a:b] for a, b in p["ranges"]]) if p["ranges"] else fins[:0]
if len(tris):
add_mesh(doc, tris, core.piece_name(label(part), p), layer, FIN_COLOR, "fin", part)
name = core.piece_name(label(part), p)
if len(tris) and add_mesh(doc, tris, name, layer, FIN_COLOR, "fin", part) is None:
dropped.append(name)
over_layer = overhangs_layer(doc)
for faces, what, color in ((over, "overhangs", OVER_COLOR), (small, "too small to fin", SMALL_COLOR)):
if len(faces):
add_mesh(doc, soup[faces], f"{label(part)} {what}", over_layer, color, "overhangs", part)
report = host.host_report(stats)
if dropped:
report += f"; Rhino refused {len(dropped)} fin mesh{'es' if len(dropped) > 1 else ''} " \
f"(not added: {', '.join(dropped)})"
_set_report(doc, part, report)
return report
def draw_wall(doc, part, a, b):
def draw_wall(doc, part, a, b, soup=None):
"""A wall from a to b (Point3d, document units: where the clicks hit the part).
soup: the part's part_soup, if the caller has it (meshing a curved Brep at 0.01 mm
takes a while: once per Draw session, not per wall).
Returns (the wall's id, the result line) or (None, the engine's reason)."""
k = mm_per_unit(doc)
if soup is None:
soup = part_soup(part, doc)
tris, info = host.host_draw_wall(ctx(), soup, [a.X * k, a.Y * k, a.Z * k],
[b.X * k, b.Y * k, b.Z * k], engine_options(doc))
@@ -263,6 +283,8 @@ def draw_wall(doc, part, a, b):
return None, info
n = len(fins_of(doc, part, {"drawn"})) + 1
wall = add_mesh(doc, tris, f"{label(part)} drawn wall {n}", fins_layer(doc), DRAWN_COLOR, "drawn", part)
if wall is None:
return None, "Rhino refused the wall's mesh"
tines = info.get("tines", 0)
return wall, (f"drawn wall {info.get('length', 0):.1f} mm long, {info.get('height', 0):.1f} mm tall, "
f"{tines} tine{'' if tines == 1 else 's'}")
+31 -16
View File
@@ -1,7 +1,7 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
# r: mini-racer==0.12.4
"""Smoke test in the real Rhino 8 (local only; CI can't run Rhino). After build.py,
with Rhino open (run StartScriptServer once in it):
@@ -18,7 +18,9 @@ plugins/rhino/build/smoke.json; its last entry says PASS or FAIL. Checks:
- running again replaces the fins (not adds); the overhangs layer is locked;
- settings saved in the document reach the engine (no tines -> no tines);
- a drawn wall under the box's overhang; a wall drawn in mid-air says why not;
- an open surface and a curve are refused with a message, not a traceback.
- a surface runs like a solid (a flat one on the bed: no walls); a curve is refused
with a message, not a traceback;
- doc.Import may show the STL options dialog on a Mac: click OK if it does.
"""
import json
import os
@@ -55,8 +57,12 @@ def tilted_box(doc, deg=50, lift=30.0):
def sorted_tris(t):
"""Triangles in a canonical order, after the plugin's weld (which drops ones the
exact weld collapses), so engine output and Rhino objects compare like for like."""
import numpy as np
t = np.round(np.asarray(t, dtype=np.float64), 6)
import supportfins_rhino_core as core
v, f = core.weld(np.asarray(t, dtype=np.float64).reshape(-1, 3, 3))
t = np.round(v[f], 6)
t = np.sort(t.reshape(-1, 3, 3), axis=1).reshape(-1, 9) # vertex order within a triangle
return t[np.lexsort(t.T[::-1])]
@@ -92,14 +98,16 @@ try:
box = tilted_box(doc)
report = sf.generate(doc, box)
fins = sf.fins_of(doc, box, {"fin"})
check("box gets fins", len(fins) > 0 and "wall" in report, report)
check("box gets fins", len(fins) > 0, report)
check("a Brep is meshed in double precision", sf.part_mesh(box, doc).Vertices.UseDoublePrecisionVertices)
check("fins are on the Support Fins layer",
all(doc.Layers[o.Attributes.LayerIndex].FullPath == sf.LAYER for o in fins))
soup = sf.part_soup(box, doc)
want, stats = host.host_compute(sf.ctx(), soup, sf.engine_options(doc))
got = objs_tris(fins, doc)
check("fin triangles are the engine's", got.shape == want.shape
and np.allclose(sorted_tris(got), sorted_tris(want), atol=1e-6), f"{got.shape} vs {want.shape}")
g, w = sorted_tris(got), sorted_tris(want)
check("fin triangles are the engine's", g.shape == w.shape and np.allclose(g, w, atol=1e-6),
f"{g.shape} vs {w.shape}")
zmin = soup[..., 2].min()
check("fins stand on the part's lowest point", abs(got[..., 2].min() - zmin) < 0.05,
f"fins {got[..., 2].min():.3f}, part {zmin:.3f}")
@@ -143,26 +151,33 @@ try:
sf.generate(doc, part)
want, _ = host.host_compute(sf.ctx(), read_stl(stl), sf.engine_options(doc))
got = objs_tris(sf.fins_of(doc, part, {"fin"}), doc)
check("STL in Rhino gives the file's fins", got.shape == want.shape
and np.allclose(sorted_tris(got), sorted_tris(want), atol=1e-5), f"{got.shape} vs {want.shape}")
g, w = sorted_tris(got), sorted_tris(want)
check("STL in Rhino gives the file's fins", g.shape == w.shape and np.allclose(g, w, atol=1e-5),
f"{g.shape} vs {w.shape}")
# ---- units ----
mm = objs_tris(sf.fins_of(doc, box, {"fin"}), doc)
# Not triangle for triangle: re-meshing in inches and x 25.4 is float noise, which
# can move a tine (ENGINE-SENSITIVITY.md). The same pieces in the same place is the
# check that units are right both ways.
def boxes(objs):
return sorted(np.round(np.r_[t.reshape(-1, 3).min(0), t.reshape(-1, 3).max(0)], 1).tolist()
for t in (mesh_tris(o, doc) for o in objs))
mm = boxes(sf.fins_of(doc, box, {"fin"}))
doc.AdjustModelUnitSystem(Rhino.UnitSystem.Inches, True) # scales the box to inches
sf.generate(doc, box)
inch = objs_tris(sf.fins_of(doc, box, {"fin"}), doc)
check("an inch document gives the same fins, in inches", inch.shape == mm.shape
and np.allclose(sorted_tris(inch * 25.4), sorted_tris(mm), atol=1e-4), f"{inch.shape} vs {mm.shape}")
inch = [[v * 25.4 for v in b] for b in boxes(sf.fins_of(doc, box, {"fin"}))]
check("an inch document gives the same fins, in inches", len(inch) == len(mm)
and np.allclose(np.round(inch, 1), mm, atol=0.15), f"{len(inch)} vs {len(mm)} pieces")
doc.AdjustModelUnitSystem(Rhino.UnitSystem.Millimeters, True)
# ---- refusals ----
srf = doc.Objects.FindId(doc.Objects.AddSurface(Rhino.Geometry.PlaneSurface(
Rhino.Geometry.Plane.WorldXY, Rhino.Geometry.Interval(0, 10), Rhino.Geometry.Interval(0, 10))))
try:
sf.generate(doc, srf)
check("an open surface still runs (flat, on the bed: nothing to hold)", True)
except ValueError as e:
check("an open surface is refused with a message", str(e), e)
report = sf.generate(doc, srf)
check("a flat surface on the bed runs: nothing to hold", "0 walls" in report, report)
except Exception as e: # noqa: BLE001
check("a flat surface on the bed runs: nothing to hold", False, e)
crv = doc.Objects.FindId(doc.Objects.AddLine(Rhino.Geometry.Point3d(0, 0, 0), Rhino.Geometry.Point3d(1, 1, 1)))
try:
sf.generate(doc, crv)
+3 -1
View File
@@ -72,4 +72,6 @@ def test_commands_are_one_script_each(project):
assert head.startswith("#! python3\n# venv: support-fins\n"), name
headers.add(head)
assert len(headers) == 1
assert "# r: mini-racer==0.14.1" in headers.pop()
# 0.12.4: the last mini-racer for Python 3.9, Rhino 8's (0.13+ needs 3.10); its fins
# are byte-identical to 0.14.1's on every tests/fixtures STL
assert "# r: mini-racer==0.12.4" in headers.pop()