Files
kompas3d-mcp/src/Kompas.Mcp.Core/Modeling/PartModeler.cs
T
mikhail 01f5f37c67 @
feat(sketch): эскиз на выбранной грани + МЦХ через API5

sketch_create_on_face: OpenSketchOnFaceAsync выбирает плоскую грань по точке
(ksPart.EntityCollection(o3d_face).SelectByPoint) и создаёт на ней эскиз —
ключевой шаг итеративного цикла «построил → выбрал грань → новый эскиз».
Общее ядро создания эскиза вынесено в CreateSketchOn.

QueryService переписан на API5 ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG):
- API7 IMassInertiaParam7.Calculate() непригоден — флаг Actual «залипает» после
  первого расчёта, повторный запрос отдавал устаревший объём (баг кэша);
- API5-маршрут считает по запросу и согласован с построением через ksPart;
- единицы (мм³/мм²/кг/мм) задаются битовым вектором.

Тест Sketch_on_face_adds_boss_on_top: запрос → штифт R5×H10 на верхней грани
цилиндра → повторный запрос видит прирост ≈785 мм³ (проверяет и грань, и кэш МЦХ).
Презентация: 20 инструментов, 15 тестов; OPEN_QUESTIONS обновлён.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
2026-05-26 09:52:56 +03:00

