114 lines
5.9 KiB
Python
114 lines
5.9 KiB
Python
"""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))
|