feat: параметрика — переменные модели (create/set/delete_variable) через API5
- 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
This commit is contained in:
@@ -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<VariableService>()`.
|
||||
- **`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`). ✓
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Параметрика: создание и изменение переменных модели (API5 <c>ksVariable</c>).
|
||||
/// Создание возможно только на коллекции переменных корневого <c>ksFeature</c> (не <c>ksPart</c>).
|
||||
/// <para>ВАЖНО: переменная управляет геометрией лишь в параметрической модели (где размеры
|
||||
/// эскизов связаны с именованными переменными). Эскизы, построенные литеральными координатами,
|
||||
/// не имеют именованных размеров — для них переменные хранятся, но геометрию не меняют.</para>
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Создать новую переменную модели с начальным значением. external=true — внешняя
|
||||
/// (доступна для управления из сборки). Возвращает вычисленное значение.</summary>
|
||||
public Task<double> 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);
|
||||
|
||||
/// <summary>Задать выражение переменной (константа «30» или формула «len*2+5») и перестроить.
|
||||
/// Возвращает вычисленное значение.</summary>
|
||||
public Task<double> 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);
|
||||
|
||||
/// <summary>Удалить переменную модели.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Прочитать текущее (вычисленное) значение переменной.</summary>
|
||||
public Task<double> GetVariableValueAsync(string name, CancellationToken ct = default)
|
||||
=> _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
RequireName(name);
|
||||
var (_, vc) = RootVariables();
|
||||
return RequireVariable(vc, name).value;
|
||||
}, ct);
|
||||
|
||||
/// <summary>Перечитать значение переменной из свежей коллекции (после RebuildModel оно пересчитано).</summary>
|
||||
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}' не найдена.");
|
||||
|
||||
/// <summary>Корневой ksFeature детали и его коллекция переменных (на ней работает AddNewVariable).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,11 @@ public sealed class ModelInspectionService
|
||||
|
||||
private static IReadOnlyList<VariableInfo> ReadVariables(ksPart part)
|
||||
{
|
||||
if (part.VariableCollection() is not ksVariableCollection vc) return Array.Empty<VariableInfo>();
|
||||
// Переменные детали живут в коллекции корневого ksFeature; ksPart.VariableCollection() даёт
|
||||
// только внешние. Берём feature-коллекцию (все переменные, включая созданные), с фолбэком.
|
||||
var vc = (part.GetFeature() as ksFeature)?.VariableCollection as ksVariableCollection
|
||||
?? part.VariableCollection() as ksVariableCollection;
|
||||
if (vc is null) return Array.Empty<VariableInfo>();
|
||||
int n = vc.GetCount();
|
||||
var list = new List<VariableInfo>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
|
||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<KompasSession>();
|
||||
builder.Services.AddSingleton<DocumentService>();
|
||||
builder.Services.AddSingleton<PartModeler>();
|
||||
builder.Services.AddSingleton<HoleService>();
|
||||
builder.Services.AddSingleton<VariableService>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
builder.Services.AddSingleton<QueryService>();
|
||||
builder.Services.AddSingleton<ModelInspectionService>();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Инструменты параметрики: переменные модели (создание, изменение, удаление).</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class VariableTools(KompasSession session, VariableService variables)
|
||||
{
|
||||
[McpServerTool(Name = "create_variable")]
|
||||
[Description("Создать переменную модели с начальным числовым значением value. note — комментарий. external=true — внешняя (доступна для управления из сборки). Имя должно быть уникальным. Примечание: переменная управляет геометрией только в параметрической модели (где размеры эскизов связаны с именами переменных); для моделей с литеральными размерами переменная просто хранится. Используйте list_variables для просмотра. Возвращает вычисленное значение.")]
|
||||
public async Task<string> 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<string> 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<string> DeleteVariable(string name)
|
||||
{
|
||||
await session.ConnectAsync();
|
||||
await variables.DeleteVariableAsync(name);
|
||||
return $"Переменная '{name}' удалена.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
|
||||
namespace Kompas.Mcp.Tests.Integration;
|
||||
|
||||
/// <summary>Интеграция: переменные модели (параметрика, API5 ksVariable).</summary>
|
||||
[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<KeyNotFoundException>(() => _vars.GetVariableValueAsync("width"));
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() => _vars.GetVariableValueAsync("height"));
|
||||
}
|
||||
finally { await _docs.CloseAsync(save: false); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user