WFreeCAD / cadshim.py
HelloSun's picture
Initial WFreeCAD static space
ebc1b3a verified
Raw History Blame Contribute Delete
27.1 kB
"""A tiny, browser-friendly subset of the FreeCAD Python API.
The shim does not perform real boundary-representation modelling. It records
the operations that a macro asks for and emits a small JSON scene graph which
the Three.js front-end can build and display. It is intentionally forgiving
so that simple Part-based macros run without a FreeCAD installation.
"""
import copy
import json
import math
import sys
import traceback
import types
PALETTE = [
(0.94, 0.68, 0.32),
(0.40, 0.64, 0.56),
(0.94, 0.49, 0.37),
(0.40, 0.50, 0.66),
(0.83, 0.78, 0.66),
(0.60, 0.47, 0.70),
]
# --------------------------------------------------------------------------
# small vector / quaternion helpers
# --------------------------------------------------------------------------
def _num(value, default=0.0):
try:
return float(value)
except Exception:
return default
class Vector:
__slots__ = ("x", "y", "z")
def __init__(self, x=0.0, y=0.0, z=0.0):
if isinstance(x, Vector):
self.x, self.y, self.z = x.x, x.y, x.z
elif isinstance(x, (tuple, list)):
values = list(x) + [0.0, 0.0, 0.0]
self.x, self.y, self.z = _num(values[0]), _num(values[1]), _num(values[2])
else:
self.x, self.y, self.z = _num(x), _num(y), _num(z)
def __iter__(self):
yield self.x
yield self.y
yield self.z
def __getitem__(self, index):
return (self.x, self.y, self.z)[index]
def __len__(self):
return 3
def __add__(self, other):
other = Vector(other)
return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
other = Vector(other)
return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, other):
if isinstance(other, (Vector, tuple, list)):
return self.dot(Vector(other))
return Vector(self.x * other, self.y * other, self.z * other)
__rmul__ = __mul__
def __truediv__(self, other):
return Vector(self.x / other, self.y / other, self.z / other)
def __neg__(self):
return Vector(-self.x, -self.y, -self.z)
def __repr__(self):
return "Vector (%g, %g, %g)" % (self.x, self.y, self.z)
def clone(self):
return Vector(self)
copy = clone
def Length(self):
return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
def normalize(self):
length = self.Length()
if length > 1e-12:
self.x /= length
self.y /= length
self.z /= length
return self
normalized = normalize
def dot(self, other):
other = Vector(other)
return self.x * other.x + self.y * other.y + self.z * other.z
def cross(self, other):
other = Vector(other)
return Vector(
self.y * other.z - self.z * other.y,
self.z * other.x - self.x * other.z,
self.x * other.y - self.y * other.x,
)
def to_list(self):
return [self.x, self.y, self.z]
def _qmul(a, b):
x1, y1, z1, w1 = a
x2, y2, z2, w2 = b
return (
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
)
def _qaxis(axis, angle_deg):
axis = Vector(axis)
length = axis.Length()
if length < 1e-12:
return (0.0, 0.0, 0.0, 1.0)
axis = axis / length
half = math.radians(_num(angle_deg)) * 0.5
sine = math.sin(half)
return (axis.x * sine, axis.y * sine, axis.z * sine, math.cos(half))
def _qnorm(q):
length = math.sqrt(sum(v * v for v in q))
if length < 1e-12:
return (0.0, 0.0, 0.0, 1.0)
return tuple(v / length for v in q)
def _qconj(q):
return (-q[0], -q[1], -q[2], q[3])
def _qrotate(q, v):
v = Vector(v)
r = _qmul(_qmul(q, (v.x, v.y, v.z, 0.0)), _qconj(q))
return Vector(r[0], r[1], r[2])
def _qmatrix(q):
x, y, z, w = _qnorm(q)
xx, yy, zz = x * x, y * y, z * z
xy, xz, yz = x * y, x * z, y * z
wx, wy, wz = w * x, w * y, w * z
return [
[1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)],
[2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)],
[2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)],
]
def _matmul(a, b):
return [[sum(a[r][k] * b[k][c] for k in range(4)) for c in range(4)] for r in range(4)]
def _mat_identity():
return [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
def _mat_translate(v):
m = _mat_identity()
m[0][3], m[1][3], m[2][3] = v.x, v.y, v.z
return m
def _mat_rotate(q):
r = _qmatrix(q)
return [
[r[0][0], r[0][1], r[0][2], 0.0],
[r[1][0], r[1][1], r[1][2], 0.0],
[r[2][0], r[2][1], r[2][2], 0.0],
[0.0, 0.0, 0.0, 1.0],
]
def _mat_to_column_major(m):
return [m[r][c] for c in range(4) for r in range(4)]
def _mat_is_identity(m):
for r in range(4):
for c in range(4):
expected = 1.0 if r == c else 0.0
if abs(m[r][c] - expected) > 1e-9:
return False
return True
class Rotation:
def __init__(self, *args):
if not args:
self.q = (0.0, 0.0, 0.0, 1.0)
elif len(args) == 1 and isinstance(args[0], Rotation):
self.q = tuple(args[0].q)
elif len(args) == 1 and isinstance(args[0], (tuple, list)) and len(args[0]) == 4:
self.q = _qnorm(tuple(_num(v) for v in args[0]))
elif len(args) == 2:
self.q = _qaxis(Vector(args[0]), args[1])
elif len(args) == 3:
yaw = _qaxis((0.0, 0.0, 1.0), args[0])
pitch = _qaxis((0.0, 1.0, 0.0), args[1])
roll = _qaxis((1.0, 0.0, 0.0), args[2])
self.q = _qnorm(_qmul(_qmul(yaw, pitch), roll))
elif len(args) == 4:
self.q = _qnorm(tuple(_num(v) for v in args))
else:
self.q = (0.0, 0.0, 0.0, 1.0)
def __repr__(self):
return "Rotation (%g, %g, %g, %g)" % self.q
def multiply(self, other):
return Rotation(_qmul(self.q, Rotation(other).q))
def __mul__(self, other):
if isinstance(other, Placement):
return Placement(other.Base, self).multiply(other.Rotation)
return self.multiply(other)
def toMatrix(self):
return _qmatrix(self.q)
@property
def Q(self):
return self.q
@property
def Angle(self):
w = max(-1.0, min(1.0, self.q[3]))
return math.degrees(2.0 * math.acos(w))
@property
def Axis(self):
x, y, z, w = self.q
s = math.sqrt(max(0.0, 1.0 - w * w))
if s < 1e-9:
return Vector(0.0, 0.0, 1.0)
return Vector(x / s, y / s, z / s)
def copy(self):
return Rotation(self)
class Placement:
def __init__(self, *args):
self.Base = Vector()
self.Rotation = Rotation()
if not args:
return
if len(args) == 1 and isinstance(args[0], Placement):
self.Base = Vector(args[0].Base)
self.Rotation = Rotation(args[0].Rotation)
elif len(args) == 1 and isinstance(args[0], (Vector, tuple, list)):
self.Base = Vector(args[0])
elif len(args) >= 2:
self.Base = Vector(args[0])
self.Rotation = Rotation(args[1])
def __repr__(self):
return "Placement [Pos=%s, Rot=%s]" % (self.Base, self.Rotation)
def copy(self):
return Placement(self)
def multiply(self, other):
other = Placement(other)
q = _qmul(self.Rotation.q, other.Rotation.q)
base = self.Base + _qrotate(self.Rotation.q, other.Base)
return Placement(base, Rotation(q))
def __mul__(self, other):
if isinstance(other, Vector):
return self.Base + _qrotate(self.Rotation.q, other)
if isinstance(other, Placement):
return self.multiply(other)
if isinstance(other, Shape):
shape = other.copy()
shape.Placement = self * shape.Placement
return shape
raise TypeError("unsupported Placement multiplication")
def __rmul__(self, other):
if isinstance(other, Vector):
return self.Base + _qrotate(self.Rotation.q, other)
return NotImplemented
def toMatrix(self):
return _mat_to_column_major(_matmul(_mat_translate(self.Base), _mat_rotate(self.Rotation.q)))
class Matrix:
def __init__(self, *args):
self._m = _mat_identity()
if len(args) == 16:
values = [_num(v) for v in args]
self._m = [[values[c * 4 + r] for c in range(4)] for r in range(4)]
elif len(args) == 1 and isinstance(args[0], Matrix):
self._m = copy.deepcopy(args[0]._m)
def move(self, vector):
self._m = _matmul(_mat_translate(Vector(vector)), self._m)
def multiply(self, other):
self._m = _matmul(self._m, other._m)
def A11(self):
return self._m[0][0]
def to_list(self):
return _mat_to_column_major(self._m)
# --------------------------------------------------------------------------
# shape tree
# --------------------------------------------------------------------------
class Shape:
def __init__(self, node=None, profile=None, placed=False):
self._node = node
self._profile = profile
self._placement = Placement()
def __repr__(self):
return "<Shape>"
@property
def Placement(self):
return self._placement
@Placement.setter
def Placement(self, value):
self._placement = Placement(value)
@property
def ShapeType(self):
return "Solid"
def copy(self):
shape = Shape(copy.deepcopy(self._node), copy.deepcopy(self._profile))
shape._placement = Placement(self._placement)
return shape
def render_node(self):
node = copy.deepcopy(self._node)
matrix = self._placement.toMatrix()
if not _mat_is_identity(_matmul(_mat_translate(self._placement.Base), _mat_rotate(self._placement.Rotation.q))):
node = {"op": "transform", "m": matrix, "child": node}
return node
def translate(self, vector, *args):
self._placement = Placement(Vector(vector)) * self._placement
return self
def rotate(self, center, axis, angle, *args):
center = Vector(center)
rot = Placement(Vector(0, 0, 0), Rotation(axis, angle))
self._placement = (
Placement(center) * rot * Placement(-center) * self._placement
)
return self
def scale(self, *args):
return self
def cut(self, tool):
return _boolean("cut", self, tool)
def fuse(self, tool):
return _boolean("fuse", self, tool)
def common(self, tool):
return _boolean("common", self, tool)
def multiFuse(self, tools):
return _multi_boolean("fuse", [self] + list(tools))
def multiCommon(self, tools):
return _multi_boolean("common", [self] + list(tools))
def extrude(self, vector):
if self._profile is None:
raise ValueError("extrude() needs a Face or closed wire")
node = {"op": "extrude", "profile": self._profile, "vector": Vector(vector).to_list()}
matrix = self._placement.toMatrix()
if not _mat_is_identity(
_matmul(_mat_translate(self._placement.Base), _mat_rotate(self._placement.Rotation.q))
):
node = {"op": "transform", "m": matrix, "child": node}
return Shape(node)
def removeSplitter(self):
return self
def isClosed(self):
return self._profile is not None
def _boolean(kind, a, b):
if not isinstance(a, Shape) or not isinstance(b, Shape):
raise TypeError("boolean operations need Shape arguments")
node = {"op": "boolean", "kind": kind, "a": a.render_node(), "b": b.render_node()}
return Shape(node)
def _multi_boolean(kind, shapes):
shapes = [s for s in shapes if isinstance(s, Shape)]
if not shapes:
raise ValueError("no shapes supplied")
if len(shapes) == 1:
return shapes[0].copy()
node = {"op": "group", "children": [s.render_node() for s in shapes]}
return Shape(node)
def _points2d(points):
result = []
for point in points:
p = Vector(point)
result.append([p.x, p.y])
return result
def _make_face(profile, placement=None):
face = Shape({"op": "face", "profile": profile}, profile=profile)
if placement is not None:
face._placement = Placement(placement)
return face
# --------------------------------------------------------------------------
# Part module
# --------------------------------------------------------------------------
def _make_box(length, width, height, pnt=None, dir=None):
return Shape(
{
"op": "box",
"l": _num(length),
"w": _num(width),
"h": _num(height),
"pnt": Vector(pnt).to_list() if pnt is not None else [0, 0, 0],
"dir": Vector(dir).to_list() if dir is not None else [0, 0, 1],
}
)
def _make_cylinder(radius, height, pnt=None, dir=None, angle=360):
return Shape(
{
"op": "cylinder",
"r": _num(radius),
"h": _num(height),
"pnt": Vector(pnt).to_list() if pnt is not None else [0, 0, 0],
"dir": Vector(dir).to_list() if dir is not None else [0, 0, 1],
"angle": _num(angle, 360),
}
)
def _make_sphere(radius, pnt=None, *args):
return Shape(
{
"op": "sphere",
"r": _num(radius),
"pnt": Vector(pnt).to_list() if pnt is not None else [0, 0, 0],
}
)
def _make_cone(radius1, radius2, height, pnt=None, dir=None, angle=360):
return Shape(
{
"op": "cone",
"r1": _num(radius1),
"r2": _num(radius2),
"h": _num(height),
"pnt": Vector(pnt).to_list() if pnt is not None else [0, 0, 0],
"dir": Vector(dir).to_list() if dir is not None else [0, 0, 1],
"angle": _num(angle, 360),
}
)
def _make_torus(radius1, radius2, pnt=None, dir=None, *args):
return Shape(
{
"op": "torus",
"r1": _num(radius1),
"r2": _num(radius2),
"pnt": Vector(pnt).to_list() if pnt is not None else [0, 0, 0],
"dir": Vector(dir).to_list() if dir is not None else [0, 0, 1],
}
)
def _make_polygon(points, closed=False):
profile = _points2d(points)
return Shape({"op": "wire", "profile": profile, "closed": bool(closed)}, profile=profile)
def _make_line(point1, point2):
return _make_polygon([point1, point2], False)
def _make_face_from(value):
if isinstance(value, Shape):
if value._profile is None:
raise ValueError("Part.Face() needs a closed wire")
return _make_face(copy.deepcopy(value._profile), value._placement)
profile = _points2d(value)
if len(profile) > 2 and profile[0] != profile[-1]:
profile.append(profile[0])
return _make_face(profile)
def _make_compound(shapes):
shapes = [s for s in shapes if isinstance(s, Shape)]
return _multi_boolean("fuse", shapes)
def _show(shape, name=None):
document = _active_document()
if document is None:
document = newDocument("Unnamed")
return document._show(shape, name)
# --------------------------------------------------------------------------
# document
# --------------------------------------------------------------------------
class ViewObject:
def __init__(self, obj):
self.obj = obj
self.ShapeColor = None
self.LineColor = None
self.Visibility = True
self.Transparency = 0
self.DisplayMode = "Flat Lines"
def update(self):
pass
class DocObject:
def __init__(self, document, type_id, name):
object.__setattr__(self, "Document", document)
object.__setattr__(self, "Name", name)
object.__setattr__(self, "Label", name)
object.__setattr__(self, "TypeId", type_id)
object.__setattr__(self, "_shape", None)
object.__setattr__(self, "_feature_shape", None)
object.__setattr__(self, "_dirty", True)
object.__setattr__(self, "ViewObject", ViewObject(self))
object.__setattr__(self, "_props", self._defaults(type_id))
if "Placement" not in self._props:
self._props["Placement"] = Placement()
@staticmethod
def _defaults(type_id):
if type_id == "Part::Box":
return {"Length": 10.0, "Width": 10.0, "Height": 10.0, "Placement": Placement()}
if type_id == "Part::Cylinder":
return {"Radius": 2.0, "Height": 10.0, "Angle": 360.0, "Placement": Placement()}
if type_id == "Part::Sphere":
return {"Radius": 5.0, "Placement": Placement()}
if type_id == "Part::Cone":
return {
"Radius1": 2.0,
"Radius2": 4.0,
"Height": 10.0,
"Angle": 360.0,
"Placement": Placement(),
}
if type_id == "Part::Torus":
return {"Radius1": 10.0, "Radius2": 2.0, "Placement": Placement()}
if type_id in ("Part::Cut", "Part::Fuse"):
return {"Base": None, "Tool": None, "Placement": Placement()}
if type_id in ("Part::MultiFuse", "Part::MultiCommon"):
return {"Shapes": [], "Placement": Placement()}
return {"Placement": Placement()}
def __getattr__(self, name):
props = object.__getattribute__(self, "_props")
if name in props:
return props[name]
raise AttributeError("'%s' object has no attribute '%s'" % (self.TypeId, name))
def __setattr__(self, name, value):
if name in ("Document", "Name", "Label", "TypeId", "ViewObject"):
object.__setattr__(self, name, value)
elif name == "Shape":
object.__setattr__(self, "_feature_shape", value)
object.__setattr__(self, "_shape", value)
object.__setattr__(self, "_dirty", False)
elif name.startswith("_"):
object.__setattr__(self, name, value)
else:
self._props[name] = value
object.__setattr__(self, "_dirty", True)
@property
def Shape(self):
if self._dirty or self._shape is None:
self._shape = self._compute()
object.__setattr__(self, "_dirty", False)
return self._shape
@Shape.setter
def Shape(self, value):
object.__setattr__(self, "_feature_shape", value)
object.__setattr__(self, "_shape", value)
object.__setattr__(self, "_dirty", False)
def _compute(self):
type_id = self.TypeId
placement = self._props.get("Placement", Placement())
if type_id == "Part::Feature":
return self._feature_shape
if type_id == "Part::Box":
shape = _make_box(self.Length, self.Width, self.Height)
elif type_id == "Part::Cylinder":
shape = _make_cylinder(self.Radius, self.Height, angle=self.Angle)
elif type_id == "Part::Sphere":
shape = _make_sphere(self.Radius)
elif type_id == "Part::Cone":
shape = _make_cone(self.Radius1, self.Radius2, self.Height, angle=self.Angle)
elif type_id == "Part::Torus":
shape = _make_torus(self.Radius1, self.Radius2)
elif type_id == "Part::Cut":
if self.Base is None or self.Tool is None:
raise ValueError("%s needs Base and Tool" % self.Name)
shape = self.Base.Shape.copy().cut(self.Tool.Shape)
elif type_id == "Part::Fuse":
if self.Base is None or self.Tool is None:
raise ValueError("%s needs Base and Tool" % self.Name)
shape = self.Base.Shape.copy().fuse(self.Tool.Shape)
elif type_id == "Part::MultiFuse":
shape = _multi_boolean("fuse", [s.Shape for s in self.Shapes])
elif type_id == "Part::MultiCommon":
shape = _multi_boolean("common", [s.Shape for s in self.Shapes])
else:
return self._feature_shape
if type_id != "Part::Feature":
shape.Placement = placement
return shape
class Document:
def __init__(self, name="Unnamed"):
self.Name = name
self.Label = name
self._objects = []
self._counter = 0
self._errors = []
@property
def Objects(self):
return list(self._objects)
def addObject(self, type_id, name=None):
return self._add(type_id, name)
def _add(self, type_id, name=None):
if name is None:
name = type_id.split("::")[-1] + "%03d" % self._counter
base_name = name
while self.getObject(name) is not None:
self._counter += 1
name = base_name + "%03d" % self._counter
obj = DocObject(self, type_id, name)
self._objects.append(obj)
self._counter += 1
return obj
def _show(self, shape, name=None):
if not isinstance(shape, Shape):
raise TypeError("Part.show() needs a Shape")
obj = self._add("Part::Feature", name or "Shape")
obj.Shape = shape.copy()
return obj
def recompute(self):
self._errors = []
for obj in list(self._objects):
try:
_ = obj.Shape
except Exception as error:
self._errors.append("%s: %s" % (obj.Name, error))
return self
def getObject(self, name):
for obj in self._objects:
if obj.Name == name:
return obj
return None
def removeObject(self, name):
obj = self.getObject(name)
if obj is not None:
self._objects.remove(obj)
def __repr__(self):
return "<Document %s>" % self.Name
# --------------------------------------------------------------------------
# module installation
# --------------------------------------------------------------------------
_freecad = None
_part = None
_documents = {}
_active = None
_log = []
def _log_message(kind):
def inner(*args, **kwargs):
text = " ".join(str(a) for a in args)
_log.append("%s: %s" % (kind, text))
return inner
def newDocument(name="Unnamed", *args, **kwargs):
global _active
document = Document(name)
if name in _documents:
index = 1
while "%s%03d" % (name, index) in _documents:
index += 1
document.Name = "%s%03d" % (name, index)
document.Label = document.Name
_documents[document.Name] = document
_active = document
if _freecad is not None:
_freecad.ActiveDocument = document
return document
def activeDocument():
return _active
def _active_document():
return _active
def closeDocument(name):
global _active
document = _documents.pop(name, None)
if _active is document:
_active = None
if _freecad is not None:
_freecad.ActiveDocument = None
return document
def listDocuments():
return dict(_documents)
def _get_color(obj, index):
color = getattr(obj.ViewObject, "ShapeColor", None)
opacity = 1.0 - (max(0.0, min(100.0, _num(getattr(obj.ViewObject, "Transparency", 0)))) / 100.0)
if color is None:
rgb = PALETTE[index % len(PALETTE)]
elif isinstance(color, str):
value = color.strip()
if value.startswith("#") and len(value) >= 7:
rgb = (
int(value[1:3], 16) / 255.0,
int(value[3:5], 16) / 255.0,
int(value[5:7], 16) / 255.0,
)
else:
rgb = PALETTE[index % len(PALETTE)]
else:
values = list(color)
values += [0.0, 0.0, 0.0]
rgb = (_num(values[0], 0.8), _num(values[1], 0.8), _num(values[2], 0.8))
return [max(0.0, min(1.0, float(c))) for c in rgb], opacity
def build_scene():
document = _active
if document is None:
return {"objects": [], "errors": [], "log": list(_log)}
document.recompute()
objects = []
for obj in document.Objects:
if getattr(obj.ViewObject, "Visibility", True) is False:
continue
try:
shape = obj.Shape
except Exception:
continue
if shape is None:
continue
color, opacity = _get_color(obj, len(objects))
objects.append(
{
"name": obj.Name,
"color": color,
"opacity": opacity,
"node": shape.render_node(),
}
)
return {"objects": objects, "errors": list(document._errors), "log": list(_log)}
class Runtime:
def reset(self):
global _active, _log
_documents.clear()
_active = None
_log = []
if _freecad is not None:
_freecad.ActiveDocument = None
def run(self, code):
self.reset()
try:
namespace = {"__name__": "__main__", "__file__": "<wfreecad>"}
exec(compile(code, "<wfreecad>", "exec"), namespace, namespace)
scene = build_scene()
return {"ok": True, "scene": scene}
except Exception:
return {"ok": False, "error": traceback.format_exc(limit=8), "log": list(_log)}
_runtime = Runtime()
def install():
global _freecad, _part
if _freecad is not None:
return
_freecad = types.ModuleType("FreeCAD")
_freecad.Vector = Vector
_freecad.Rotation = Rotation
_freecad.Placement = Placement
_freecad.Matrix = Matrix
_freecad.newDocument = newDocument
_freecad.activeDocument = activeDocument
_freecad.closeDocument = closeDocument
_freecad.listDocuments = listDocuments
_freecad.ActiveDocument = None
_freecad.Console = types.SimpleNamespace(
PrintMessage=_log_message("Message"),
PrintWarning=_log_message("Warning"),
PrintError=_log_message("Error"),
)
sys.modules["FreeCAD"] = _freecad
_part = types.ModuleType("Part")
_part.makeBox = _make_box
_part.makeCylinder = _make_cylinder
_part.makeSphere = _make_sphere
_part.makeCone = _make_cone
_part.makeTorus = _make_torus
_part.makePolygon = _make_polygon
_part.makeLine = _make_line
_part.makeCompound = _make_compound
_part.Face = _make_face_from
_part.Wire = lambda *args, **kwargs: _make_polygon(args[0] if args else [], True)
_part.show = _show
_part.Shape = Shape
sys.modules["Part"] = _part
try:
sys.modules["FreeCADGui"] = types.ModuleType("FreeCADGui")
except Exception:
pass
install()
__wfcad_runtime = _runtime