# Key implementation facts (don't relearn)

Проверенные COM-цепочки сервера. Перенесено из `CLAUDE.md`, чтобы не грузить эти ~19 КБ
в каждую сессию: они нужны при доработке сервера, а не при любом вопросе по репозиторию.
Правь этот файл, когда проверил новую цепочку вживую.

- **Interop assemblies are not shipped** (`KompasAPI7`, `Kompas6API5`, `Kompas6Constants`,
  `Kompas6Constants3D` belong to ASCON). `libs/kompas-interop` is referenced with `<Private>false</Private>`
  — compile-time only, absent from `bin/` **and** from the published archive/`deps.json`. At runtime
  `KompasInteropLoader.Install()` (first line of `Program.cs`) hooks `AssemblyLoadContext.Default.Resolving`;
  lookup order: `KOMPAS_INTEROP_DIR` → next to `kompas-mcp.exe` → cache → the installed КОМПАС.
  **The install has no loose interop DLLs** — they live only inside `<install>\SDK\Samples\CSharp.zip`
  under `Common/`, so the provider unzips them into `%LOCALAPPDATA%\kompas-mcp\interop\<key>` (key = hash
  of the zip's path+size+mtime, so a КОМПАС upgrade re-extracts; staging dir + `Directory.Move` for
  atomicity). The install dir comes from `HKCR\<ProgID>\CLSID` → `HKCR\CLSID\{…}\LocalServer32`
  (`"…\Bin\kHome.Exe"` on v24 Home) — `HKLM\SOFTWARE\ASCON` holds settings, not the path. All interop
  identities are `Version=1.0.0.0` with **no strong name**, so resolving by simple name is version-agnostic
  across КОМПАС releases. Failure throws `KompasInteropException` with an actionable message; `Program`
  logs it at startup (non-fatal — `--version` must work without КОМПАС) and `kompas_status` returns it
  (it checks interop **before** touching `KompasSession`, otherwise the MCP SDK shows only «An error
  occurred invoking …»). **The test project copies the DLLs itself**
  (`<None Include="$(KompasInteropDir)\*.dll">`) — the Linux CI runner has no КОМПАС to resolve from.
- **All COM calls run on one dedicated STA thread** (`KompasDispatcher.InvokeAsync`); КОМПАС is
  single-instance STA. Services never touch COM off that thread.
- **Connection**: API5 (`KOMPAS.Application.5` → `KompasObject`) then `ksGetApplication7()` → `IApplication`;
  the API5 root is needed for `ksPart` 3D building and `ksDocument3D` snapshots. API7 drives app/documents,
  holes, assemblies, drawings and direct B-rep edit.
- **3D via API5 `ksPart`**: `NewEntity(o3d_*)` + matching definition; always check `entity.Create()` return.
  Entity codes: `o3d_sketch`/`o3d_bossExtrusion`/`o3d_cutExtrusion`/`o3d_bossRotated`/`o3d_fillet`/`o3d_chamfer`,
  `o3d_shellOperation=43`, `o3d_ribOperation=44`, `o3d_baseEvolution=45` (sweep), `o3d_baseLoft=30`,
  `o3d_planeOffset=14`, `o3d_meshCopy=35` (linear pattern), `o3d_circularCopy=36`, `o3d_mirrorOperation=48`,
  `o3d_mirrorAllOperation=49`, `o3d_incline=42` (draft), axes `o3d_axisOX/OY/OZ=71/72/73`. Definitions:
  `ksBossExtrusionDefinition`/`ksCutExtrusionDefinition`/`ksBossRotatedDefinition`/`ksFilletDefinition`/
  `ksChamferDefinition`/`ksShellDefinition`/`ksRibDefinition`/`ksBaseEvolutionDefinition`/
  `ksBaseLoftDefinition`/`ksPlaneOffsetDefinition`/`ksMeshCopyDefinition`/`ksCircularCopyDefinition`/
  `ksMirrorCopyDefinition`/`ksMirrorCopyAllDefinition`/`ksInclineDefinition`. Cut-through holes use
  `dtBoth` + `etThroughAll`.
