mirror of
https://github.com/gittrahan/support-fins.git
synced 2026-10-22 20:02:53 -05:00
options.json gains nozzle (hostSupplied: slicer, 0.2-0.8, default 0.4). The entry sends it in tunables on every call, so a host's long-lived engine context can't keep the last part's widths. Cura: machine_nozzle_size on the part's extruder. Orca: nozzle_diameter, the wall extruder's entry. FreeCAD, Blender, Fusion, SolidWorks and the CLI pick it up from the schema.
238 lines
10 KiB
Python
238 lines
10 KiB
Python
"""Offline tests for the Orca plugin's Python side.
|
|
|
|
python3 plugins/orca/build.py && python3 -m pytest -q plugins/orca/tests/
|
|
|
|
Runs the BUILT single-file plugin (engine bundle inlined) against fake_orca, a
|
|
stand-in for Orca's embedded module that slices parts with trimesh. What only a
|
|
real OrcaSlicer build can confirm is listed in plugins/orca/README.md.
|
|
"""
|
|
import importlib.util
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import trimesh
|
|
from shapely.geometry import Polygon as SPoly
|
|
from shapely.ops import unary_union
|
|
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
import fake_orca # noqa: E402
|
|
|
|
ORCA = fake_orca.install()
|
|
BUILT = HERE.parent / "build" / "support_fins_orca.py"
|
|
MODELS = HERE.parents[2] / "prototype" / "stress" / "models"
|
|
|
|
|
|
def load_plugin():
|
|
if not BUILT.exists():
|
|
pytest.skip("run plugins/orca/build.py first")
|
|
spec = importlib.util.spec_from_file_location("support_fins_orca", BUILT)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
SF = load_plugin()
|
|
|
|
|
|
def rot_x(deg, shift=(0, 0, 0), scale=1.0):
|
|
m = trimesh.transformations.rotation_matrix(np.radians(deg), [1, 0, 0])
|
|
m[:3, :3] *= scale
|
|
m[:3, 3] = shift
|
|
return m
|
|
|
|
|
|
# ------------------------------------------------------------------ frame
|
|
def test_slice_frame_round_trip_and_refusal():
|
|
f = SF.SliceFrame(np.array([100.0, 50.0]), np.array([140.0, 70.0]),
|
|
(-20_000_000, -10_000_000, 20_000_000, 10_000_000), 1e-6)
|
|
pts = f.to_scaled(np.array([[100.0, 50.0], [140.0, 70.0], [120.0, 60.0]]))
|
|
assert pts.tolist() == [[-20_000_000, -10_000_000], [20_000_000, 10_000_000], [0, 0]]
|
|
with pytest.raises(ValueError): # bbox 3x too big: not the footprint we think
|
|
SF.SliceFrame(np.array([0.0, 0.0]), np.array([40.0, 20.0]),
|
|
(-60_000_000, -30_000_000, 60_000_000, 30_000_000), 1e-6)
|
|
|
|
|
|
# ------------------------------------------------------------------ end to end
|
|
def expected_islands(po, fins_posed):
|
|
"""Independent answer: trimesh-slice the part and every fin shell, shapely-union."""
|
|
out = []
|
|
shells = SF.split_shells(fins_posed)
|
|
for L in po.layers():
|
|
z = L.slice_z
|
|
polys = []
|
|
for mesh in [po.posed] + [trimesh.Trimesh(*_weld(sh)) for sh in shells]:
|
|
sec = mesh.section(plane_origin=[0, 0, z + SF.PLANE_NUDGE], plane_normal=[0, 0, 1])
|
|
if sec is None:
|
|
continue
|
|
planar, to3d = sec.to_2D()
|
|
for p in planar.polygons_full:
|
|
ext = po._to_xy(np.asarray(p.exterior.coords), to3d)
|
|
holes = [po._to_xy(np.asarray(r.coords), to3d) for r in p.interiors]
|
|
polys.append(SPoly(ext * fake_orca.SCALE, [h * fake_orca.SCALE for h in holes]).buffer(0))
|
|
out.append(unary_union(polys) if polys else SPoly())
|
|
return out
|
|
|
|
|
|
def _weld(tris):
|
|
V, inv = np.unique(np.round(tris.reshape(-1, 3), 6), axis=0, return_inverse=True)
|
|
return V, inv.reshape(-1, 3)
|
|
|
|
|
|
def run_case(model, trafo, layer_height=0.2):
|
|
part = trimesh.load(MODELS / f"{model}.stl")
|
|
po = fake_orca.FakePrintObject(part, trafo, layer_height=layer_height)
|
|
# the fins the plugin will compute, in the same centred frame as po.posed
|
|
soup = SF.posed_part_soup(po)
|
|
soup = soup - np.array([po.center_mm[0], po.center_mm[1], soup[:, :, 2].min()])
|
|
fins, stats = SF.compute_fins(soup, layer_height, dict(SF._DEFAULTS))
|
|
want = expected_islands(po, fins)
|
|
cap = SF.SupportFinsSlicing()
|
|
res = cap.execute(fake_orca.Ctx(po))
|
|
return po, res, want, stats
|
|
|
|
|
|
@pytest.mark.parametrize("model,deg,shift,scale", [
|
|
("cube", 45, (137.25, 88.5, 12.0), 1.0), # parked off-centre, lifted
|
|
("wedge", 45, (40.0, 200.0, 0.0), 1.0),
|
|
("ramp", 45, (-30.0, 5.0, 0.0), 1.004), # with XY/Z shrinkage compensation
|
|
])
|
|
def test_fins_land_exactly_where_a_finned_stl_would(model, deg, shift, scale):
|
|
po, res, want, stats = run_case(model, rot_x(deg, shift, scale))
|
|
assert res.status is fake_orca.PluginResult.Success, res
|
|
assert stats["braces"] >= 1
|
|
worst, total_fin = 0.0, 0.0
|
|
for L, w in zip(po.layers(), want):
|
|
got = L.islands()
|
|
diff = got.symmetric_difference(w).area / fake_orca.SCALE ** 2 # mm^2
|
|
worst = max(worst, diff)
|
|
# tolerance: slicer vertex rounding (1 nm) and chord differences, per layer
|
|
assert worst < 0.05, f"{res.message}: worst per-layer mismatch {worst:.4f} mm^2"
|
|
assert " walls, " in res.message or " wall, " in res.message
|
|
|
|
|
|
def test_no_fins_still_reports_overhangs_left_bare(monkeypatch):
|
|
# The torus case: nothing placed, but 2 overhangs too shallow -- never "no fins needed".
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45, (50, 50, 0)))
|
|
monkeypatch.setattr(SF, "compute_fins", lambda *a: (np.zeros((0, 3, 3)), {"unserved": 2}))
|
|
assert "2 overhangs are too shallow" in SF.inject_fins(po, dict(SF._DEFAULTS), 0.2, 1e-6)
|
|
monkeypatch.setattr(SF, "compute_fins", lambda *a: (np.zeros((0, 3, 3)), {}))
|
|
assert SF.inject_fins(po, dict(SF._DEFAULTS), 0.2, 1e-6) == "no fins needed"
|
|
|
|
|
|
def test_fins_that_reach_no_layer_are_not_reported_as_added(monkeypatch):
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45, (50, 50, 0)))
|
|
far = np.array([[[0, 0, 900.0], [1, 0, 900.0], [0, 1, 901.0]]]) # above every layer
|
|
monkeypatch.setattr(SF, "compute_fins", lambda *a: (far, {"braces": 1, "tines": 2}))
|
|
assert "nothing was added" in SF.inject_fins(po, dict(SF._DEFAULTS), 0.2, 1e-6)
|
|
|
|
|
|
def test_fins_actually_add_material_under_the_overhang():
|
|
po, res, want, _ = run_case("cube", rot_x(45, (50, 50, 0)))
|
|
part_only = fake_orca.FakePrintObject(trimesh.load(MODELS / "cube.stl"), rot_x(45, (50, 50, 0)))
|
|
added = sum(L.islands().area - P.islands().area for L, P in zip(po.layers(), part_only.layers()))
|
|
assert added > 0, "no fin material was added"
|
|
|
|
|
|
def test_first_layer_fins_get_elephant_foot_compensation_like_orca():
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
tr = rot_x(45, (60, 60, 0))
|
|
plain = fake_orca.FakePrintObject(part, tr)
|
|
efc = fake_orca.FakePrintObject(part, tr, config={"elefant_foot_compensation": "0.1",
|
|
"elefant_foot_compensation_layers": "1"})
|
|
raft = fake_orca.FakePrintObject(part, tr, config={"elefant_foot_compensation": "0.1", "raft_layers": "2"})
|
|
for po in (plain, efc, raft):
|
|
SF.SupportFinsSlicing().execute(fake_orca.Ctx(po))
|
|
a0, e0, r0 = (po.layers()[0].islands().area for po in (plain, efc, raft))
|
|
assert e0 < a0, "layer 0 fins were not compensated"
|
|
assert r0 == a0, "compensation must be off on a raft, like Orca"
|
|
assert efc.layers()[1].islands().area == plain.layers()[1].islands().area, "only the first layer shrinks"
|
|
|
|
|
|
def test_part_with_orca_supports_on_is_left_alone():
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45), config={"enable_support": "1"})
|
|
before = [L.islands().area for L in po.layers()]
|
|
res = SF.SupportFinsSlicing().execute(fake_orca.Ctx(po))
|
|
assert "skipped" in res.message
|
|
assert [L.islands().area for L in po.layers()] == before
|
|
|
|
|
|
def test_other_steps_and_disabled_config_do_nothing():
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45))
|
|
before = [L.islands().area for L in po.layers()]
|
|
cap = SF.SupportFinsSlicing()
|
|
cap.execute(fake_orca.Ctx(po, step=fake_orca.Step.posPerimeters))
|
|
cap._config = json.dumps({"enabled": False})
|
|
assert "disabled" in cap.execute(fake_orca.Ctx(po)).message
|
|
assert [L.islands().area for L in po.layers()] == before
|
|
|
|
|
|
def test_errors_are_reported_not_raised(monkeypatch):
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45))
|
|
monkeypatch.setattr(SF, "compute_fins", lambda *a: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
res = SF.SupportFinsSlicing().execute(fake_orca.Ctx(po))
|
|
assert res.status is fake_orca.PluginResult.RecoverableError and "boom" in res.message
|
|
|
|
|
|
def test_plugin_registers_its_capability():
|
|
ORCA.registered.clear()
|
|
SF.SupportFinsPlugin().register_capabilities()
|
|
assert ORCA.registered == [SF.SupportFinsSlicing]
|
|
|
|
|
|
def test_mirrored_part_gets_the_same_fins_as_its_twin():
|
|
"""Orca's mirror tool leaves a matrix with det < 0, which flips every triangle's
|
|
winding. The engine finds overhangs from face normals, so the plugin must put the
|
|
winding back: a mirrored part gets the same fins as the same shape modelled
|
|
mirrored. (Before the fix: lbracket at 35 deg dropped from 4 fins to 1.)"""
|
|
part = trimesh.load(MODELS / "lbracket.stl")
|
|
mirror = np.diag([-1.0, 1.0, 1.0, 1.0])
|
|
po = fake_orca.FakePrintObject(part, rot_x(35) @ mirror)
|
|
soup = SF.posed_part_soup(po)
|
|
signed = np.einsum("ij,ij->i", soup[:, 0], np.cross(soup[:, 1], soup[:, 2])).sum() / 6
|
|
assert signed > 0, "mirrored part came through inside-out"
|
|
twin = part.copy()
|
|
twin.apply_transform(mirror) # trimesh repairs the winding itself
|
|
ref = fake_orca.FakePrintObject(twin, rot_x(35))
|
|
_, s_mir = SF.compute_fins(soup, 0.2, dict(SF._DEFAULTS))
|
|
_, s_ref = SF.compute_fins(SF.posed_part_soup(ref), 0.2, dict(SF._DEFAULTS))
|
|
assert s_ref["braces"] >= 1
|
|
assert (s_mir["braces"], s_mir["tines"]) == (s_ref["braces"], s_ref["tines"])
|
|
|
|
|
|
class _Cfg:
|
|
def __init__(self, **kv):
|
|
self.kv = kv
|
|
|
|
def config_value(self, key):
|
|
return self.kv.get(key)
|
|
|
|
|
|
@pytest.mark.parametrize("cfg,want", [
|
|
({}, None), # Orca doesn't say: engine's 0.4
|
|
({"nozzle_diameter": "0.6"}, 0.6),
|
|
({"nozzle_diameter": "0.4,0.8", "wall_filament": "2"}, 0.8), # the wall extruder's
|
|
({"nozzle_diameter": [0.4, 0.8], "wall_filament": "9"}, 0.8), # past the list: the last
|
|
({"nozzle_diameter": "junk"}, None),
|
|
])
|
|
def test_nozzle_comes_from_orcas_printer_settings(cfg, want):
|
|
assert SF._nozzle(_Cfg(**cfg)) == want
|
|
|
|
|
|
def test_the_nozzle_reaches_the_engine():
|
|
part = trimesh.load(MODELS / "cube.stl")
|
|
po = fake_orca.FakePrintObject(part, rot_x(45))
|
|
soup = SF.posed_part_soup(po)
|
|
soup = soup - np.array([0.0, 0.0, soup[:, :, 2].min()])
|
|
thin, _ = SF.compute_fins(soup, 0.2, dict(SF._DEFAULTS))
|
|
wide, _ = SF.compute_fins(soup, 0.2, dict(SF._DEFAULTS), 0.8)
|
|
assert thin.shape != wide.shape or not np.allclose(thin, wide)
|