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>
    public Task<int> ExtrudeAsync(
        int sketchId, double depth, bool cut = false, bool forward = true,
        double draftAngle = 0, bool throughAll = false, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            if (!throughAll && depth <= 0)
                throw new ArgumentOutOfRangeException(nameof(depth), "Глубина должна быть > 0 (или используйте throughAll).");
            var sketch = RequireSketch(sketchId);
            CloseSketchCore(sketch); // эскиз должен быть закрыт перед операцией

            var part = GetTopPart();
            var type = cut ? Obj3dType.o3d_cutExtrusion : Obj3dType.o3d_bossExtrusion;
            var entity = part.NewEntity((short)type) as ksEntity
                ?? throw new InvalidOperationException("NewEntity операции выдавливания вернул null.");

            // Конкретные типы определений (без 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);
                }
                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);
                def.SetSketch(sketch.Entity);
            }

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

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <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,
        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 = part.NewEntity((short)type) as ksEntity
                ?? throw new InvalidOperationException("NewEntity операции вращения вернул null.");

            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.");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <summary>
    /// Скруглить ребро детали, найденное по точке (мировые координаты, мм), радиусом <paramref name="radius"/>.
    /// Возвращает id операции.
    /// </summary>
    public Task<int> FilletEdgeAsync(double x, double y, double z, double radius, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            return CreateFillet(part, SelectEdgeByPoint(part, x, y, z), radius);
        }, ct);

    /// <summary>Скруглить ребро детали по индексу из list_edges радиусом <paramref name="radius"/>.</summary>
    public Task<int> FilletEdgeIndexAsync(int edgeIndex, double radius, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            return CreateFillet(part, SelectEdgeByIndex(part, edgeIndex), radius);
        }, ct);

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

        var entity = part.NewEntity((short)Obj3dType.o3d_fillet) as ksEntity
            ?? throw new InvalidOperationException("NewEntity(o3d_fillet) вернул null.");
        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.");
        arr.Add(edge);

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

        var id = _nextId++;
        _features[id] = entity;
        return id;
    }

    /// <summary>
    /// Снять фаску с ребра детали, найденного по точке (мировые координаты, мм), катетом <paramref name="distance"/>.
    /// Возвращает id операции.
    /// </summary>
    public Task<int> ChamferEdgeAsync(double x, double y, double z, double distance, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            return CreateChamfer(part, SelectEdgeByPoint(part, x, y, z), distance);
        }, ct);

    /// <summary>Снять фаску с ребра детали по индексу из list_edges катетом <paramref name="distance"/>.</summary>
    public Task<int> ChamferEdgeIndexAsync(int edgeIndex, double distance, CancellationToken ct = default)
        => _dispatcher.InvokeAsync(() =>
        {
            var part = GetTopPart();
            return CreateChamfer(part, SelectEdgeByIndex(part, edgeIndex), distance);
        }, ct);

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

        var entity = part.NewEntity((short)Obj3dType.o3d_chamfer) as ksEntity
            ?? throw new InvalidOperationException("NewEntity(o3d_chamfer) вернул null.");
        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.");
        arr.Add(edge);

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

        var id = _nextId++;
        _features[id] = entity;
        return id;
    }

    /// <summary>Превратить тело в оболочку: удалить (открыть) грани с индексами faceIndices,
    /// задать толщину стенки thickness (мм). outward=false — толщина внутрь (габарит сохраняется),
    /// true — наружу. Возвращает id операции.</summary>
    public Task<int> ShellAsync(IReadOnlyList<int> faceIndices, double thickness, bool outward = false, 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 = part.NewEntity((short)Obj3dType.o3d_shellOperation) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_shellOperation) вернул null.");
            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 (толщина больше локального радиуса/стенки или несовместимая топология?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, 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, 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 = part.NewEntity((short)Obj3dType.o3d_ribOperation) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_ribOperation) вернул null.");
            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?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <summary>Кинематическая операция (sweep): переместить профиль (эскиз profileSketchId)
    /// вдоль траектории (эскиз pathSketchId), образуя сплошное тело. Возвращает id операции.</summary>
    public Task<int> SweepAsync(int profileSketchId, int pathSketchId, 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 = part.NewEntity((short)Obj3dType.o3d_baseEvolution) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_baseEvolution) вернул null.");
            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 (начало траектории не на плоскости профиля или несовместимая геометрия?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <summary>Операция по сечениям (loft): построить сплошное тело по ≥2 закрытым эскизам-сечениям
    /// (в порядке списка). closed — замкнуть сечения в кольцо. Возвращает id операции.</summary>
    public Task<int> LoftAsync(IReadOnlyList<int> sketchIds, bool closed = false, 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 = part.NewEntity((short)Obj3dType.o3d_baseLoft) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_baseLoft) вернул null.");
            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 (несовместимые сечения или неверный порядок?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, 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, 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 = part.NewEntity((short)Obj3dType.o3d_meshCopy) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_meshCopy) вернул null.");
            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 (копии самопересекаются или несовместимая геометрия?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, 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, 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 = part.NewEntity((short)Obj3dType.o3d_circularCopy) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_circularCopy) вернул null.");
            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 (копии самопересекаются или неверная ось?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <summary>Зеркальная копия операций featureIds относительно координатной плоскости plane.
    /// Возвращает id операции.</summary>
    public Task<int> MirrorOperationAsync(IReadOnlyList<int> featureIds, BasePlane plane, 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 = part.NewEntity((short)Obj3dType.o3d_mirrorOperation) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_mirrorOperation) вернул null.");
            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 (копия пересекает исходную геометрию?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

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

            var entity = part.NewEntity((short)Obj3dType.o3d_mirrorAllOperation) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_mirrorAllOperation) вернул null.");
            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 (тело пересекает плоскость симметрии?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

    /// <summary>Уклон граней: наклонить грани faceIndices на угол angle° относительно нейтральной
    /// (опорной) координатной плоскости neutralPlane — сечение тела в этой плоскости остаётся
    /// неизменным. outward=true — расширение (материал добавляется), false — сужение. Грани должны
    /// граничить/пересекаться с нейтральной плоскостью. Возвращает id операции.</summary>
    public Task<int> DraftAsync(IReadOnlyList<int> faceIndices, BasePlane neutralPlane, double angle, bool outward = false, 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 = part.NewEntity((short)Obj3dType.o3d_incline) as ksEntity
                ?? throw new InvalidOperationException("NewEntity(o3d_incline) вернул null.");
            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 (грань не граничит с нейтральной плоскостью или несовместимая геометрия?).");

            var id = _nextId++;
            _features[id] = entity;
            return id;
        }, ct);

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