using System.Runtime.Versioning;
using Kompas6API5;
using Kompas6Constants3D;

namespace Kompas.Mcp.Core.Modeling;

/// <summary>PartModeler: формообразующие операции (выдавливание, вращение, скругление, фаска).</summary>
[SupportedOSPlatform("windows")]
public sealed partial class PartModeler
{
    /// <summary>
    /// Выдавить эскиз. <paramref name="cut"/> — вырезание вместо добавления материала.
    /// Возвращает id операции.
    /// </summary>
    /// <param name="throughAll">Сквозное выдавливание/вырез через всю деталь в обе стороны
    /// (для отверстий — гарантированно пробивает материал; <paramref name="depth"/> игнорируется).</param>
    /// <param name="thinThickness">Толщина тонкой стенки, мм: контур эскиза трактуется не как граница
    /// сплошного сечения, а как средняя/крайняя линия стенки такой толщины. 0 — сплошное тело.</param>
    /// <param name="thinSide">Куда откладывать толщину от контура: Outward | Inward | Both.</param>
    /// <param name="name">Имя операции в дереве построения («Рельеф букв OldMan»); пусто — имя от КОМПАС.</param>
    public Task<int> ExtrudeAsync(
        int sketchId, double depth, bool cut = false, bool forward = true,
        double draftAngle = 0, bool throughAll = false,
        double thinThickness = 0, ThinSide thinSide = ThinSide.Outward,
        string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            if (!throughAll && depth <= 0)
                throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть > 0 (или используйте throughAll).");
            if (thinThickness < 0)
                throw new ArgumentOutOfRangeException(nameof(thinThickness), "Толщина стенки должна быть >= 0.");
            var sketch = RequireSketch(sketchId);
            CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией

            var part = GetTopPart();
            var type = cut ? Obj3dType.o3d_cutExtrusion : Obj3dType.o3d_bossExtrusion;
            var entity = NewEntity(part, type, name);

            // Конкретные типы определений (без dynamic): ошибки связывания — на этапе компиляции.
            if (cut)
            {
                var def = entity.GetDefinition() as ksCutExtrusionDefinition
                    ?? throw new InvalidOperationException("GetDefinition() выреза вернул не ksCutExtrusionDefinition.");
                if (throughAll)
                {
                    def.directionType = (short)Direction_Type.dtBoth;
                    def.SetSideParam(true, (short)End_Type.etThroughAll, depth, draftAngle, false);
                    def.SetSideParam(false, (short)End_Type.etThroughAll, depth, draftAngle, false);
                }
                else
                {
                    def.directionType = (short)Direction_Type.dtNormal;
                    def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
                }
                var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
                def.SetThinParam(thin, thinType, normal, reverse);
                def.SetSketch(sketch.Entity);
            }
            else
            {
                if (throughAll)
                    throw new ArgumentException("throughAll поддерживается только для выреза (cut).", nameof(throughAll));
                var def = entity.GetDefinition() as ksBossExtrusionDefinition
                    ?? throw new InvalidOperationException("GetDefinition() выдавливания вернул не ksBossExtrusionDefinition.");
                def.directionType = (short)Direction_Type.dtNormal;
                def.SetSideParam(forward, (short)End_Type.etBlind, depth, draftAngle, false);
                var (thin, thinType, normal, reverse) = ThinParams(thinThickness, thinSide);
                def.SetThinParam(thin, thinType, normal, reverse);
                def.SetSketch(sketch.Entity);
            }

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() операции выдавливания вернул FALSE." + (thinThickness > 0
                        ? " Тонкая стенка по многоконтурному эскизу (например, по надписи) не строится, " +
                          "если пересекает уже созданное тело: постройте её ДО этого тела либо соберите " +
                          "форму через primitive(result=union|subtract)."
                        : " Эскиз пуст, контур не замкнут или операция не пересекает тело?"));

            return Register(entity);
        }, ct);

    /// <summary>Разложить толщину стенки в параметры ksBoss/CutExtrusionDefinition.SetThinParam.</summary>
    private static (bool thin, short thinType, double normal, double reverse) ThinParams(double thickness, ThinSide side)
    {
        if (thickness <= 0) return (false, 0, 0, 0);
        // Толщина читается из того поля, которое отвечает выбранному направлению: при dtReverse
        // normalThickness игнорируется, и стенка нулевой толщины молча даёт СПЛОШНОЕ сечение.
        return side switch
        {
            ThinSide.Inward => (true, (short)Direction_Type.dtReverse, 0, thickness),
            ThinSide.Both => (true, (short)Direction_Type.dtBoth, thickness / 2, thickness / 2),
            _ => (true, (short)Direction_Type.dtNormal, thickness, 0),
        };
    }

    /// <summary>
    /// Вращать эскиз вокруг его осевой линии (добавьте её через AddAxis). <paramref name="cut"/> —
    /// вырезание вместо добавления материала. Угол <paramref name="angle"/> — в градусах.
    /// Возвращает id операции.
    /// </summary>
    public Task<int> RevolveAsync(
        int sketchId, double angle = 360, bool cut = false, bool forward = true,
        string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            if (angle is <= 0 or > 360)
                throw new ArgumentOutOfRangeException(nameof(angle), "Угол вращения должен быть в (0; 360].");
            var sketch = RequireSketch(sketchId);
            CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией

            var part = GetTopPart();
            var type = cut ? Obj3dType.o3d_cutRotated : Obj3dType.o3d_bossRotated;
            var entity = NewEntity(part, type, name);

            if (cut)
            {
                var def = entity.GetDefinition() as ksCutRotatedDefinition
                    ?? throw new InvalidOperationException("GetDefinition() выреза вращением вернул не ksCutRotatedDefinition.");
                def.directionType = (short)Direction_Type.dtNormal;
                def.SetThinParam(false, 0, 0, 0);
                def.SetSideParam(forward, angle);
                def.SetSketch(sketch.Entity);
            }
            else
            {
                var def = entity.GetDefinition() as ksBossRotatedDefinition
                    ?? throw new InvalidOperationException("GetDefinition() вращения вернул не ksBossRotatedDefinition.");
                def.directionType = (short)Direction_Type.dtNormal;
                def.toroidShapeType = false; // сфероид (сплошное тело), не тороид
                def.SetThinParam(false, 0, 0, 0);
                def.SetSideParam(forward, angle);
                def.SetSketch(sketch.Entity);
            }

            if (!entity.Create())
                throw new InvalidOperationException("Create() операции вращения вернул FALSE.");

            return Register(entity);
        }, ct);

    /// <summary>
    /// Скруглить ребро детали, найденное по точке (мировые координаты, мм), радиусом <paramref name="radius"/>.
    /// Возвращает id операции.
    /// </summary>
    public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, string? name = null, CancellationToken ct = default)
        => FilletEdgesByPointsAsync([new Point3d(x, y, z)], radius, name, ct);

    /// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
    public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, string? name = null, CancellationToken ct = default)
        => FilletEdgesAsync([edgeIndex], radius, name, ct);

    /// <summary>Скруглить одним скруглением несколько рёбер по индексам из list_edges
    /// (одна операция в дереве вместо N). Возвращает id операции.</summary>
    public Task<int> FilletEdgesAsync(IReadOnlyList<int> edgeIndices, double radius, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            // Диапазон всех индексов проверяем до мутации модели.
            var edges = RequireEdges(part, edgeIndices);
            return CreateFillet(part, edges, radius, name);
        }, ct);

    /// <summary>Скруглить ОДНОЙ операцией рёбра, найденные по точкам (мировые координаты, мм).
    /// Путь для тел, где список рёбер неподъёмен по объёму: N углов — одна операция, а не N.</summary>
    public Task<int> FilletEdgesByPointsAsync(IReadOnlyList<Point3d> points, double radius, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            var edges = RequireEdgesAt(part, points);
            return CreateFillet(part, edges, radius, name);
        }, ct);

    /// <summary>Разобрать список индексов рёбер в сущности (с проверкой пустоты и диапазона).</summary>
    private static List<ksEntity> RequireEdges(ksPart part, IReadOnlyList<int> edgeIndices)
    {
        ArgumentNullException.ThrowIfNull(edgeIndices);
        var indices = edgeIndices.Distinct().ToList();
        if (indices.Count == 0)
            throw new ArgumentException("Нужен хотя бы один индекс ребра.", nameof(edgeIndices));
        return indices.Select(i => SelectEdgeByIndex(part, i)).ToList();
    }

    /// <summary>Найти рёбра по списку точек. Ненайденные перечисляются ВСЕ сразу: иначе агент
    /// выясняет их по одной, теряя вызов на каждую точку.</summary>
    private static List<ksEntity> RequireEdgesAt(ksPart part, IReadOnlyList<Point3d> points)
    {
        ArgumentNullException.ThrowIfNull(points);
        if (points.Count == 0)
            throw new ArgumentException("Нужна хотя бы одна точка на ребре.", nameof(points));

        var found = new List<ksEntity>(points.Count);
        var missing = new List<string>();
        for (var i = 0; i < points.Count; i++)
        {
            var edge = TrySelectEdgeByPoint(part, points[i].X, points[i].Y, points[i].Z);
            if (edge is null) missing.Add(FormattableString.Invariant($"[{i}] ({points[i].X}, {points[i].Y}, {points[i].Z})"));
            else found.Add(edge);
        }

        if (missing.Count > 0)
            throw new InvalidOperationException(
                "Ребра нет в точках: " + string.Join("; ", missing) +
                ". Обычная причина — ребро уже съедено соседней операцией либо точка не лежит на ребре. " +
                "Повторите вызов без этих точек или уточните координаты по list_edges.");

        return found;
    }

    private int CreateFillet(ksPart part, IReadOnlyList<ksEntity> edges, double radius, string? name)
    {
        if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус скругления должен быть > 0.");

        var entity = NewEntity(part, Obj3dType.o3d_fillet, name);
        var def = entity.GetDefinition() as ksFilletDefinition
            ?? throw new InvalidOperationException("GetDefinition() скругления вернул не ksFilletDefinition.");
        def.radius = radius;
        def.tangent = false;
        var arr = def.array() as ksEntityCollection
            ?? throw new InvalidOperationException("array() скругления вернул не ksEntityCollection.");
        foreach (var edge in edges) arr.Add(edge);

        if (!entity.Create())
            throw new InvalidOperationException("Create() скругления вернул FALSE (радиус слишком велик для ребра?).");

        return Register(entity);
    }

    /// <summary>
    /// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
    /// Возвращает id операции.
    /// </summary>
    public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, string? name = null, CancellationToken ct = default)
        => ChamferEdgesByPointsAsync([new Point3d(x, y, z)], distance, name, ct);

    /// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
    public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, string? name = null, CancellationToken ct = default)
        => ChamferEdgesAsync([edgeIndex], distance, name, ct);

    /// <summary>Снять одной операцией фаску с нескольких рёбер по индексам из list_edges.
    /// Возвращает id операции.</summary>
    public Task<int> ChamferEdgesAsync(IReadOnlyList<int> edgeIndices, double distance, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            var edges = RequireEdges(part, edgeIndices);
            return CreateChamfer(part, edges, distance, name);
        }, ct);

    /// <summary>Снять ОДНОЙ операцией фаску с рёбер, найденных по точкам (мировые координаты, мм).</summary>
    public Task<int> ChamferEdgesByPointsAsync(IReadOnlyList<Point3d> points, double distance, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            var edges = RequireEdgesAt(part, points);
            return CreateChamfer(part, edges, distance, name);
        }, ct);

    private int CreateChamfer(ksPart part, IReadOnlyList<ksEntity> edges, double distance, string? name)
    {
        if (distance <= 0) throw new ArgumentOutOfRangeException(nameof(distance), "Катет фаски должен быть > 0.");

        var entity = NewEntity(part, Obj3dType.o3d_chamfer, name);
        var def = entity.GetDefinition() as ksChamferDefinition
            ?? throw new InvalidOperationException("GetDefinition() фаски вернул не ksChamferDefinition.");
        def.tangent = false;
        def.SetChamferParam(true, distance, distance);
        var arr = def.array() as ksEntityCollection
            ?? throw new InvalidOperationException("array() фаски вернул не ksEntityCollection.");
        foreach (var edge in edges) arr.Add(edge);

        if (!entity.Create())
            throw new InvalidOperationException("Create() фаски вернул FALSE (катет слишком велик для ребра?).");

        return Register(entity);
    }

    /// <summary>Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices,
    /// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется),
    /// true — наружу. Возвращает id операции.</summary>
    public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            if (thickness <= 0) throw new ArgumentOutOfRangeException(nameof(thickness), "Толщина должна быть > 0.");
            ArgumentNullException.ThrowIfNull(faceIndices);
            var indices = faceIndices.Distinct().ToList();
            if (indices.Count == 0) throw new ArgumentException("Нужна хотя бы одна удаляемая грань.", nameof(faceIndices));

            var part = GetTopPart();
            // Проверяем диапазон ВСЕХ индексов до мутации модели.
            var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList();

            var entity = NewEntity(part, Obj3dType.o3d_shellOperation, name);
            var def = entity.GetDefinition() as ksShellDefinition
                ?? throw new InvalidOperationException("GetDefinition() оболочки вернул не ksShellDefinition.");
            var arr = def.FaceArray() as ksEntityCollection
                ?? throw new InvalidOperationException("FaceArray() оболочки вернул не ksEntityCollection.");
            foreach (var f in faces) arr.Add(f);
            def.thickness = thickness;
            def.thinType = !outward; // true=внутрь, false=наружу

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() оболочки вернул FALSE (толщина больше локального радиуса/стенки или несовместимая топология?).");

            return Register(entity);
        }, ct);

    /// <summary>Построить ребро жёсткости от разомкнутого контура эскиза до тела.
    /// thickness — толщина стенки (мм); side — направление достройки (left|right|up|down);
    /// symmetric — толщина симметрична плоскости эскиза; angle — уклон стенок (градусы).
    /// Возвращает id операции.</summary>
    public Task<int> RibAsync(int sketchId, double thickness, string side = "up", bool symmetric = true, double angle = 0, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            SketchGeometry.RequirePositive(thickness, nameof(thickness)); // ловит и NaN (!(NaN>0))
            if (!double.IsFinite(angle)) throw new ArgumentException("Угол уклона должен быть конечным числом.", nameof(angle));
            var sideCode = SketchGeometry.RibSide(side);
            var sketch = RequireSketch(sketchId);
            CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией

            var part = GetTopPart();
            var entity = NewEntity(part, Obj3dType.o3d_ribOperation, name);
            var def = entity.GetDefinition() as ksRibDefinition
                ?? throw new InvalidOperationException("GetDefinition() ребра вернул не ksRibDefinition.");
            def.SetSketch(sketch.Entity);
            def.index = 0;          // первый сегмент контура задаёт уклон
            def.angle = angle;
            def.side = sideCode;
            if (symmetric)
                def.SetThinParam((short)Direction_Type.dtBoth, thickness / 2, thickness / 2);
            else
                def.SetThinParam((short)Direction_Type.dtNormal, thickness, 0);

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() ребра вернул FALSE (контур не пересекает тело или неверное направление side?).");

            return Register(entity);
        }, ct);

    /// <summary>Кинематическая операция (sweep): переместить профиль (эскиз profileSketchId)
    /// вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Возвращает id операции.</summary>
    public Task<int> SweepAsync(int profileSketchId, int pathSketchId, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            if (profileSketchId == pathSketchId)
                throw new ArgumentException("Профиль и траектория должны быть разными эскизами.", nameof(pathSketchId));
            var profile = RequireSketch(profileSketchId);
            var path = RequireSketch(pathSketchId);
            CloseSketchCore(profile); // оба эскиза должны быть закрыты перед операцией
            CloseSketchCore(path);

            var part = GetTopPart();
            var entity = NewEntity(part, Obj3dType.o3d_baseEvolution, name);
            var def = entity.GetDefinition() as ksBaseEvolutionDefinition
                ?? throw new InvalidOperationException("GetDefinition() кинематической операции вернул не ksBaseEvolutionDefinition.");
            def.SetSketch(profile.Entity);
            var pathArr = def.PathPartArray() as ksEntityCollection
                ?? throw new InvalidOperationException("PathPartArray() вернул не ksEntityCollection.");
            pathArr.Add(path.Entity);
            def.sketchShiftType = 0;             // перенос сечения с сохранением угла
            def.SetThinParam(false, 0, 0, 0);    // сплошное тело

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() кинематической операции вернул FALSE (начало траектории не на плоскости профиля или несовместимая геометрия?).");

            return Register(entity);
        }, ct);

    /// <summary>Операция по сечениям (loft): построить сплошное тело по ≥2 закрытым эскизам-сечениям
    /// (в порядке списка). closed — замкнуть сечения в кольцо. Возвращает id операции.</summary>
    public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            ArgumentNullException.ThrowIfNull(sketchIds);
            var ids = sketchIds.Distinct().ToList();
            if (ids.Count < 2)
                throw new ArgumentException("Нужно минимум 2 сечения для операции по сечениям.", nameof(sketchIds));
            var sketches = ids.Select(RequireSketch).ToList(); // проверяем все id до мутации
            foreach (var s in sketches) CloseSketchCore(s);

            var part = GetTopPart();
            var entity = NewEntity(part, Obj3dType.o3d_baseLoft, name);
            var def = entity.GetDefinition() as ksBaseLoftDefinition
                ?? throw new InvalidOperationException("GetDefinition() операции по сечениям вернул не ksBaseLoftDefinition.");
            var arr = def.Sketchs() as ksEntityCollection
                ?? throw new InvalidOperationException("Sketchs() вернул не ksEntityCollection.");
            foreach (var s in sketches) arr.Add(s.Entity);
            def.SetLoftParam(closed, false, true); // closed, flipVertex=false, autoPath=true
            def.SetThinParam(false, 0, 0, 0);      // сплошное тело

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() операции по сечениям вернул FALSE (несовместимые сечения или неверный порядок?).");

            return Register(entity);
        }, ct);

    /// <summary>Линейный массив (по сетке вдоль одной координатной оси): размножить операции
    /// featureIds вдоль оси axis, count экземпляров всего (включая исходный, ≥2), шаг step (мм)
    /// между соседними. geometric=true — быстрое геометрическое копирование. Возвращает id операции.</summary>
    public Task<int> LinearPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool geometric = false, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            ArgumentNullException.ThrowIfNull(featureIds);
            var ids = featureIds.Distinct().ToList();
            if (ids.Count == 0) throw new ArgumentException("Нужна хотя бы одна операция для массива.", nameof(featureIds));
            SketchGeometry.RequireMin(count, 2, nameof(count)); // count включает исходный
            SketchGeometry.RequirePositive(step, nameof(step)); // ловит и NaN

            // Собираем источники до мутации модели (проверка существования всех id).
            var features = ids.Select(RequireFeature).ToList();

            var part = GetTopPart();
            var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
                ?? throw new InvalidOperationException("Не удалось получить координатную ось.");

            var entity = NewEntity(part, Obj3dType.o3d_meshCopy, name);
            var def = entity.GetDefinition() as ksMeshCopyDefinition
                ?? throw new InvalidOperationException("GetDefinition() массива вернул не ksMeshCopyDefinition.");
            def.SetAxis1(axisEntity);
            def.SetCopyParamAlongAxis(true, 0, count, step, false); // ось 1: angle=0, factor=false (шаг между соседними)
            def.count2 = 1;        // второе направление выключено (1D-массив)
            def.geomArray = geometric;
            var arr = def.OperationArray() as ksEntityCollection
                ?? throw new InvalidOperationException("OperationArray() массива вернул не ksEntityCollection.");
            foreach (var f in features) arr.Add(f);

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() линейного массива вернул FALSE (копии самопересекаются или несовместимая геометрия?).");

            return Register(entity);
        }, ct);

    /// <summary>Круговой массив: размножить операции featureIds вокруг координатной оси axis,
    /// count экземпляров всего (включая исходный, ≥2), угловой шаг step (градусы) между соседними.
    /// reverse — обратное направление вращения. geometric=true — быстрое геометрическое копирование.
    /// Возвращает id операции.</summary>
    public Task<int> CircularPatternAsync(IReadOnlyList<int> featureIds, CoordinateAxis axis, int count, double step, bool reverse = false, bool geometric = false, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            ArgumentNullException.ThrowIfNull(featureIds);
            var ids = featureIds.Distinct().ToList();
            if (ids.Count == 0) throw new ArgumentException("Нужна хотя бы одна операция для массива.", nameof(featureIds));
            SketchGeometry.RequireMin(count, 2, nameof(count)); // count включает исходный
            SketchGeometry.RequirePositive(step, nameof(step)); // угловой шаг > 0, ловит NaN

            // Собираем источники до мутации модели (проверка существования всех id).
            var features = ids.Select(RequireFeature).ToList();

            var part = GetTopPart();
            var axisEntity = part.GetDefaultEntity((short)CoordinateAxes.ToObj3dType(axis)) as ksEntity
                ?? throw new InvalidOperationException("Не удалось получить координатную ось.");

            var entity = NewEntity(part, Obj3dType.o3d_circularCopy, name);
            var def = entity.GetDefinition() as ksCircularCopyDefinition
                ?? throw new InvalidOperationException("GetDefinition() массива вернул не ksCircularCopyDefinition.");
            def.SetAxis(axisEntity);
            // Чистый круговой массив: радиальное направление выключено (count1=1),
            // экземпляры идут по кольцу (count2=count) с угловым шагом step° между соседними.
            def.count1 = 1;
            def.step1 = 0;
            def.factor1 = false;
            def.count2 = count;
            def.step2 = step;
            def.factor2 = false;        // шаг между соседними (не общий угол)
            def.inverce = reverse;
            def.geomArray = geometric;
            var arr = def.GetOperationArray() as ksEntityCollection
                ?? throw new InvalidOperationException("GetOperationArray() массива вернул не ksEntityCollection.");
            foreach (var f in features) arr.Add(f);

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() кругового массива вернул FALSE (копии самопересекаются или неверная ось?).");

            return Register(entity);
        }, ct);

    /// <summary>Зеркальная копия операций featureIds относительно координатной плоскости plane.
    /// Возвращает id операции.</summary>
    public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            ArgumentNullException.ThrowIfNull(featureIds);
            var ids = featureIds.Distinct().ToList();
            if (ids.Count == 0) throw new ArgumentException("Нужна хотя бы одна операция для зеркала.", nameof(featureIds));

            // Собираем источники до мутации модели (проверка существования всех id).
            var features = ids.Select(RequireFeature).ToList();

            var part = GetTopPart();
            var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
                ?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");

            var entity = NewEntity(part, Obj3dType.o3d_mirrorOperation, name);
            var def = entity.GetDefinition() as ksMirrorCopyDefinition
                ?? throw new InvalidOperationException("GetDefinition() зеркала вернул не ksMirrorCopyDefinition.");
            def.SetPlane(planeEntity);
            var arr = def.GetOperationArray() as ksEntityCollection
                ?? throw new InvalidOperationException("GetOperationArray() зеркала вернул не ksEntityCollection.");
            foreach (var f in features) arr.Add(f);

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() зеркальной копии вернул FALSE (копия пересекает исходную геометрию?).");

            return Register(entity);
        }, ct);

    /// <summary>Зеркально отразить всё тело относительно координатной плоскости plane (получить
    /// симметричную деталь: оригинал + зеркало). Возвращает id операции.</summary>
    public Task<int> MirrorBodyAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
                ?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");

            var entity = NewEntity(part, Obj3dType.o3d_mirrorAllOperation, name);
            var def = entity.GetDefinition() as ksMirrorCopyAllDefinition
                ?? throw new InvalidOperationException("GetDefinition() зеркала тела вернул не ksMirrorCopyAllDefinition.");
            def.SetPlane(planeEntity);
            // ChooseBodies() в этом interop не приводится к ksChooseBodies (даёт null), а вызов и не
            // требуется: o3d_mirrorAllOperation по умолчанию отражает ВСЕ тела детали, сохраняя
            // оригинал (проверено тестом — объём удваивается). Поэтому выбор тел не настраиваем.

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() зеркального отражения тела вернул FALSE (тело пересекает плоскость симметрии?).");

            return Register(entity);
        }, ct);

    /// <summary>Уклон граней: наклонить грани faceIndices на угол angle° относительно нейтральной
    /// (опорной) координатной плоскости neutralPlane — сечение тела в этой плоскости остаётся
    /// неизменным. outward=true — расширение (материал добавляется), false — сужение. Грани должны
    /// граничить/пересекаться с нейтральной плоскостью. Возвращает id операции.</summary>
    public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, string? name = null, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            SketchGeometry.RequireDraftAngle(angle, nameof(angle));
            ArgumentNullException.ThrowIfNull(faceIndices);
            var indices = faceIndices.Distinct().ToList();
            if (indices.Count == 0) throw new ArgumentException("Нужна хотя бы одна грань для уклона.", nameof(faceIndices));

            var part = GetTopPart();
            // Проверяем диапазон ВСЕХ индексов до мутации модели.
            var faces = indices.Select(i => SelectFaceByIndex(part, i)).ToList();

            var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(neutralPlane)) as ksEntity
                ?? throw new InvalidOperationException("Не удалось получить координатную плоскость.");

            var entity = NewEntity(part, Obj3dType.o3d_incline, name);
            var def = entity.GetDefinition() as ksInclineDefinition
                ?? throw new InvalidOperationException("GetDefinition() уклона вернул не ksInclineDefinition.");
            var arr = def.FaceArray() as ksEntityCollection
                ?? throw new InvalidOperationException("FaceArray() уклона вернул не ksEntityCollection.");
            foreach (var f in faces) arr.Add(f);
            def.SetPlane(planeEntity);
            def.angle = angle;
            def.direction = !outward; // эмпирически: direction=false → расширение, true → сужение

            if (!entity.Create())
                throw new InvalidOperationException(
                    "Create() уклона вернул FALSE (грань не граничит с нейтральной плоскостью или несовместимая геометрия?).");

            return Register(entity);
        }, ct);

    /// <summary>Перестроить документ.</summary>
    public Task RebuildAsync(CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var doc3d = ActiveDoc3D();
            doc3d.RebuildDocument();
        }, ct);
}
