feat(core): PartModeler — эскизы и выдавливание (цикл моделирования)
- BasePlane (XOY/XOZ/YOZ) + маппинг в Obj3dType - PartModeler (API5 ksPart): OpenSketch/AddLine/AddCircle/AddRectangle/CloseSketch, Extrude (boss/cut, blind/throughAll), Rebuild; реестр эскизов и операций по id - tests: построение цилиндра со сквозным отверстием + снимок (визуально проверено) Полный цикл "эскиз -> выдавливание -> эскиз -> сквозной вырез -> снимок" работает. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
using Kompas6Constants3D;
|
||||
|
||||
namespace Kompas.Mcp.Core.Modeling;
|
||||
|
||||
/// <summary>Базовая координатная плоскость для эскиза.</summary>
|
||||
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)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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
|
||||
{
|
||||
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 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);
|
||||
|
||||
/// <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.");
|
||||
|
||||
// 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);
|
||||
|
||||
/// <summary>Перестроить документ.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Vision;
|
||||
|
||||
namespace Kompas.Mcp.Tests.Integration;
|
||||
|
||||
/// <summary>Интеграция: построение 3D (эскиз → выдавливание) и снимок.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user