rhino: Rhino 8 plugin (#321) -- SupportFins / SupportFinsDraw / SupportFinsSettings run the site's engine through the shared Python host (mini-racer + numpy pip-installed by Rhino's own # r: lines, one .yak for Mac and Windows); Breps meshed at the site's STEP tessellation, document units to mm and back, one mesh object per engine piece on a Support Fins layer, red/amber overhangs on a locked sublayer, settings from options.json saved in the .3dm; build.py stages a .rhproj and compiles it with rhinocode when Rhino is installed; plain-Python tests in CI, rhino_smoke.py for the real Rhino

This commit is contained in:
matthewtrahan authored and Matthew Trahan committed 2026-10-18 15:34:39 -05:00
1 parent 9e87d835ad
commit 68c5f271fe
14 files changed
+1289 -1

No files matched your search

+20
View File
@@ -217,6 +217,26 @@ jobs:
path: plugins/blender/build/support_fins-*.zip
if-no-files-found: error
rhino:
# The Rhino 8 plugin's settings mapping, mesh weld and staged project (rhinocode,
# which compiles the .yak, needs Rhino: built locally; rhino_smoke.py runs in Rhino).
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- 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
- name: Test
run: python -m pytest -q plugins/rhino/tests/
- name: Stage the project
run: python plugins/rhino/build.py
blender-smoke:
# Install the Linux zip into a real headless Blender (the oldest it supports and
# a current one) and use it: Generate, out of date, settings, units, Draw wall, Lay face flat, 3MF, overlays, Clear.
+1
View File
@@ -39,5 +39,6 @@ plugins/solidworks/build/
# Blender: built extension zips and the cached mini-racer wheels
plugins/blender/build/
plugins/rhino/build/
# user-reported parts (other people's files): measured locally, never committed
prototype/examples/reports/
+75
View File
@@ -0,0 +1,75 @@
# Support Fins — Rhino 8 plugin (experimental)
printfins.com's breakaway support fins under a part in Rhino 8 (Mac and Windows):
**SupportFins**, pick the part, and each wall, sway brace and the bed pad appears as its
own mesh object on a **Support Fins** layer, ready to export with the part.
Same engine as the site and every plugin: the part's triangles go to `computeFins` in V8
(mini-racer) through the shared host, `plugins/shared/py/supportfins_host.py`. No fin
geometry lives in the plugin. Rhino 7 isn't supported: its Python (IronPython 2.7)
can't run mini-racer.
## Commands
- **SupportFins** — select a solid, surface, extrusion, SubD or mesh (or one of its
fins). Fins for the part as it sits: the **bed** is the world XY plane at its lowest
point, z up. Run it again to replace them (after moving the part or changing a
setting). The overhangs the fins hold are shown in red, and ones too small to fin in
amber, on the locked **Support Fins::Overhangs** layer (hide it to get them out of
the way). The result line (walls, tines, and what wasn't reached) goes to the
command line.
- **SupportFinsDraw** — one wall by hand, like the site's Draw mode: pick the part,
then the wall's two ends on its underside. Repeats until Enter. SupportFins leaves
drawn walls alone.
- **SupportFinsSettings** — the site's settings with the site's defaults and ranges
(built from `plugins/shared/engine/options.json`; ones the engine would ignore are
hidden). They're saved in the document, so they travel with the .3dm.
A Brep is meshed the way the site tessellates a STEP (0.01 mm chord, 0.1 rad); a mesh is
used as it is. Any document units work: the part goes to the engine in mm and the fins
come back in the document's units.
Export: select the part and its fins, **File ▸ Export Selected**, .3mf or .stl.
## Install
From a `.yak` file (until it's on the Package Manager): drag it onto Rhino's window, or
`"/Applications/Rhino 8.app/Contents/Resources/bin/yak" install support-fins-rhino-0.1.yak`
(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
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)
```
`build.py` stages a Rhino script project (`SupportFins.rhproj`: the three commands, and
`lib/` with the plugin's modules, the shared host, options.json and the engine bundle),
then `rhinocode project build` compiles it into a `.rhp` and a `.yak` (Rhino needn't be
running). CI can't run Rhino, so it tests and stages only.
In Rhino, without installing (run `StartScriptServer` once in Rhino first):
```
RC="/Applications/Rhino 8.app/Contents/Resources/bin/rhinocode"
"$RC" script plugins/rhino/build/SupportFins/commands/SupportFins.py # one command, from the staged build
"$RC" script plugins/rhino/tests/rhino_smoke.py # the smoke test -> build/smoke.json
```
`rhino_smoke.py` opens a new document and checks a tilted box, STL parity with the
engine on the file's own triangles, inch documents, settings, Draw mode and refusals.
## Layout
```
SupportFins/commands/ one script per command (+ `# @bootstrap`, where build.py inlines
bootstrap.py: finding lib/ next to the installed plugin)
SupportFins/lib/ supportfins_rhino.py (Rhino calls: mesh the part, add the fins)
supportfins_rhino_core.py (no Rhino: settings <-> engine, weld)
tests/ test_core.py, test_build.py (plain Python); rhino_smoke.py (in Rhino)
```
+33
View File
@@ -0,0 +1,33 @@
"""Inlined at the top of every command by build.py (# @bootstrap): a built command
is one script, run by Rhino from inside the plugin, so it can't import this file.
"""
import Rhino
PLUGIN_ID = "6a881861-fe75-4314-ba84-6f833da35639" # = SupportFins.rhproj "id"
def _lib():
"""Put the plugin's shared files on sys.path: next to the built plugin (yak), or
../lib in a checkout's staged build (rhinocode script)."""
import os
import sys
import System
places = []
try:
rhp = Rhino.PlugIns.PlugIn.PathFromId(System.Guid(PLUGIN_ID))
if rhp:
d = os.path.dirname(str(rhp))
places += [os.path.join(d, "shared", "lib"), os.path.join(d, "shared"), os.path.join(d, "lib")]
except Exception: # noqa: BLE001 -- not installed as a plugin
pass
if "__file__" in globals():
places.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"))
for p in places:
if os.path.isfile(os.path.join(p, "supportfins_rhino.py")):
if p not in sys.path:
sys.path.insert(0, p)
return
raise RuntimeError("Support Fins: can't find the plugin's files (looked in "
+ ", ".join(places) + "); reinstall it")
@@ -0,0 +1,47 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
"""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:
SupportFinsSettings. One wall by hand: SupportFinsDraw.
"""
import Rhino
import scriptcontext as sc
# @bootstrap
def RunCommand():
_lib()
import supportfins_rhino as sf
doc = sc.doc
go = Rhino.Input.Custom.GetObject()
go.SetCommandPrompt("Select the part to hold up")
go.GeometryFilter = (Rhino.DocObjects.ObjectType.Brep | Rhino.DocObjects.ObjectType.Extrusion
| Rhino.DocObjects.ObjectType.Mesh | Rhino.DocObjects.ObjectType.SubD)
go.SubObjectSelect = False
go.Get()
if go.CommandResult() != Rhino.Commands.Result.Success:
return go.CommandResult()
part = sf.part_for(doc, go.Object(0).Object())
if part is None:
Rhino.RhinoApp.WriteLine("Support Fins: that fin's part is gone")
return Rhino.Commands.Result.Failure
Rhino.RhinoApp.WriteLine("Support Fins: computing...")
Rhino.RhinoApp.Wait()
try:
report = sf.generate(doc, part)
except Exception as e: # noqa: BLE001 -- the user's message, not a traceback
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
doc.Objects.UnselectAll()
doc.Views.Redraw()
Rhino.RhinoApp.WriteLine(f"Support Fins: {report}")
return Rhino.Commands.Result.Success
if __name__ == "__main__":
RunCommand()
@@ -0,0 +1,70 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
"""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
object.
"""
import Rhino
import scriptcontext as sc
# @bootstrap
def _pick_end(prompt, mesh, start=None):
gp = Rhino.Input.Custom.GetPoint()
gp.SetCommandPrompt(prompt)
gp.Constrain(mesh, False) # on the part, where the wall meets it
if start is not None:
gp.SetBasePoint(start, True)
gp.DrawLineFromPoint(start, True)
gp.AcceptNothing(True)
gp.Get()
return gp.Point() if gp.CommandResult() == Rhino.Commands.Result.Success and gp.Result() == Rhino.Input.GetResult.Point else None
def RunCommand():
_lib()
import supportfins_rhino as sf
doc = sc.doc
go = Rhino.Input.Custom.GetObject()
go.SetCommandPrompt("Select the part to draw a wall under")
go.GeometryFilter = (Rhino.DocObjects.ObjectType.Brep | Rhino.DocObjects.ObjectType.Extrusion
| Rhino.DocObjects.ObjectType.Mesh | Rhino.DocObjects.ObjectType.SubD)
go.Get()
if go.CommandResult() != Rhino.Commands.Result.Success:
return go.CommandResult()
part = sf.part_for(doc, go.Object(0).Object())
if part is None:
Rhino.RhinoApp.WriteLine("Support Fins: that fin's part is gone")
return Rhino.Commands.Result.Failure
doc.Objects.UnselectAll()
try:
mesh = sf.part_mesh(part, doc)
except Exception as e: # noqa: BLE001
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
drawn = 0
while True:
a = _pick_end("First end of the wall (on the part's underside; Enter to finish)", mesh)
if a is None:
break
b = _pick_end("Other end of the wall", mesh, a)
if b is None:
break
try:
wall, info = sf.draw_wall(doc, part, a, b)
except Exception as e: # noqa: BLE001
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
Rhino.RhinoApp.WriteLine(f"Support Fins: {info}" if wall else f"Support Fins: no wall there: {info}")
drawn += wall is not None
doc.Views.Redraw()
return Rhino.Commands.Result.Success if drawn else Rhino.Commands.Result.Nothing
if __name__ == "__main__":
RunCommand()
@@ -0,0 +1,119 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
"""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
(existing fins aren't rebuilt until SupportFins runs again).
"""
import Eto.Drawing as drawing
import Eto.Forms as forms
import Rhino
import Rhino.UI
import scriptcontext as sc
# @bootstrap
class SettingsDialog(forms.Dialog[bool]):
def __init__(self, sf, core, values):
super().__init__()
self.sf, self.values = sf, dict(values)
self.Title = "Support Fins settings"
self.Padding = drawing.Padding(10)
self.Resizable = False
self.rows = {} # key -> (label, control)
layout = forms.DynamicLayout()
layout.Spacing = drawing.Size(6, 4)
for section, specs in core.sections(sf.SCHEMA):
head = forms.Label()
head.Text = section
head.Font = drawing.Font(head.Font.Family, head.Font.Size, drawing.FontStyle.Bold)
layout.AddRow(head)
for s in specs:
label = forms.Label()
label.Text = s["label"]
label.ToolTip = s["tooltip"]
control = self._control(s)
control.ToolTip = s["tooltip"]
self.rows[s["key"]] = (label, control)
layout.AddRow(label, control)
ok, cancel = forms.Button(), forms.Button()
ok.Text, cancel.Text = "OK", "Cancel"
ok.Click += lambda *_: self.Close(True)
cancel.Click += lambda *_: self.Close(False)
self.DefaultButton, self.AbortButton = ok, cancel
reset = forms.Button()
reset.Text = "Site defaults"
reset.Click += self._reset
layout.AddRow(None)
layout.AddSeparateRow(reset, None, cancel, ok)
self.Content = layout
self._show_relevant()
def _control(self, s):
key = s["key"]
if s["kind"] == "bool":
c = forms.CheckBox()
c.Checked = bool(self.values[key])
c.CheckedChanged += lambda *_: self._set(key, bool(c.Checked))
elif s["kind"] == "choice":
c = forms.DropDown()
for _, text in s["choices"]:
c.Items.Add(text)
values = [v for v, _ in s["choices"]]
c.SelectedIndex = values.index(self.values[key])
c.SelectedIndexChanged += lambda *_: self._set(key, values[c.SelectedIndex])
else:
c = forms.NumericStepper()
c.MinValue, c.MaxValue = s["min"], s["max"]
c.Increment, c.DecimalPlaces = s["step"], s["decimals"]
c.Value = float(self.values[key])
c.ValueChanged += lambda *_: self._set(key, float(c.Value))
return c
def _set(self, key, value):
self.values[key] = value
self._show_relevant()
def _reset(self, *_):
import supportfins_rhino_core as core
self.values = core.defaults(self.sf.SCHEMA)
for s in core.specs(self.sf.SCHEMA):
c, v = self.rows[s["key"]][1], self.values[s["key"]]
if s["kind"] == "bool":
c.Checked = v
elif s["kind"] == "choice":
c.SelectedIndex = [x for x, _ in s["choices"]].index(v)
else:
c.Value = v
self._show_relevant()
def _show_relevant(self):
for key, (label, control) in self.rows.items():
shown = self.sf.visible(key, self.values)
label.Visible = control.Visible = shown
def RunCommand():
_lib()
import supportfins_rhino as sf
import supportfins_rhino_core as core
doc = sc.doc
dialog = SettingsDialog(sf, core, sf.controls(doc))
if not dialog.ShowModal(Rhino.UI.RhinoEtoApp.MainWindow):
return Rhino.Commands.Result.Cancel
try:
sf.engine_options(doc, dialog.values) # the engine refuses a bad value here
except ValueError as e:
Rhino.RhinoApp.WriteLine(f"Support Fins: {e}")
return Rhino.Commands.Result.Failure
sf.save_controls(doc, dialog.values)
Rhino.RhinoApp.WriteLine("Support Fins: settings saved in this document; run SupportFins to rebuild fins")
return Rhino.Commands.Result.Success
if __name__ == "__main__":
RunCommand()
@@ -0,0 +1,274 @@
"""Support Fins for Rhino: the part in, fin objects out. No fin geometry is computed
here: the website's engine runs in V8 (mini-racer, installed by Rhino from the
commands' `# r:` lines) through the shared Python host, supportfins_host.py.
Frames: the engine takes the part in millimetres, z up, as it sits in the model --
the bed is the world XY plane at the part's lowest point, so the part prints the way
it sits. Document units become millimetres through the model's unit system.
A Brep (or extrusion, or SubD) is meshed the way printfins.com tessellates a STEP:
0.01 mm chord, 0.1 rad. A mesh is used as it is.
Fins are mesh objects on the "Support Fins" layer, one per wall / brace / pad (the
engine's `pieces`), so any one can be selected, hidden or deleted. Each carries user
strings:
sf_role 'fin' (SupportFins'), 'drawn' (SupportFinsDraw), 'overhangs' (the site's
red and amber faces, on the locked "Support Fins::Overhangs" layer, so an
overhang nothing holds is shown, not hidden)
sf_part the id of the part they belong to
The part carries sf_report (the last result line). The settings are saved in the
document (document strings), so they travel with the .3dm.
"""
import json
import os
import subprocess
import sys
import Rhino
import System
from Rhino.Geometry import Mesh, MeshingParameters
import supportfins_host as host
import supportfins_rhino_core as core
HERE = os.path.dirname(os.path.abspath(__file__))
SCHEMA = json.load(open(os.path.join(HERE, "options.json"), encoding="utf-8"))
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
FIN_COLOR = (38, 115, 242)
DRAWN_COLOR = (242, 140, 26)
OVER_COLOR = (230, 10, 5) # the site's red: overhangs the fins hold
SMALL_COLOR = (242, 166, 26) # the site's amber: past the angle, too small to fin
_ctx = None
def _jit_allowed():
"""Whether this Rhino may run V8's JIT. macOS refuses JIT memory to a hardened app
without the allow-jit entitlement, and V8 dies on it rather than falling back;
jitless is ~25x slower on a big part. Rhino's .NET runtime needs that entitlement
itself, but read it from the app's signature rather than assume."""
if sys.platform != "darwin":
return True
app = os.path.abspath(os.path.join(Rhino.RhinoApp.GetExecutableDirectory().FullName, "..", ".."))
try:
out = subprocess.run(["codesign", "-d", "--entitlements", "-", "--xml", app],
capture_output=True, timeout=10).stdout
except Exception: # noqa: BLE001 -- unknown: the safe, slow way
return False
return b"com.apple.security.cs.allow-jit" in out
def ctx():
"""The V8 context with the engine bundle (started once per Rhino session)."""
global _ctx
if _ctx is None:
with open(os.path.join(HERE, "fins_engine.js"), encoding="utf-8") as fh:
_ctx = host.host_engine(fh.read(), jit=_jit_allowed())
return _ctx
# ---- settings --------------------------------------------------------------
def controls(doc):
"""The settings dialog's values saved in this document (the site's defaults if none)."""
return core.load_values(SCHEMA, doc.Strings.GetValue(SECTION, ENTRY))
def save_controls(doc, values):
doc.Strings.SetString(SECTION, ENTRY, core.dump_values(values))
def engine_options(doc, values=None):
"""The settings as engine options, checked by the engine (host_options)."""
return host.host_options(ctx(), core.dialog_values(SCHEMA, values if values is not None else controls(doc)))
def visible(key, values):
return host.host_visible(ctx(), key, core.dialog_values(SCHEMA, values))
# ---- the part --------------------------------------------------------------
def mm_per_unit(doc):
return Rhino.RhinoMath.UnitScale(doc.ModelUnitSystem, Rhino.UnitSystem.Millimeters)
def is_fin(obj):
return bool(obj.Attributes.GetUserString("sf_role"))
def part_for(doc, obj):
"""What a selection means: a fin stands for its part."""
if obj is None:
return None
if is_fin(obj):
return doc.Objects.FindId(System.Guid(obj.Attributes.GetUserString("sf_part")))
return obj
def part_mesh(obj, doc):
"""The part as one Rhino mesh, document units. Raises with a message for the
user on anything that isn't a solid's surface."""
geo = obj.Geometry
name = obj.Name or "the part"
if isinstance(geo, Rhino.Geometry.Mesh):
mesh = geo.DuplicateMesh()
elif isinstance(geo, Rhino.Geometry.SubD):
mesh = Mesh.CreateFromSubD(geo, 4)
else:
brep = geo.ToBrep() if isinstance(geo, Rhino.Geometry.Extrusion) else geo
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 ""))
mp = MeshingParameters()
mp.Tolerance = CHORD_MM / mm_per_unit(doc)
mp.RelativeTolerance = 0.0
mp.GridAngle = ANGLE_RAD
mp.RefineAngle = ANGLE_RAD
mp.RefineGrid = True
mp.SimplePlanes = False
mp.JaggedSeams = False
mesh = Mesh()
for m in Mesh.CreateFromBrep(brep, mp) or []:
mesh.Append(m)
if mesh is None or mesh.Faces.Count == 0:
raise ValueError(f"{name} has no faces to hold up")
mesh.Faces.ConvertQuadsToTriangles()
return mesh
def mesh_soup(mesh, k):
"""A triangle mesh -> (M,3,3) float64 triangles, mm (k mm per document unit)."""
import numpy as np
pts = mesh.Vertices.ToPoint3dArray()
P = np.array([(p.X, p.Y, p.Z) for p in pts], dtype=np.float64) * k
tri = np.array(list(mesh.Faces.ToIntArray(True)), dtype=np.int64).reshape(-1, 3)
soup = P[tri]
if not np.isfinite(soup).all():
raise ValueError("the part has a vertex that isn't a number")
return soup
def part_soup(obj, doc):
return mesh_soup(part_mesh(obj, doc), mm_per_unit(doc))
# ---- fin objects -----------------------------------------------------------
def _color(rgb):
return System.Drawing.Color.FromArgb(*rgb)
def layer_index(doc, name, color, parent=None, locked=False):
path = f"{parent.FullPath}::{name}" if parent is not None else name
i = doc.Layers.FindByFullPath(path, -1)
if i >= 0:
return i
layer = Rhino.DocObjects.Layer()
layer.Name = name
layer.Color = _color(color)
layer.IsLocked = locked
if parent is not None:
layer.ParentLayerId = parent.Id
return doc.Layers.Add(layer)
def fins_layer(doc):
return layer_index(doc, LAYER, FIN_COLOR)
def overhangs_layer(doc):
return layer_index(doc, OVER_LAYER, OVER_COLOR, parent=doc.Layers[fins_layer(doc)], locked=True)
def rhino_mesh(tris_mm, k):
"""(K,3,3) mm triangles -> a Rhino mesh in document units (double precision)."""
verts, faces = core.weld(tris_mm)
verts = verts / k
mesh = Mesh()
mesh.Vertices.UseDoublePrecisionVertices = True
for x, y, z in verts.tolist():
mesh.Vertices.Add(x, y, z)
for a, b, c in faces.tolist():
mesh.Faces.AddFace(a, b, c)
mesh.Normals.ComputeNormals()
mesh.Compact()
return mesh
def add_mesh(doc, tris_mm, name, layer, color, role, part):
attr = doc.CreateDefaultAttributes()
attr.LayerIndex = layer
attr.Name = name
attr.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject
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)
def fins_of(doc, part, roles):
return [o for o in doc.Objects.FindByUserString("sf_part", str(part.Id), True)
if o.Attributes.GetUserString("sf_role") in roles and not o.IsDeleted]
def remove(doc, objs):
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)
def label(part):
return part.Name or "Part"
def generate(doc, part):
"""Replace the part's fins with the engine's for its current pose and settings, and
refresh the red / amber overhang faces. Drawn walls are left as they are. Returns
the report line."""
import numpy as np
soup = part_soup(part, doc)
options = engine_options(doc)
# the engine first: if it fails, the document is still as it was
fins, stats, pieces, over, small = host.host_compute_pieces(ctx(), soup, options)
remove(doc, fins_of(doc, part, {"fin", "overhangs"}))
layer = fins_layer(doc)
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)
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)
_set_report(doc, part, report)
return report
def draw_wall(doc, part, a, b):
"""A wall from a to b (Point3d, document units: where the clicks hit the part).
Returns (the wall's id, the result line) or (None, the engine's reason)."""
k = mm_per_unit(doc)
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)
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'}")
def _set_report(doc, part, report):
attr = part.Attributes.Duplicate()
attr.SetUserString("sf_report", report)
doc.Objects.ModifyAttributes(part, attr, True)
@@ -0,0 +1,130 @@
"""Support Fins for Rhino: the parts that don't need Rhino, so tests run in plain Python.
specs / dialog_values options.json -> the settings dialog's controls, and back to
the engine's dialog values (supportfins_host.host_options checks
them). A new engine option shows up in the dialog without
touching the plugin.
load_values / dump_values
the dialog's values as saved in the .3dm (document strings,
one JSON entry), so a file's fins can be rebuilt as they were
weld (K,3,3) triangles -> vertices + faces for a Rhino mesh
piece_name what each fin object is called in the document
Units: a percent option shows 0-100 like the site's slider and goes to the engine as
value / 100. A host-supplied number (layer height) keeps options.json's range as the
dialog's range only: any positive value goes.
"""
import json
KIND_NAMES = {"prop": "wall", "sway": "sway brace", "pad": "bed pad", "wedge": "wedge"}
def _unit(option):
hint = option.get("hint", "")
return hint.split(" ")[0] if hint.startswith(("mm", "°")) else ""
def _decimals(step):
text = f"{step:.6f}".rstrip("0")
return len(text.split(".")[1]) if "." in text else 0
def specs(schema):
"""One dict per option, what an Eto control needs: key, kind ('bool' | 'number' |
'choice'), section (label), label, tooltip, default and, by kind: choices
[(value, label)]; min, max, step, decimals, percent. Numbers are in the CONTROL's
units (percent = x 100)."""
sections = {s["id"]: s["label"] for s in schema["sections"]}
out = []
for o in schema["options"]:
unit = _unit(o)
spec = {"key": o["key"], "kind": o["type"], "section": sections[o["section"]],
"label": o["label"] + (f" ({unit})" if unit else ""), "tooltip": o.get("tooltip", "")}
if o["type"] == "bool":
spec["default"] = bool(o["default"])
elif o["type"] == "choice":
spec["default"] = o["default"]
spec["choices"] = [(c["value"], c["label"]) for c in o["choices"]]
else:
scale = 100.0 if o.get("percent") else 1.0
step = o["step"] * scale
spec.update(default=float(o["default"]) * scale, percent=bool(o.get("percent")),
step=step, decimals=_decimals(step),
min=0.001 if o.get("hostSupplied") else o["min"] * scale,
max=1e6 if o.get("hostSupplied") else o["max"] * scale)
out.append(spec)
return out
def sections(schema):
"""[(section label, [spec, ...])] in options.json's order, for the dialog."""
by = {}
for s in specs(schema):
by.setdefault(s["section"], []).append(s)
return [(sec["label"], by[sec["label"]]) for sec in schema["sections"] if sec["label"] in by]
def defaults(schema):
"""{key: control value} at the site's defaults."""
return {s["key"]: s["default"] for s in specs(schema)}
def dialog_values(schema, controls):
"""{options.json key: value in the ENGINE's units} from {key: control value}.
A key missing from `controls` takes its default."""
values = {}
for s in specs(schema):
v = controls.get(s["key"], s["default"])
if s["kind"] == "number":
# six places: an Eto stepper's double can carry float noise past the range
v = round(float(v) / 100.0 if s["percent"] else float(v), 6)
elif s["kind"] == "bool":
v = bool(v)
values[s["key"]] = v
return values
def load_values(schema, text):
"""The dialog's control values saved in a document (dump_values' text, or None).
Keys the engine no longer takes are dropped, a choice no longer offered or a
value of the wrong type or out of range falls back to the default, and new
options take theirs."""
out = defaults(schema)
try:
saved = json.loads(text) if text else {}
except ValueError:
saved = {}
if not isinstance(saved, dict):
saved = {}
for s in specs(schema):
v = saved.get(s["key"])
if s["kind"] == "bool" and isinstance(v, bool):
out[s["key"]] = v
elif s["kind"] == "choice" and v in [c[0] for c in s["choices"]]:
out[s["key"]] = v
elif s["kind"] == "number" and isinstance(v, (int, float)) and not isinstance(v, bool) \
and s["min"] <= v <= s["max"]:
out[s["key"]] = float(v)
return out
def dump_values(controls):
return json.dumps(controls, sort_keys=True)
def weld(tris):
"""(K,3,3) triangles -> (vertices (N,3), faces (F,3)). Vertices are merged only
where they are identical: no tolerance that could close a print gap. Triangles
the weld collapses are dropped (a Rhino mesh face needs three vertices)."""
import numpy as np
verts, inverse = np.unique(np.ascontiguousarray(tris, dtype=np.float64).reshape(-1, 3),
axis=0, return_inverse=True)
faces = inverse.reshape(-1, 3)
keep = (faces[:, 0] != faces[:, 1]) & (faces[:, 1] != faces[:, 2]) & (faces[:, 0] != faces[:, 2])
return verts, faces[keep]
def piece_name(part_name, piece):
"""'Bracket wall 3', 'Bracket bed pad': one object per engine piece."""
label = KIND_NAMES.get(piece["kind"], piece["kind"])
return f"{part_name} {label}" if piece["kind"] == "pad" else f"{part_name} {label} {piece['id']}"
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Build the Rhino 8 plugin.
python3 plugins/rhino/build.py # -> plugins/rhino/build/SupportFins/ (the project)
# + build/support-fins-rhino-<version>.yak, if
# Rhino 8's rhinocode is on this machine
1. Stages a Rhino script project (SupportFins.rhproj): the commands, with
SupportFins/bootstrap.py inlined at their `# @bootstrap` line (a built command is one
script, so it can't import its neighbour), and lib/: the plugin's modules, the shared
Python host (plugins/shared/py/supportfins_host.py), options.json and the
printfins.com engine (web/*.js, untouched) bundled into fins_engine.js
(plugins/shared/bundle.py; needs esbuild via npx).
2. `rhinocode project build` compiles it into a .rhp plugin and a .yak package (Rhino
needn't be running). Without Rhino the staged project still runs a command as a
script: `rhinocode script build/SupportFins/commands/SupportFins.py`, or open it in
Rhino's ScriptEditor.
Nothing is vendored: the commands' `# r:` lines have Rhino pip-install numpy and
mini-racer (one py3-none wheel per platform) into the plugin's own `support-fins`
environment on first run, so one .yak serves Mac and Windows.
"""
import json
import os
import pathlib
import shutil
import subprocess
import sys
HERE = pathlib.Path(__file__).resolve().parent
SRC = HERE / "SupportFins"
SHARED = HERE.parent / "shared"
ROOT = HERE.parent.parent
OUT = HERE / "build"
PROJECT = OUT / "SupportFins"
sys.path.insert(0, str(SHARED))
from bundle import bundle_engine # noqa: E402
PLUGIN_ID = "6a881861-fe75-4314-ba84-6f833da35639"
VERSION = "0.1"
# fixed ids, so a rebuild is the same plugin and the same commands to Rhino
COMMANDS = {
"SupportFins": "0f6b2c1e-8f43-4f0e-9a51-5d1b7c3e2a10",
"SupportFinsDraw": "6d3e9a24-1b7f-4c58-8e2d-93a0f4b6c711",
"SupportFinsSettings": "b2c48f17-5e6a-4d93-a1b0-7c2e8d5f9a32",
}
RHINOCODE = {
"darwin": ["/Applications/Rhino 8.app/Contents/Resources/bin/rhinocode"],
"win32": [os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"), "Rhino 8", "System", "rhinocode.exe")],
}
def bootstrap():
"""bootstrap.py from PLUGIN_ID on (not its docstring or imports): what each command gets."""
text = (SRC / "bootstrap.py").read_text(encoding="utf-8")
body = text[text.index("PLUGIN_ID = "):]
if f'PLUGIN_ID = "{PLUGIN_ID}"' not in body:
sys.exit("bootstrap.py's PLUGIN_ID isn't build.py's")
return body.rstrip() + "\n"
def stage_command(src, dest):
text = src.read_text(encoding="utf-8")
if text.count("# @bootstrap\n") != 1:
sys.exit(f"{src.name}: needs exactly one '# @bootstrap' line")
dest.write_text(text.replace("# @bootstrap\n", bootstrap()), encoding="utf-8")
def rhproj(lib_files):
py3 = {"id": "*.*.python", "version": "3.*.*"}
return {
"id": PLUGIN_ID,
"identity": {
"name": "Support Fins",
"version": VERSION,
"publisher": {"name": "printfins.com", "url": "https://printfins.com/"},
"description": "Breakaway support fins for 3D printing, from printfins.com: "
"tined walls that hold up a part's overhangs and snap off clean.",
"license": "GPL-3.0-or-later",
"url": "https://printfins.com/",
},
"settings": {
"buildPath": "build/",
"buildTarget": {"host": {"name": "Rhino3D", "version": "8"}, "title": "Rhino3D (8.*)", "slug": "rh8"},
"publishTarget": {"title": "McNeel Yak Server"},
},
"codes": [{"id": cid, "language": py3, "title": name, "uri": f"commands/{name}.py"}
for name, cid in COMMANDS.items()],
"resources": [{"id": rid, "uri": f"lib/{f}"} for f, rid in lib_files],
}
def resource_id(name):
"""A stable id per shared file (uuid5 of its name under the plugin's id)."""
import uuid
return str(uuid.uuid5(uuid.UUID(PLUGIN_ID), name))
def stage():
if PROJECT.exists():
shutil.rmtree(PROJECT)
(PROJECT / "commands").mkdir(parents=True)
lib = PROJECT / "lib"
lib.mkdir()
for name in COMMANDS:
stage_command(SRC / "commands" / f"{name}.py", PROJECT / "commands" / f"{name}.py")
for f in sorted((SRC / "lib").glob("*.py")):
shutil.copy2(f, lib / f.name)
shutil.copy2(SHARED / "py" / "supportfins_host.py", lib / "supportfins_host.py")
shutil.copy2(SHARED / "engine" / "options.json", lib / "options.json")
js = bundle_engine(lib / "fins_engine.js")
files = sorted(p.name for p in lib.iterdir())
(PROJECT / "SupportFins.rhproj").write_text(
json.dumps(rhproj([(f, resource_id(f)) for f in files]), indent=2) + "\n", encoding="utf-8")
return len(js)
def rhinocode():
for p in RHINOCODE.get(sys.platform, []):
if os.path.isfile(p):
return p
return shutil.which("rhinocode")
def main():
if sys.argv[1:]:
sys.exit(f"unknown arguments {' '.join(sys.argv[1:])}; see the docstring")
js = stage()
print(f"staged {PROJECT.relative_to(ROOT)} (engine {js / 1024:.0f} KB)")
rc = rhinocode()
if rc is None:
print("no Rhino 8 here (rhinocode): built the project only, no .yak")
return
subprocess.run([rc, "project", "build", str(PROJECT / "SupportFins.rhproj")], check=True)
yaks = sorted((PROJECT / "build").rglob("*.yak"), key=lambda p: p.stat().st_mtime)
if not yaks:
sys.exit("rhinocode built no .yak")
for old in OUT.glob("support-fins-rhino-*.yak"):
old.unlink()
dest = OUT / f"support-fins-rhino-{VERSION}.yak"
shutil.copy2(yaks[-1], dest)
print(f"built {dest.relative_to(ROOT)} ({dest.stat().st_size / 1e6:.1f} MB)")
if __name__ == "__main__":
main()
+177
View File
@@ -0,0 +1,177 @@
#! python3
# venv: support-fins
# r: numpy
# r: mini-racer==0.14.1
"""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):
"/Applications/Rhino 8.app/Contents/Resources/bin/rhinocode" script plugins/rhino/tests/rhino_smoke.py
Works in a new, empty document it opens itself. Results go to
plugins/rhino/build/smoke.json; its last entry says PASS or FAIL. Checks:
- a tilted box (Brep) gets fins: one object per engine piece, on the Support Fins
layer, standing on its lowest point (the XY bed), and the same triangles the
engine gives for the plugin's own soup of it;
- STL parity: tests/fixtures/bowl.stl imported into Rhino gives the fins the engine
gives for the file's own triangles (Rhino's mesh path adds nothing);
- units: the same box in an inch document gives the same fins, in inches;
- 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.
"""
import json
import os
import sys
import traceback
import Rhino
import scriptcontext as sc
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
LIB = os.path.join(HERE, "..", "build", "SupportFins", "lib")
OUT = os.path.join(HERE, "..", "build", "smoke.json")
sys.path.insert(0, LIB)
results, failed = [], []
def check(name, ok, detail=""):
results.append({"check": name, "ok": bool(ok), "detail": str(detail)})
if not ok:
failed.append(name)
print(("ok " if ok else "FAIL ") + name + (f" ({detail})" if detail else ""))
json.dump(results, open(OUT, "w"), indent=1)
def tilted_box(doc, deg=50, lift=30.0):
box = Rhino.Geometry.Box(Rhino.Geometry.Plane.WorldXY, Rhino.Geometry.Interval(0, 40),
Rhino.Geometry.Interval(0, 20), Rhino.Geometry.Interval(0, 20)).ToBrep()
box.Transform(Rhino.Geometry.Transform.Rotation(deg * 3.141592653589793 / 180,
Rhino.Geometry.Vector3d.XAxis, Rhino.Geometry.Point3d.Origin))
box.Transform(Rhino.Geometry.Transform.Translation(0, 0, lift))
return doc.Objects.FindId(doc.Objects.AddBrep(box))
def sorted_tris(t):
import numpy as np
t = np.round(np.asarray(t, dtype=np.float64), 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])]
def mesh_tris(obj, doc):
import supportfins_rhino as sf
return sf.mesh_soup(obj.Geometry, 1.0)
def objs_tris(objs, doc):
import numpy as np
return np.concatenate([mesh_tris(o, doc) for o in objs]) if objs else np.empty((0, 3, 3))
def read_stl(path):
import numpy as np
raw = open(path, "rb").read()
n = int.from_bytes(raw[80:84], "little")
rec = np.frombuffer(raw[84:84 + 50 * n], dtype=np.dtype([("n", "<f4", 3), ("v", "<f4", (3, 3)), ("a", "<u2")]))
return rec["v"].astype(np.float64)
try:
import numpy as np
import supportfins_host as host
import supportfins_rhino as sf
Rhino.RhinoApp.RunScript("_-New _None", False)
doc = sc.doc = Rhino.RhinoDoc.ActiveDoc
doc.AdjustModelUnitSystem(Rhino.UnitSystem.Millimeters, False)
# ---- a tilted box ----
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("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}")
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}")
over = sf.fins_of(doc, box, {"overhangs"})
check("overhangs shown on a locked layer", over and doc.Layers[over[0].Attributes.LayerIndex].IsLocked)
n = len(fins)
sf.generate(doc, box)
check("running again replaces the fins", len(sf.fins_of(doc, box, {"fin"})) == n
and len(sf.fins_of(doc, box, {"overhangs"})) == len(over))
# ---- settings ----
values = sf.controls(doc)
values["tines"] = False
sf.save_controls(doc, values)
check("settings saved in the document", sf.controls(doc)["tines"] is False)
report = sf.generate(doc, box)
check("settings reach the engine (no tines)", "0 tines" in report, report)
sf.save_controls(doc, sf.core.defaults(sf.SCHEMA))
# ---- Draw mode ----
# across the box's bottom face (z=0 before the tilt), 10 mm in from its low edge
import math
c, s_ = math.cos(math.radians(50)), math.sin(math.radians(50))
under = Rhino.Geometry.Point3d
a, b = under(5, 10 * c, 30 + 10 * s_), under(35, 10 * c, 30 + 10 * s_)
wall, info = sf.draw_wall(doc, box, a, b)
check("a drawn wall under the box", wall is not None, info)
wall2, why = sf.draw_wall(doc, box, under(100, 100, 50), under(120, 100, 50))
check("a wall in mid-air says why not", wall2 is None and isinstance(why, str) and why, why)
sf.generate(doc, box)
check("SupportFins leaves drawn walls alone", len(sf.fins_of(doc, box, {"drawn"})) == 1)
# ---- STL parity ----
stl = os.path.join(REPO, "tests", "fixtures", "bowl.stl")
before = {o.Id for o in doc.Objects}
doc.Import(stl)
imported = [o for o in doc.Objects if o.Id not in before and isinstance(o.Geometry, Rhino.Geometry.Mesh)]
check("STL imports as one mesh", len(imported) == 1, len(imported))
part = imported[0]
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}")
# ---- units ----
mm = objs_tris(sf.fins_of(doc, box, {"fin"}), doc)
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}")
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)
crv = doc.Objects.FindId(doc.Objects.AddLine(Rhino.Geometry.Point3d(0, 0, 0), Rhino.Geometry.Point3d(1, 1, 1)))
try:
sf.generate(doc, crv)
check("a curve is refused", False)
except ValueError as e:
check("a curve is refused with a message", "pick a solid" in str(e), e)
except Exception: # noqa: BLE001
check("no crash", False, traceback.format_exc())
results.append({"check": "PASS" if not failed else "FAIL", "ok": not failed, "detail": ", ".join(failed)})
json.dump(results, open(OUT, "w"), indent=1)
print("PASS" if not failed else "FAIL: " + ", ".join(failed))
+75
View File
@@ -0,0 +1,75 @@
"""build.py's staged Rhino project, without Rhino (rhinocode compiles it; the real run
is rhino_smoke.py).
python3 -m pytest -q plugins/rhino/tests/test_build.py
Pinned:
- SupportFins.rhproj lists every command and every shared file, with fixed ids (a
rebuild is the same plugin to Rhino), the plugin id the commands look themselves
up by, and a Rhino 8 / Python 3 target;
- each built command is one script: the bootstrap inlined, it parses, and it starts
with the same `# r:` requirements in the same `support-fins` environment (two
commands asking for different packages would fight over one environment).
"""
import ast
import importlib.util
import json
import pathlib
import pytest
HERE = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("rhino_build", HERE.parent / "build.py")
build = importlib.util.module_from_spec(spec)
spec.loader.exec_module(build)
@pytest.fixture(scope="module")
def project(tmp_path_factory):
out = tmp_path_factory.mktemp("rhino")
mp = pytest.MonkeyPatch()
mp.setattr(build, "PROJECT", out / "SupportFins")
# the bundle's contents are the shared suite's business: a stand-in keeps this fast
mp.setattr(build, "bundle_engine", lambda dest: (dest.write_text("var SupportFinsEngine={};"),
"var SupportFinsEngine={};")[1])
build.stage()
mp.undo()
return out / "SupportFins"
def test_rhproj_lists_commands_and_files(project):
proj = json.loads((project / "SupportFins.rhproj").read_text(encoding="utf-8"))
assert proj["id"] == build.PLUGIN_ID
assert proj["settings"]["buildTarget"]["host"] == {"name": "Rhino3D", "version": "8"}
assert [c["title"] for c in proj["codes"]] == list(build.COMMANDS)
assert all(c["language"] == {"id": "*.*.python", "version": "3.*.*"} for c in proj["codes"])
for item in proj["codes"] + proj["resources"]:
assert (project / item["uri"]).is_file(), item["uri"]
files = {r["uri"] for r in proj["resources"]}
assert {"lib/supportfins_rhino.py", "lib/supportfins_rhino_core.py", "lib/supportfins_host.py",
"lib/options.json", "lib/fins_engine.js"} <= files
ids = [proj["id"]] + [c["id"] for c in proj["codes"]] + [r["id"] for r in proj["resources"]]
assert len(set(ids)) == len(ids)
def test_ids_are_stable(project, tmp_path):
first = (project / "SupportFins.rhproj").read_text(encoding="utf-8")
assert build.resource_id("lib/x.py") == build.resource_id("lib/x.py")
assert json.loads(first)["resources"][0]["id"] == build.resource_id(
json.loads(first)["resources"][0]["uri"].split("/", 1)[1])
def test_commands_are_one_script_each(project):
headers = set()
for name in build.COMMANDS:
text = (project / "commands" / f"{name}.py").read_text(encoding="utf-8")
assert "# @bootstrap" not in text
assert f'PLUGIN_ID = "{build.PLUGIN_ID}"' in text
tree = ast.parse(text)
funcs = {n.name for n in tree.body if isinstance(n, ast.FunctionDef)}
assert {"_lib", "RunCommand"} <= funcs, name
head = text.split('"""', 1)[0]
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()
+119
View File
@@ -0,0 +1,119 @@
"""The Rhino plugin's settings and mesh welding, without Rhino: options.json -> dialog
controls -> the engine's dialog values. (The real Rhino run is rhino_smoke.py.)
python3 -m pytest -q plugins/rhino/tests/
Pinned:
- every options.json option becomes exactly one control, in its section; bounded
numbers carry the engine's range; layer height (host-supplied) any positive value;
- the controls' defaults ARE the site's defaults, read back in the engine's units --
and the engine itself accepts them (host_options);
- a percent control shows 0-100 and goes to the engine / 100;
- settings saved in a document come back; a stale, retired or broken entry falls
back to the defaults instead of failing;
- the weld merges identical vertices only and drops collapsed triangles.
"""
import json
import pathlib
import sys
import numpy as np
import pytest
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "SupportFins" / "lib"))
import supportfins_rhino_core as core # noqa: E402
SHARED = HERE.parents[1] / "shared"
SCHEMA = json.loads((SHARED / "engine" / "options.json").read_text(encoding="utf-8"))
SPECS = core.specs(SCHEMA)
BY_KEY = {o["key"]: o for o in SCHEMA["options"]}
def test_every_option_is_one_control():
assert [s["key"] for s in SPECS] == [o["key"] for o in SCHEMA["options"]]
assert [t for t, _ in core.sections(SCHEMA)] == [s["label"] for s in SCHEMA["sections"]]
assert sum(len(specs) for _, specs in core.sections(SCHEMA)) == len(SPECS)
def test_ranges():
for s in SPECS:
o = BY_KEY[s["key"]]
if s["kind"] != "number":
continue
scale = 100 if o.get("percent") else 1
if o.get("hostSupplied"):
assert s["min"] > 0 and s["max"] > o["max"]
else:
assert (s["min"], s["max"]) == pytest.approx((o["min"] * scale, o["max"] * scale))
assert s["min"] <= s["default"] <= s["max"]
assert round(s["step"], s["decimals"]) == pytest.approx(s["step"])
def test_defaults_read_back_as_the_sites_defaults():
values = core.dialog_values(SCHEMA, core.defaults(SCHEMA))
for key, v in values.items():
want = BY_KEY[key]["default"]
assert v == pytest.approx(want) if isinstance(v, float) else v == want, key
def test_units_on_the_way_to_the_engine():
set_to = {"gap": 0.3, "tineDensity": 35.0, "sway.reach": 20.0, "coverage": 100.0,
"padStyle": "sure", "tines": False}
values = core.dialog_values(SCHEMA, set_to)
assert values["gap"] == 0.3 and values["tineDensity"] == 0.35
assert values["sway.reach"] == 0.2 and values["coverage"] == 1.0
assert values["padStyle"] == "sure" and values["tines"] is False
assert values["threshold"] == 45.0 # not set: the default
def test_saved_settings_round_trip():
want = core.defaults(SCHEMA)
want.update({"gap": 0.3, "cutout": "lattice", "sway.on": True, "tineDensity": 40.0})
assert core.load_values(SCHEMA, core.dump_values(want)) == want
@pytest.mark.parametrize("text", [None, "", "not json", "[1, 2]", '{"gap": "wide"}',
'{"gap": 9}', '{"cutout": "star"}', '{"tines": 1}',
'{"Material": "PETG"}'])
def test_bad_saved_settings_fall_back_to_defaults(text):
assert core.load_values(SCHEMA, text) == core.defaults(SCHEMA)
@pytest.fixture(scope="module")
def engine(tmp_path_factory):
pytest.importorskip("py_mini_racer", reason="needs mini-racer==0.14.1")
sys.path.insert(0, str(SHARED / "py"))
sys.path.insert(0, str(SHARED))
import supportfins_host as host
from bundle import bundle_engine
js = bundle_engine(tmp_path_factory.mktemp("engine") / "fins_engine.js")
return host, host.host_engine(js)
def test_the_engine_takes_what_the_dialog_sends(engine):
host, ctx = engine
defaults = host.host_options(ctx, core.dialog_values(SCHEMA, core.defaults(SCHEMA)))
assert defaults["gap"] == 0.2 and "sway" not in defaults # sway off: not sent
# every bounded number at the top of its control's range is still in the engine's
tops = {s["key"]: s["max"] for s in SPECS if s["kind"] == "number" and s["max"] < 1e6}
tops["sway.on"] = True
opts = host.host_options(ctx, core.dialog_values(SCHEMA, tops))
assert opts["coverage"] == 1.0 and opts["sway"]["reach"] == 0.5 # 100 % and 50 % arrive / 100
assert opts["gap"] == 0.4
def test_weld_merges_identical_vertices_only():
a, b, c, d = [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]
near = [1, 1e-9, 0] # not b: a gap stays a gap
tris = np.array([[a, b, c], [b, d, c], [near, d, b], [a, a, c]], dtype=np.float64)
verts, faces = core.weld(tris)
assert len(verts) == 5
assert len(faces) == 3 # the collapsed [a, a, c] is gone
np.testing.assert_array_equal(verts[faces], tris[:3])
def test_piece_names():
assert core.piece_name("Bracket", {"id": 3, "kind": "prop"}) == "Bracket wall 3"
assert core.piece_name("Bracket", {"id": 9, "kind": "pad"}) == "Bracket bed pad"
assert core.piece_name("Bracket", {"id": 4, "kind": "newkind"}) == "Bracket newkind 4"
+2 -1
View File
@@ -59,7 +59,8 @@ numpy, and moves here only when another plugin wants per-fin objects.
Used by: [Orca](../orca/README.md) (inlines the bundle into its single-file plugin),
[Cura](../cura/README.md) and [FreeCAD](../freecad/README.md) (ship the bundle and the
Python host next to the plugin), and [Blender](../blender/README.md) (the same, with
mini-racer as a wheel Blender installs; it uses the per-fin pieces and drawWall).
mini-racer as a wheel Blender installs; it uses the per-fin pieces and drawWall), and
[Rhino](../rhino/README.md) (the same, with mini-racer pip-installed by Rhino 8 itself).
Onshape (FeatureScript) and Prusa (Lua) can't run JavaScript, so they don't use this.
CI: [`.github/workflows/plugins.yml`](../../.github/workflows/plugins.yml) rebuilds and tests