CAD-сервис
← Все соединения
FL-03Замки и направляющие

Ласточкин хвост

Расширенный профиль скользит в ответном поднутрённом пазу.

Скачать STEP
FL-03 / CAD ASSEMBLY / MMЛасточкин хвост, CAD-рендерВыберите деталь нажатием

Вращение — левая кнопка мыши. Масштаб — колесо. Нажатие выбирает деталь.

Выступ вводят с открытого торца и сдвигают вдоль паза. Наклонные грани удерживают детали от поперечного разъединения.

Сохранённая анимация исходной моделиЛасточкин хвост, демонстрация движенияСкачать GIF

Устройство и последовательность

Три стадии работы Ласточкин хвост

Выступ вводят с открытого торца и сдвигают вдоль паза. Наклонные грани удерживают детали от поперечного разъединения.

Схема показывает принцип. Для точной геометрии используйте 3D-модель и STEP.

Источник: Prusa / соединители и ласточкин хвост

Как выглядит
Трапециевидная направляющая и охватывающий её паз с наклонными боковыми стенками.
Как удерживается
Выступ вводят с открытого торца и сдвигают вдоль паза. Наклонные грани удерживают детали от поперечного разъединения.
Как снять
Освободить торцевой стопор, если он предусмотрен, и сдвинуть деталь обратно.
Где применять
Съёмные борта, держатели датчиков, кассеты и направляющие.
Печать и сборка
Выбрать ориентацию, в которой профиль опирается на стол или его наклоны печатаются постепенно. Зазор подбирают коротким образцом.
Что проверить
Направляющая сама по себе допускает продольный выход. Длинная посадка чувствительна к короблению и загрязнению.

Генератор, который строит эту модель

Python запускается в FreeCAD. Размеры передаются словарём; геометрия доступна для дальнейших операций fuse, cut и Placement. Общий модуль common.py должен лежать на один каталог выше генератора.

