"""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 "" @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 "" % 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__": ""} exec(compile(code, "", "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