diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 11fb927..0e4e0cb 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -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. diff --git a/.gitignore b/.gitignore index 4caa69f..d5b433d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/plugins/rhino/README.md b/plugins/rhino/README.md new file mode 100644 index 0000000..19bd87d --- /dev/null +++ b/plugins/rhino/README.md @@ -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) +``` diff --git a/plugins/rhino/SupportFins/bootstrap.py b/plugins/rhino/SupportFins/bootstrap.py new file mode 100644 index 0000000..b13ed4a --- /dev/null +++ b/plugins/rhino/SupportFins/bootstrap.py @@ -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") diff --git a/plugins/rhino/SupportFins/commands/SupportFins.py b/plugins/rhino/SupportFins/commands/SupportFins.py new file mode 100644 index 0000000..9fe0e68 --- /dev/null +++ b/plugins/rhino/SupportFins/commands/SupportFins.py @@ -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() diff --git a/plugins/rhino/SupportFins/commands/SupportFinsDraw.py b/plugins/rhino/SupportFins/commands/SupportFinsDraw.py new file mode 100644 index 0000000..e45a47b --- /dev/null +++ b/plugins/rhino/SupportFins/commands/SupportFinsDraw.py @@ -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() diff --git a/plugins/rhino/SupportFins/commands/SupportFinsSettings.py b/plugins/rhino/SupportFins/commands/SupportFinsSettings.py new file mode 100644 index 0000000..78fc01f --- /dev/null +++ b/plugins/rhino/SupportFins/commands/SupportFinsSettings.py @@ -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() diff --git a/plugins/rhino/SupportFins/lib/supportfins_rhino.py b/plugins/rhino/SupportFins/lib/supportfins_rhino.py new file mode 100644 index 0000000..c16fafc --- /dev/null +++ b/plugins/rhino/SupportFins/lib/supportfins_rhino.py @@ -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) diff --git a/plugins/rhino/SupportFins/lib/supportfins_rhino_core.py b/plugins/rhino/SupportFins/lib/supportfins_rhino_core.py new file mode 100644 index 0000000..8b74f1e --- /dev/null +++ b/plugins/rhino/SupportFins/lib/supportfins_rhino_core.py @@ -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']}" diff --git a/plugins/rhino/build.py b/plugins/rhino/build.py new file mode 100644 index 0000000..22b3fb6 --- /dev/null +++ b/plugins/rhino/build.py @@ -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-.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() diff --git a/plugins/rhino/tests/rhino_smoke.py b/plugins/rhino/tests/rhino_smoke.py new file mode 100644 index 0000000..3229a1a --- /dev/null +++ b/plugins/rhino/tests/rhino_smoke.py @@ -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", " 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)) diff --git a/plugins/rhino/tests/test_build.py b/plugins/rhino/tests/test_build.py new file mode 100644 index 0000000..952f1ce --- /dev/null +++ b/plugins/rhino/tests/test_build.py @@ -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() diff --git a/plugins/rhino/tests/test_core.py b/plugins/rhino/tests/test_core.py new file mode 100644 index 0000000..b6260e8 --- /dev/null +++ b/plugins/rhino/tests/test_core.py @@ -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" diff --git a/plugins/shared/README.md b/plugins/shared/README.md index cb72a47..290a03a 100644 --- a/plugins/shared/README.md +++ b/plugins/shared/README.md @@ -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