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> @
267 lines
14 KiB
C#
267 lines
14 KiB
C#
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 */ }
|
||
}
|
||
}
|