From bc52b5d920eb907bfdc396aa1dc40cbc15378bb8 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Wed, 27 May 2026 11:14:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BA=D0=B0=20=E2=80=94=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20(create/set/delete=5Fvariable)=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20API5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VariableService: CreateVariableAsync/SetVariableAsync/DeleteVariableAsync/GetVariableValueAsync - создание ТОЛЬКО на ksFeature.VariableCollection (ksPart — только внешние); RebuildModel + re-fetch - Expression — ведущее поле (константа/формула со ссылками); value пересчитывается - list_variables (ModelInspectionService) исправлен: читает feature-коллекцию (все переменные) - инструменты create_variable/set_variable/delete_variable (VariableTools) + DI - интеграционный тест CRUD round-trip (создание, формула width*2, удаление по зависимостям) - ОГРАНИЧЕНИЕ: переменные драйвят геометрию лишь в параметрической модели (эскизы на литералах — нет) - 116 тестов; спек docs/superpowers/specs/2026-05-27-parametrics-design.md --- .../specs/2026-05-27-parametrics-design.md | 73 ++++++++++++ .../Modeling/VariableService.cs | 111 ++++++++++++++++++ .../Query/ModelInspectionService.cs | 6 +- src/Kompas.Mcp.Host/Program.cs | 1 + src/Kompas.Mcp.Host/Tools/VariableTools.cs | 44 +++++++ .../Integration/VariableTests.cs | 64 ++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-27-parametrics-design.md create mode 100644 src/Kompas.Mcp.Core/Modeling/VariableService.cs create mode 100644 src/Kompas.Mcp.Host/Tools/VariableTools.cs create mode 100644 tests/Kompas.Mcp.Tests/Integration/VariableTests.cs diff --git a/docs/superpowers/specs/2026-05-27-parametrics-design.md b/docs/superpowers/specs/2026-05-27-parametrics-design.md new file mode 100644 index 0000000..00841ee --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-parametrics-design.md @@ -0,0 +1,73 @@ +# Дизайн: параметрика (пакет D) — переменные модели + +**Дата:** 2026-05-27 +**Статус:** реализовано и проверено (тест зелёный); на ревью (ревьюер — Codex) + +## Цель + +Дать запись параметрики: **создание, изменение и удаление переменных модели** (`list_variables` +уже читает). Это фундамент управляемых моделей: пользовательские переменные, формулы со ссылками, +внешние переменные для сборок. + +## Сигнатуры (рефлексия interop + справка) + +``` +ksVariableCollection: + object AddNewVariable(string name, double value, string note) // создать (ТОЛЬКО на ksFeature!) + object GetByName(string name, bool testFullName, bool testIgnoreCase) + bool RemoveVariable(string name) + int GetCount(); object GetByIndex(int) +ksVariable: + string name; string Expression; double value; bool external; bool Information; string note +ksPart: bool RebuildModel() // применить изменения переменных +ksFeature: object VariableCollection // СВОЙСТВО (не метод), все переменные детали +``` + +## Ключевые факты (исследование + эмпирика spike) + +1. **Создавать переменные можно только на коллекции корневого `ksFeature`** + (`ksPart.GetFeature().VariableCollection`), НЕ на `ksPart.VariableCollection()` (та — только + внешние). `AddNewVariable` на ksPart-коллекции не создаёт пользовательскую переменную. +2. **`Expression` — ведущее поле**, `value` — вычисленный результат. Менять значение надо через + `Expression` (константа «30» или формула «width*2»); прямое `value=` ненадёжно (перезапишется). +3. **Изменения вступают в силу после `ksPart.RebuildModel()`**; `value` пересчитывается из + `Expression`. RCW переменной после RebuildModel «застревает» на старом значении — **перечитывать** + переменную из свежей коллекции (`ReadValueFresh`). +4. **`list_variables` исправлен**: читал `ksPart.VariableCollection()` (только внешние) → теперь + `ksPart.GetFeature().VariableCollection` (все, включая созданные), с фолбэком. Иначе созданная + переменная не была бы видна агенту. +5. **Удаление**: нельзя удалить переменную, на которую ссылаются другие (RRemoveVariable→FALSE); + удалять в порядке зависимостей. +6. **ОГРАНИЧЕНИЕ (важно для контракта):** переменная управляет геометрией только в + **параметрической** модели (размеры эскизов связаны с именами переменных). Наши эскизы строятся + литеральными координатами — у них нет именованных размеров, поэтому set_variable хранит/считает + значение, но геометрию не меняет. Привязка размеров эскиза к переменным (параметрические эскизы) + — отдельный большой пласт (будущее). Поэтому тест проверяет CRUD/вычисление через чтение, а не + изменение геометрии. + +## MCP-инструменты (новая группа, `VariableTools`) + +| Инструмент | Параметры | Поведение | +|---|---|---| +| `create_variable` | `name`, `value`, `note?`, `external=false` | Создать переменную. Возвращает значение. | +| `set_variable` | `name`, `expression` | Задать выражение (константа/формула), перестроить, вернуть значение. | +| `delete_variable` | `name` | Удалить (нельзя, если есть зависимые). | + +Чтение — существующий `list_variables` (теперь видит все переменные). + +## Реализация + +- **`src/Kompas.Mcp.Core/Modeling/VariableService.cs`** (новый): `CreateVariableAsync`, + `SetVariableAsync`, `DeleteVariableAsync`, `GetVariableValueAsync` (read-back) на STA-потоке. + Хелпер `RootVariables()` → (`ksPart`, feature-коллекция); `ReadValueFresh(name)` после RebuildModel. + Валидация: имя не пусто, value конечно, выражение не пусто. +- **`VariableTools.cs`** (новый): 3 инструмента; DI `AddSingleton()`. +- **`ModelInspectionService.ReadVariables`**: источник → feature-коллекция (фолбэк на ksPart). +- Транзитные RCW не освобождаем точечно — консистентно (долг v2-2). + +## Тестирование (Integration, `VariableTests`) + +CRUD round-trip: построить коробку (дерево построения) → `create_variable("width",40)` (значение 40; +видна в `list_variables`) → `set_variable("width","12+8")` → 20 → создать `height`, `set "width*2"` → +40 (формула со ссылкой) → удалить `height`, затем `width` (порядок зависимостей) → обе исчезли +(`GetVariableValueAsync` бросает `KeyNotFoundException`). ✓ diff --git a/src/Kompas.Mcp.Core/Modeling/VariableService.cs b/src/Kompas.Mcp.Core/Modeling/VariableService.cs new file mode 100644 index 0000000..f810177 --- /dev/null +++ b/src/Kompas.Mcp.Core/Modeling/VariableService.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Threading; +using Kompas6API5; +using Kompas6Constants3D; + +namespace Kompas.Mcp.Core.Modeling; + +/// +/// Параметрика: создание и изменение переменных модели (API5 ksVariable). +/// Создание возможно только на коллекции переменных корневого ksFeature (не ksPart). +/// ВАЖНО: переменная управляет геометрией лишь в параметрической модели (где размеры +/// эскизов связаны с именованными переменными). Эскизы, построенные литеральными координатами, +/// не имеют именованных размеров — для них переменные хранятся, но геометрию не меняют. +/// +[SupportedOSPlatform("windows")] +public sealed class VariableService +{ + private readonly KompasSession _session; + private readonly KompasDispatcher _dispatcher; + + public VariableService(KompasSession session, KompasDispatcher dispatcher) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// Создать новую переменную модели с начальным значением. external=true — внешняя + /// (доступна для управления из сборки). Возвращает вычисленное значение. + public Task CreateVariableAsync(string name, double value, string? note = null, bool external = false, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + RequireName(name); + if (!double.IsFinite(value)) throw new ArgumentException("Значение должно быть конечным.", nameof(value)); + var (part, vc) = RootVariables(); + if (vc.GetByName(name, true, false) is ksVariable) + throw new InvalidOperationException($"Переменная '{name}' уже существует."); + var v = vc.AddNewVariable(name, value, note ?? "") as ksVariable + ?? throw new InvalidOperationException("AddNewVariable вернул null (недопустимое имя?)."); + if (external) v.external = true; + part.RebuildModel(); + return ReadValueFresh(name); // значение перечитываем после перестроения + }, ct); + + /// Задать выражение переменной (константа «30» или формула «len*2+5») и перестроить. + /// Возвращает вычисленное значение. + public Task SetVariableAsync(string name, string expression, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + RequireName(name); + if (string.IsNullOrWhiteSpace(expression)) + throw new ArgumentException("Выражение не может быть пустым.", nameof(expression)); + var (part, vc) = RootVariables(); + var v = RequireVariable(vc, name); + v.Expression = expression; + part.RebuildModel(); + return ReadValueFresh(name); // переменную перечитываем после перестроения (value пересчитан) + }, ct); + + /// Удалить переменную модели. + public Task DeleteVariableAsync(string name, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + RequireName(name); + var (part, vc) = RootVariables(); + RequireVariable(vc, name); // понятная ошибка, если её нет + if (!vc.RemoveVariable(name)) + throw new InvalidOperationException($"Не удалось удалить переменную '{name}'."); + part.RebuildModel(); + }, ct); + + /// Прочитать текущее (вычисленное) значение переменной. + public Task GetVariableValueAsync(string name, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => + { + RequireName(name); + var (_, vc) = RootVariables(); + return RequireVariable(vc, name).value; + }, ct); + + /// Перечитать значение переменной из свежей коллекции (после RebuildModel оно пересчитано). + private double ReadValueFresh(string name) + { + var (_, vc) = RootVariables(); + return RequireVariable(vc, name).value; + } + + private static void RequireName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Имя переменной не может быть пустым.", nameof(name)); + } + + private static ksVariable RequireVariable(ksVariableCollection vc, string name) + => vc.GetByName(name, true, false) as ksVariable + ?? throw new KeyNotFoundException($"Переменная '{name}' не найдена."); + + /// Корневой ksFeature детали и его коллекция переменных (на ней работает AddNewVariable). + private (ksPart part, ksVariableCollection vc) RootVariables() + { + var doc3d = _session.Kompas.ActiveDocument3D() as ksDocument3D + ?? throw new InvalidOperationException("Нет активного 3D-документа."); + var part = doc3d.GetPart((short)Part_Type.pTop_Part) as ksPart + ?? throw new InvalidOperationException("Не удалось получить вершинный компонент (ksPart)."); + var feature = part.GetFeature() as ksFeature + ?? throw new InvalidOperationException("GetFeature() вернул null (нет дерева построения)."); + var vc = feature.VariableCollection as ksVariableCollection + ?? throw new InvalidOperationException("VariableCollection вернул не ksVariableCollection."); + return (part, vc); + } +} diff --git a/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs b/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs index 0e565fb..c59c61d 100644 --- a/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs +++ b/src/Kompas.Mcp.Core/Query/ModelInspectionService.cs @@ -260,7 +260,11 @@ public sealed class ModelInspectionService private static IReadOnlyList ReadVariables(ksPart part) { - if (part.VariableCollection() is not ksVariableCollection vc) return Array.Empty(); + // Переменные детали живут в коллекции корневого ksFeature; ksPart.VariableCollection() даёт + // только внешние. Берём feature-коллекцию (все переменные, включая созданные), с фолбэком. + var vc = (part.GetFeature() as ksFeature)?.VariableCollection as ksVariableCollection + ?? part.VariableCollection() as ksVariableCollection; + if (vc is null) return Array.Empty(); int n = vc.GetCount(); var list = new List(n); for (int i = 0; i < n; i++) diff --git a/src/Kompas.Mcp.Host/Program.cs b/src/Kompas.Mcp.Host/Program.cs index 83be864..dd7150d 100644 --- a/src/Kompas.Mcp.Host/Program.cs +++ b/src/Kompas.Mcp.Host/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kompas.Mcp.Host/Tools/VariableTools.cs b/src/Kompas.Mcp.Host/Tools/VariableTools.cs new file mode 100644 index 0000000..7f89f2e --- /dev/null +++ b/src/Kompas.Mcp.Host/Tools/VariableTools.cs @@ -0,0 +1,44 @@ +using System.ComponentModel; +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Modeling; +using ModelContextProtocol.Server; + +namespace Kompas.Mcp.Host.Tools; + +/// Инструменты параметрики: переменные модели (создание, изменение, удаление). +[McpServerToolType] +public sealed class VariableTools(KompasSession session, VariableService variables) +{ + [McpServerTool(Name = "create_variable")] + [Description("Создать переменную модели с начальным числовым значением value. note — комментарий. external=true — внешняя (доступна для управления из сборки). Имя должно быть уникальным. Примечание: переменная управляет геометрией только в параметрической модели (где размеры эскизов связаны с именами переменных); для моделей с литеральными размерами переменная просто хранится. Используйте list_variables для просмотра. Возвращает вычисленное значение.")] + public async Task CreateVariable( + string name, + [Description("Начальное числовое значение")] double value, + [Description("Комментарий (необязательно)")] string? note = null, + [Description("Внешняя переменная (управляема из сборки)")] bool external = false) + { + await session.ConnectAsync(); + var v = await variables.CreateVariableAsync(name, value, note, external); + return $"Переменная '{name}' создана (значение {v})."; + } + + [McpServerTool(Name = "set_variable")] + [Description("Задать выражение существующей переменной: константа (например «30») или формула со ссылками на другие переменные (например «width*2+5»). Модель перестраивается, значение пересчитывается. Возвращает вычисленное значение.")] + public async Task SetVariable( + string name, + [Description("Выражение: число или формула (width*2+5)")] string expression) + { + await session.ConnectAsync(); + var v = await variables.SetVariableAsync(name, expression); + return $"Переменная '{name}' = «{expression}» → {v}."; + } + + [McpServerTool(Name = "delete_variable")] + [Description("Удалить переменную модели по имени. Нельзя удалить переменную, на которую ссылаются другие (сначала удалите зависимые).")] + public async Task DeleteVariable(string name) + { + await session.ConnectAsync(); + await variables.DeleteVariableAsync(name); + return $"Переменная '{name}' удалена."; + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/VariableTests.cs b/tests/Kompas.Mcp.Tests/Integration/VariableTests.cs new file mode 100644 index 0000000..bff3b3f --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/VariableTests.cs @@ -0,0 +1,64 @@ +using Kompas.Mcp.Core.Documents; +using Kompas.Mcp.Core.Modeling; +using Kompas.Mcp.Core.Query; + +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: переменные модели (параметрика, API5 ksVariable). +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class VariableTests +{ + private readonly DocumentService _docs; + private readonly PartModeler _modeler; + private readonly VariableService _vars; + private readonly ModelInspectionService _inspect; + + public VariableTests(KompasFixture fx) + { + _docs = new DocumentService(fx.Session, fx.Dispatcher); + _modeler = new PartModeler(fx.Session, fx.Dispatcher); + _vars = new VariableService(fx.Session, fx.Dispatcher); + _inspect = new ModelInspectionService(fx.Session, fx.Dispatcher); + } + + [Fact] + public async Task Variable_crud_roundtrip() + { + await _docs.CreateAsync(KompasDocumentType.Part); + try + { + // Небольшая геометрия, чтобы было дерево построения. + var s = await _modeler.OpenSketchAsync(BasePlane.XOY); + await _modeler.AddRectangleAsync(s, 0, 0, 20, 20); + await _modeler.CloseSketchAsync(s); + await _modeler.ExtrudeAsync(s, depth: 10); + + // create + var created = await _vars.CreateVariableAsync("width", 40, "ширина"); + Assert.Equal(40, created, 3); + Assert.Equal(40, await _vars.GetVariableValueAsync("width"), 3); + + // видна в list_variables (пользовательское чтение) + var list = await _inspect.ListVariablesAsync(); + Assert.Contains(list, v => v.Name == "width"); + + // set expression (формула → вычисленное значение) + var computed = await _vars.SetVariableAsync("width", "12+8"); + Assert.Equal(20, computed, 3); + Assert.Equal(20, await _vars.GetVariableValueAsync("width"), 3); + + // вторая переменная, ссылающаяся на первую (формула с именем переменной) + await _vars.CreateVariableAsync("height", 5); + var doubled = await _vars.SetVariableAsync("height", "width * 2"); + Assert.Equal(40, doubled, 3); // width=20 → 40 + + // delete (в порядке зависимостей: сначала зависимая height, затем width) + await _vars.DeleteVariableAsync("height"); + await _vars.DeleteVariableAsync("width"); + await Assert.ThrowsAsync(() => _vars.GetVariableValueAsync("width")); + await Assert.ThrowsAsync(() => _vars.GetVariableValueAsync("height")); + } + finally { await _docs.CloseAsync(save: false); } + } +}