- **Face/edge selection**: `ksPart.EntityCollection(o3d_face|o3d_edge)` → `SelectByPoint(x,y,z)` (world mm)
  → `GetByIndex(0)`; or by stable index from `list_faces`/`list_edges` (helpers `SelectFaceByIndex`/
  `SelectEdgeByIndex`, range-checked before mutation; by-index is more reliable than by-point). Classify:
  `ksFaceDefinition.IsPlanar/IsCylinder/…` + `GetArea(ST_MIX_MM)`; `ksEdgeDefinition.IsLineSeg/IsCircle/IsArc/…`
  + `GetLength()`. A sketch axis is a line with system style **3** (осевая).
- **МЦХ / mass props**: use **API5 `ksPart.CalcMassInertiaProperties(ST_MIX_MM|ST_MIX_KG)`** →
  `ksMassInertiaParam` (`v`/`m`/`F`/`xc`/`yc`/`zc`), computed on demand. **Do NOT** use API7
  `IMassInertiaParam7.Calculate()` — its `Actual` flag sticks after the first calc and won't refresh on
  API5 geometry changes (stale volume). Bounding box: `ksPart.GetGabarit(full:false, …)`.
- **Snapshot**: `ksDocument3D.SaveAsToRasterFormat(file, ksRasterFormatParam)` renders to a temp file (the
  in-memory `resultArrayBytes` stays empty when a filename is given); read the bytes back. Return to MCP via
  `ImageContentBlock.FromBytes(bytes, mime)` — **not** `Data = bytes` (Data holds base64). Use
  `model_snapshot` only for visually-spatial questions; **prefer `describe_model` first** (structural
  «passport» — bbox + МЦХ + bodies + topology + feature tree + variables, no image token cost).
- Logs go to **stderr** (stdout is the MCP channel). Integration tests reuse one КОМПАС via `KompasFixture`;
  artifacts land in gitignored `.scratch/`. All integration test classes inherit `IntegrationTestBase`
  (`IAsyncLifetime`) → `DocumentService.CloseAllAsync` after each test (prevents tab accumulation).
- **Feature tree API**: read via API5 `ksPart.GetFeature()` → `(ksFeature).SubFeatureCollection(true,false)`
  → `ksFeatureCollection`. Casting `(ksFeature)part` does NOT work (RCW QI returns null) — must use
  `GetFeature()`. `ksFeature.type` returns only a coarse `o3d_entity` and does NOT distinguish operations;
  the precise type and params come from `ksFeature.GetObject()` → `ksEntity.GetDefinition()` + the matching
  definition (`ksBossExtrusionDefinition.GetSideParam`, `ksFilletDefinition.radius`,
  `ksChamferDefinition.GetChamferParam`, …). Node names («Элемент выдавливания:1») are localized by КОМПАС.
  Features registered by id in `_features` support patterns/mirror; helpers `RequireSketch`/`RequireFeature`.
- **Variables (API5)**: create/read via `ksPart.GetFeature().VariableCollection` (property on the root
  ksFeature) — **NOT** `ksPart.VariableCollection()` (returns external-only). Expression is the leading
  field; value is derived. Apply via `ksPart.RebuildModel()` (check the return); after a rebuild the RCW
  variable is stale → re-read via a fresh collection (`ReadValueFresh`). Dependents recalculate
  automatically; deletion fails while dependents exist (`RemoveVariable` returns FALSE).
  **Geometry limitation:** variables drive geometry only in a parametric model (sketch dims linked to
  variable names); our sketches use literal coordinates → `set_variable` stores/computes the value but does
  NOT move geometry. Parametric sketches are **unavailable via COM** (`ksCDimWithVariable` is not
  constructible from external automation — see memory `kompas-parametric-sketch-findings`; do not retry
  without new information).
