Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/openscad.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

318 lines
9.2 KiB
Python

# Copyright (c) 2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union, Iterable, Sequence, Optional
from pathlib import Path
import enum
import platform
import shutil
import subprocess
from uuid import uuid4
import tempfile
import ezdxf
from ezdxf.math import Matrix44, UVec, Vec3, Vec2
from ezdxf.render import MeshBuilder, MeshTransformer
from ezdxf.addons import meshex
DEFAULT_WIN_OPENSCAD_PATH = ezdxf.options.get(
"openscad-addon", "win_exec_path"
).strip('"')
CMD = "openscad"
class Operation(enum.Enum):
union = 0
difference = 1
intersection = 2
UNION = Operation.union
DIFFERENCE = Operation.difference
INTERSECTION = Operation.intersection
def get_openscad_path() -> str:
if platform.system() in ("Linux", "Darwin"):
return CMD
else:
return DEFAULT_WIN_OPENSCAD_PATH
def is_installed() -> bool:
r"""Returns ``True`` if OpenSCAD is installed. On Windows only the
default install path 'C:\\Program Files\\OpenSCAD\\openscad.exe' is checked.
"""
if platform.system() in ("Linux", "Darwin"):
return shutil.which(CMD) is not None
return Path(DEFAULT_WIN_OPENSCAD_PATH).exists()
def run(script: str, exec_path: Optional[str] = None) -> MeshTransformer:
"""Executes the given `script` by OpenSCAD and returns the result mesh as
:class:`~ezdxf.render.MeshTransformer`.
Args:
script: the OpenSCAD script as string
exec_path: path to the executable as string or ``None`` to use the
default installation path
"""
if exec_path is None:
exec_path = get_openscad_path()
workdir = Path(tempfile.gettempdir())
uuid = str(uuid4())
# The OFF format is more compact than the default STL format
off_path = workdir / f"ezdxf_{uuid}.off"
scad_path = workdir / f"ezdxf_{uuid}.scad"
scad_path.write_text(script)
subprocess.call(
[
exec_path,
"--quiet",
"-o",
str(off_path),
str(scad_path),
]
)
# Remove the OpenSCAD temp file:
scad_path.unlink(missing_ok=True)
new_mesh = MeshTransformer()
# Import the OpenSCAD result from OFF file:
if off_path.exists():
new_mesh = meshex.off_loads(off_path.read_text())
# Remove the OFF temp file:
off_path.unlink(missing_ok=True)
return new_mesh
def str_matrix44(m: Matrix44) -> str:
# OpenSCAD uses column major order!
import numpy as np
def cleanup(values: Iterable) -> Iterable:
for value in values:
if isinstance(value, np.float64):
yield float(value)
else:
yield value
s = ", ".join([str(list(cleanup(c))) for c in m.columns()])
return f"[{s}]"
def str_polygon(
path: Iterable[UVec],
holes: Optional[Sequence[Iterable[UVec]]] = None,
) -> str:
"""Returns a ``polygon()`` command as string. This is a 2D command, all
z-axis values of the input vertices are ignored and all paths and holes
are closed automatically.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
Args:
path: exterior path
holes: a sequences of one or more holes as vertices
"""
def add_vertices(vertices):
index = len(points)
indices = []
vlist = Vec2.list(vertices)
if not vlist[0].isclose(vlist[-1]):
vlist.append(vlist[0])
for v in vlist:
indices.append(index)
points.append(f" [{v.x:g}, {v.y:g}],")
index += 1
return indices
points: list[str] = []
paths = [add_vertices(path)]
if holes is not None:
for hole in holes:
paths.append(add_vertices(hole))
lines = ["polygon(points = ["]
lines.extend(points)
lines.append("],")
if holes is not None:
lines.append("paths = [")
for indices in paths:
lines.append(f" {str(indices)},")
lines.append("],")
lines.append("convexity = 10);")
return "\n".join(lines)
class Script:
def __init__(self) -> None:
self.data: list[str] = []
def add(self, data: str) -> None:
"""Add a string."""
self.data.append(data)
def add_polyhedron(self, mesh: MeshBuilder) -> None:
"""Add `mesh` as ``polyhedron()`` command.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Primitive_Solids#polyhedron
"""
self.add(meshex.scad_dumps(mesh))
def add_polygon(
self,
path: Iterable[UVec],
holes: Optional[Sequence[Iterable[UVec]]] = None,
) -> None:
"""Add a ``polygon()`` command. This is a 2D command, all
z-axis values of the input vertices are ignored and all paths and holes
are closed automatically.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Using_the_2D_Subsystem#polygon
Args:
path: exterior path
holes: a sequence of one or more holes as vertices, or ``None`` for no holes
"""
self.add(str_polygon(path, holes))
def add_multmatrix(self, m: Matrix44) -> None:
"""Add a transformation matrix of type :class:`~ezdxf.math.Matrix44` as
``multmatrix()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#multmatrix
"""
self.add(f"multmatrix(m = {str_matrix44(m)})") # no pending ";"
def add_translate(self, v: UVec) -> None:
"""Add a ``translate()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#translate
Args:
v: translation vector
"""
vec = Vec3(v)
self.add(f"translate(v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
def add_rotate(self, ax: float, ay: float, az: float) -> None:
"""Add a ``rotation()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
Args:
ax: rotation about the x-axis in degrees
ay: rotation about the y-axis in degrees
az: rotation about the z-axis in degrees
"""
self.add(f"rotate(a = [{ax:g}, {ay:g}, {az:g}])")
def add_rotate_about_axis(self, a: float, v: UVec) -> None:
"""Add a ``rotation()`` operation about the given axis `v`.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#rotate
Args:
a: rotation angle about axis `v` in degrees
v: rotation axis as :class:`ezdxf.math.UVec` object
"""
vec = Vec3(v)
self.add(f"rotate(a = {a:g}, v = [{vec.x:g}, {vec.y:g}, {vec.z:g}])")
def add_scale(self, sx: float, sy: float, sz: float) -> None:
"""Add a ``scale()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#scale
Args:
sx: scaling factor for the x-axis
sy: scaling factor for the y-axis
sz: scaling factor for the z-axis
"""
self.add(f"scale(v = [{sx:g}, {sy:g}, {sz:g}])")
def add_resize(
self,
nx: float,
ny: float,
nz: float,
auto: Optional[Union[bool, tuple[bool, bool, bool]]] = None,
) -> None:
"""Add a ``resize()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#resize
Args:
nx: new size in x-axis
ny: new size in y-axis
nz: new size in z-axis
auto: If the `auto` argument is set to ``True``, the operation
auto-scales any 0-dimensions to match. Set the `auto` argument
as a 3-tuple of bool values to auto-scale individual axis.
"""
main = f"resize(newsize = [{nx:g}, {ny:g}, {nz:g}]"
if auto is None:
self.add(main + ")")
return
elif isinstance(auto, bool):
flags = str(auto).lower()
else:
flags = ", ".join([str(a).lower() for a in auto])
flags = f"[{flags}]"
self.add(main + f", auto = {flags})")
def add_mirror(self, v: UVec) -> None:
"""Add a ``mirror()`` operation.
OpenSCAD docs: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#mirror
Args:
v: the normal vector of a plane intersecting the origin through
which to mirror the object
"""
n = Vec3(v).normalize()
self.add(f"mirror(v = [{n.x:g}, {n.y:g}, {n.z:g}])")
def get_string(self) -> str:
"""Returns the OpenSCAD build script."""
return "\n".join(self.data)
def boolean_operation(
op: Operation, mesh1: MeshBuilder, mesh2: MeshBuilder
) -> str:
"""Returns an `OpenSCAD`_ script to apply the given boolean operation to the
given meshes.
The supported operations are:
- UNION
- DIFFERENCE
- INTERSECTION
"""
assert isinstance(op, Operation), "enum of type Operation expected"
script = Script()
script.add(f"{op.name}() {{")
script.add_polyhedron(mesh1)
script.add_polyhedron(mesh2)
script.add("}")
return script.get_string()