diff --git a/src/Kompas.Mcp.Core/Modeling/BasePlane.cs b/src/Kompas.Mcp.Core/Modeling/BasePlane.cs new file mode 100644 index 0000000..30def47 --- /dev/null +++ b/src/Kompas.Mcp.Core/Modeling/BasePlane.cs @@ -0,0 +1,30 @@ +using Kompas6Constants3D; + +namespace Kompas.Mcp.Core.Modeling; + +/// Базовая координатная плоскость для эскиза. +public enum BasePlane +{ + XOY, + XOZ, + YOZ, +} + +public static class BasePlanes +{ + public static Obj3dType ToObj3dType(BasePlane plane) => plane switch + { + BasePlane.XOY => Obj3dType.o3d_planeXOY, + BasePlane.XOZ => Obj3dType.o3d_planeXOZ, + BasePlane.YOZ => Obj3dType.o3d_planeYOZ, + _ => throw new ArgumentOutOfRangeException(nameof(plane), plane, "Неизвестная плоскость."), + }; + + public static BasePlane Parse(string value) => value.Trim().ToUpperInvariant() switch + { + "XOY" or "XY" => BasePlane.XOY, + "XOZ" or "XZ" => BasePlane.XOZ, + "YOZ" or "YZ" => BasePlane.YOZ, + _ => throw new ArgumentException($"Неизвестная плоскость '{value}'. Допустимо: XOY, XOZ, YOZ.", nameof(value)), + }; +} diff --git a/src/Kompas.Mcp.Core/Modeling/PartModeler.cs b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs new file mode 100644 index 0000000..42540af --- /dev/null +++ b/src/Kompas.Mcp.Core/Modeling/PartModeler.cs @@ -0,0 +1,187 @@ +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6API5; +using Kompas6Constants3D; + +namespace Kompas.Mcp.Core.Modeling; + +/// +/// Построение 3D-детали через API5 (ksPart): эскизы и формообразующие операции. +/// Эскизы и операции регистрируются под целочисленными id, чтобы инструменты MCP могли +/// ссылаться на них в цикле «эскиз → операция → эскиз». +/// +[SupportedOSPlatform("windows")] +public sealed class PartModeler +{ + private const int MainLineStyle = 1; // системный стиль «Основная» + + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + private int _nextId = 1; + private readonly Dictionary _sketches = new(); + private readonly Dictionary _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, пока эскиз открыт на редактирование + } + + // ---- Эскизы ------------------------------------------------------------ + + /// Создать эскиз на базовой плоскости и открыть его на редактирование. Возвращает id эскиза. + public Task OpenSketchAsync(BasePlane plane, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var part = GetTopPart(); + 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."); + var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity + ?? throw new InvalidOperationException("Не удалось получить базовую плоскость."); + + 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; + }, ct); + + /// Добавить отрезок в открытый эскиз. + 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); + + /// Добавить окружность в открытый эскиз. + 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); + + /// Добавить прямоугольник (4 отрезка) по двум углам. + 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); + + /// Завершить редактирование эскиза. + public Task CloseSketchAsync(int sketchId, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => CloseSketchCore(RequireSketch(sketchId)), ct); + + // ---- Операции ---------------------------------------------------------- + + /// + /// Выдавить эскиз. — вырезание вместо добавления материала. + /// Возвращает id операции. + /// + /// Сквозное выдавливание/вырез через всю деталь в обе стороны + /// (для отверстий — гарантированно пробивает материал; игнорируется). + public Task 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."); + + // ksBossExtrusionDefinition и ksCutExtrusionDefinition имеют одинаковый набор + // нужных членов, но не общий интерфейс — настраиваем через dynamic. + dynamic def = entity.GetDefinition() + ?? throw new InvalidOperationException("GetDefinition() операции вернул null."); + + 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); + + if (!entity.Create()) + throw new InvalidOperationException("Create() операции выдавливания вернул FALSE."); + + var id = _nextId++; + _features[id] = entity; + return id; + }, ct); + + /// Перестроить документ. + public Task RebuildAsync(CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + var doc3d = ActiveDoc3D(); + doc3d.RebuildDocument(); + }, 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(); + s.Editor = null; + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs b/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs new file mode 100644 index 0000000..e786e95 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/ModelingTests.cs @@ -0,0 +1,55 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Vision; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: построение 3D (эскиз → выдавливание) и снимок. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class ModelingTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly SnapshotService _snap; + + public ModelingTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _snap = new SnapshotService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Build_cylinder_then_cut_hole_and_snapshot() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Эскиз 1: окружность R20 на XOY → выдавить boss на 40 (цилиндр). + var s1 = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddCircleAsync(s1, 0, 0, 20); + await _modeler.CloseSketchAsync(s1); + var boss = await _modeler.ExtrudeAsync(s1, depth: 40); + Assert.True(boss > 0); + + // Эскиз 2: окружность R8 на XOY → вырезать насквозь (отверстие). + var s2 = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddCircleAsync(s2, 0, 0, 8); + await _modeler.CloseSketchAsync(s2); + var cut = await _modeler.ExtrudeAsync(s2, depth: 40, cut: true, throughAll: true); + Assert.True(cut > 0); + + await _modeler.RebuildAsync(); + + var path = TestPaths.NewFile(".png"); + var snap = await _snap.CaptureActiveModelAsync(saveToFile: path); + Assert.True(snap.Bytes.Length > 1000, "Снимок цилиндра подозрительно мал."); + Assert.True(File.Exists(path)); + } + finally + { + await _docs.CloseAsync(save: false); + } + } +}