Files
kompas3d-mcp/src/Kompas.Mcp.Core/Modeling/PartModeler.Features.cs
T
mikhail 143877c6cc fix: правки draft по ревью Codex
- угол уклона вынесен в чистый SketchGeometry.RequireDraftAngle (0<angle<90, finite) + unit-тесты границ — P2
- описание инструмента уточнено: строго 0 < angle < 90 — P2
- интеграционный тест Draft_outward_expands_box — страхует маппинг направления (outward→!direction) — P2
- 115 тестов (69 unit + 46 integration)
2026-05-27 10:28:23 +03:00

530 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}