feat: assembly_add_mate — сопряжения компонентов сборки (API7)

Веха СБОРКИ, инкремент 2. AssemblyService.AddMateAsync (API7): top.MateConstraints
→ Add(MateConstraintType) → BaseObject1/2 (грани через FindObjectsByPoint
FirstLevel=false) → ParamValue → Update → RebuildModel. Свой enum MateType
(Coincidence/Distance) → MateConstraintType. MCP-инструмент assembly_add_mate.

Подтверждено спайком и тестами: coincidence сводит грани заподлицо (ширина 120→40),
distance(30) держит зазор 30 (→70); Update=True Valid=True. Решатель может двигать
любой компонент → проверка по ширине габарита.

Ревью Codex учтено: scope урезан до coincidence/distance (только геометрически
проверенные); валидация value по типу (RequireMateValue); полный откат при любой
ошибке после Add (mate.Owner.Delete); возврат AddedMate{Type,Valid}; усиленные
ассерты (Valid, размеры компонентов неизменны). Точечный release RCW отклонён
(конвенция v2-2). Прочие типы (parallel/concentric/angle/…) — будущие инкременты.

Тесты: +14 unit (Mates.Parse/RequiresValue, RequireMateValue), +2 integration
(AssemblyMateTests). Итого 97 unit + 57 integration = 154 зелёных.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:39:04 +03:00
parent 8afad88241
commit 8282716773
8 changed files with 324 additions and 3 deletions
@@ -41,4 +41,46 @@ public sealed class AssemblyValidationTests
[Fact]
public void RequireFinitePosition_accepts_finite()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireFinitePosition(50, -10, 0)));
// ── Сопряжения (Mates) ───────────────────────────────────────────────
[Theory]
[InlineData("coincidence", MateType.Coincidence)]
[InlineData("совпадение", MateType.Coincidence)]
[InlineData("DISTANCE", MateType.Distance)]
[InlineData(" расстояние ", MateType.Distance)]
public void Mates_Parse_maps_known(string text, MateType expected)
=> Assert.Equal(expected, Mates.Parse(text));
[Theory]
[InlineData("parallel")] // пока не поддержан
[InlineData("сопряжение")]
[InlineData("")]
public void Mates_Parse_throws_on_unknown(string text)
=> Assert.Throws<ArgumentException>(() => Mates.Parse(text));
// Корректность ToConstraintType проверяется интеграционно (coincidence даёт прилегание,
// distance — зазор), а не unit-ассертом: КОМПАС-enum встроен (EmbedInteropTypes) и не виден
// тестовому проекту через границу сборки.
[Theory]
[InlineData(MateType.Distance, true)]
[InlineData(MateType.Coincidence, false)]
public void Mates_RequiresValue(MateType type, bool expected)
=> Assert.Equal(expected, Mates.RequiresValue(type));
[Fact]
public void RequireMateValue_ignores_value_when_not_required()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireMateValue(false, double.NaN)));
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(-1)]
public void RequireMateValue_throws_on_bad_required_value(double value)
=> Assert.Throws<ArgumentOutOfRangeException>(() => AssemblyValidation.RequireMateValue(true, value));
[Fact]
public void RequireMateValue_accepts_nonnegative_required_value()
=> Assert.Null(Record.Exception(() => AssemblyValidation.RequireMateValue(true, 30)));
}
@@ -0,0 +1,98 @@
using Kompas.Mcp.Core.Assemblies;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
namespace Kompas.Mcp.Tests.Integration;
/// <summary>Интеграция: сопряжения компонентов сборки (API7 IMateConstraints3D).</summary>
[Trait("Category", "Integration")]
[Collection(KompasCollection.Name)]
public sealed class AssemblyMateTests : IntegrationTestBase
{
private readonly DocumentService _docs;
private readonly PartModeler _modeler;
private readonly AssemblyService _assembly;
private readonly QueryService _query;
public AssemblyMateTests(KompasFixture fx) : base(fx)
{
_docs = new DocumentService(fx.Session, fx.Dispatcher);
_modeler = new PartModeler(fx.Session, fx.Dispatcher);
_assembly = new AssemblyService(fx.Session, fx.Dispatcher);
_query = new QueryService(fx.Session, fx.Dispatcher);
}
private async Task<string> BuildAndSaveCubeAsync(double side)
{
var path = TestPaths.NewFile(".m3d");
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
await _modeler.AddRectangleAsync(s, 0, 0, side, side);
await _modeler.CloseSketchAsync(s);
await _modeler.ExtrudeAsync(s, depth: side);
await _modeler.RebuildAsync();
await _docs.SaveAsAsync(path);
}
finally { await _docs.CloseAsync(save: false); }
return path;
}
/// <summary>Создать сборку из двух кубов 20³: A в нуле (world x[0,20]), B со сдвигом +100 (x[100,120]).</summary>
private async Task<string> SetupTwoCubesAsync()
{
var cube = await BuildAndSaveCubeAsync(20);
await _docs.CreateAsync(KompasDocumentType.Assembly);
await _assembly.AddComponentAsync(cube, 0, 0, 0);
await _assembly.AddComponentAsync(cube, 100, 0, 0);
return cube;
}
[Fact]
public async Task Coincidence_mate_brings_faces_flush()
{
await SetupTwoCubesAsync();
try
{
var bbBefore = await _query.GetBoundingBoxAsync();
Assert.InRange(bbBefore.MaxX - bbBefore.MinX, 118, 122); // 2 куба врозь ≈ 120
// Совпадение +X грани A (20,10,10) с X гранью B (100,10,10).
var m = await _assembly.AddMateAsync(MateType.Coincidence, 20, 10, 10, 100, 10, 10, value: 0);
Assert.True(m.Valid, "Сопряжение должно быть валидным.");
var bbAfter = await _query.GetBoundingBoxAsync();
Assert.InRange(bbAfter.MaxX - bbAfter.MinX, 38, 42); // кубы соприкоснулись ≈ 40
// Размеры самих компонентов не изменились (двигаются, не деформируются).
var comps = await _query.ListComponentsAsync();
Assert.Equal(2, comps.Count);
Assert.All(comps, c => Assert.InRange(c.SizeX, 19, 21));
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Distance_mate_holds_faces_apart()
{
await SetupTwoCubesAsync();
try
{
var bbBefore = await _query.GetBoundingBoxAsync();
Assert.InRange(bbBefore.MaxX - bbBefore.MinX, 118, 122);
// Расстояние 30 между +X гранью A и −X гранью B → ширина 20+30+20 = 70.
var m = await _assembly.AddMateAsync(MateType.Distance, 20, 10, 10, 100, 10, 10, value: 30);
Assert.True(m.Valid, "Сопряжение должно быть валидным.");
var bbAfter = await _query.GetBoundingBoxAsync();
Assert.InRange(bbAfter.MaxX - bbAfter.MinX, 66, 74); // ≈ 70
var comps = await _query.ListComponentsAsync();
Assert.All(comps, c => Assert.InRange(c.SizeX, 19, 21));
}
finally { await _docs.CloseAsync(save: false); }
}
}