"""Reusable FreeCAD dovetail. All parameters are in mm, insertion is along -Y.""" import FreeCAD as App # isort: split import argparse import json import math from dataclasses import asdict, dataclass from pathlib import Path import MeshPart import Part V = App.Vector @dataclass(frozen=True) class Parameters: length: float = 52.0 slider_length: float = 36.0 tail_width: float = 18.0 neck_width: float = 12.0 tail_height: float = 6.0 base_thickness: float = 3.0 wall_thickness: float = 4.0 deck_thickness: float = 3.0 clearance: float = 0.25 end_stop: float = 3.0 lead_in: float = 0.6 def __post_init__(self): for name, value in asdict(self).items(): if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError(f"{name} must be a number") if not math.isfinite(value) or value < 0: raise ValueError(f"{name} must be finite and non-negative") for name in ( "length", "slider_length", "tail_width", "neck_width", "tail_height", "base_thickness", "wall_thickness", "deck_thickness", ): if getattr(self, name) == 0: raise ValueError(f"{name} must be positive") if self.neck_width >= self.tail_width: raise ValueError("neck_width must be smaller than tail_width") if self.slider_length + self.end_stop + 2 * self.clearance > self.length: raise ValueError("slider_length plus end_stop and end gaps exceed length") if self.lead_in >= min(self.base_thickness, self.wall_thickness): raise ValueError("lead_in must be smaller than the base and wall thickness") if self.lead_in >= self.length - self.end_stop: raise ValueError("lead_in leaves no straight guide") if self.tail_width <= 2 * self.mouth_half_width: raise ValueError("clearance is too large to retain the dovetail vertically") @property def slope(self): return (self.tail_width - self.neck_width) / (2 * self.tail_height) @property def side_offset(self): return self.clearance * math.sqrt(1 + self.slope**2) @property def mouth_half_width(self): return self.neck_width / 2 + self.slope * self.clearance + self.side_offset @property def base_half_width(self): return ( self.tail_width / 2 + self.slope * self.clearance + self.side_offset + self.wall_thickness ) @property def seated_y(self): return self.end_stop + self.clearance @property def travel(self): return self.length + 4 - self.seated_y def profile(points, y): """Closed XZ wire at the given Y station.""" vertices = [V(x, y, z) for x, z in points] return Part.makePolygon(vertices + vertices[:1]) def make_cutter(p: Parameters): """Female cavity, ready to subtract from an enclosing body in the same frame.""" bottom = p.tail_width / 2 + p.slope * p.clearance + p.side_offset top = p.neck_width / 2 + p.side_offset z0, z1 = p.base_thickness, p.base_thickness + p.clearance + p.tail_height section = [(-bottom, z0), (bottom, z0), (top, z1), (-top, z1)] cutter = Part.Face(profile(section, p.end_stop)).extrude( V(0, p.length - p.end_stop + 1, 0) ) if p.lead_in: flared = [ (-bottom - p.lead_in, z0 - p.lead_in), (bottom + p.lead_in, z0 - p.lead_in), (top + p.lead_in, z1), (-top - p.lead_in, z1), ] entry = Part.makeLoft( [profile(section, p.length - p.lead_in), profile(flared, p.length)], True, False, ) cutter = cutter.fuse(entry).removeSplitter() return cutter def make_receiver(p: Parameters): """Receiver with a closed rear stop and one open, flared entry.""" stock = Part.makeBox( 2 * p.base_half_width, p.length, p.base_thickness + p.tail_height, V(-p.base_half_width, 0, 0), ) return stock.cut(make_cutter(p)).removeSplitter() def make_slider(p: Parameters): """Male part with Y=0 at its rear face. Place at Y=p.seated_y to assemble.""" z0 = p.base_thickness + p.clearance z1 = z0 + p.tail_height tail = Part.Face( profile( [ (-p.tail_width / 2, z0), (p.tail_width / 2, z0), (p.neck_width / 2, z1), (-p.neck_width / 2, z1), ], 0, ) ).extrude(V(0, p.slider_length, 0)) deck = Part.makeBox( 2 * p.base_half_width, p.slider_length, p.deck_thickness, V(-p.base_half_width, 0, z1), ) return tail.fuse(deck).removeSplitter() def validate(p: Parameters): """Check the assembled solids, the whole sliding path, and geometric retention.""" receiver = make_receiver(p) slider = make_slider(p).translated(V(0, p.seated_y, 0)) for shape in (receiver, slider): assert shape.isValid() and len(shape.Solids) == 1 and shape.Volume > 0 penetration = max( receiver.common(slider.translated(V(0, p.travel * i / 60, 0))).Volume for i in range(61) ) assert penetration < 1e-7, penetration # Lift far enough to cross the clearance before the tail escapes through the mouth. contact_lift = (p.side_offset / p.slope + p.tail_height - p.clearance) / 2 assert receiver.common(slider.translated(V(0, 0, contact_lift))).Volume > 1e-5 if p.end_stop: assert ( receiver.common( slider.translated(V(0, -p.clearance - min(0.2, p.end_stop / 2), 0)) ).Volume > 1e-5 ) return { "receiver_valid": True, "slider_valid": True, "sliding_positions": 61, "maximum_interference_mm3": penetration, "minimum_clearance_mm": receiver.distToShape(slider)[0], "vertical_retention_checked": True, "rear_stop_checked": bool(p.end_stop), "base_width_mm": 2 * p.base_half_width, "travel_mm": p.travel, "volumes_mm3": [receiver.Volume, slider.Volume], } def mesh_data(shape): vertices, triangles = shape.tessellate(0.025) return { "vertices": [[round(v.x, 7), round(v.y, 7), round(v.z, 7)] for v in vertices], "triangles": [list(t) for t in triangles], "edges": [ [ [round(v.x, 7), round(v.y, 7), round(v.z, 7)] for v in e.discretize(Deflection=0.04) ] for e in shape.Edges ], "volume": shape.Volume, } def export_demo(p: Parameters, destination: Path): destination.mkdir(parents=True, exist_ok=True) receiver = make_receiver(p) slider = make_slider(p).translated(V(0, p.seated_y, 0)) checks = validate(p) doc = App.newDocument("DovetailDemo") objects = [] for name, label, shape in [ ("Receiver", "Основание с направляющей", receiver), ("Slider", "Ползун", slider), ]: obj = doc.addObject("Part::Feature", name) obj.Label, obj.Shape = label, shape obj.addProperty("App::PropertyString", "GeneratorParameters", "Generator") obj.GeneratorParameters = json.dumps(asdict(p), indent=2) objects.append(obj) Part.export([obj], str(destination / f"{name.lower()}.step")) printable = shape.copy() if name == "Slider": printable = printable.rotated(V(), V(1, 0, 0), 180) b = printable.BoundBox printable.translate(V(-b.XMin, -b.YMin, -b.ZMin)) mesh = MeshPart.meshFromShape( Shape=printable, LinearDeflection=0.025, AngularDeflection=0.1, ) assert mesh.isSolid() mesh.write(str(destination / f"{name.lower()}.stl")) doc.recompute() doc.saveAs(str(destination / "demo.FCStd")) Part.export(objects, str(destination / "demo.step")) restored = Part.read(str(destination / "demo.step")) assert restored.isValid() and len(restored.Solids) == 2 assert abs(restored.Volume - receiver.Volume - slider.Volume) < 1e-5 for shape in (receiver, slider): assert shape.cut(restored).Volume < 1e-6 App.closeDocument(doc.Name) data = { "title": "Ласточкин хвост", "parameters": asdict(p), "units": "mm", "travel": p.travel, "width": 2 * p.base_half_width, "parts": [ { "id": "receiver", "name": "Основание", "color": [0.88, 0.89, 0.86], "motion": [0, 0, 0], **mesh_data(receiver), }, { "id": "slider", "name": "Ползун", "color": [1, 0.42, 0.09], "motion": [0, p.travel, 0], **mesh_data(slider), }, ], } (destination / "model.js").write_text( "globalThis.CADModel = " + json.dumps(data, ensure_ascii=False) + ";\n" ) (destination / "checks.json").write_text(json.dumps(checks, indent=2) + "\n") (destination / "parameters.json").write_text(json.dumps(asdict(p), indent=2) + "\n") return checks def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--params", type=Path) parser.add_argument( "--output", type=Path, default=Path(__file__).resolve().parent / "generated" ) args = parser.parse_args() values = json.loads(args.params.read_text()) if args.params else {} if not isinstance(values, dict): parser.error("The parameter file must contain a JSON object") parameters = Parameters(**values) print(json.dumps(export_demo(parameters, args.output), indent=2)) if __name__ == "__main__": main()