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);
+ }
+ }
+}