mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-18 18:03:27 -05:00
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:
1 parent
68c5f271fe
commit
a5a0866421
8 files changed
+78
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,31 +250,41 @@ 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)
|
||||
soup = part_soup(part, 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))
|
||||
if tris is None:
|
||||
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'}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user