- **Topology / measure API**: `ksFaceDefinition.EdgeCollection` / `GetCylinderParam` /
  `GetSurface().GetNormal` + `normalOrientation`; `ksEdgeDefinition.GetAdjacentFace(bool)` /
  `GetVertex(bool)` → `ksVertexDefinition.GetPoint`; bodies via `ksPart.BodyCollection()` → `ksBody`
  (`IsSolid` / `FaceCollection`); measurements via `ksPart.GetMeasurer()` → `ksMeasurer` (`SetObject1/2`,
  `unit=ST_MIX_MM`, `Calc`, `distance`/`MinDistance`/`angle` in degrees / `IsAngleValid`).
- **«STEP import without history»** is detected structurally: `bodyCount > 0 && no formative features &&
  features.Count <= bodyCount+1` (only origin + body). A STEP that has been edited («Смещённая плоскость» /
  «Разрезать» / «Переместить грани» / «Булева операция») already has history.
- **STEP import/export COM pattern** (verified): `IApplication.get_Converter((object)(int)ksConverterFromSTEP=-3)`
  (pass the format code, not a DLL path) → `IConverter.ConverterParameters(cmd)` →
  `IAdditionConvertParameters.Format=ksConverterFromSTEP` → `IKompasDocument3D1.ConvertFromAdditionFormat(path, prm)`.
  Export: `ConvertToAdditionFormat` (format codes AP203/AP214/AP242). Param co-classes
  (`AdditionConvertParameters` etc.) are NOT CoCreatable — only via the converter factories. Assembly
  traversal (`list_components`): `IKompasDocument3D.TopPart → IPart7.Parts (IParts7)`. Extract a component:
  `prm.NeedCreateComponentsFiles=true` (writes .m3d files next to the STEP) + `IPart7.OpenSourceDocument`.
- **Direct B-rep face editing (`move_face`)** (verified): API7 face via
  `IPart7.FindObjectsByPoint(x,y,z,true)` → cast to `KompasAPI7.IFace`; containers via runtime COM-QI
  `(ISurfaceContainer)part`, `(IModelContainer)part`; `FaceMover` (SetFaces + Offset + Direction + Update)
  moves the face. distance>0 = outward (adds material), <0 = inward. Works on parametric and imported B-rep.
  The full split-reposition workflow (SplitSolids/BodyRepositions) is driveable; only partly exposed as tools
  (`split_solid_by_plane`, `move_body`, `boolean_union`).
- **Forming ops (API5 definitions)**:
  - `shell` (`ksShellDefinition`, o3d_shellOperation=43): `thickness` + `thinType` (bool, `= !outward`) +
    `FaceArray()` (≥1 open face by index). Transient RCWs intentionally not released (consistent with
    Fillet/Chamfer).
  - `rib` (`ksRibDefinition`, =44): `SetSketch(sketch)` → `index=0`, `angle`, `side`
    (`left|right|up|down` → 0/1/2/3 via `SketchGeometry.RibSide()`); `SetThinParam(dtBoth, t/2, t/2)` if
    symmetric, else `(dtNormal, t, 0)`. Empirical: the rib contour must float in the gap (not touch the body)
    — КОМПАС extends the web automatically.
  - `sweep` (`ksBaseEvolutionDefinition`, =45): `SetSketch(profile)` → `PathPartArray().Add(path)` →
    `sketchShiftType=0` → `SetThinParam(false)` → `Create()`. Profile and path on different (usually ⟂)
    planes; `profileSketchId != pathSketchId`.
  - `loft` (`ksBaseLoftDefinition`, =30): `Sketchs().Add(each)` → `SetLoftParam(false,false,true)` →
    `SetThinParam(false)`. ≥2 closed sections on parallel planes — use `sketch_create(plane, offset)` for
    non-base sections.
  - offset plane (`sketch_create` with `offset≠0`, `ksPlaneOffsetDefinition`, o3d_planeOffset=14):
    `SetPlane(base)` + `offset` mm + `direction` → `Create()` → sketch on it (`OpenSketchOnOffsetPlaneAsync`).
  - `draft` (`ksInclineDefinition`, o3d_incline=42 — API5 calls the op **Incline**, NOT Draft): `FaceArray()`
    (by index) + `SetPlane(neutral base plane)` + `angle` (degrees) + `direction`. **Empirical, opposite to
    the SDK docs:** `direction=false` = expanding/outward (adds material), `true` = tapering/inward → the code
    maps `def.direction = !outward`. Validate `0<angle<90`. (`o3d_DraftFromEdges=644` / `IDraftFromEdges` is
    a different op — not used.)