Python / generator.py
Скачать
"""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()
Общий CAD-модуль / common.py
Python / common.py
Скачать
"""Shared CAD primitives, rigid poses, mesh export and geometric checks. Units: mm."""

import FreeCAD as App

# isort: split
import argparse
import json
import math
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from tempfile import TemporaryDirectory

import MeshPart
import Part

V = App.Vector
WHITE, ORANGE, METAL = [0.88, 0.89, 0.86], [1, 0.42, 0.09], [0.35, 0.36, 0.34]


def box(x, y, z, dx, dy, dz):
    return Part.makeBox(dx, dy, dz, V(x, y, z))


def cyl(r, h, at=(0, 0, 0), axis=(0, 0, 1)):
    return Part.makeCylinder(r, h, V(*at), V(*axis))


def cone(r1, r2, height, at=None):
    at = V() if at is None else at
    return (
        Part.makeCylinder(r1, height, at)
        if abs(r1 - r2) < 1e-9
        else Part.makeCone(r1, r2, height, at)
    )


def polygon(points, z, height):
    pts = [V(x, y, z) for x, y in points]
    return Part.Face(Part.makePolygon(pts + pts[:1])).extrude(V(0, 0, height))


def section(points, y, width):
    pts = [V(x, y, z) for x, z in points]
    return Part.Face(Part.makePolygon(pts + pts[:1])).extrude(V(0, width, 0))


def join(*shapes):
    return (
        shapes[0].multiFuse(list(shapes[1:])).removeSplitter()
        if len(shapes) > 1
        else shapes[0]
    )


def ring(outer, inner, height, z=0):
    return cyl(outer, height, (0, 0, z)).cut(cyl(inner, height + 2, (0, 0, z - 1)))


def sector_ring(outer, inner, start, end, height, z=0):
    # Polygonal arcs give the split lips reproducible printable geometry.
    angles = [math.radians(start + (end - start) * i / 40) for i in range(41)]
    pts = [(outer * math.cos(a), outer * math.sin(a)) for a in angles]
    pts += [(inner * math.cos(a), inner * math.sin(a)) for a in reversed(angles)]
    return polygon(pts, z, height)


def hexagon(radius, height, z=0):
    return polygon(
        [
            (radius * math.cos(i * math.pi / 3), radius * math.sin(i * math.pi / 3))
            for i in range(6)
        ],
        z,
        height,
    )


@lru_cache(maxsize=48)
def thread(radius, pitch, length, extra=0):
    if extra:
        matrix = App.Matrix()
        matrix.A11 = matrix.A22 = 1 + extra / radius
        return thread(radius, pitch, length, 0).transformGeometry(matrix)
    root, major = radius - pitch * 0.42, radius
    if root <= 0:
        raise ValueError("Thread root radius must be positive")
    flat = min(0.09, pitch * 0.08)
    inner = root - flat
    half = flat + (major - inner) / math.sqrt(3)
    start = -pitch
    points = [
        V(inner, 0, start - half),
        V(major, 0, start - flat),
        V(major, 0, start + flat),
        V(inner, 0, start + half),
    ]
    profile = Part.makePolygon(points + points[:1])
    helix = Part.Wire(Part.makeHelix(pitch, length + 2 * pitch, root).Edges).translated(
        V(0, 0, start)
    )
    rib = helix.makePipeShell([profile], True, True)
    stock = cyl(root, length + 4 * pitch, (0, 0, -2 * pitch)).fuse(rib)
    return stock.common(cyl(major + 0.1, length)).removeSplitter()


def phase(t, begin=0, end=1):
    return max(0, min(1, (t - begin) / (end - begin)))


@dataclass
class Pose:
    offset: tuple = (0, 0, 0)
    angle: float = 0
    axis: tuple = (0, 0, 1)
    pivot: tuple = (0, 0, 0)

    def placement(self):
        rotation = App.Rotation(V(*self.axis), self.angle)
        pivot = V(*self.pivot)
        return App.Placement(
            V(*self.offset) + pivot - rotation.multVec(pivot), rotation
        )

    def data(self, t):
        return {
            "t": t,
            "translation": list(self.offset),
            "angle": self.angle,
            "axis": list(self.axis),
            "pivot": list(self.pivot),
        }


@dataclass
class Piece:
    id: str
    name: str
    shape: object
    motion: Callable = lambda t: Pose()
    deform: Callable | None = None
    color: list | None = None
    role: str = "printed"
    note: str = ""
    fabrication_shape: object | None = None

    def at(self, t):
        shape = (self.deform(t) if self.deform else self.shape).copy()
        shape.Placement = self.motion(t).placement().multiply(shape.Placement)
        return shape


def params(defaults, supplied=None):
    supplied = {} if supplied is None else supplied
    if not isinstance(supplied, dict):
        raise TypeError("Parameters must be a JSON object")
    unknown = set(supplied) - set(defaults)
    if unknown:
        raise ValueError(f"Unknown parameters: {sorted(unknown)}")
    result = defaults | supplied
    for key, value in result.items():
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError(f"{key} must be numeric")
        if not math.isfinite(value) or value < 0:
            raise ValueError(f"{key} must be finite and non-negative")
        if value == 0 and key not in {"clearance", "interference", "gap", "preload"}:
            raise ValueError(f"{key} must be positive")
    return result


def require(condition, message):
    if not condition:
        raise ValueError(message)


def mesh_data(shape):
    vertices, triangles = shape.tessellate(0.10)
    return {
        "vertices": [[round(v.x, 6), round(v.y, 6), round(v.z, 6)] for v in vertices],
        "triangles": [list(tri) for tri in triangles],
        "edges": [
            [
                [round(v.x, 6), round(v.y, 6), round(v.z, 6)]
                for v in edge.discretize(Deflection=0.12)
            ]
            for edge in shape.Edges
        ],
        "volume": shape.Volume,
    }


def check(parts, mode="rigid"):
    final = []
    for part in parts:
        shape = part.at(1)
        assert shape.isValid() and len(shape.Solids) == 1 and shape.Volume > 1e-7, (
            part.id
        )
        final.append(shape)
    overlaps = []
    motion_overlaps = []
    maximum = 0.0
    for i, left in enumerate(parts):
        for j in range(i + 1, len(parts)):
            right = parts[j]
            volume = final[i].common(final[j]).Volume
            if volume > 1e-5:
                overlaps.append(
                    {"parts": [left.id, right.id], "volume_mm3": round(volume, 6)}
                )
            pair_max, pair_time = 0, 0
            for k in range(9):
                volume = left.at(k / 8).common(right.at(k / 8)).Volume
                if volume > pair_max:
                    pair_max, pair_time = volume, k / 8
            maximum = max(maximum, pair_max)
            if pair_max > 1e-5:
                motion_overlaps.append(
                    {
                        "parts": [left.id, right.id],
                        "volume_mm3": round(pair_max, 6),
                        "t": pair_time,
                    }
                )
    expected = {
        "interference": {frozenset(("receiver", "pin"))},
        "thermal-fit": {frozenset(("receiver", "insert"))},
        "thread-forming": {frozenset(("receiver", "screw"))},
    }.get(mode, set())
    unexpected = [
        item for item in motion_overlaps if frozenset(item["parts"]) not in expected
    ]
    require(not unexpected, f"Детали пересекаются при сборке: {unexpected}")
    return {
        "valid_solids": len(parts),
        "final_overlaps": overlaps,
        "motion_sample_count": 9,
        "maximum_motion_overlap_mm3": round(maximum, 6),
        "mode": mode,
        "motion_overlaps": motion_overlaps,
        "physical_test": "pending",
    }


def export(model_id, folder, values, parts, note, mode="rigid"):
    checks = check(parts, mode)
    folder = Path(folder)
    folder.mkdir(parents=True, exist_ok=True)
    with TemporaryDirectory(dir=folder) as temp:
        work = Path(temp)
        doc = App.newDocument("FastenerDemo")
        objects, payload = [], []
        for index, piece in enumerate(parts):
            shape = piece.at(1)
            obj = doc.addObject("Part::Feature", piece.id)
            obj.Label, obj.Shape = piece.name, shape
            obj.addProperty("App::PropertyString", "GeneratorParameters", "Generator")
            obj.GeneratorParameters = json.dumps(values)
            obj.addProperty("App::PropertyString", "Role", "Generator")
            obj.Role = piece.role
            objects.append(obj)
            Part.export([obj], str(work / f"{piece.id}.step"))
            printable = (
                piece.fabrication_shape.copy()
                if piece.fabrication_shape is not None
                else piece.shape.copy()
            )
            b = printable.BoundBox
            printable.translate(V(-b.XMin, -b.YMin, -b.ZMin))
            mesh = MeshPart.meshFromShape(
                Shape=printable, LinearDeflection=0.1, AngularDeflection=0.18
            )
            assert mesh.isSolid(), piece.id
            if piece.role == "printed":
                mesh.write(str(work / f"{piece.id}.stl"))
            entry = {
                "id": piece.id,
                "name": piece.name,
                "role": piece.role,
                "note": piece.note,
                "color": piece.color or (WHITE if index == 0 else ORANGE),
                "motion": [0, 0, 0],
                **mesh_data(piece.shape),
            }
            if piece.role != "printed" and piece.color is None:
                entry["color"] = METAL
            entry["keyframes"] = [piece.motion(i / 32).data(i / 32) for i in range(33)]
            if piece.deform:
                entry["geometryFrames"] = []
                for i in range(33):
                    deformed = piece.deform(i / 32)
                    assert deformed.isValid() and len(deformed.Solids) == 1, (
                        piece.id,
                        i,
                    )
                    entry["geometryFrames"].append({"t": i / 32, **mesh_data(deformed)})
            payload.append(entry)
        if mode == "print-in-place":
            assembly = Part.Compound(
                [p.shape for p in parts if p.role == "printed"]
            )
            assembly.translate(V(0, 0, -assembly.BoundBox.ZMin))
            mesh = MeshPart.meshFromShape(
                Shape=assembly, LinearDeflection=0.1, AngularDeflection=0.18
            )
            assert mesh.isSolid()
            mesh.write(str(work / "assembly.stl"))
        doc.recompute()
        doc.saveAs(str(work / "demo.FCStd"))
        Part.export(objects, str(work / "demo.step"))
        restored = Part.read(str(work / "demo.step"))
        assert restored.isValid() and len(restored.Solids) == len(parts)
        expected_volume = sum(p.at(1).Volume for p in parts)
        delta = abs(restored.Volume - expected_volume)
        checks["step_volume_relative_error"] = delta / expected_volume
        assert delta < max(1e-3, expected_volume * 1e-4), (
            restored.Volume,
            expected_volume,
            delta,
        )
        App.closeDocument(doc.Name)
        data = {
            "id": model_id,
            "units": "mm",
            "parameters": values,
            "parts": payload,
            "note": note,
            "mode": mode,
            "travel": 0,
            "initialProgress": 0.45,
        }
        (work / "model.js").write_text(
            "globalThis.CADModel = " + json.dumps(data, ensure_ascii=False) + ";\n"
        )
        (work / "checks.json").write_text(
            json.dumps(checks, ensure_ascii=False, indent=2) + "\n"
        )
        (work / "parameters.json").write_text(json.dumps(values, indent=2) + "\n")
        manifest = {
            "id": model_id,
            "parameters": values,
            "note": note,
            "mode": mode,
            "parts": [
                {"id": p.id, "name": p.name, "role": p.role, "note": p.note}
                for p in parts
            ],
        }
        (work / "manifest.json").write_text(
            json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
        )
        for file in sorted(work.iterdir(), key=lambda f: f.name == "model.js"):
            file.replace(folder / file.name)
    return checks


def main(build, defaults, model_id, location, note, mode="rigid"):
    parser = argparse.ArgumentParser(description=note)
    parser.add_argument("--params", type=Path)
    parser.add_argument(
        "--output", type=Path, default=Path(location).resolve().parent / "generated"
    )
    args = parser.parse_args()
    values = params(
        defaults, json.loads(args.params.read_text()) if args.params else {}
    )
    result = export(model_id, args.output, values, build(values), note, mode)
    print(json.dumps(result, ensure_ascii=False), flush=True)

Запросить модель программно

Можно передать только изменённые размеры. Сервер вернёт результат либо задачу с адресом для проверки статуса. Произвольный Python на сервере не исполняется.

Shell / запрос модели
curl -X POST 'https://fastening.home.mivx.ru/api/generate/dovetail' \
  -H 'Content-Type: application/json' \
  -d '{"parameters":{"clearance":0.25}}'
POST /api/probe/dovetail

Создать печатный пробник из тех же параметров.

GET /api/models/dovetail

Описание, размерные параметры и ссылки на исходники.

Открыть JSON

Агенты и MCP

MCP: https://fastening.home.mivx.ru/mcp

Инструменты поиска, параметров, генерации CAD, пробника и статуса задачи используют те же модели, что и этот интерфейс.

Документация API и MCP

Скачать skill для агента