Add the solidcraft-checking skill (#880)

This commit is contained in:
Igor Churmeev authored and GitHub committed 2026-10-13 22:37:07 -04:00
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()
+98
View File
@@ -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()
+113
View File
@@ -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")