- **Patterns & mirror (API5)** — one tool each, the variant is a parameter: `pattern(kind=linear)`
  (`ksMeshCopyDefinition`, =35): `SetAxis1(axis)` +
  `SetCopyParamAlongAxis(true,0,count,step,false)` + `count2=1` (disables the 2nd direction → 1D); features
  via `OperationArray()`. `pattern(kind=circular)` (`ksCircularCopyDefinition`, =36): `SetAxis(axis)`; `count1=1`
  (radial off), `count2=count` (ring), `step2=angle°` between neighbours, `factor2=false`, `inverce=reverse`;
  `GetOperationArray().Add(feature)`. `mirror(featureIds)` (`ksMirrorCopyDefinition`, =48): `SetPlane(base)` +
  `GetOperationArray().Add(feature)`. `mirror()` without ids (`ksMirrorCopyAllDefinition`, =49): `SetPlane(base)`;
  `ChooseBodies()` casts to null in the interop and is NOT needed — it mirrors all bodies keeping the original
  (volume doubles). Empirical: `count` includes the original instance (count=3 → 3 instances). Axes:
  `CoordinateAxis{X,Y,Z}` → `o3d_axisOX/OY/OZ` (`Core/Modeling/CoordinateAxis.cs`, analogous to `BasePlane.cs`).
- **Hole ops (API7 only — `ksHoleDefinition` is absent from the API5 interop; `HoleService`)**:
  `HoleCore(ksHoleTypeEnum holeType, Action<IHole3D> configure)` — `configure` sets `HoleParameters` **after**
  `HoleType` is assigned (the type-specific sub-interface is inaccessible until the type is fixed). Placement
  is shared: `IModelContainer`/`IHole3D`/`IHoleDisposal` + `Points3D`, direction chosen by volume delta,
  orphan rollback. None are registered in `_features` → no id returned. One tool `hole`, the variant comes
  from `type`: `simple`=`ksHTBase`, configure=null.
  `counterbore`=`ksHTCounterbore` + `(ISpotfacingHoleParameters)` (`SpotfacingDiameter`/`SpotfacingDepth`).
  `countersink`=`ksHTCountersinking` + `(ICountersinkHoleParameters)` (`CountersinkType=ksCTDiameterAngle`,
  `CountersinkDiameter`/`Angle`). `conic`=`ksHTConic` + `(IConicHoleParameters)` (`ConicType=ksCNAngle`,
  `ConicAngle`).
