Add the solidcraft-checking skill (#880)
This commit is contained in:
1 parent
2b70ed7b72
commit
f037c92370
13 files changed
+1209
-2
No files matched your search
@@ -0,0 +1,54 @@
|
||||
"""Compare two STEP files exactly with OCCT: volume, area, centre of mass, and the volume of their
|
||||
symmetric difference (xor) with where each piece sits. Exits 1 when xor exceeds the tolerance.
|
||||
|
||||
Run: uv run --no-project --with build123d python compare_step.py ACTUAL.step EXPECTED.step
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from build123d import CenterOf, Shape, import_step
|
||||
|
||||
from run import number, point
|
||||
|
||||
PIECES_SHOWN = 5
|
||||
|
||||
|
||||
def fused(shape: Shape) -> Shape:
|
||||
"""Overlapping solids in one file would otherwise count their overlap twice."""
|
||||
solids = shape.solids()
|
||||
return solids[0].fuse(*solids[1:]) if len(solids) > 1 else shape
|
||||
|
||||
|
||||
def pieces(shape: Shape) -> list[tuple[float, tuple, tuple]]:
|
||||
found = [(s.volume, tuple(s.bounding_box().min), tuple(s.bounding_box().max)) for s in shape.solids()]
|
||||
return sorted(found, reverse=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("actual")
|
||||
parser.add_argument("expected")
|
||||
parser.add_argument("--tolerance", type=float, default=1e-3, help="largest xor volume that counts as equal, mm3")
|
||||
args = parser.parse_args()
|
||||
|
||||
actual, expected = fused(import_step(args.actual)), fused(import_step(args.expected))
|
||||
shift = max(abs(a - b) for a, b in zip(actual.center(CenterOf.MASS), expected.center(CenterOf.MASS)))
|
||||
print(f"volume {number(actual.volume)} vs {number(expected.volume)} mm3")
|
||||
print(f"area {number(actual.area)} vs {number(expected.area)} mm2")
|
||||
print(f"centre of mass shift {number(shift)} mm")
|
||||
|
||||
sides = {"only in actual": pieces(actual - expected), "only in expected": pieces(expected - actual)}
|
||||
xor = sum(volume for found in sides.values() for volume, _, _ in found)
|
||||
count = sum(map(len, sides.values()))
|
||||
print(f"xor {number(xor)} mm3" + (f" in {count} pieces" if count else ""))
|
||||
for side, found in sides.items():
|
||||
for volume, low, high in found[:PIECES_SHOWN]:
|
||||
print(f" {side}: {number(volume)} mm3 at {point(low)}..{point(high)}")
|
||||
if len(found) > PIECES_SHOWN:
|
||||
print(f" {side}: {len(found) - PIECES_SHOWN} smaller pieces")
|
||||
sys.exit(1 if xor > args.tolerance else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a SolidCraft command script and print a short report: a failure, feature warnings,
|
||||
sketches that are not fully constrained, and body measurements. A document.inspect inside the
|
||||
script reports that state too, its lines prefixed with its step; the end state comes last."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
INSPECT = {"command": "document.inspect", "params": {"measure": True}}
|
||||
WARNING_LENGTH = 160
|
||||
|
||||
|
||||
class SolidCraftError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_commands(commands: list, design: Path | None = None, save: Path | None = None) -> list:
|
||||
"""Results of every command, followed by a final document.inspect."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "script.json"
|
||||
script.write_text(json.dumps({"commands": [*commands, INSPECT]}))
|
||||
args = ["solidcraft-cli", "run", str(script), "--results"]
|
||||
if design:
|
||||
args += ["--in", str(design)]
|
||||
if save:
|
||||
args += ["--save", str(save)]
|
||||
done = subprocess.run(args, capture_output=True, text=True)
|
||||
if done.returncode:
|
||||
raise SolidCraftError(done.stderr.strip().removeprefix("error: "))
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def execute(commands: list, design: Path | None = None, save: Path | None = None) -> dict:
|
||||
"""The document.inspect of the design after the commands."""
|
||||
return run_commands(commands, design, save)[-1]
|
||||
|
||||
|
||||
def number(value: float) -> str:
|
||||
return f"{round(value, 3) + 0.0:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def point(values: list) -> str:
|
||||
return "(" + ", ".join(map(number, values)) + ")"
|
||||
|
||||
|
||||
def report(inspection: dict) -> list[str]:
|
||||
"""Warnings come from the timeline: a rebuild after a parameter change reports them only there."""
|
||||
lines = [f'WARN "{feature["name"]}": {feature["warning"][:WARNING_LENGTH]}'
|
||||
for feature in inspection["timeline"] if feature.get("warning")]
|
||||
for sketch in inspection.get("sketches", []):
|
||||
if sketch["dof"] or sketch["status"] != "solved":
|
||||
lines.append(f'SKETCH "{sketch["name"]}": dof {sketch["dof"]}, {sketch["status"]}')
|
||||
lines += map(body_line, inspection["bodies"])
|
||||
if not inspection["bodies"]:
|
||||
lines.append("no bodies")
|
||||
return lines
|
||||
|
||||
|
||||
def body_line(body: dict) -> str:
|
||||
if "volume_mm3" not in body: # inspected without measure
|
||||
return f'BODY {body["name"]}'
|
||||
bbox = body["bbox"]
|
||||
return (f'BODY {body["name"]}: volume {number(body["volume_mm3"])} mm3, area {number(body["area_mm2"])} mm2, '
|
||||
f'bbox {point(bbox["min"])}..{point(bbox["max"])}, com {point(body["center_of_mass"])}')
|
||||
|
||||
|
||||
def script_report(commands: list, results: list) -> list[str]:
|
||||
lines = [f"step {step} {line}"
|
||||
for step, (command, result) in enumerate(zip(commands, results), 1)
|
||||
if command["command"] == "document.inspect"
|
||||
for line in report(result)]
|
||||
return lines + report(results[-1])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("script", type=Path, help='command script: {"commands": [...]}')
|
||||
parser.add_argument("--in", dest="design", type=Path, help="design to run the script on")
|
||||
parser.add_argument("--save", type=Path, help="save the resulting design; a .json name keeps it JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
commands = json.loads(args.script.read_text())["commands"]
|
||||
try:
|
||||
results = run_commands(commands, args.design, args.save)
|
||||
except SolidCraftError as error:
|
||||
print(f"FAIL {error}")
|
||||
sys.exit(1)
|
||||
print("\n".join(script_report(commands, results)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""SolidCraft builder: write command scripts whose sketches come out fully constrained.
|
||||
|
||||
Each sketch helper makes one sketch and dimensions its geometry from the sketch origin. `at` is
|
||||
where the geometry starts: put it near the solved position, on the side of the origin it must
|
||||
stay on, because a distance dimension keeps the side it starts on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FEATURE_COMMANDS = ("solid.", "construct.", "sheet.", "surface.", "mesh.", "file.insert_", "sketch.create")
|
||||
|
||||
|
||||
class Script:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[dict] = []
|
||||
self.names: set[str] = set()
|
||||
self.body_names: set[str] = set()
|
||||
|
||||
def cmd(self, command: str, /, **params) -> None:
|
||||
if "name" in params and command.startswith(FEATURE_COMMANDS) and not command.endswith(".manage_rules"):
|
||||
self._claim(params["name"], self.names, "a sketch or feature")
|
||||
if "body_name" in params:
|
||||
self._claim(params["body_name"], self.body_names, "a body")
|
||||
self.commands.append({"command": command, "params": params})
|
||||
|
||||
@staticmethod
|
||||
def _claim(name: str, taken: set[str], kind: str) -> None:
|
||||
"""SolidCraft resolves a sketch or feature name to its first match and renames a repeated
|
||||
body name, so either repeat silently points at the wrong thing."""
|
||||
if name in taken:
|
||||
raise ValueError(f"{name}: {kind} already has this name")
|
||||
taken.add(name)
|
||||
|
||||
def parameter(self, name: str, expression: str, unit: str | None = None, comment: str = "") -> None:
|
||||
"""A user parameter; unit "" makes a unitless one such as a count."""
|
||||
params = {"name": name, "expression": expression}
|
||||
if unit is not None:
|
||||
params["unit"] = unit
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
self.cmd("parameters.add", **params)
|
||||
|
||||
@staticmethod
|
||||
def _check(point: str, at: tuple, x: str | None, y: str | None) -> None:
|
||||
if x is None and y is None and tuple(at) != (0, 0):
|
||||
raise ValueError(f"{point}: give x or y, or start it at the origin")
|
||||
for axis, start, distance in (("x", at[0], x), ("y", at[1], y)):
|
||||
if distance is not None and start == 0:
|
||||
raise ValueError(f"{point}: the {axis} distance starts at 0 and would lose its side")
|
||||
|
||||
def place(self, point: str, at: tuple, x: str | None, y: str | None) -> None:
|
||||
"""Distances of `point` from the origin; None puts it on that axis."""
|
||||
self._check(point, at, x, y)
|
||||
if x is None and y is None:
|
||||
return # a point created on the origin is made coincident with it
|
||||
if x is None:
|
||||
self.cmd("sketch.constraint.horizontal_vertical", points=["origin", point], mode="vertical")
|
||||
else:
|
||||
self.cmd("sketch.dimension", entities=["origin", point], type="horizontal", value=x)
|
||||
if y is None:
|
||||
self.cmd("sketch.constraint.horizontal_vertical", points=["origin", point], mode="horizontal")
|
||||
else:
|
||||
self.cmd("sketch.dimension", entities=["origin", point], type="vertical", value=y)
|
||||
|
||||
def _sketch(self, name: str, offset: str | None) -> None:
|
||||
self.cmd("sketch.create", plane="XY", name=name, **({"offset": offset} if offset else {}))
|
||||
|
||||
def hexagon(self, sketch: str, across_flats: str, at: tuple, x: str | None = None, y: str | None = None,
|
||||
offset: str | None = None, approx: float = 20) -> None:
|
||||
"""Regular hexagon with vertices up and down and flat sides facing ±x, on the XY plane
|
||||
raised by `offset`. `approx` is a rough across-flats size for the solver to start from."""
|
||||
self._check(sketch, at, x, y)
|
||||
self._sketch(sketch, offset)
|
||||
self.cmd("sketch.polygon.circumscribed", center=list(at), radius=approx / 2, sides=6, angle=0)
|
||||
self.cmd("sketch.dimension", entities=["c1"], type="diameter", value=across_flats)
|
||||
# Equal sides tangent to the circle still let the corners alternate in and out
|
||||
self.cmd("sketch.constraint.polygon", lines=["l1", "l2", "l3", "l4", "l5", "l6"])
|
||||
self.cmd("sketch.constraint.horizontal_vertical", line="l3", mode="vertical")
|
||||
self.place("c1.center", at, x, y)
|
||||
self.cmd("sketch.finish")
|
||||
|
||||
def circle(self, sketch: str, diameter: str, at: tuple, x: str | None = None, y: str | None = None,
|
||||
offset: str | None = None, approx: float = 20) -> None:
|
||||
self._check(sketch, at, x, y)
|
||||
self._sketch(sketch, offset)
|
||||
self.cmd("sketch.circle.center", center=list(at), radius=approx / 2)
|
||||
self.cmd("sketch.dimension", entities=["c1"], type="diameter", value=diameter)
|
||||
self.place("c1.center", at, x, y)
|
||||
self.cmd("sketch.finish")
|
||||
|
||||
def rounded_rectangle(self, sketch: str, width: str, height: str, bottom: str, corner_radius: str,
|
||||
at: tuple, offset: str | None = None) -> None:
|
||||
"""Rectangle symmetric about the y axis with its bottom edge `bottom` from the origin.
|
||||
`at` is (left, bottom, right, top) to start from."""
|
||||
left, low, right, top = at
|
||||
if low == 0:
|
||||
raise ValueError(f"{sketch}: the bottom distance starts at 0 and would lose its side")
|
||||
self._sketch(sketch, offset)
|
||||
self.cmd("sketch.rectangle.two_point", p0=[left, low], p1=[right, top])
|
||||
# Sides: l1 bottom, l2 right, l3 top, l4 left; corners p1..p4 from bottom left, anticlockwise
|
||||
self.cmd("sketch.constraint.symmetry", a="p1", b="p2", line="y")
|
||||
self.cmd("sketch.dimension", entities=["l4", "l2"], type="distance", value=width)
|
||||
self.cmd("sketch.dimension", entities=["origin", "l1"], type="distance", value=bottom)
|
||||
self.cmd("sketch.dimension", entities=["l4"], type="length", value=height)
|
||||
self.cmd("sketch.fillet", points=["p1", "p2", "p3", "p4"], radius=corner_radius)
|
||||
self.cmd("sketch.finish")
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
Path(path).write_text(json.dumps({"commands": self.commands}, indent=1))
|
||||
@@ -0,0 +1,66 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import run # noqa: E402
|
||||
|
||||
|
||||
def cube_step(path: Path, x: float) -> Path:
|
||||
run.execute([
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Cube"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Cube"], "translate": [x, 0, 0]}},
|
||||
{"command": "file.export", "params": {"path": str(path), "bodies": ["Cube"]}},
|
||||
])
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cubes(tmp_path):
|
||||
return cube_step(tmp_path / "a.step", 0), cube_step(tmp_path / "b.step", 1)
|
||||
|
||||
|
||||
def compare(*args):
|
||||
return subprocess.run([sys.executable, SCRIPTS / "compare_step.py", *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_identical_solids_have_no_difference(cubes):
|
||||
a, _ = cubes
|
||||
result = compare(a, a)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "xor 0 mm3" in result.stdout
|
||||
|
||||
|
||||
def test_shifted_solid_shows_where_it_differs(cubes):
|
||||
a, b = cubes
|
||||
result = compare(b, a)
|
||||
assert result.returncode == 1, result.stderr
|
||||
assert "xor 200 mm3 in 2 pieces" in result.stdout
|
||||
assert "only in actual: 100 mm3 at (10, 0, 0)..(11, 10, 10)" in result.stdout
|
||||
assert "only in expected: 100 mm3 at (0, 0, 0)..(1, 10, 10)" in result.stdout
|
||||
|
||||
|
||||
def test_tolerance(cubes):
|
||||
a, b = cubes
|
||||
result = compare(b, a, "--tolerance", "500")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "xor 200 mm3 in 2 pieces" in result.stdout
|
||||
|
||||
|
||||
def two_boxes_step(path: Path, shift: float) -> Path:
|
||||
run.execute([
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "A"}},
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "B"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["B"], "translate": [shift, 0, 0]}},
|
||||
{"command": "file.export", "params": {"path": str(path), "bodies": ["A", "B"]}},
|
||||
])
|
||||
return path
|
||||
|
||||
|
||||
def test_overlapping_solids_in_one_file_count_once(tmp_path):
|
||||
result = compare(two_boxes_step(tmp_path / "a.step", 5), two_boxes_step(tmp_path / "b.step", 10))
|
||||
assert "volume 1500 vs 2000 mm3" in result.stdout, result.stdout + result.stderr
|
||||
assert "xor 500 mm3" in result.stdout
|
||||
@@ -0,0 +1,428 @@
|
||||
"""SolidCraft behaviour that SKILL.md states, and the facts behind the corrections in
|
||||
solidcraft-modeling. A failure here means the skill text is out of date."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from math import cos, dist, pi, radians, sin
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
from run import SolidCraftError, execute, run_commands # noqa: E402
|
||||
|
||||
|
||||
def cmd(command: str, /, **params) -> dict:
|
||||
return {"command": command, "params": params}
|
||||
|
||||
|
||||
def bbox(body: dict) -> tuple:
|
||||
return (*body["bbox"]["min"], *body["bbox"]["max"])
|
||||
|
||||
|
||||
def sketch_dof(commands: list) -> int:
|
||||
return execute(commands)["sketches"][-1]["dof"]
|
||||
|
||||
|
||||
def warnings(commands: list) -> list:
|
||||
return [f["warning"] for f in execute(commands)["timeline"] if f.get("warning")]
|
||||
|
||||
|
||||
def volumes(inspection: dict) -> dict:
|
||||
return {b["name"]: b["volume_mm3"] for b in inspection["bodies"]}
|
||||
|
||||
|
||||
BLOCK = [cmd("solid.box", length=40, width=40, height=12, body_name="B", name="Block")]
|
||||
|
||||
|
||||
def tapered_cut(start: str, distance: str, taper: str = "10 deg") -> list:
|
||||
return [
|
||||
cmd("sketch.create", plane="XY", offset=start, name="Cut sketch"),
|
||||
cmd("sketch.circle.center", center=[20, 20], diameter=20),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Cut sketch", distance=distance, taper=taper, operation="cut", targets=["B"],
|
||||
name="Cut"),
|
||||
]
|
||||
|
||||
|
||||
def holes(count: str) -> list:
|
||||
return [
|
||||
cmd("parameters.add", name="n", expression=count, unit=""),
|
||||
*BLOCK,
|
||||
cmd("solid.cylinder", diameter="4 mm", height="20 mm", base=["5 mm", "5 mm", "-1 mm"], operation="cut",
|
||||
targets=["B"], name="Hole"),
|
||||
cmd("solid.pattern.rectangular", features=["Hole"], dir1=[1, 0, 0], count1="n", spacing1="10 mm",
|
||||
name="Row"),
|
||||
]
|
||||
|
||||
|
||||
# Traps
|
||||
|
||||
def test_a_dimension_driven_to_zero_can_break_the_next_change():
|
||||
with pytest.raises(SolidCraftError, match="would conflict"):
|
||||
execute([
|
||||
cmd("parameters.add", name="y", expression="5 mm"),
|
||||
cmd("sketch.create", plane="XY", name="Pin sketch"),
|
||||
cmd("sketch.circle.center", center=[-10, -5], radius=2),
|
||||
cmd("sketch.dimension", entities=["origin", "c1.center"], type="horizontal", value="10 mm"),
|
||||
cmd("sketch.dimension", entities=["origin", "c1.center"], type="vertical", value="y"),
|
||||
cmd("sketch.dimension", entities=["c1"], type="radius", value="2 mm"),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Pin sketch", distance="1 mm"),
|
||||
cmd("parameters.change", name="y", expression="0 mm"),
|
||||
cmd("parameters.change", name="y", expression="5 mm"),
|
||||
])
|
||||
|
||||
|
||||
def test_a_slot_held_by_other_constraints_can_cross_to_the_other_side_at_zero():
|
||||
body = execute([
|
||||
cmd("parameters.add", name="n", expression="3", unit=""),
|
||||
cmd("sketch.create", plane="XY", name="Slot sketch"),
|
||||
cmd("sketch.slot.overall", p0=[-6, -15], p1=[-6, 15], width=2),
|
||||
cmd("sketch.constraint.symmetry", a="a1.center", b="a2.center", line="x"),
|
||||
cmd("sketch.dimension", entities=["a1"], type="radius", value="1 mm"),
|
||||
cmd("sketch.dimension", entities=["a1.center", "a2.center"], type="vertical", value="28 mm"),
|
||||
cmd("sketch.dimension", entities=["origin", "a1.center"], type="horizontal", value="(n - 1) * 3 mm"),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Slot sketch", distance="1 mm", body_name="Slot"),
|
||||
cmd("parameters.change", name="n", expression="1"),
|
||||
cmd("parameters.change", name="n", expression="3"),
|
||||
])["bodies"][0]
|
||||
# Started at x = -6; after n 3 -> 1 -> 3 it sits at +6, with no error
|
||||
assert (body["bbox"]["min"][0] + body["bbox"]["max"][0]) / 2 == pytest.approx(6, abs=1e-3)
|
||||
|
||||
|
||||
def centred_row(position: list) -> list:
|
||||
"""A row of n holes centred on x = 0, its first hole placed by `position`, walked n 3 -> 1 -> 3."""
|
||||
return [
|
||||
cmd("parameters.add", name="n", expression="3", unit=""),
|
||||
cmd("solid.box", length=100, width=40, height=4, center=[0, 0, 2], body_name="Plate"),
|
||||
cmd("sketch.create", plane="XY", offset="4 mm", name="Hole sketch"),
|
||||
cmd("sketch.circle.center", center=[-10, 0], radius=1),
|
||||
cmd("sketch.dimension", entities=["c1"], type="diameter", value="2 mm"),
|
||||
*position,
|
||||
cmd("sketch.constraint.horizontal_vertical", points=["origin", "c1.center"], mode="horizontal"),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Hole sketch", distance="6 mm", direction="negative", operation="cut",
|
||||
targets=["Plate"], name="Hole"),
|
||||
cmd("solid.pattern.rectangular", features=["Hole"], dir1=[1, 0, 0], count1="n", spacing1="10 mm"),
|
||||
cmd("parameters.change", name="n", expression="1"),
|
||||
cmd("parameters.change", name="n", expression="3"),
|
||||
]
|
||||
|
||||
|
||||
def test_a_position_measured_from_an_anchor_point_survives_reaching_zero():
|
||||
with pytest.raises(SolidCraftError, match="would conflict"):
|
||||
execute(centred_row([
|
||||
cmd("sketch.dimension", entities=["origin", "c1.center"], type="horizontal", value="(n - 1) / 2 * 10 mm"),
|
||||
]))
|
||||
body = execute(centred_row([
|
||||
cmd("sketch.point", point=[-100, 0]),
|
||||
cmd("sketch.dimension", entities=["origin", "p1"], type="horizontal", value="100 mm"),
|
||||
cmd("sketch.constraint.horizontal_vertical", points=["origin", "p1"], mode="horizontal"),
|
||||
cmd("sketch.dimension", entities=["p1", "c1.center"], type="horizontal", value="100 mm - (n - 1) / 2 * 10 mm"),
|
||||
]))["bodies"][0]
|
||||
assert body["volume_mm3"] == pytest.approx(100 * 40 * 4 - 3 * pi * 4, rel=2e-4)
|
||||
|
||||
|
||||
def test_pattern_counts_run_from_1_to_1000_and_a_range_rule_does_not_stop_0():
|
||||
with pytest.raises(SolidCraftError, match="pattern count must be 1…1000"):
|
||||
execute([
|
||||
*holes("2"),
|
||||
cmd("config.configure", params=["n"]),
|
||||
cmd("config.rule", rule={"id": "n-range", "name": "n", "condition": {"type": "always"},
|
||||
"effect": {"type": "parameter_range", "parameter": "n", "minimum": "1",
|
||||
"maximum": "9"}}),
|
||||
cmd("parameters.change", name="n", expression="0"),
|
||||
])
|
||||
|
||||
|
||||
def test_a_fractional_count_rounds_to_the_nearest_whole_number():
|
||||
def copies(n):
|
||||
return len(execute([
|
||||
cmd("parameters.add", name="n", expression=n, unit=""),
|
||||
cmd("solid.cylinder", diameter="2 mm", height="2 mm", operation="new", body_name="Pin", name="Pin"),
|
||||
cmd("solid.pattern.rectangular", bodies=["Pin"], dir1=[1, 0, 0], count1="n", spacing1="10 mm"),
|
||||
])["bodies"])
|
||||
|
||||
assert (copies("6.4"), copies("6.5")) == (6, 7)
|
||||
|
||||
|
||||
HEXAGON = [
|
||||
cmd("sketch.create", plane="XY"),
|
||||
cmd("sketch.polygon.circumscribed", center=[0, 0], radius=10, sides=6, angle=0),
|
||||
cmd("sketch.dimension", entities=["c1"], type="diameter", value="20 mm"),
|
||||
cmd("sketch.constraint.horizontal_vertical", line="l3", mode="vertical"),
|
||||
]
|
||||
|
||||
|
||||
def test_a_circumscribed_polygon_needs_the_polygon_constraint():
|
||||
assert sketch_dof(HEXAGON) == 1
|
||||
assert sketch_dof([*HEXAGON, cmd("sketch.constraint.polygon", lines=[f"l{i}" for i in range(1, 7)])]) == 0
|
||||
|
||||
|
||||
def test_in_a_plain_block_only_a_tapered_tool_on_a_face_logs_a_boolean_retry():
|
||||
assert any("coincident-face" in w for w in warnings([*BLOCK, *tapered_cut("0 mm", "12 mm")]))
|
||||
assert warnings([*BLOCK, *tapered_cut("-1 mm", "14 mm")]) == []
|
||||
assert warnings([*BLOCK, *tapered_cut("0 mm", "12 mm", taper="0 deg")]) == []
|
||||
|
||||
|
||||
def test_round_features_measure_about_1e_4_off():
|
||||
body = execute([cmd("solid.cylinder", diameter="20 mm", height="10 mm")])["bodies"][0]
|
||||
error = abs(body["volume_mm3"] / (pi * 100 * 10) - 1)
|
||||
assert 1e-7 < error < 1.5e-4
|
||||
|
||||
|
||||
def test_shallow_arcs_measure_much_worse_than_round_features():
|
||||
radius, span = 1000, radians(20)
|
||||
half = radius * sin(span / 2)
|
||||
rise = radius - radius * cos(span / 2)
|
||||
body = execute([
|
||||
cmd("sketch.create", plane="XY", name="Segment sketch"),
|
||||
cmd("sketch.arc.three_point", start=[-half, 0], through=[0, rise], end=[half, 0]),
|
||||
cmd("sketch.line", points=[[-half, 0], [half, 0]]),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Segment sketch", distance="10 mm"),
|
||||
])["bodies"][0]
|
||||
exact = radius ** 2 / 2 * (span - sin(span)) * 10
|
||||
assert 2e-4 < abs(body["volume_mm3"] / exact - 1) < 5e-3
|
||||
|
||||
|
||||
def test_centre_of_mass_and_bounding_box_measure_within_5e_5_of_the_part_size():
|
||||
radius = 100
|
||||
body = execute([
|
||||
cmd("sketch.create", plane="XY", name="Half disc sketch"),
|
||||
cmd("sketch.arc.three_point", start=[-radius, 0], through=[0, radius], end=[radius, 0]),
|
||||
cmd("sketch.line", points=[[-radius, 0], [radius, 0]]),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Half disc sketch", distance="10 mm"),
|
||||
])["bodies"][0]
|
||||
exact_box = (-radius, 0, 0, radius, radius, 10)
|
||||
size = dist(exact_box[:3], exact_box[3:])
|
||||
assert max(abs(a - b) for a, b in zip(bbox(body), exact_box)) < 5e-5 * size
|
||||
assert abs(body["center_of_mass"][1] - 4 * radius / (3 * pi)) < 5e-5 * size
|
||||
|
||||
|
||||
def test_expressions_have_no_conditionals_and_sign_of_zero_is_one():
|
||||
def evaluate(expression):
|
||||
done = subprocess.run(
|
||||
["solidcraft-cli", "exec", "expr.evaluate", json.dumps({"expression": expression, "kind": "unitless"})],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
assert not evaluate("if(1 > 0, 1, 2)")["ok"]
|
||||
assert evaluate("sign(0)")["value"] == 1
|
||||
assert evaluate("sign(-3)")["value"] == -1
|
||||
assert evaluate("log(100)")["value"] == 2
|
||||
with pytest.raises(SolidCraftError, match="got an angle"):
|
||||
execute([cmd("parameters.add", name="a", expression="atan(1)", unit="")])
|
||||
|
||||
|
||||
def test_a_point_created_on_the_origin_snaps_to_it():
|
||||
assert sketch_dof([cmd("sketch.create", plane="XY"), cmd("sketch.circle.center", center=[0, 0], radius=5)]) == 1
|
||||
|
||||
|
||||
def test_symmetric_rectangle_sides_conflict_and_corners_do_not():
|
||||
rectangle = [cmd("sketch.create", plane="XY"), cmd("sketch.rectangle.two_point", p0=[-10, 2], p1=[10, 8])]
|
||||
with pytest.raises(SolidCraftError, match="conflicts"):
|
||||
execute([*rectangle, cmd("sketch.constraint.symmetry", a="l4", b="l2", line="y")])
|
||||
execute([*rectangle, cmd("sketch.constraint.symmetry", a="p1", b="p2", line="y")])
|
||||
|
||||
|
||||
def test_a_repeated_body_name_is_renamed_and_targets_reach_the_first_body():
|
||||
inspection = execute([
|
||||
cmd("solid.box", length=10, width=10, height=10, body_name="M"),
|
||||
cmd("solid.box", length=10, width=10, height=10, center=[30, 0, 5], body_name="M"),
|
||||
cmd("solid.cylinder", diameter="4 mm", height="20 mm", base=["30 mm", "0 mm", "-5 mm"], operation="cut",
|
||||
targets=["M"]),
|
||||
])
|
||||
assert volumes(inspection) == pytest.approx({"M": 1000, "Body1": 1000})
|
||||
|
||||
|
||||
# Conventions
|
||||
|
||||
def test_positive_taper_widens_in_either_direction():
|
||||
for direction, z in (("positive", 10), ("negative", -10)):
|
||||
body = execute([
|
||||
cmd("sketch.create", plane="XY", name="Square"),
|
||||
cmd("sketch.rectangle.center", center=[5, 5], corner=[10, 10]),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Square", distance="10 mm", direction=direction, taper="10 deg"),
|
||||
])["bodies"][0]
|
||||
assert body["bbox"]["max"][0] > 10.5, direction
|
||||
assert body["bbox"]["max"][2] == pytest.approx(max(z, 0), abs=1e-6)
|
||||
|
||||
|
||||
def test_a_circumscribed_hexagon_at_angle_0_has_vertices_up_and_down():
|
||||
sketch = run_commands([
|
||||
cmd("sketch.create", plane="XY"),
|
||||
cmd("sketch.polygon.circumscribed", center=[0, 0], radius=10, sides=6, angle=0),
|
||||
cmd("sketch.inspect"),
|
||||
])[-2]
|
||||
vertices = [p["at"] for p in sketch["points"] if p["id"].startswith("p")]
|
||||
assert sorted(round(y, 3) for x, y in vertices if abs(x) < 1e-6) == [-11.547, 11.547]
|
||||
|
||||
|
||||
def test_a_pattern_of_a_cylinder_cut_repeats_the_cut():
|
||||
body = execute([
|
||||
cmd("parameters.add", name="d", expression="4 mm"),
|
||||
*BLOCK,
|
||||
cmd("solid.cylinder", diameter="d", height="d * 5", base=["5 mm", "d + 1 mm", "-1 mm"], operation="cut",
|
||||
targets=["B"], name="Hole"),
|
||||
cmd("solid.pattern.rectangular", features=["Hole"], dir1=[1, 0, 0], count1=3, spacing1="10 mm"),
|
||||
])["bodies"][0]
|
||||
assert body["volume_mm3"] == pytest.approx(40 * 40 * 12 - 3 * pi * 4 * 12, rel=2e-4)
|
||||
|
||||
|
||||
def test_a_later_join_fills_an_earlier_cut():
|
||||
body = execute([
|
||||
*BLOCK,
|
||||
cmd("solid.cylinder", diameter="6 mm", height="20 mm", base=["20 mm", "20 mm", "-4 mm"], operation="cut",
|
||||
targets=["B"]),
|
||||
cmd("solid.cylinder", diameter="10 mm", height="6 mm", base=["20 mm", "20 mm", "3 mm"], operation="join",
|
||||
targets=["B"]),
|
||||
])["bodies"][0]
|
||||
assert body["volume_mm3"] == pytest.approx(40 * 40 * 12 - pi * 9 * 12 + pi * 9 * 6, rel=2e-4)
|
||||
|
||||
|
||||
def test_a_failed_feature_names_itself_and_saves_nothing(tmp_path):
|
||||
design = tmp_path / "design.json"
|
||||
run_commands(holes("2"), save=design)
|
||||
with pytest.raises(SolidCraftError, match="Row: pattern count"):
|
||||
run_commands([cmd("parameters.change", name="n", expression="0")], design=design, save=tmp_path / "out.json")
|
||||
assert not (tmp_path / "out.json").exists()
|
||||
|
||||
|
||||
def test_a_command_script_works_as_a_design(tmp_path):
|
||||
script = tmp_path / "build.json"
|
||||
script.write_text(json.dumps({"commands": BLOCK}))
|
||||
assert execute([], design=script)["bodies"][0]["volume_mm3"] == pytest.approx(40 * 40 * 12)
|
||||
shot = tmp_path / "shot.png"
|
||||
subprocess.run(["solidcraft-cli", "snapshot", script, "--out", shot, "--width", "64", "--height", "64"],
|
||||
capture_output=True, check=True)
|
||||
assert shot.stat().st_size > 0
|
||||
|
||||
|
||||
# Corrections to solidcraft-modeling
|
||||
|
||||
def slot_along_y(last: dict) -> list:
|
||||
return [
|
||||
cmd("sketch.create", plane="XY"),
|
||||
cmd("sketch.draw.dimensioned", command="sketch.slot.overall", params={"p0": [10, 4], "p1": [10, 34], "width": 2},
|
||||
dimensions={"length": "30 mm", "width": "2 mm"}),
|
||||
cmd("sketch.dimension", entities=["origin", "a1.center"], type="horizontal", value="10 mm"),
|
||||
cmd("sketch.dimension", entities=["origin", "a1.center"], type="vertical", value="5 mm"),
|
||||
last,
|
||||
]
|
||||
|
||||
|
||||
def test_a_slot_along_y_takes_a_horizontal_dimension_on_its_second_arc():
|
||||
with pytest.raises(SolidCraftError, match="over-constrain"):
|
||||
execute(slot_along_y(cmd("sketch.dimension", entities=["origin", "a2.center"], type="vertical", value="33 mm")))
|
||||
assert sketch_dof(slot_along_y(
|
||||
cmd("sketch.dimension", entities=["origin", "a2.center"], type="horizontal", value="10 mm"))) == 0
|
||||
|
||||
|
||||
OPEN_BOX = [
|
||||
cmd("solid.box", length=40, width=30, height=20, center=[0, 0, 10], body_name="B"),
|
||||
cmd("solid.fillet", edges=[[20, 15, 10], [-20, 15, 10], [20, -15, 10], [-20, -15, 10]], radius=3),
|
||||
]
|
||||
RIM = cmd("solid.fillet", edges=[[0, -15, 20]], radius=1)
|
||||
SHELL = cmd("solid.shell", faces=[[0, 0, 20]], thickness=2)
|
||||
FILLET_CENTROID = (10 - 3 * pi) / (12 - 3 * pi) # distance of a fillet's cross-section centroid from its edge, per unit radius
|
||||
|
||||
|
||||
def test_an_open_rim_is_rounded_after_the_shell():
|
||||
with pytest.raises(SolidCraftError, match="offset"):
|
||||
execute([*OPEN_BOX, RIM, SHELL])
|
||||
outer = (40 * 30 - (4 - pi) * 9) * 20
|
||||
cavity = (36 * 26 - (4 - pi) * 1) * 18
|
||||
rim = (1 - pi / 4) * (2 * (40 - 6) + 2 * (30 - 6) + 2 * pi * (3 - FILLET_CENTROID))
|
||||
body = execute([*OPEN_BOX, SHELL, RIM])["bodies"][0]
|
||||
assert body["volume_mm3"] == pytest.approx(outer - cavity - rim, rel=2e-4)
|
||||
|
||||
|
||||
def test_the_centre_of_a_centred_rectangle_is_p5():
|
||||
inspection = run_commands([
|
||||
cmd("sketch.create", plane="XY"),
|
||||
cmd("sketch.rectangle.center", center=[3, 4], corner=[13, 9]),
|
||||
cmd("sketch.inspect"),
|
||||
])[-2]
|
||||
assert {p["id"]: p["at"] for p in inspection["points"]}["p5"] == [3, 4]
|
||||
|
||||
|
||||
SHELLED_CASE = [
|
||||
cmd("parameters.add", name="h", expression="5 mm"),
|
||||
cmd("solid.box", length=91, width=62, height=30, center=[0, 0, 15], body_name="Case"),
|
||||
cmd("solid.fillet", edges=[[45.5, 31, 15], [-45.5, 31, 15], [45.5, -31, 15], [-45.5, -31, 15]], radius=3),
|
||||
cmd("solid.shell", faces=[[0, 0, 30]], thickness=2),
|
||||
cmd("solid.fillet", edges=[[0, -31, 30]], radius=1),
|
||||
]
|
||||
STANDOFF_GRID = dict(dir1=[1, 0, 0], count1=2, spacing1="58 mm", dir2=[0, 1, 0], count2=2, spacing2="49 mm")
|
||||
RING_GROWTH = 4 * pi * (9 - 1.5625) # mm3 per mm of standoff height: four rings, 6 mm outside, 2.5 mm hole
|
||||
TWO_CYLINDERS = [
|
||||
cmd("solid.cylinder", diameter="6 mm", height="h", base=["-39 mm", "-24.5 mm", "2 mm"], operation="join",
|
||||
targets=["Case"], name="Standoff"),
|
||||
cmd("solid.cylinder", diameter="2.5 mm", height="h", base=["-39 mm", "-24.5 mm", "2 mm"], operation="cut",
|
||||
targets=["Case"], name="Pilot"),
|
||||
cmd("solid.pattern.rectangular", features=["Standoff", "Pilot"], **STANDOFF_GRID),
|
||||
]
|
||||
RING = [
|
||||
cmd("sketch.create", plane="XY", offset="2 mm", name="Ring sketch"),
|
||||
cmd("sketch.circle.center", center=[-39, -24.5], radius=3),
|
||||
cmd("sketch.circle.center", center=[-39, -24.5], radius=1.25),
|
||||
cmd("sketch.dimension", entities=["c1"], type="diameter", value="6 mm"),
|
||||
cmd("sketch.dimension", entities=["c2"], type="diameter", value="2.5 mm"),
|
||||
cmd("sketch.dimension", entities=["origin", "c1.center"], type="horizontal", value="39 mm"),
|
||||
cmd("sketch.dimension", entities=["origin", "c1.center"], type="vertical", value="24.5 mm"),
|
||||
cmd("sketch.finish"),
|
||||
cmd("solid.extrude", sketch="Ring sketch", distance="h", profiles=[{"point": [-36.9, -24.5]}],
|
||||
operation="join", targets=["Case"], name="Ring"),
|
||||
cmd("solid.pattern.rectangular", features=["Ring"], **STANDOFF_GRID),
|
||||
]
|
||||
|
||||
|
||||
def standoffs_at(standoffs: list, height: float) -> tuple[float, dict]:
|
||||
"""Volume gap at `height` against the height-5 volume plus the rings' growth, and each feature's warning."""
|
||||
low = execute([*SHELLED_CASE, *standoffs])["bodies"][0]["volume_mm3"]
|
||||
high = execute([*SHELLED_CASE, *standoffs, cmd("parameters.change", name="h", expression=f"{height} mm")])
|
||||
gap = high["bodies"][0]["volume_mm3"] - low - (height - 5) * RING_GROWTH
|
||||
return gap, {f["name"]: f["warning"] for f in high["timeline"] if f.get("warning")}
|
||||
|
||||
|
||||
def test_a_boolean_retry_can_leave_material_in_a_cut():
|
||||
gap, warned = standoffs_at(TWO_CYLINDERS, 7)
|
||||
assert gap > 10
|
||||
assert "coincident-face" in warned["Standoff"]
|
||||
assert "tool displacement" in warned["Pilot"]
|
||||
|
||||
|
||||
def test_sinking_the_join_and_overcutting_the_hole_does_not_fix_it():
|
||||
gap, warned = standoffs_at([
|
||||
cmd("solid.cylinder", diameter="6 mm", height="h + 1 mm", base=["-39 mm", "-24.5 mm", "1 mm"], operation="join",
|
||||
targets=["Case"], name="Standoff"),
|
||||
cmd("solid.cylinder", diameter="2.5 mm", height="h + 1 mm", base=["-39 mm", "-24.5 mm", "2 mm"], operation="cut",
|
||||
targets=["Case"], name="Pilot"),
|
||||
cmd("solid.pattern.rectangular", features=["Standoff", "Pilot"], **STANDOFF_GRID),
|
||||
], 7)
|
||||
assert gap > 10
|
||||
assert "Standoff" not in warned
|
||||
|
||||
|
||||
@pytest.mark.parametrize("height", [3, 7, 12, 20])
|
||||
def test_a_ring_profile_builds_the_same_standoffs_exactly(height):
|
||||
gap, warned = standoffs_at(RING, height)
|
||||
assert abs(gap) < 0.5
|
||||
assert warned == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base, height, retries", [("2 mm", "h", True), ("1 mm", "h + 1 mm", False)])
|
||||
def test_a_straight_join_on_a_shelled_floor_retries_unless_sunk(base, height, retries):
|
||||
warned = warnings([
|
||||
*SHELLED_CASE,
|
||||
cmd("solid.cylinder", diameter="6 mm", height=height, base=["-39 mm", "-24.5 mm", base], operation="join",
|
||||
targets=["Case"], name="Standoff"),
|
||||
])
|
||||
assert any("coincident-face" in w for w in warned) == retries
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import run # noqa: E402
|
||||
|
||||
BOX = [{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Cube"}}]
|
||||
|
||||
|
||||
def report(tmp_path, commands, *args):
|
||||
script = tmp_path / "script.json"
|
||||
script.write_text(json.dumps({"commands": commands}))
|
||||
return subprocess.run(
|
||||
[sys.executable, SCRIPTS / "run.py", script, *args], capture_output=True, text=True
|
||||
)
|
||||
|
||||
|
||||
def test_reports_bodies(tmp_path):
|
||||
result = report(tmp_path, BOX)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "BODY Cube: volume 1000 mm3, area 600 mm2, bbox (0, 0, 0)..(10, 10, 10), com (5, 5, 5)" in result.stdout
|
||||
|
||||
|
||||
def test_flags_underconstrained_sketch(tmp_path):
|
||||
result = report(tmp_path, [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Loose"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 5}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'SKETCH "Loose": dof 3, solved' in result.stdout
|
||||
|
||||
|
||||
def test_reports_step_warnings(tmp_path):
|
||||
result = report(tmp_path, [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "12 mm", "name": "Plate"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 40]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Plate", "distance": "12 mm", "direction": "negative",
|
||||
"body_name": "P"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Hole"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 20}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Hole", "distance": "12 mm", "taper": "-20 deg",
|
||||
"operation": "cut", "targets": ["P"], "name": "Flush cut"}},
|
||||
])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert 'WARN "Flush cut": Boolean retry' in result.stdout
|
||||
|
||||
|
||||
def test_fails_on_a_bad_step(tmp_path):
|
||||
result = report(tmp_path, [{"command": "solid.box", "params": {"width": 10}}])
|
||||
assert result.returncode == 1
|
||||
assert result.stdout.startswith("FAIL") and "step 1 (solid.box)" in result.stdout
|
||||
|
||||
|
||||
def test_applies_a_script_to_a_saved_design(tmp_path):
|
||||
design = tmp_path / "cube.json"
|
||||
built = report(tmp_path, [
|
||||
{"command": "parameters.add", "params": {"name": "size", "expression": "10 mm"}},
|
||||
{"command": "solid.box", "params": {"length": "size", "width": "size", "height": "size", "body_name": "Cube"}},
|
||||
], "--save", design)
|
||||
assert built.returncode == 0, built.stderr
|
||||
|
||||
changed = report(tmp_path, [
|
||||
{"command": "parameters.change", "params": {"name": "size", "expression": "20 mm"}},
|
||||
], "--in", design)
|
||||
assert changed.returncode == 0, changed.stderr
|
||||
assert "BODY Cube: volume 8000 mm3" in changed.stdout
|
||||
|
||||
|
||||
def test_execute_returns_the_final_inspection():
|
||||
inspection = run.execute(BOX)
|
||||
assert [b["name"] for b in inspection["bodies"]] == ["Cube"]
|
||||
|
||||
|
||||
def test_execute_raises_on_failure():
|
||||
with pytest.raises(run.SolidCraftError, match="step 1"):
|
||||
run.execute([{"command": "solid.box", "params": {"width": 10}}])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flush_design(tmp_path):
|
||||
"""A cut 10 mm deep into a 12 mm plate; at depth 13 mm its tapered end lies on the plate top."""
|
||||
design = tmp_path / "plate.json"
|
||||
built = report(tmp_path, [
|
||||
{"command": "parameters.add", "params": {"name": "depth", "expression": "10 mm"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Plate sketch"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 40]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Plate sketch", "distance": "12 mm", "body_name": "P"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "offset": "-1 mm", "name": "Cut sketch"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [5, 5], "diameter": 20}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Cut sketch", "distance": "depth", "taper": "10 deg",
|
||||
"operation": "cut", "targets": ["P"], "name": "Cut"}},
|
||||
], "--save", design)
|
||||
assert built.returncode == 0 and "WARN" not in built.stdout, built.stdout + built.stderr
|
||||
return design
|
||||
|
||||
|
||||
def depth(value: str) -> dict:
|
||||
return {"command": "parameters.change", "params": {"name": "depth", "expression": value}}
|
||||
|
||||
|
||||
def test_reports_warnings_after_a_parameter_change(tmp_path, flush_design):
|
||||
flush = report(tmp_path, [depth("13 mm")], "--in", flush_design)
|
||||
assert 'WARN "Cut": Boolean retry' in flush.stdout
|
||||
|
||||
|
||||
def test_reports_every_inspection_in_the_script(tmp_path, flush_design):
|
||||
walk = report(tmp_path, [depth("13 mm"), {"command": "document.inspect", "params": {"measure": True}},
|
||||
depth("10 mm")], "--in", flush_design)
|
||||
lines = walk.stdout.splitlines()
|
||||
assert any(line.startswith('step 2 WARN "Cut": Boolean retry') for line in lines), walk.stdout
|
||||
assert any(line.startswith("step 2 BODY P: volume") for line in lines)
|
||||
assert not any(line.startswith("WARN") for line in lines)
|
||||
|
||||
|
||||
def test_reports_bodies_inspected_without_measurements(tmp_path):
|
||||
result = report(tmp_path, [*BOX, {"command": "document.inspect", "params": {}}])
|
||||
assert "step 2 BODY Cube\n" in result.stdout
|
||||
assert "BODY Cube: volume 1000 mm3" in result.stdout
|
||||
|
||||
|
||||
def test_says_so_when_there_is_nothing_to_report(tmp_path):
|
||||
result = report(tmp_path, [{"command": "parameters.add", "params": {"name": "size", "expression": "10 mm"}}])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "no bodies"
|
||||
@@ -0,0 +1,156 @@
|
||||
import json
|
||||
import sys
|
||||
from math import pi, sqrt
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]))
|
||||
import run # noqa: E402
|
||||
import scb # noqa: E402
|
||||
|
||||
|
||||
def built(script: scb.Script, sketch: str) -> dict:
|
||||
"""Inspection after extruding the sketch 1 mm."""
|
||||
script.cmd("solid.extrude", sketch=sketch, distance="1 mm", body_name="Body")
|
||||
inspection = run.execute(script.commands)
|
||||
assert [(s["name"], s["dof"], s["status"]) for s in inspection["sketches"]] == [(sketch, 0, "solved")]
|
||||
return inspection["bodies"][0]
|
||||
|
||||
|
||||
def box(body: dict) -> tuple:
|
||||
return (*body["bbox"]["min"], *body["bbox"]["max"])
|
||||
|
||||
|
||||
HALF_HEIGHT = 20 / sqrt(3) # centre to vertex of a hexagon 20 mm across flats
|
||||
|
||||
|
||||
def test_hexagon_on_the_x_axis():
|
||||
script = scb.Script()
|
||||
script.hexagon("Hex", across_flats="20 mm", at=(-30, 0), x="30 mm")
|
||||
assert box(built(script, "Hex")) == pytest.approx((-40, -HALF_HEIGHT, 0, -20, HALF_HEIGHT, 1), abs=1e-6)
|
||||
|
||||
|
||||
def test_hexagon_off_both_axes_on_an_offset_plane():
|
||||
script = scb.Script()
|
||||
script.hexagon("Hex", across_flats="20 mm", at=(-30, 15), x="30 mm", y="15 mm", offset="5 mm")
|
||||
assert box(built(script, "Hex")) == pytest.approx(
|
||||
(-40, 15 - HALF_HEIGHT, 5, -20, 15 + HALF_HEIGHT, 6), abs=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_hexagon_at_the_origin():
|
||||
script = scb.Script()
|
||||
script.hexagon("Hex", across_flats="20 mm", at=(0, 0))
|
||||
assert box(built(script, "Hex")) == pytest.approx((-10, -HALF_HEIGHT, 0, 10, HALF_HEIGHT, 1), abs=1e-6)
|
||||
|
||||
|
||||
def test_hexagon_stays_regular_when_its_parameter_changes():
|
||||
script = scb.Script()
|
||||
script.parameter("across", "20 mm")
|
||||
script.hexagon("Hex", across_flats="across", at=(-30, 0), x="30 mm")
|
||||
script.cmd("parameters.change", name="across", expression="34 mm")
|
||||
half_height = 34 / sqrt(3)
|
||||
assert box(built(script, "Hex")) == pytest.approx((-47, -half_height, 0, -13, half_height, 1), abs=1e-6)
|
||||
|
||||
|
||||
def test_hexagon_needs_a_position_unless_at_the_origin():
|
||||
with pytest.raises(ValueError):
|
||||
scb.Script().hexagon("Hex", across_flats="20 mm", at=(-30, 0))
|
||||
|
||||
|
||||
def test_circle():
|
||||
script = scb.Script()
|
||||
script.circle("Round", diameter="10 mm", at=(20, -10), x="20 mm", y="10 mm")
|
||||
assert box(built(script, "Round")) == pytest.approx((15, -15, 0, 25, -5, 1), abs=1e-3)
|
||||
|
||||
|
||||
def test_rounded_rectangle_symmetric_about_the_y_axis():
|
||||
script = scb.Script()
|
||||
script.rounded_rectangle("Plate", width="100 mm", height="50 mm", bottom="10 mm", corner_radius="5 mm",
|
||||
at=(-50, -10, 50, 40))
|
||||
body = built(script, "Plate")
|
||||
assert box(body) == pytest.approx((-50, -10, 0, 50, 40, 1), abs=1e-6)
|
||||
assert body["volume_mm3"] == pytest.approx(100 * 50 - (4 - pi) * 25, rel=1e-4)
|
||||
|
||||
|
||||
def test_parameter():
|
||||
script = scb.Script()
|
||||
script.parameter("size", "2 * 10 mm", comment="across the flats")
|
||||
params = run.execute(script.commands)["params"]
|
||||
assert [(p["name"], p["value"], p["comment"]) for p in params] == [("size", 20, "across the flats")]
|
||||
|
||||
|
||||
def test_save(tmp_path):
|
||||
script = scb.Script()
|
||||
script.cmd("solid.box", length=10, width=10, height=10)
|
||||
script.save(tmp_path / "build.json")
|
||||
assert json.loads((tmp_path / "build.json").read_text()) == {
|
||||
"commands": [{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}]
|
||||
}
|
||||
|
||||
|
||||
def test_rejects_a_repeated_name():
|
||||
# SolidCraft resolves a name to its first match, so a repeat silently points at the wrong feature
|
||||
script = scb.Script()
|
||||
script.circle("Hole sketch", diameter="5 mm", at=(10, 10), x="10 mm", y="10 mm")
|
||||
with pytest.raises(ValueError, match="Hole sketch"):
|
||||
script.circle("Hole sketch", diameter="5 mm", at=(20, 10), x="20 mm", y="10 mm")
|
||||
script.cmd("solid.extrude", sketch="Hole sketch", distance="1 mm", name="Cut")
|
||||
with pytest.raises(ValueError, match="Cut"):
|
||||
script.cmd("solid.extrude", sketch="Hole sketch", distance="2 mm", name="Cut")
|
||||
with pytest.raises(ValueError, match="Hole sketch"):
|
||||
script.cmd("solid.cylinder", diameter="5 mm", height="1 mm", name="Hole sketch")
|
||||
|
||||
|
||||
def test_parameter_names_are_not_feature_names():
|
||||
script = scb.Script()
|
||||
script.parameter("size", "10 mm")
|
||||
script.cmd("parameters.change", name="size", expression="20 mm")
|
||||
script.cmd("solid.box", length="size", width="size", height="size", name="size")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("at, x, y", [((0, 0), "10 mm", None), ((10, 0), None, "5 mm")])
|
||||
def test_place_rejects_a_dimension_that_starts_at_zero(at, x, y):
|
||||
with pytest.raises(ValueError, match="starts at 0"):
|
||||
scb.Script().circle("Round", diameter="5 mm", at=at, x=x, y=y)
|
||||
|
||||
|
||||
def test_rejects_a_repeated_body_name():
|
||||
# SolidCraft renames the second body silently, so targets would hit only the first
|
||||
script = scb.Script()
|
||||
script.cmd("solid.box", length=10, width=10, height=10, body_name="M")
|
||||
with pytest.raises(ValueError, match="M"):
|
||||
script.cmd("solid.cylinder", diameter="5 mm", height="5 mm", body_name="M")
|
||||
|
||||
|
||||
def test_cmd_passes_a_nested_command_parameter():
|
||||
script = scb.Script()
|
||||
script.cmd("sketch.create", plane="XY", name="Plate sketch")
|
||||
script.cmd("sketch.draw.dimensioned", command="sketch.rectangle.center", params={"center": [0, 0], "corner": [5, 3]},
|
||||
dimensions={"width": "10 mm", "height": "6 mm"})
|
||||
script.cmd("sketch.finish")
|
||||
script.cmd("solid.extrude", sketch="Plate sketch", distance="1 mm", body_name="Plate")
|
||||
assert run.execute(script.commands)["bodies"][0]["volume_mm3"] == pytest.approx(60)
|
||||
|
||||
|
||||
def test_rounded_rectangle_rejects_a_bottom_that_starts_at_zero():
|
||||
with pytest.raises(ValueError, match="starts at 0"):
|
||||
scb.Script().rounded_rectangle("Plate sketch", width="100 mm", height="50 mm", bottom="10 mm",
|
||||
corner_radius="5 mm", at=(-50, 0, 50, 50))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["surface.patch", "sheet.flange", "mesh.convert", "file.insert_step"])
|
||||
def test_names_from_other_feature_commands_count_too(command):
|
||||
script = scb.Script()
|
||||
script.cmd("solid.box", length=10, width=10, height=10, name="Part")
|
||||
with pytest.raises(ValueError, match="Part"):
|
||||
script.cmd(command, name="Part")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["sheet.manage_rules", "plastic.manage_rules"])
|
||||
def test_rule_names_are_not_feature_names(command):
|
||||
script = scb.Script()
|
||||
script.cmd("solid.box", length=10, width=10, height=10, name="Part")
|
||||
script.cmd(command, name="Part")
|
||||
script.cmd(command, name="Part")
|
||||
Reference in new issue
Block a user