diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 23ee10ab..b205f294 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -841,6 +841,8 @@ fn skills_name_only_real_tools_and_commands() { include_str!("../../../skills/solidcraft-modeling/SKILL.md"), include_str!("../../../skills/solidcraft-modeling/reference.md"), include_str!("../../../skills/solidcraft-editing/SKILL.md"), + include_str!("../../../skills/solidcraft-checking/SKILL.md"), + include_str!("../../../skills/solidcraft-checking/reference/porting.md"), ]; let mut s = server(); let tools: Vec = tool_definitions().iter().filter_map(|t| t["name"].as_str()).map(str::to_owned).collect(); diff --git a/docs/mcp.md b/docs/mcp.md index 94c1188c..c0a7e35e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -106,11 +106,12 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: ### Agent skills -[`skills/`](https://github.com/bherbruck/solidcraft/tree/main/skills) holds two [Agent Skills](https://agentskills.io) for agents that model through this server: +[`skills/`](https://github.com/bherbruck/solidcraft/tree/main/skills) holds three [Agent Skills](https://agentskills.io) for agents that model through this server: - `solidcraft-modeling` covers building a new part. - `solidcraft-editing` covers changing an existing design. +- `solidcraft-checking` adds checks for errors SolidCraft does not report, regression tests and porting, next to the other two. -They cover the workflow, the command shapes, how to verify with `measure`, and the known traps. With them an agent builds a part in a few `batch` calls and reads far less output. To install them for Claude Code, copy both folders into `~/.claude/skills/`; [skills/README.md](https://github.com/bherbruck/solidcraft/blob/main/skills/README.md) has the details. +They cover the workflow, the command shapes, how to verify with `measure`, and the known traps. With them an agent builds a part in a few `batch` calls and reads far less output. To install them for Claude Code, copy the folders into `~/.claude/skills/`; [skills/README.md](https://github.com/bherbruck/solidcraft/blob/main/skills/README.md) has the details. ## Tools diff --git a/skills/README.md b/skills/README.md index 7f010acc..bf4a6709 100644 --- a/skills/README.md +++ b/skills/README.md @@ -8,6 +8,7 @@ description that tell the agent when to load it, and an optional supporting refe |---|---| | [`solidcraft-modeling`](solidcraft-modeling/SKILL.md) | builds a new part (enclosure, bracket, plate, flange, …) | | [`solidcraft-editing`](solidcraft-editing/SKILL.md) | changes, fixes or extends an existing design | +| [`solidcraft-checking`](solidcraft-checking/SKILL.md) | builds, changes or ports a design, next to the two above: parameter ranges, every allowed value, exact STEP comparison, regression tests, designs kept in git | They teach the agent to: - plan the part as numbers @@ -44,5 +45,6 @@ Copy-Item -Recurse solidcraft\skills\solidcraft-* $HOME\.claude\skills\ - The worked example in `solidcraft-modeling/reference.md` runs in CI. `skill_reference_example_builds_as_documented` in `crates/mcp/src/tests.rs` builds it, checks the documented volume, changes a parameter and checks it again. - The sketch-plane table is covered by `sketch_plane_axes_match_the_skill_reference` in the same file. - `skills_name_only_real_tools_and_commands` checks that every tool and command the skills call exists. +- `solidcraft-checking/scripts/tests` checks that skill's helpers, its traps and conventions, and the facts behind the corrections in `solidcraft-modeling`: `PYTHONDONTWRITEBYTECODE=1 uv run --no-project --with pytest --with build123d pytest -p no:cacheprovider skills/solidcraft-checking/scripts/tests`. The tests run the `solidcraft-cli` on `PATH`, not the workspace build, and CI does not run them. - When a command, a parameter name or a behaviour that these files describe changes, update them in the same PR. - Facts that describe current limitations, such as edge picks without a distance limit, should go once the limitation is fixed. diff --git a/skills/solidcraft-checking/SKILL.md b/skills/solidcraft-checking/SKILL.md new file mode 100644 index 00000000..2ee521cc --- /dev/null +++ b/skills/solidcraft-checking/SKILL.md @@ -0,0 +1,49 @@ +--- +name: solidcraft-checking +description: Use alongside solidcraft-modeling and solidcraft-editing whenever you build, change or port a SolidCraft design. Checks for errors SolidCraft does not report (parameter ranges, every allowed value, exact STEP comparison), regression tests, designs kept in git, porting from build123d, CadQuery, OpenSCAD or STEP. +--- + +# SolidCraft checking + +`solidcraft-modeling` and `solidcraft-editing` cover the commands, plane axes, ids, edge picks and the MCP workflow: load the one that fits first. This skill adds checks for errors SolidCraft does not report, and the work around a design kept in a repo. + +`$SKILL` below is the folder this file is in (`~/.claude/skills/solidcraft-checking` for a user install). Written against SolidCraft 0.8.0. `scripts/tests/test_facts.py` checks every trap and convention below, and the facts behind the corrections in `solidcraft-modeling`. After a SolidCraft upgrade, run the tests and fix the text where they fail: +`PYTHONDONTWRITEBYTECODE=1 uv run --no-project --with pytest --with build123d pytest -p no:cacheprovider $SKILL/scripts/tests` + +## Scripts + +An MCP `batch` and a command script (`{"commands": [...]}`, run by `solidcraft-cli run`) take the same command list. Build with a script when the design goes into a repo or must be rebuilt later: a script rebuilds the design from scratch on every run. Subagents share the session's one MCP design, so agents that run in parallel use `solidcraft-cli` only. + +In `$SKILL/scripts/`: +- `python3 $SKILL/scripts/run.py script.json [--in ] [--save .json]` runs a script and prints `FAIL`, a `WARN` line per feature warning, a `SKETCH` line per sketch above dof 0 and a `BODY` line per body, for the end state and for each `document.inspect` inside the script. +- `scb.py` gives a generator a `Script` with sketch helpers that come out at dof 0 (`hexagon`, `circle`, `rounded_rectangle`, `place`) and `cmd` for any other command. It rejects repeated sketch, feature and body names. Import it with `sys.path.insert(0, "<$SKILL>/scripts")`. +- `uv run --no-project --with build123d python $SKILL/scripts/compare_step.py actual.step expected.step` gives the exact volume of the difference between two STEP files and where it sits. It and the tests run OpenCASCADE through build123d as a separate tool, the way `tools/freecad-oracle` runs FreeCAD: SolidCraft does not use, link, vendor or ship any of it. +- `solidcraft-cli snapshot --out shot.png --view iso` renders a PNG to look at. + +## Steps to add + +1. **Parameters and features.** Each parameter comment states the allowed range ("whole number, 4 or more"): the user reads it in the Parameters table, and SolidCraft enforces no range. Derive sizes from measured values, so that a count parameter changes how many features there are, not how big they are. An expression that more than one sketch or feature uses becomes a named parameter. Add the derived numbers the user needs (parts to print) and mark the driving parameters favourite (`parameters.favorite`). Give each thing the user thinks of (a port, a row of standoffs) its own feature, named after it, so it can be found and changed on its own. Choose the origin so that no sketch dimension reaches 0 for any allowed value (trap 1). +2. **Every sketch at dof 0.** A sketch with dof left can solve to a different shape after a parameter change, without an error. `run.py` lists them. +3. **Every allowed value.** One script of `parameters.change` commands walks each driving parameter to both ends of its range, one change after another, and back to the default, as edits in the app do: trap 1 fails only on the change after the one that reaches 0. Put a `document.inspect {measure: true}` after each end of a range and run the walk with `run.py walk.json --in `. Every state must be clean: no `FAIL`, no `SKETCH`, and `BODY` volumes and centres of mass that match the values you compute for that state. A boolean retry can leave the geometry wrong with only a `WARN` line, and the volume shows it (trap 4); a mirrored sketch (trap 1) keeps the volume and moves the centre of mass. +4. **Exact check** when a port has a reference, or a volume gap leaves doubt: export STEP (`file.export {path, bodies}`) and run `compare_step.py`. Before porting, read `$SKILL/reference/porting.md`. +5. **Kept in a repo.** Save as `.json` with `run.py build.json --save .json`: a `.json` design stays plain JSON when it is saved again, so git diffs stay readable. Saving over a file leaves `.json.bak`, so ignore `*.bak`. From then on the design file is the source, and changes go into it with `--in --save `. Copy `$SKILL/templates/test_design.py` next to the design, fill in its cases, set its tolerance as its comment says, and run it until it passes. Expected values come from an exact reference when porting, from hand calculation for simple shapes, or from a build the user has reviewed. After a change, update frozen values only where the change is meant to change them. + +## Traps + +1. **Zero dimension.** A sketch dimension driven to 0 loses its side. On the next parameter change it either fails with `its constraints would conflict` (a point placed by dimensions from the origin) or, with no error, puts the point on either side (a slot that other constraints also held came back mirrored). Check every distance expression against the whole allowed range of each parameter in it, counts included; the walk in step 3 catches the failure, and only the volume or the position catches the mirror. For a point on an axis, pass `x=None` or `y=None` to `place`: it aligns the point instead of dimensioning it. For a position that does reach 0, such as the first of a centred row of one, dimension it from a sketch point placed outside the part: a point at x = -100 mm, then the distance `100 mm - (n - 1) / 2 * pitch` from it. +2. **Counts.** Solid patterns take counts 1..1000; a count that reaches 0 fails the rebuild. A fractional count rounds to the nearest whole number without an error (6.5 gives 7 copies), while every other expression uses the exact value, so a centred row shifts. Nothing in SolidCraft enforces a parameter range during a parameter change (configuration rules apply only when a configuration row is activated): choose a range that keeps every count at 1 or more, write "whole number" and the range in the comment, and use `round(n)` wherever the count also positions something. +3. **Circumscribed polygons.** `sketch.polygon.circumscribed` with a size dimension and one vertical side still has 1 dof: its corners can alternate in and out. `sketch.constraint.polygon` makes it regular; `scb.hexagon` adds it. +4. **Boolean retries.** A `Boolean retry: ...` warning means the result may be wrong (see "Boolean warning" in the `solidcraft-modeling` reference), so check the volume of that state. `temporary coincident-face separation` comes from faces that coincide. In a plain block, a tapered tool whose start or end lies on a face logs it and a straight one does not; on the floor of a filleted, shelled box, a straight cylinder joined onto the floor logs it too, and sunk 1 mm into the floor it does not. Starting cutters about 1 mm past the faces they pierce (sizes extrapolated along any taper) and sinking joins about 1 mm into the body remove the warning on a single feature. They did not fix standoffs built as a joined cylinder and a cut pilot hole, patterned together: those still kept material in the holes, and one ring profile fixed it ("Boolean warning" in the `solidcraft-modeling` reference). Check the volume of every state either way. +5. **Measurement accuracy.** The `solidcraft-modeling` reference gives how far measured volumes stray from computed ones. Centre of mass and bounding box come out up to about 5e-5 of the part size off. Set test tolerances as the template says. +6. **Expressions.** They have `+ - * / ^ %`, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod trunc exp ln log log10 pow sign hypot` (`log` is base 10), `pi` and units; no `if` and no comparisons. Build steps from `floor`, `mod`, `min`, `max` and `sign`, which gives 1 at 0. `asin acos atan atan2` return angles, so a unitless parameter cannot hold them. +7. **Snapping.** A point created exactly on an existing point or the origin becomes coincident with it (`connect` defaults to true). +8. **Rectangle symmetry.** Opposite sides of a rectangle run in opposite directions, so `sketch.constraint.symmetry` on two sides conflicts with the rectangle; make two corner points symmetric instead. +9. **Repeated body names.** A repeated `body_name` is renamed (`Body1`) without an error, so `targets` reach only the first body. + +## Conventions + +- `taper` positive widens along the extrude direction, negative narrows it, also with `direction: negative`. +- `sketch.polygon.circumscribed` with 6 sides and `angle: 0` puts vertices up and down and flat sides facing ±x. +- A pattern of a join or cut feature repeats that join or cut. Put every join before the cuts that must stay open: a later join fills them. +- When a timeline feature fails, `solidcraft-cli run` writes nothing and the error names the feature. `run --in` and `snapshot` also accept a command script in place of a design. +- To build in the user's open app, see the bridged mode in [docs/mcp.md](https://github.com/bherbruck/solidcraft/blob/main/docs/mcp.md): the app and the server both start with extra flags, so ask the user. diff --git a/skills/solidcraft-checking/reference/porting.md b/skills/solidcraft-checking/reference/porting.md new file mode 100644 index 00000000..84a3c75f --- /dev/null +++ b/skills/solidcraft-checking/reference/porting.md @@ -0,0 +1,24 @@ +# Porting a model into SolidCraft + +For a model that already exists as code (build123d, CadQuery, OpenSCAD) or as STEP. The port is +done when the SolidCraft STEP export matches the source geometry for every parameter value you +check: `compare_step.py` reports xor at most 1e-3 mm3. Volume, area and centre of mass alone are +not enough: in one port, a hexagon left at dof 1 changed the volume by 2e-7 of the total and the +centre of mass by 3e-4 mm, while xor showed 56 mm3 in 96 pieces along the outer walls. + +1. **Reference geometry in the design's frame.** The SolidCraft origin is chosen by trap 1 of SKILL.md, so it + often differs from the source. Translate the source model to the same frame and export it as + STEP for each checked parameter value. Pick values that reach every branch of the layout logic. +2. **Deliberate differences.** Logic that SolidCraft expressions cannot hold (conditionals, + search) gets a closed formula. Check the formula against the source over a wide range of + values in plain Python first, list where it differs, and apply the formula to the reference + too (patch the source function), so the comparison stays like for like. Tell the user each + difference. +3. **Export and compare.** Add `parameters.change` and `file.export {path, bodies: [name]}` per + value to a script, run it, then + `uv run --no-project --with build123d python $SKILL/scripts/compare_step.py actual.step reference.step`. + The pieces' boxes show where the shapes differ; a pair of pieces on the two halves of one face + means that face is turned, usually by an under-constrained sketch. +4. **Freeze the test.** Measure the reference (volume, area, centre of mass, bounding box) and + put the numbers into the copy of `templates/test_design.py` next to the design. Ask the user + before removing the source model; git history keeps it. diff --git a/skills/solidcraft-checking/scripts/compare_step.py b/skills/solidcraft-checking/scripts/compare_step.py new file mode 100644 index 00000000..82a5423e --- /dev/null +++ b/skills/solidcraft-checking/scripts/compare_step.py @@ -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() diff --git a/skills/solidcraft-checking/scripts/run.py b/skills/solidcraft-checking/scripts/run.py new file mode 100755 index 00000000..3e7c803b --- /dev/null +++ b/skills/solidcraft-checking/scripts/run.py @@ -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() diff --git a/skills/solidcraft-checking/scripts/scb.py b/skills/solidcraft-checking/scripts/scb.py new file mode 100644 index 00000000..780e07f7 --- /dev/null +++ b/skills/solidcraft-checking/scripts/scb.py @@ -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)) diff --git a/skills/solidcraft-checking/scripts/tests/test_compare_step.py b/skills/solidcraft-checking/scripts/tests/test_compare_step.py new file mode 100644 index 00000000..95107d3e --- /dev/null +++ b/skills/solidcraft-checking/scripts/tests/test_compare_step.py @@ -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 diff --git a/skills/solidcraft-checking/scripts/tests/test_facts.py b/skills/solidcraft-checking/scripts/tests/test_facts.py new file mode 100644 index 00000000..6cc313ad --- /dev/null +++ b/skills/solidcraft-checking/scripts/tests/test_facts.py @@ -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 diff --git a/skills/solidcraft-checking/scripts/tests/test_run.py b/skills/solidcraft-checking/scripts/tests/test_run.py new file mode 100644 index 00000000..a17077a3 --- /dev/null +++ b/skills/solidcraft-checking/scripts/tests/test_run.py @@ -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" diff --git a/skills/solidcraft-checking/scripts/tests/test_scb.py b/skills/solidcraft-checking/scripts/tests/test_scb.py new file mode 100644 index 00000000..af944397 --- /dev/null +++ b/skills/solidcraft-checking/scripts/tests/test_scb.py @@ -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") diff --git a/skills/solidcraft-checking/templates/test_design.py b/skills/solidcraft-checking/templates/test_design.py new file mode 100644 index 00000000..d47ffc72 --- /dev/null +++ b/skills/solidcraft-checking/templates/test_design.py @@ -0,0 +1,80 @@ +"""Regression test for a SolidCraft design. One session walks the parameters through the cases in +order, as edits in the app do, and compares a body after each case with frozen values. +Copy next to the design, then fill in DESIGN, BODY and CASES. Needs solidcraft-cli on PATH.""" + +import json +import subprocess +from math import dist +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import NamedTuple + +import pytest + +DESIGN = Path(__file__).parent / "design.json" +BODY = "Body" +# SolidCraft measures a tessellation, and its volume and area error depends on the shape. Measure it +# for this design: run.py's BODY volume against the OCCT volume compare_step.py prints for the +# design's own STEP export (compared with itself). Set REL to about 3 times that error. +REL = 2e-4 + + +class Measures(NamedTuple): + volume: float + area: float + center_of_mass: tuple[float, float, float] + bbox_min: tuple[float, float, float] + bbox_max: tuple[float, float, float] + + +# Parameter values of each case, in the order the session applies them, and the body's expected +# measures: from an exact reference when porting, from hand calculation for simple shapes, or a +# frozen build the user has reviewed. A case changes only the parameters it names; the others keep +# the values of the cases before it. +CASES = { + # (("rows", "7"), ("cells", "4")): Measures(volume=..., area=..., center_of_mass=(...), bbox_min=(...), bbox_max=(...)), +} + + +@pytest.fixture(scope="module") +def inspections() -> dict: + """document.inspect after each case, all in one session. A fixture, so that a failed session + is reported to every test instead of rerun for each.""" + commands, at, current = [], [], {} + for case in CASES: + for name, value in case: + if current.get(name) != value: + commands.append({"command": "parameters.change", "params": {"name": name, "expression": value}}) + current[name] = value + at.append(len(commands)) + commands.append({"command": "document.inspect", "params": {"measure": True}}) + with TemporaryDirectory() as tmp: + script = Path(tmp) / "script.json" + script.write_text(json.dumps({"commands": commands})) + done = subprocess.run( + ["solidcraft-cli", "run", script, "--in", DESIGN, "--results"], capture_output=True, text=True + ) + assert done.returncode == 0, done.stderr + results = json.loads(done.stdout) + return {case: results[index] for case, index in zip(CASES, at)} + + +def body(inspection: dict) -> Measures: + found = {b["name"]: b for b in inspection["bodies"]}[BODY] + bbox = found["bbox"] + return Measures(found["volume_mm3"], found["area_mm2"], tuple(found["center_of_mass"]), + tuple(bbox["min"]), tuple(bbox["max"])) + + +def assert_matches(actual: Measures, expected: Measures) -> None: + assert actual.volume == pytest.approx(expected.volume, rel=REL) + assert actual.area == pytest.approx(expected.area, rel=REL) + # Centre of mass and bounding box come out up to about 5e-5 of the part size off; allow 3 times that + tolerance = max(1e-3, 1.5e-4 * dist(expected.bbox_min, expected.bbox_max)) + for point in ("center_of_mass", "bbox_min", "bbox_max"): + assert getattr(actual, point) == pytest.approx(getattr(expected, point), abs=tolerance), point + + +@pytest.mark.parametrize("case", CASES) +def test_body_matches(inspections, case): + assert_matches(body(inspections[case]), CASES[case])