mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-18 18:03:27 -05:00
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:
1 parent
9e87d835ad
commit
68c5f271fe
14 files changed
+1289
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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']}"
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user