- **Assembly (API7, `AssemblyService`, namespace `Kompas.Mcp.Core.Assemblies`)**: `assembly_add_component` —
  `IParts7.AddFromFile(path, ExternalFile=true, Redraw=true)` → `IPart7.Placement` (`IPlacement3D`) →
  `SetOrigin(x,y,z)` (mm, assembly world CS) → `UpdatePlacement(true)` → `top.RebuildModel(true)`.
  **Empirical:** `UpdatePlacement` returns FALSE for a manually-positioned component (no mates) — NOT an error.
  Validate `DocumentType==ksDocumentAssembly` before touching `TopPart`; rollback via `IFeature7.Delete` +
  `RebuildModel`. `assembly_add_mate` — `top.MateConstraints` (`IMateConstraints3D`) →
  `Add(MateConstraintType)` → `BaseObject1/2` (faces via `top.FindObjectsByPoint(x,y,z,FirstLevel=false)`) →
  `ParamValue` (distance) → `Update()` → `RebuildModel(true)`; check `mate.Valid` (false → rollback
  `mate.Owner.Delete`). Types `coincidence` (mc_Coincidence=0) and `distance` (mc_Distance=5, value>0) are
  verified live; the rest are backlog.
- **Drawing (API7, `DrawingService`, namespace `Kompas.Mcp.Core.Drawings`)** — entry via `IKompasDocument2D`;
  requires an active drawing document (`DocumentType==ksDocumentDrawing`). All coordinates are **view-local**
  (mm) unless noted. The model file must be saved before placing views.
  - `drawing_create_standard_views`: `doc2d.ViewsAndLayersManager.Views.AddStandartViews(path, "#Спереди",
    projTypes, x, y, scale, dx=20, dy=20)` where `projTypes=object[]{1,3,5}` (Front/Up/Left as SAFEARRAY VT_I4).
    Success: `created==3`; content check: sum of the new views' `IView.ObjectCount` > 0 else rollback
    `IView.Delete`. Returns `ViewNumbers` (the `IView.Number` values — address views by these when placing
    annotations).
  - `drawing_fill_title_block`: `doc2d.LayoutSheets.ItemByNumber[1].Stamp` (`IStamp`) →
    `stamp.Text[columnId].Str = text` (indexed property; `IText.Str` overwrites, no Clear needed) →
    `stamp.Update()`. Fields→columns (`ksStampEnum`): Name=1, Designation=2, Material=3. Non-transactional
    (all cells → a single `Update`).
  - **Dimensions go into `(ISymbols2DContainer)view`, NOT the sheet, and are NOT counted in `IView.ObjectCount`**
    (that counts geometry in `IDrawingContainer`) — verify placement via the container's `*.Count`. Common flow:
    `Add()` → set coords → `AutoNominalValue=true` → `Update()` → check `Valid` → read
    `((IDimensionText)dim).NominalValue`; rollback `dim.Delete()` on FALSE/invalid/zero. Leader and angle are in
    **radians** (`DimensionAngles.ToRadians`). Free placement = set coordinates directly; **associative**
    (diametral/radial only) = set `BaseObject` to a projected circle → the value is read from geometry.
    - linear (`ILineDimension`): `X1,Y1,X2,Y2` (extension points) + `X3,Y3` (dimension line) + `Orientation`
      (`DimensionOrientation{Horizontal,Vertical,Parallel}` → `ksLinDParallel=0`/`Horizontal=1`/`Vertical=2`;
      parallel needs no explicit Angle).
    - diametral (`IDiametralDimension`): `Xc,Yc,Radius` + `Angle`; `NominalValue` = 2·Radius.
    - radial (`IRadialDimension`): `Xc,Yc,Radius` + `Angle`, `DimensionType=true`; **`NominalValue` = Radius,
      NOT diameter** — contrary to the SDK docs, confirmed by a spike.
    - **associative** diametral/radial (`associate=true`): instead of coordinates, find a projected circle in
      `(IDrawingContainer)view.Circles` by a center+radius key (`CircularObjectMatch.SelectMatchIndex`, tol 1 mm;
      throws on none/ambiguous; concentric disambiguated by radius), set `dim.BaseObject = circle` (`_Circle`
      implements `IDrawingObject`) → `NominalValue` read from geometry. `RequireViewContainers` gives both QIs;
      the free path stays on `RequireSymbols2DContainer`. Arc/angular/rough binding — not yet (needs its own
      spike; `ILineDimension` has no `BaseObject`).
    - angular (`IAngleDimension`, `AngleDimensions.Add(ksDrADimension=10)`): vertex `Xc,Yc` + side points
      `X1,Y1`/`X2,Y2` + arc-position `X3,Y3` (positions the arc only, does NOT pick the angle); the measured
      angle is chosen by `DimensionType` = `AngleDimensionType{Min,Max,More}` (on rays 0°/45° → min=45° /
      max=135° supplement / more=315° reflex). `NominalValue` in degrees.
  - `drawing_add_rough` (`Roughs.Add()`/`IRough`: `BranchX0/Y0`, `Angle`) + `(IRoughParams)rough` QI:
    `SignType` = `RoughSignType{NoProcessing,DeleteMaterial,WithoutDeleteMaterial}`, value (Ra/Rz) =
    `RoughParamText.Str`.
  - `drawing_add_text` — **text lives in `IDrawingContainer`, NOT `ISymbols2DContainer`, and NOT in
    `IView.ObjectCount`**: `(IDrawingContainer)view.DrawingTexts.Add()`/`IDrawingText` (`X/Y/Angle`) +
    `(IText)dt.Str` = text; count via `DrawingTexts.Count`. Do NOT set `Height` (that's block height, not font).
  - `drawing_set_technical_requirements` — **document-level**: `(IDrawingDocument)doc.TechnicalDemand` /
    `ITechnicalDemand`: `td.Text.Str = text` → `td.Update()` (`IsCreated` False→True on first set; overwrites;
    `\n` multiline).
  - `drawing_add_leader` — `ISymbols2DContainer.Leaders.Add(ksDrLeader)` → `IBaseLeader`. **Critical order
    (spike):** a fresh leader has 0 branches → `(IBranchs)bl.AddBranchByPoint(0,x,y)` (the tip) BEFORE
    `SetBranchTextPosition(textX,textY)`/`Update`, otherwise КОМПАС dies with `RPC_E_SERVERFAULT`. Text —
    `(ILeader)bl.TextOnShelf.Str`; `ShelfDirection` (enum `ShelfDirection{Auto,Right,Left,Up,Down}`, Auto = leave
    unset). `AddBranchByPoint`/`SetBranchTextPosition` return bool — check them.
  - `drawing_set_sheet_format` — `RequireLayoutSheet(n).Format` (`ISheetFormat`): `Format`=`ksDocumentFormatEnum`
    (`PaperFormat{A0..A5,User}` + `PaperFormats.Parse/ToKompas/FromKompas`); standard → set
    `VerticalOrientation=!landscape` (W/H auto-computed from enum+orientation, e.g. A3 landscape → 420×297);
    **User → set `FormatWidth/Height` only, КОМПАС derives `VerticalOrientation` from W/H, ignores the flag and
    never swaps W/H (spike)** → `sheet.Update()`. A new drawing defaults to A4 portrait.
    `ValidateFormatDimensions`: User needs W/H>0, standard forbids W/H (must be 0).
  - `DrawingAnnotationResult{Value(read-back), ViewNumber}` is the shared result for rough/text/leader.
- **Richer sketch primitives (API5 `ksDocument2D` wrappers)**: `ksArcBy3Points`; `ksArcByAngle` (centre/radius/
  start-end angles in degrees + counterClockwise flag); `ksEllipse` via `ksEllipseParam`
  (`KompasObject.GetParamStruct(ko_EllipseParam=22)`; **the property names are `A`/`B` UPPERCASE in the interop**);
  `ksRegularPolygon` via `ksRegularPolygonParam` (`ko_RegularPolygonParam=92`; `describe = !inscribed`); a NURBS
  spline via `ksNurbs(order=4)` + `ksNurbsPoint` loop + `ksEndObj`; `ksPoint`. Param structs are released after
  use. `PartModeler` is partial: `.cs` (registry/helpers/`NewParam<T>`/reset), `.Sketch.cs` (2D primitives),
  `.Features.cs` (extrude/revolve/fillet/chamfer). Static `SketchGeometry` (`Core/Modeling`) holds the enum maps
  + validators (`RequirePositive`, `RequireVertexCount`, `RequirePoints`, `RequireMin`). Point lists use
  `record SketchPoint(X, Y)` (JSON names `x`/`y`).
- **Text in a sketch → curves (`type=text`, verified live)**: a text object is *annotation* — a formative
  operation never sees it. The chain that works: build `ksTextParam` (`ko_TextParam=28`) → `Init()` →
  `GetParagraphParam()` (`ksParagraphParam`: `x`, `y`, `ang`, `height`, `width`) → `SetParagraphParam`;
  one `ksTextItemParam` (`ko_TextItemParam=31`, `type=ksTItString=0`, `s=<string>`) whose `GetItemFont()`
  (`ksTextItemFont`) carries `fontName`/`height` (mm)/`ksu` (width factor)/`bitVector`
  (`ITALIC_ON=0x40`, `BOLD_ON=0x100` — from `Include/ldefin2d.h`); wrap it in
  `KompasObject.GetDynamicArray(TEXT_ITEM_ARR=4)` → `ksTextLineParam` (`ko_TextLineParam=29`,
  `SetTextItemArr`) → `GetDynamicArray(TEXT_LINE_ARR=3)` → `SetTextLineArr`; then
  `ksDocument2D.ksTextEx(param, align)` → **`ksConvertTextToCurve(ref)`** — this is what turns glyphs into
  NURBS/lines that extrude. Traps: the `align` argument of `ksTextEx` does **not** move the string (a
  centred value still lays it out right-and-up from the insertion point) — instead read
  `ksGetTextLengthFromReference(ref)` (advance width in mm, ≈ 10 % wider than the glyph bbox) and place the
  text yourself; the dynamic arrays live in КОМПАС, so `ksDeleteArray()` them in a `finally`; an unknown
  font name is silently substituted rather than failing; `height` sizes the cap+ascender, not the bbox.
- **Glyph bounding box (verified live)**: `ksConvertTextToCurve(ref)` **returns a new reference — the
  curves**, and only that one gives the real box. `ksDocument2D.ksGetObjGabaritRect(reference, ksRectParam)`
  (`ko_RectParam`; `GetpBot()`/`GetpTop()` → `ksMathPointParam.x/y`, **millimetres of the sketch**, not
  sheet centimetres) on the *text* reference returns the cell (23.75 mm for Arial h=10 «HH») while the
  same call on the *converted* reference returns the glyphs (19.97 mm, matching the extruded solid to
  0.05 mm). Returns 0 rather than throwing when the object has no box — treat as «unknown», don't fail
  the primitive. Test: `SketchTextTests.Reported_glyph_box_matches_the_extruded_geometry`.
- **Feature names in the tree (`name` on every mutating tool, verified live)**: API5 `ksEntity.name` is
  read/write and must be assigned **before `Create()`** — `PartModeler.NewEntity(part, type, name)` does it
  for every entity, sketches included. API7 objects created outside `ksPart` (`IElementaryBody` from
  `ElementaryBodies.Add`, `IHole3D` from `Holes3D.Add`) take it via `IModelObject.Name` set before
  `Update()`. Reading back is asymmetric: `ksFeature.name` (what `list_features`/`describe_model` walk) is
  **read-only** — the tree shows the assigned name, but you cannot rename through `ksFeature`.
  Test: `FeatureNamingTests`.
- **Elementary bodies / primitives (`primitive`, verified live)**: `(IPart7 as IModelContainer)
  .ElementaryBodies.Add(ksObj3dTypeEnum)` → cast to `IBlockBySizes` / `ICylinder` / `ISphere` /
  `IConeByHeight` → set sizes → `Update()`. Types: `o3d_BlockBySizes=668`, `o3d_Cylinder=663`,
  `o3d_Sphere=662`, `o3d_ConeByHeight=670` — and `Add` takes **`ksObj3dTypeEnum`, not `Obj3dType`**
  (same numbers, different type; the compiler catches it). Placement is the primitive's own
  `Position` (a `LocalCoordinateSystem` with writable X/Y/Z); for a block the anchor is a corner
  (`BlockPosition = ksVertex`), for bodies of revolution the base centre. `OperationResult`
  (`ksOperationUnion=0 / NewBody=1 / Cut=2 / Intersect=3`) makes the primitive a boolean in itself —
  a pocket needs no sketch and no cut extrusion. Traps: `Height`, `HeightType`, `HeightOffset` and
  cone's `Diameter` are **indexed properties** (`Height[true] = …`, index = the direction side), plain
  assignment does not compile; and `Update()` returns TRUE for a subtraction that misses the body
  entirely, so `PrimitiveService` compares the volume before/after and rolls the feature back
  (`Owner.Delete()`) when nothing changed.
- **Part name/marking (`set_part_info`)**: write through API7 `IPart7.Name` / `IPart7.Marking` on
  `IKompasDocument3D.TopPart`, then **`top.Update()`** — without it the tree keeps showing «Деталь».
  Read-back through the same properties (API5 `ksPart.name` / `ksPart.marking` also expose them, which is
  what `ModelInspectionService` uses, since its `GetTopPart` is already an API5 `ksPart`). The document's
  file name is a different thing entirely: saving as `Badge.m3d` does not rename the part.
- **`IKompasDocument.SaveAs(path)` returns `void` and fails silently** when a file of that name is already
  open in КОМПАС as another document: nothing is written, no error, the document simply stays unsaved.
  `DocumentService.SaveAsAsync` therefore verifies afterwards — `doc.PathName` must equal the requested
  path AND `File.Exists(path)` — and `SaveAsync` checks `doc.Changed == false`. Covered by
  `DocumentTests.SaveAs_over_a_name_held_by_another_open_document_fails_loudly`. To free the name,
  `document_close(all: true)` (→ `DocumentService.CloseAllAsync`) closes every open document.
- **Thin-wall extrusion (`extrude(thinThickness, thinSide)`)**: `ksBoss/CutExtrusionDefinition.SetThinParam(
  thin, thinType, normalThickness, reverseThickness)` with `thinType` = `dtNormal` (outward), `dtReverse`
  (inward), `dtBoth` (half each side). **Trap: the thickness must sit in the field matching the direction**
  — with `dtReverse` the *reverseThickness* argument is the one read; passing it as `normalThickness`
  leaves the wall at zero and КОМПАС silently extrudes a SOLID section instead of failing (caught only by
  the volume: a 82×10.5 plate came out full instead of a 1.2 mm frame). `outward` over a closed contour is the cheap equivalent of a 2D
  equidistant: it grows a rim around it (verified — a 20×20 square with 2 mm outward wall gives a 24×24
  frame). Building the same sketch twice (solid + outward wall) yields a silhouette with an offset border,
  which is how a lettered badge gets its backing plate without `ksEquidistant`.
  **Intersection trap (isolated by experiment, not guessed):** a thin-wall extrusion over a
  *multi-contour* sketch (a text line is ~10 closed contours, several with inner loops) returns
  `Create()==FALSE` when the wall **intersects an already existing body**. Bisected: empty part → OK;
  body present but not touched by the wall → OK; wall crosses the body → FAIL. Body count is
  irrelevant (2+ bodies with a plain rectangular contour build fine), so is the body's origin
  (sketch or `ElementaryBodies` primitive), and `chooseType` (tried `-1/0/1/2/3`) changes nothing —
  the earlier "multi-body" explanation was wrong. Practical rule: build the thin wall before the body
  it would cross; `primitive(result=union|subtract)` has no such restriction.