267 lines
14 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.InteropServices;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
using Kompas6Constants3D;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Построение 3D-детали через API5 (<c>ksPart</c>): эскизы и формообразующие операции.
/// Эскизы и операции регистрируются под целочисленными id, чтобы инструменты MCP могли
/// ссылаться на них в цикле «эскиз → операция → эскиз».
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class PartModeler : IDisposable
{
private const int MainLineStyle = 1; // системный стиль «Основная»
private readonly KompasSession _session;
private readonly KompasDispatcher _dispatcher;
private int _nextId = 1;
private readonly Dictionary<int, SketchEntry> _sketches = new();
private readonly Dictionary<int, ksEntity> _features = new();
public PartModeler(KompasSession session, KompasDispatcher dispatcher)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
private sealed class SketchEntry
{
public required ksEntity Entity { get; init; }
public required ksSketchDefinition Definition { get; init; }
public ksDocument2D? Editor { get; set; } // не null, пока эскиз открыт на редактирование
}
// ---- Эскизы ------------------------------------------------------------
/// <summary>Создать эскиз на базовой плоскости и открыть его на редактирование. Возвращает id эскиза.</summary>
public Task<int> OpenSketchAsync(BasePlane plane, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить базовую плоскость.");
return CreateSketchOn(part, planeEntity);
}, ct);
/// <summary>
/// Создать эскиз на плоской грани детали, найденной по точке <paramref name="x"/>,<paramref name="y"/>,<paramref name="z"/>
/// (мировые координаты модели, мм), и открыть его на редактирование. Возвращает id эскиза.
/// Это ключ к итеративному циклу «построил → выбрал грань → новый эскиз на ней».
/// </summary>
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var faces = part.EntityCollection((short)Obj3dType.o3d_face) as ksEntityCollection
?? throw new InvalidOperationException("Не удалось получить коллекцию граней.");
// Оставляем в коллекции только грани, проходящие через указанную точку.
faces.SelectByPoint(x, y, z);
if (faces.GetCount() == 0)
throw new InvalidOperationException(
$"В точке ({x}, {y}, {z}) не найдено грани. Сделайте снимок и сверьте координаты.");
var face = faces.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Грань из коллекции не приводится к ksEntity.");
return CreateSketchOn(part, face);
}, ct);
/// <summary>Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование.</summary>
private int CreateSketchOn(ksPart part, ksEntity planeEntity)
{
var entity = part.NewEntity((short)Obj3dType.o3d_sketch) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_sketch) вернул null.");
var def = entity.GetDefinition() as ksSketchDefinition
?? throw new InvalidOperationException("GetDefinition() эскиза вернул не ksSketchDefinition.");
def.SetPlane(planeEntity);
entity.Create();
var editor = def.BeginEdit() as ksDocument2D
?? throw new InvalidOperationException("BeginEdit() не вернул ksDocument2D.");
var id = _nextId++;
_sketches[id] = new SketchEntry { Entity = entity, Definition = def, Editor = editor };
return id;
}
/// <summary>Добавить отрезок в открытый эскиз.</summary>
public Task AddLineAsync(int sketchId, double x1, double y1, double x2, double y2, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksLineSeg(x1, y1, x2, y2, MainLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (отрезок не создан).");
}, ct);
/// <summary>Добавить окружность в открытый эскиз.</summary>
public Task AddCircleAsync(int sketchId, double centerX, double centerY, double radius, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (radius <= 0) throw new ArgumentOutOfRangeException(nameof(radius), "Радиус должен быть > 0.");
var editor = RequireOpenSketch(sketchId).Editor!;
if (editor.ksCircle(centerX, centerY, radius, MainLineStyle) == 0)
throw new InvalidOperationException("ksCircle вернул 0 (окружность не создана).");
}, ct);
/// <summary>Добавить прямоугольник (4 отрезка) по двум углам.</summary>
public Task AddRectangleAsync(int sketchId, double x1, double y1, double x2, double y2, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var editor = RequireOpenSketch(sketchId).Editor!;
void Seg(double ax, double ay, double bx, double by)
{
if (editor.ksLineSeg(ax, ay, bx, by, MainLineStyle) == 0)
throw new InvalidOperationException("ksLineSeg вернул 0 (сторона прямоугольника не создана).");
}
Seg(x1, y1, x2, y1);
Seg(x2, y1, x2, y2);
Seg(x2, y2, x1, y2);
Seg(x1, y2, x1, y1);
}, ct);
/// <summary>Завершить редактирование эскиза.</summary>
public Task CloseSketchAsync(int sketchId, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => CloseSketchCore(RequireSketch(sketchId)), ct);
// ---- Операции ----------------------------------------------------------
/// <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>Перестроить документ.</summary>
public Task RebuildAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var doc3d = ActiveDoc3D();
doc3d.RebuildDocument();
}, ct);
/// <summary>
/// Сбросить реестр эскизов/операций и освободить их COM-объекты. Вызывать при смене
/// активного документа (создание/открытие/закрытие) — id больше не действительны.
/// </summary>
public Task ResetAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(ResetCore, ct);
// ---- Внутреннее --------------------------------------------------------
private ksDocument3D ActiveDoc3D()
=> _session.Kompas.ActiveDocument3D() as ksDocument3D
?? throw new InvalidOperationException("Нет активного 3D-документа.");
private ksPart GetTopPart()
=> ActiveDoc3D().GetPart((short)Part_Type.pTop_Part) as ksPart
?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart).");
private SketchEntry RequireSketch(int id)
=> _sketches.TryGetValue(id, out var s) ? s
: throw new KeyNotFoundException($"Эскиз с id={id} не найден.");
private SketchEntry RequireOpenSketch(int id)
{
var s = RequireSketch(id);
if (s.Editor is null)
throw new InvalidOperationException($"Эскиз id={id} уже закрыт — добавление геометрии невозможно.");
return s;
}
private static void CloseSketchCore(SketchEntry s)
{
if (s.Editor is null) return;
s.Definition.EndEdit();
ReleaseCom(s.Editor); // редактор после EndEdit больше не нужен
s.Editor = null;
}
private void ResetCore()
{
foreach (var s in _sketches.Values)
{
ReleaseCom(s.Editor);
ReleaseCom(s.Definition);
ReleaseCom(s.Entity);
}
foreach (var f in _features.Values)
ReleaseCom(f);
_sketches.Clear();
_features.Clear();
_nextId = 1;
}
private static void ReleaseCom(object? comObject)
{
if (comObject is not null && Marshal.IsComObject(comObject))
{
try { Marshal.ReleaseComObject(comObject); }
catch { /* освобождение не критично */ }
}
}
public void Dispose()
{
// Освобождаем COM на STA-потоке; не виснем на старте, если поток занят.
try { _dispatcher.InvokeAsync(ResetCore).Wait(TimeSpan.FromSeconds(5)); }
catch { /* при остановке диспетчера COM освободит GC */ }
}
}