Skip to main content
Glama
MCP_CATIA_MechDog

MCP_CATIA_MechDog

by wudahua

CATIA V5 MCP Server — lite fork

0.3.129 · what changed · releases · known limits

A trimmed, verification-first fork of daiemon12/catia-v5-mcp-server (MIT, by Adam M'HAMDI). Upstream keeps the whole CATIA surface; this fork keeps only what builds and checks a solid from a written spec, and fixes the defects that make that loop lie to you. Most of the work since 0.3.0 came from people rebuilding real assemblies with it: the changelog is a list of things that produced a plausible number, a wrong number, or no explanation at all.

151 tools (upstream 98 + 53 added, and nothing dropped any more: knowledge came back in 0.3.63 and drafting in 0.3.64, each after its tools were measured on B30 rather than trusted). Nothing in the COM layer was rewritten — the value of upstream is the field-verified knowledge in its comments, so this fork is strictly add / subtract / patch. The count is asserted by tests/test_readme_tool_counts.py, so it cannot drift from the code again.

What it can verify, not just build

  • Every cut is measured. pad / pocket / hole / shaft / groove / circ_pattern report the volume they moved before and after, so a feature that silently did nothing is visible on the spot. Volume proves a pattern built its copies, not that they point anywhere: an 8-blade fan came out with blades that were placed at 45° and never turned, at exactly 8× the seed's volume (2026-10-30). catia_circ_pattern now rotates each copy (radius_aligned=true, the default) and says which mode it used.

  • Edges have fingerprints, not indices. catia_list_edges returns L17.6112|Pad.6;2|Sketch.7;2; fillet / chamfer resolve it at call time, so a multi-fillet script survives its own edits.

  • Sketches are editable after the fact. catia_sketch_edit reopens a closed sketch, catia_sketch_delete_element removes one wrong element, and the part rebuilds. A wrong profile is no longer a full rebuild.

  • Assemblies are measurable. catia_get_inertia on a Product aggregates the components' volumes, areas and world centroids (verified to 0.009 mm on a 9-part assembly) and expands sub-assemblies — their leaves are reported with a path (ASM/PART.1) and their placement composed, so components adds up to the totals. The totals carry complete, instances and, when anything was left out, incomplete_because: a partial sum says so instead of wearing the assembly's name. catia_get_bounding_box reports per-component world boxes and expands sub-assemblies the same way, so a nested assembly is bounded rather than listed as skipped.

  • Out of range is UNVERIFIED:, not a success. When a measurement cannot be made the reply says which one failed and why.


What this fork changes

Dropped (nothing, since 0.3.64)

Two modules were cut when this fork was made - drawing (needs a Drafting licence) and knowledge (parameters / design tables). Both are back, measured: knowledge in 0.3.63, drafting in 0.3.64. What was dropped was not the capability but the claim that it worked.

export (STEP/STL hand-off) and http_transport (remote agent) stay for the same reason they always did: domain-neutral and cheap. See FORK.md §8 if you want to cut further.

Added (36 tools over upstream)

Five of them came with the fork, twenty-one were added while building parts from a spec (0.3.1: three, 0.3.2: four, 0.3.4: one, 0.3.5: one, 0.3.8: two, 0.3.17: two - catia_delete_body, catia_solid_loft; 0.3.19: one - catia_copy_component; 0.3.51: one - catia_check_surface; 0.3.53: one - catia_gsd_boundary; 0.3.55: one - catia_gsd_heal; 0.3.56: two - catia_gsd_fillet_surface, catia_gsd_spine; 0.3.60: two - catia_gsd_rotate, catia_gsd_translate; 0.3.82: three - catia_sketch_copy, catia_gsd_circ_pattern, catia_gsd_plane_normal_to_curve; 0.3.84: one - catia_gsd_plane_angle). 98 + 50 added = 148, plus catia_server_info (0.3.127) = 149, plus the two drawing readers (0.3.128) = 151: the 31 below, catia_apply_design_table under Knowledgeware, catia_drawing_set_sheet and catia_drawing_add_frame under Drafting (0.3.65), the three colour tools in their own table below (0.3.79), catia_replace_component (0.3.80), catia_split_solid (0.3.94), catia_measure_deviation (0.3.119), catia_solid_tube (0.3.121), catia_gsd_splines (0.3.122), and catia_solid_sweep_variable (0.3.124, elliptic sections since 0.3.126), and catia_set_constraint_value (0.3.125), catia_server_info (0.3.127), and catia_read_drawing / catia_import_drawing (0.3.128). 0.3.85 added arguments instead of tools: catia_gsd_boundary(edge=N, propagation=…) takes one loop of a border (a selector AddNewBoundaryOfSurface does not have), catia_gsd_fill accepts Edge.N, and catia_gsd_circ_pattern(cap_center=true, cap_curve=…) closes the centre of a full ring. 0.3.20 added arguments instead of tools: a sketch support given as origin/u/v, edges given as coordinates, arcs given as their two endpoints, and one plan per several documents.

Tool

What it gives you

catia_run_plan

Runs an ordered {tool, args} recipe in one call, then asserts volume / area / face / edge / vertex counts / centre of gravity. dry_run runs the tools' own semantic checks, not just the argument schemas (0.3.126) - a 2026-11-14 plan passed dry_run and then died at step 10 of 21 on major (5.2) is shorter than minor (5.8) after nine steps had already built geometry. rollback: true deletes what a failed plan had built, newest first, and reads the tree back to say which names are gone - data.created_features is reported whether or not you ask for it

catia_measure_topology

Face, edge and vertex counts — the cheapest guard against a part with the right volume and the wrong shape

catia_set_component_transform

Absolute placement from a 3×3 in either convention (matrix = P = A·p + T, basis = row i is where local axis i points) or CATIA's native 12 numbers; reads back and reports deviation, det, orthogonality and where each local axis ended up

catia_get_component_transform

The same placement in both conventions, plus where local X/Y/Z point in the assembly

catia_rename_component

A readable assembly tree. Part.Name is read-only on V5-6R2020, so every part arrives as Part1.1 / Part2.1 / …; the instance name is writable

catia_sketch_copy

The "copy the profile, paste it on the next plane" step. Replays a sketch's recorded geometry into a new sketch on another plane - a main plane, a frame, or a datum plane by name - coordinates unchanged; redrawing by hand is what leaves two copies that are not coordinate-identical, and a loft section is sensitive to that. Refused for a sketch reopened from disk, because the record is the only source of coordinates. The record now follows a delete (0.3.90): measured 2026-11-09, two lines with the second deleted still left BOTH in the record, so the copy came back with twice the source's outline - the shape of a report's "2 spline(s)". A shape made of several elements (a rectangle is four lines) cannot be replayed after one side goes, so the record is dropped whole and the reply says catia_sketch_copy will refuse that sketch

catia_sketch_strokes

Draws a whole letter outline (or any set of polylines) in ONE call, as straight segments. A glyph is 8-20 segments and the one-element tools force one call per segment, so a 2026-11-10 report computed control points by hand for dozens of calls per letter. It does not interpolate: catia_sketch_spline through the same points rounds every corner - that is what turned their hand-built "A" into an omega. Each stroke that repeats its first point is closed, degenerate segments are refused before anything is drawn, and the segments are recorded as lines so catia_sketch_copy can replay them

catia_gsd_circ_pattern

Repeats one surface/curve N times around an axis and joins the copies in one call. Not a CATIA GSD pattern: this build's HybridShapeFactory declares 160 members and none contains "Pattern" (measured 2026-11-08), so the tree gets N-1 rotations plus a join, and the reply says so

catia_gsd_plane_normal_to_curve

Plane normal to a curve with its origin ON that curve at a fraction of its length - the tutorial's "Normal to Curve + Set Origin on Curve". A tree element, unlike the frame sketch that used to be the only way; the point's measured coordinates are reported back

catia_gsd_plane_angle

A datum plane rotated a given angle about an axis - the tutorial's 36-degree construction and the general 1/N-sector plane. A named, editable, referenceable tree element, unlike a frame sketch. The axis must LIE IN the reference plane (rotating a plane about its own normal leaves it where it was and CATIA answers with a generic failure), and the angle is in degrees

Added while building parts from a spec (26 tools)

Tool

Version

Why it exists

catia_curve_on_surface

0.3.106

Put a planar outline ON a curved face. This is the step seven reports named as the one structural gap: "computed on the surface" needed the surface's own equation, so the caller mapped the glyph by hand, guessed an offset, then calibrated by carving, reading removed/predicted and re-carving (one word, six ratios 0.93/1.45/1.40/1.20/1.42/1.24, three grooves deleted). Each point is snapped with AddNewPointOnSurfaceWithReference and the stroke is threaded into one open spline. Measured: an 18-point 'C' snapped from a plane 60 mm in front of a closed lofted bottle moved 26.053-26.137 mm, and catia_surface_engrave then removed 3.026 mm3 against a 3.042 mm3 prediction - 0.99, with no tuning. Two measured limits are in the reply: a point lands on the NEAREST part of the surface (four directions and three faces all moved the same point 26.0052 mm), and a SOLID BODY reference is refused outright

catia_sketch_ellipse

0.3.106

A full ellipse - centre, semi-axes, angle of the major axis - from Factory2D.CreateClosedEllipse. The 2026-11-11 report built a slanted handle hole out of a 28-point polyline because it believed no ellipse primitive existed; it does (37 members, read on B30), so the hole is one smooth wall instead of 28 flats

catia_split_solid

0.3.94

Cut a solid with a surface and keep one half (CATIA's Split), for the engraving chain a 2026-11-10 report could not finish. ShapeFactory.AddNewSplit(surface, side) is declared and works - measured on B30 before the tool was written: a 100×100×10 block cut by a plane at Z=3 gave 30000 mm³ with one side value and 70000 with the other, and the same block cut by a 400 mm extruded wall gave 70000 / 30000. Two things a caller cannot guess are in the message instead: the cutter must divide the body completely (a 40 mm wall on the same block fails at update, and CATIA only says "method Update failed"), and side means "the side the cutter's normal points to", which is why the argument is `keep='front'

catia_new_body

0.3.1

A spec's part is often several solids. catia_pad used to always land in the body that was already in work, and there was no way back once work had moved past the second body

catia_copy_component

0.3.19

A 24-instance assembly had to be built by calling add_component once per instance - N isolated processes, and N peer components instead of N instances of one reference. Products.AddComponent(ref) does it in one call (measured: the new instance's ReferenceProduct is the same product)

catia_solid_loft

0.3.17

The surface route cannot make a thin solid - offsetting a 2.30 mm section by 1.27+1.27 self-intersects - and this server had claimed CATIA had no solid loft at all. ShapeFactory.AddNewLoft does exist: two circles gave a 18 765.8 mm³ solid in one update

catia_delete_body

0.3.17

catia_delete_feature looks inside bodies and never sees one, so an experimental body could only be emptied, never removed — a part ended up carrying nine empty bodies

catia_list_bodies

0.3.1

Per-body volume and face count — the only way to check a nine-solid part body by body, since catia_get_inertia reports just the one in work

catia_boolean

0.3.1

Join / cut / intersect two bodies, for the cases where the spec merges what the source kept separate

catia_set_active_body

0.3.2

catia_new_body only moves forward; this is the way back to the main body

catia_set_visibility

0.3.2

A GSD surface stays visible after close_surface and overlays the solid, so screenshots and the STL envelope describe a part that does not exist. Measured enum: SetShow(0) shows, (1) hides, (2) is accepted and does nothing

catia_save_as_copy

0.3.2

SaveAs is refused once a model has geometry; this saves and then copies, which is the only way to snapshot such a model

catia_activate_document

0.3.5

With a Part and a Product open there was no way back to the Part except closing the Product; this activates by name

catia_sketch_edit

0.3.8

A closed sketch was unreachable: OpenEdition on it works, so a wrong profile can be repaired instead of deleting the sketch and rebuilding the part

catia_sketch_delete_element

0.3.8

Removes one element (the wrong third line) and keeps the rest of the profile and the features built on it

catia_set_part_number

0.3.4

Every part cloned from a template carries Part1, so a 24-instance assembly of 24 distinct parts reports 24 identical part numbers and CATIA fills the screen with “零件编号 Part1 已存在”. The number, not the instance name, is what the BOM reads

catia_gsd_extract

0.3.2

catia_gsd_fill needs a boundary curve, and a sketch reference is rejected — without an extract there was no way to cap a lofted surface into a solid

catia_gsd_boundary

0.3.53

A patch with a C0 seam has to be rebuilt from its own border, and catia_gsd_extract cannot do it - an extract of a face returns a surface, not the wire round it. AddNewBoundaryOfSurface takes one argument and updates: on a 30 mm extrusion of a r=10 circle the boundary measured 125.664 mm, exactly the two circles

catia_gsd_heal

0.3.55

Two surfaces that do not quite meet cannot be joined (a join wants a shared edge) and cannot be lofted together. AddNewHealing stitches them: measured on B30 2026-11-03, two lofts 0.05 mm apart went from 48 open seam edges to 0 with merging distance 0.5, while 0.001 changed nothing. It does NOT change continuity - a 0.52 deg seam stayed 0.52 deg - and the same operator had been written off in 0.3.51 because the test never registered a body with AddBodyToHeal

catia_gsd_fillet_surface

0.3.56

0.3.53 called this object unverified and shipped nothing. Verified on B30 2026-11-03: the one orientation pair CATIA accepts on a straight corner, (-1,+1), builds a true rolling-ball fillet - on 60/90/120 deg wedges every mesh vertex sat at exactly 5.0000 mm from the axis for r=5 (min = max) and the area was 314.159 / 418.879 / 209.440 mm² = (π − θ)·r·L, and a floor-and-wall corner built in another direction behaved identically. The tool tries the four pairs and keeps the survivor, refuses when none survives (two disjoint cylinders: 0 of 9) and refuses when more than one does (disc + standing cylinder built two different surfaces, 573.4 and 391.3 mm²) instead of choosing for the caller

catia_gsd_spine

0.3.56

0.3.53 said the typelib "names no way to give it sections". It does: HybridShapeSpine declares 40 members including AddSection, AddGuide, GetNumberOfSections and ModifySectionCurve, and the old probe had only looked at dir(spine). Measured: sections register, UpdateObject succeeds with two or more (one fails), and the curve is the spine - normal to the sections, not through their centres (three parallel rings gave 40.0000 mm and did not bend for a 30 mm sideways offset; a 45° tilted ring made it a 37.9224 mm curve that leaves that axis by 7.05 mm)

catia_gsd_rotate

0.3.60

A five-lobe bottle base is one lobe plus four rotations of 72°, and there was no way to rotate a surface at all - catia_circ_pattern is a Part Design feature and wants a solid - so the lobe was REBUILT five times, once per position, which is four extra constructions of the same geometry and a shape whose positions could not be moved later. AddNewRotate(element, axis, angle) was in the typelib the whole time. Measured on B30 2026-11-04: a cylinder whose axis sits at (20,0) rotated +90° about z lands at (0,20), so the sign is counter-clockwise by the right-hand rule, and the original stays

catia_gsd_translate

0.3.60

Same gap, same run. AddNewTranslate(element, direction, distance) measured on the same day: a cylinder spanning z 0..20 moved 30 mm along +z spans 30..50 and no longer meets z=0. The direction must be the Direction OBJECT, not a Reference - passing the reference raises “type mismatch, argument 2” - while AddNewRotate takes a Reference to an axis line, so the two members of one family disagree and neither argument kind is guessed

catia_shape_audit

0.3.99

A five-lug bottle base reproduced a tutorial to within 3.15 % of its volume, with the wall thickness matching to 0.0001 mm, and the shape was still wrong - and the tutorial's own appendix describes eight earlier versions that passed every such check. Volume and area cannot see a shape; r(theta) can: bins every mesh vertex by angle and reports the amplitude, the peak angles and whether N repeats agree

catia_text_strokes

0.3.103

Text into a sketch, from a font that ships with the server. Factory2D has no CreateText (37 members, measured 2026-11-10), so a glyph can only be geometry; catia_sketch_strokes already took a whole outline, and what was missing was the outlines. The face is Hershey simplex, single-line - one stroke per stem, because a 0.4 mm groove cannot resolve two walls of the same stem and comes out as one ragged slot. height is the cap height; a character with no glyph is skipped and named rather than drawn as a box. Any script Hershey does not cover - CJK, accented letters - comes from a REAL FONT'S OUTLINES (0.3.120): a TrueType reader inside this package, no extra dependency, found automatically on Windows/macOS/Linux or named with font_path. Those are closed contours, so they pad and pocket as a profile (the reply says so, and catia_close_sketch reports such a sketch as a profile rather than as lettering); a stem then has two walls, so a 0.4 mm groove down its middle cuts both. Measured on B30 2026-11-14: 中文 at height 8 is 5 closed contours over 321 segments, and a 2 mm pad over them measures 107.47 mm3. Then the caller meets this: engraving a glyph through a projection refuses a parameter point depending on how much of it lands - the same 'O' failed at 81.4 % and passed at 57.2 %, 9 of 12 letters passing at their default placement. The reason is not confirmed; the numbers are in catia_diagnose's ceilings, and the reply repeats them

catia_check_surface

0.3.51

A surface task's acceptance criteria were "check with zebra stripes that the valley is smooth", and CATIA V5 gives automation no zebra, no curvature comb and no Match object (measured: the viewer answers RenderingMode and returns an unresolvable attribute for Zebra / EnvironmentMapping / AnalysisMode). The mesh can answer it anyway: the dihedral angle across every interior edge, so a G0 fold reads as degrees instead of needing a picture

Fixed (latent upstream bugs, reproduced on a live V5-6R2020 B30 session)

Defect

Effect upstream

Position.GetComponents never fills a Python list (SAFEARRAY out-param does not marshal through late-bound IDispatch)

catia_move_component wrote twelve zeros back and erased the component's rotation; catia_list_components reported every component at (0, 0, 0)

doc.Part / doc.Product raise AttributeError: <unknown>.Part on a gen_py-typed document

catia_new_part and every measurement failed depending on cache state

SaveAs leaves a modal dialog open, freezing the serial STA worker

Documented upstream as a runtime limit ("close the dialog yourself") — but the worker thread that would close it is the blocked one

Topology.Edge on a body also returns the consumed sketch's lines

A 40×20×10 box counted 16 edges / 12 vertices instead of 12 / 8

catia_shaft / catia_groove called AddNewShaft(sketch) and assigned FirstAngle = angle

The feature could never be updated (E_FAIL), so no turned part could be built at all — piston, cylinder-head cover and front cover had no other route

catia_hole with a finite depth reported success while cutting nothing

The depth is stored on BottomLimit but not applied on B30; the tree carried a dead feature that poisoned every later measurement

catia_hole ignored where the profile was drawn

AddNewHoleFromSketch takes the bore's axis from a point in the sketch, so a sketch holding one circle was drilled at the sketch origin: circles at (0, 0) and (10, 0) produced bit-identical parts, and a circle outside the material drilled through its middle. The removed volume stayed exactly pi*r^2*h, so nothing could see it

| catia_hole with threaded set wrote ThreadingMode = 1, commented # catThreaded | CatHoleThreadingMode is 0 = threaded, 1 = smooth, and 1 is also what a hole in a fresh part already carries - so the write was a no-op on a property nothing read back, and every hole asked for as threaded since 0.3.0 was smooth while the reply said it was created. Feature names do not differ (both 孔.1), so nothing downstream could see it. A hole also inherits the mode of the hole before it, so threaded: false has to be written as well | | catia_sketch_constraint passed geometry_index_1 straight to CreateReferenceFromObject | GeometricElements item 1 is the sketch's absolute axis, not the first thing the caller drew (绝对轴 at 1, 直线.1 at 2). So "my first line" = 1 landed on the axis and CATIA answered a bare 方法 AddMonoEltCst 失败, a message that names neither. The delete path had refused index 1 for releases; the constraint path had not |

The fixes: reads go through SystemService.Evaluate (the same route that fixed GetCOG upstream in 0.2.2); connection.part_of() retries via dynamic.Dispatch; dialogs.py removes a pre-existing target, retries, and runs a watchdog thread that answers #32770 message boxes while the COM call is in flight; topology counts filter Selection_Wire* elements. Revolution now goes through AddNewShaftFromRef + SetProfileElement + RevoluteAxis with FirstAngle.Value, and every cut is measured before it is reported.

Modelling a written spec: the arguments that were missing

Argument

Why a spec needs it

catia_create_sketch → offset, or {"type": "offset", "base", "distance"}

A spec says "on the z=530 face". Only three datum planes existed, so most features of a real part could not be placed. The offset plane is inserted into the body — AddNewPad rejects a sketch whose support lives in a geometrical set

catia_create_sketch → {"type": "face", "index"}

For a face that is not parallel to a datum plane. Sketches on a face use that face's own coordinate frame, which cannot be read back through automation — prefer the offset form whenever the face is axis-aligned

catia_create_sketch → {"type": "frame", "origin", "u", "v"}

The way specs actually describe an inclined sketch: "O=(17.525, 185.862, 0), u=(0.995584, -0.093875, 0), v=-Z". The datum plane is built through the origin with H = u and V = v, so the numbers can be typed in unchanged. Without it, ten steps of a 2026-10-25 report could not be modelled at all (8 grooves at 5.39°, 2 faces at 14.04°)

catia_sketch_arc → p1, p2, direction

Reverse-engineered data gives "centre, R, (x1,y1) -> (x2,y2)" and no angles. Converting by hand is what turns the one 240° major arc of 236 into a 120° minor one. Every arc reply now also states its swept angle

catia_sketch_line → construction

A revolution axis or reference geometry that must not join the profile

catia_pocket / catia_hole → through_all

CATIA's "up to last": the cut finds its own extent instead of you guessing a depth taller than the part

catia_shaft / catia_groove → axis (horizontal/vertical)

Which of the sketch's two absolute axes to revolve about. A construction line is accepted by the property setter and then rejected at update time, so it is not offered

catia_fillet / catia_chamfer → edge_anchors (+ anchor_tol)

The other half of a spec: "fillet R2.5 on the edge at (0, 65, -20)". Each anchor is matched to the edge whose minimum distance to it is smallest, and the reply states the edge name and the distance — an anchor that matches nothing, or two edges equally well, is refused instead of filleting the wrong one

catia_list_edges → coords: true

Adds point_on_edge — a coordinate that lies ON the edge (the midpoint for a straight edge, measured) — which is both the value to feed back as an anchor and the thing to compare with a spec sheet

Details, measurements and caveats: FORK.md (Chinese).

A GSD write moves the in-work object, and fillets then fail for no reason

Any HybridShapeFactory write (a circle, a point, a loft) leaves CATIA's Part.InWorkObject on the geometrical set. A Part Design factory called in that state answers a bare E_FAIL — AddNewSolidEdgeFilletWithConstantRadius 失败 — about a perfectly good edge, and the message names nothing useful. Until 0.3.41 this made catia_fillet(edge_anchors=...) fail permanently in any document that had ever touched a GSD tool, while edge_name='Edge.1' on the same edge worked: the anchor path creates a scratch point set to measure distances, and creating that set is itself a HybridBody write. It now hands the body back when the set is deleted, and the rollback path (_safe_update) no longer raises NameError instead of saying the feature was removed again.

Fillets on a real part: the edges move

Edge.N is an enumeration position, not an identity — every feature that changes the topology renumbers it. A part with 17 fillets therefore used to need "fillet one, re-list all 67 edges, fillet the next": 7 listings of ~50 000 characters and 34 calls.

catia_list_edges now hands out a fingerprint instead, built from the faces that bound the edge and its length, and catia_fillet / catia_chamfer resolve it when the call runs:

{"tool": "catia_fillet", "args": {"radius": 2.54, "edge_names": [
  "L39.8982|Pad.6;1|Pad.6;0",        // paste back what the listing printed
  {"length_mm": 17.6112, "faces": ["Pad.6;2", "Sketch.7;2"]}
]}}

Faces decide the match and the length only breaks ties, because a neighbouring fillet legitimately shortens an edge (measured: 17.6112 → 9.8429 mm while the bounding faces stayed the same). The listing also filters (min_length, max_length, faces) and pages (offset, limit), and include_nls: false keeps the reply small.

Rounding every edge: the feature reference never built

Omitting edge_name used to pass a Reference to the last feature into AddNewSolidEdgeFilletWithConstantRadius and call that "all edges of the last feature". Measured on B30, that call fails at construction on a 2-edge cylinder, a 12-edge rectangle, a 54-edge polyline and a 246-edge polyline — and the failure arrives as a bare COM_ERROR naming no edge, which is what a 2026-11-11 report hit twice. Four controlled cells rule out the arguments: feature reference versus whole body, propagation 0 versus 1, four failures. AddNewChamfer behaves the same, measured separately because it is a different method.

Both tools now enumerate the body's edges and build one feature per edge, so the reply can name what took and what did not:

Every edge of '零件几何体' (12 edge(s)): …
Fillet R1.5: 4/12 edge(s) done (Edge.1 …, Edge.2 …) [applied Edge.1@R1.5, …].
Rejected: Edge.3 (L80.0000|Sketch.1;3|Pad.1;2) -> rebuild failed: …

Four out of twelve is not a radius problem, and this is the number worth knowing before trying smaller radii: on an 80×40×20 box at all 12 edges, R0.25 / 0.5 / 1.0 / 1.5 / 3.0 each took the same four 40 mm edges and removed 2.1460 / 8.5841 / 34.3363 / 77.2567 / 309.0266 mm³ — every one exactly 4·40·(R² − πR²/4). The eight that refused are not short (the shortest edge is 20 mm, and 2·R is 3 mm); edges that share a vertex fight over the same material. A retry costs a rebuild and a feature per edge — about 0.6 s for an edge that takes and more for one that refuses — so max_edges (default 200) bounds it, and above that the batch runs once and the reply says which number it stopped at. The batch is not the cheap option: on the 381-edge part from the 2026-11-11 report it took 186 s and rounded nothing, which is why the reply now puts the short-edge count before that paragraph.

What a real outline does to this, measured 2026-11-12 on that same part (SiphonBottle.CATPart: 2 features, 129 faces, 67 278.8 mm³ — and 381 edges, which is three times the face count):

request

took

time

left

R1.5, default max_edges

0 / 381

186 s (batch only)

2 shapes

R0.25, max_edges=400

101 / 381

458 s

103 shapes, −3.73 mm³

At R0.25 every edge is longer than 2·R, so length is not what stops it — the corners are. A 381-edge outline cannot be rounded by picking a radius; it has to lose corners first (draw the curved stretches as arcs with catia_sketch_profile instead of faceting them into 100 points).

Two related defaults: propagation defaults to none, so one picked edge is one fillet instead of its whole tangent chain, and a batch whose rebuild fails is retried one edge at a time rather than lost.

When the source gives coordinates instead of edge names, skip the listing entirely:

{"tool": "catia_fillet", "args": {"radius": 2.5,
  "edge_anchors": [[0, 65, -20], [50, 65, -20]], "anchor_tol": 0.5}}
// -> Fillet R2.5: 2/2 edge(s) done [applied Edge.6@R2.5, Edge.9@R2.5]
//    Edge anchors: Anchor [0.0, 65.0, -20.0] -> Edge.6 @ 0.0000 mm; ...

Measurable.GetMinimumDistance takes a point reference — it rejects a Measurable with "type mismatch" — so the anchors are turned into scratch points in a temporary geometrical set that is deleted before the reply. An anchor that lands beyond anchor_tol, or exactly where two edges meet, is refused with the candidates and their distances: a fillet on the wrong edge is a perfectly legal feature nobody asked for.

Every bad anchor is listed at once, and on_anchor_miss: "skip" fillets the ones that do match while reporting the rest — a 20-row coordinate table should not cost 20 round trips because one row is wrong:

{"tool": "catia_fillet", "args": {"radius": 0.5, "on_anchor_miss": "skip",
  "edge_anchors": [[0, 5, 4], [999, 999, 999]]}}
// -> Fillet R0.5: 1/1 edge(s) done [applied Edge.1@R0.5]
//    Edge anchors: Anchor [0, 5, 4] -> Edge.1 @ 0.0000 mm;
//    SKIPPED (on_anchor_miss='skip'): 1 anchor(s) beyond anchor_tol=0.5:
//    [999, 999, 999] is 1710.727 mm from the nearest edge (Edge.3, L20.0).

A hole goes where the profile is

CATIA's Hole takes the bore's axis from a point in the sketch, not from the circle's centre, so catia_hole on a sketch holding one circle drilled at the sketch origin — six positions, one bore, and the volume matched pi*r^2*h every time. Three things changed:

  • A construction point is added at the circle's centre before the feature is built when the sketch was drawn in this session and holds no point, and the reply says so. A point that is away from the circle is refused before anything is created, with the distance: "holds a point at (0, 0) and a circle centred at (10, 0), 10.000 mm apart … Nothing was created."

  • The bore is verified after the cut, not before it: the centroid of the removed material is recovered from the volume and centre-of-gravity readings (V0·G0 − V1·G1 = v·g), compared with the profile in the sketch plane, and a bore that missed is deleted and reported — the one failure mode a volume check cannot see. Above 500× amplification (a nick in a heavy part) the check declines to accuse instead of inventing an error.

  • depth is measured from the sketch plane. With the sketch on an offset datum plane, depth 5 in a 20 mm block drills 15 mm; the reply converts the removed volume into the length it drilled and says which datum it used. There is no reverse argument because Hole exposes no direction property (DirectionOrientation raises on B30) — a bore that removes nothing on the far side of the sketch plane is reported as such, with through_all or a sketch on the face as the way out.

A rollback that takes the sketch puts it back

A punch consumes its input sketch, and whether CATIA removes it with the failed node depends on the document's history — so after NO_MATERIAL the same call left the sketch in place once and gone twice, and a retry answered SKETCH_NOT_FOUND (0.3.30 disclosed this; 0.3.31 closes it). The sketch is now re-created from this session's own drawing record — same plane, same drawing calls — and the old name is aliased to the new one:

... It has been re-created from its recorded definition as '草图.3' (same plane,
same drawing calls), and '草图.2' now resolves to it, so retrying the same call
works and nothing else in the part changed.

NO_MATERIAL therefore always means "the sketch is still there". Measured on B30: after a failed pad, the retry with the old name answered NO_MATERIAL again; a second rollback rebuilt 草图.3 as 草图.4. When the record cannot reproduce the sketch — a reopened file, a support that is not a datum plane, an arc whose start angle is not in the record — the message says which of those it is instead of leaving a sketch-shaped hole.

0.3.32 makes the name mean the same thing everywhere. Every rebuild re-points the entries that pointed at the sketch CATIA took, so three rollbacks leave one alias (草图.2 → 草图.5) rather than a chain that dies on the third retry; the alias is resolved by the read and edit tools too, not only by Part Design; and it shows in the places a caller looks for "is my sketch still there":

catia_sketch_get_geometry {sketch_name: "草图.2"}
  -> {"sketch": "草图.5", "found": "by alias '草图.5'", "elements": [...]}

catia_list_features        -> sketches: [{"name": "草图.6",
                               "aliases": ["草图.2", "草图.3", "草图.4", "草图.5"]}]
catia_pad {sketch_name: "草图.99"}
  -> No sketch named '草图.99'. Available: 草图.1, 草图.6 (alias: 草图.2, 草图.3,
     草图.4, 草图.5).

The rebuild's own reply also repeats what the sketch held (It held: rectangle (-10, -5) .. (10, 5) on plane 'xy' at offset 0), so the profile is visible even to a caller that never asks again.

Every tool that takes a sketch name resolves it that way — Part Design, the sketcher's read/edit calls, GSD's element= (measured: catia_gsd_spline {element: "草图.2"} answered "No outline recorded" before 0.3.33 and returns the 4 corners as "草图.2 (rebuilt as 草图.3)" after) and a loft section. And the error messages are checked against the tool list: every catia_* name in a message has to be a tool this server actually serves, because three of them used to send the caller to catia_list_sketches, which does not exist.

A pad that adds nothing tries the other side

catia_pad now behaves the way catia_pocket has since 0.3.4: if the requested direction grows no material, the same feature is flipped and re-measured, and a flip that works is kept and reported rather than turned into a diagnosis.

{"tool": "catia_pad", "args": {"height": 40}}   // sketch sits on the bottom face
// -> Pad created: 40.000 mm (reverse). Feature: '凸台.2'
//    Volume 96000.0 -> 100000.0 mm3 (delta +4000.0).
//    (direction auto-flipped to reach material: ...)

The failure path no longer builds a second pad to answer "is the direction the problem?" — that probe was built from a sketch the failed pad had already consumed, so it answered "nothing either way" for a direction that works, and removing it deleted the caller's sketch. The same node is flipped instead, and the reply says whether the sketch survived.


Measuring an assembly without owning ActiveDocument

Two clients can drive one CATIA, and ActiveDocument is theirs to change between your calls. Everything that can name its target should:

{"tool": "catia_get_bounding_box",
 "args": {"document": "M113_asm.CATProduct"}}          // include_curved defaults to true
// -> {"document": "M113_asm.CATProduct", "part_boxes": 28,
//     "part_boxes_by_vertices": 26, "part_boxes_by_mesh": 2, "complete": true,
//     "mesh_fallback": ["P09_cam_lens_body"], "mesh_fallback_instances": 6,
//     "bounds": "mixed: 6 instance(s) …",
//     "instances": {"total": 70, "measured": 70, "skipped": 0},
//     "components": [{"component": "P03_prop", "min": {…}, "max": {…},
//                     "dimensions": {…}, "source": "vertices"}, …],
//     "parts": [{"part": "P03_prop", "source": "vertices", "instances": 10,
//                "vertices_used": 64, "cells_scanned": 214,
//                "points_route": "batched", …}, …]}

The world box is arithmetic on each component's placement, so it never needed the assembly to be active. Each PART is bounded once and only the placement repeated — 70 instances of 28 parts is 28 boxes, not 70. The cost counters (vertices_used, cells_scanned, nls_chars, points_route) are per PART and are published once in parts, not on every instance entry; cells_scanned counts CATIA topology cells before construction geometry was filtered, and the per-part time is dominated by that filter, not by the vertices. nls_chars is the total length of the NLS paths that filter read (one per cell): a size indicator for how much construction geometry a part carries, not a cost predictor — on 26 parts of one model nls/cell varied 1.31× while ms/cell varied 5.49× (r = 0.34), and the part with the longest paths was the fastest. component='P24.1' reads one placement, not seventy.

A part with no vertex at any extremum (a fully curved part) cannot be bounded by a vertex sweep. It is bounded from its STL mesh instead — include_curved: false turns that off and reports those instances in skipped — and its entry says source: "mesh_fallback" with mesh_vertices. Every mesh vertex is transformed by the instance's placement, not the local box's two corners: a diagonal rotated by 34° is a line with almost no extent across it, which made a disc report ±100.29 / ±21.32 where the truth is ±72.5 / ±72.5 (fixed 2026-10-26; the entry now matches an independent cloud rotation to 0.00003 mm, while the 8-corner box of a round part is 2–4 mm too big). A mesh box chords curved faces, so bounds says "mixed: N instance(s) are bounded from their mesh" rather than claiming an exact envelope. Measured on a 24-instance drone assembly: complete: true, 11 of 24 instances from the mesh, 106.8 s.

The STL export behind that route runs with DisplayFileAlerts off — on B32 it otherwise puts up a modal "另存为" that, unanswered, holds the call for 89 s.

What an exact box costs

The vertex route reads one topology cell at a time, so its price is set by the cell count, not the vertex count — a 16-vertex plate can still have hundreds of cells. Since 0.3.31 that read happens inside CATIA: one SystemService.Evaluate call enumerates the selection, drops the construction cells by their NLS name and returns the coordinates, instead of a Python→COM round trip per cell (measured: 827 ms one-call vs 1134 ms per-cell, −27 %, identical boxes — sweep_route says which one ran, and a build that refuses CATScript still falls back to the per-cell reader). And the per-cell cost depends on the session, not only on the part. The same 70-instance assembly measured three times in one session:

call, same session, same method

time

per cell

1st

194.7 s

53.7 ms (26 vertex parts)

2nd

41.4 s

10.6 ms

3rd

41.5 s

10.6 ms

cells_scanned, vertices_used and nls_chars were identical all three times, so the work is the same — the first pass pays CATIA's own setup for each part. Warm the fit is ≈ 200 ms + 3–5 ms × cells after 0.3.31's one-call sweep (3.2 ms/cell on 0.3.32's session, 3.8–4.9 on 0.3.34's, 9.5 before the sweep); cold it is 16–90 ms/cell and grows with the part. Read these as ranges, not constants — the same model moved 4.65× → 7.6× → 7.24× in three versions with the same geometry, and a single number quoted in a docstring reads as a promise the next measurement can only break. Every number in this section that was measured as "one part by name" is a COLD number, and reading it as a property of the part is what made an earlier release claim 17–93 ms per cell as the model. parts[].first_measure, parts[].cold_reason, regime and session_first_parts say which regime a call ran in; compare elapsed_ms across sessions only with that in view.

The 0.3.32 acceptance run on the same model measured cold 173 s, warm 22.9 s — the warm side had been cut in half, so the ratio grew to 7.6×; 0.3.33 measured 183 s cold / 25.2–26.0 s warm (7.24×) on the same model, and 0.3.34's cold note quotes the range plus that call's own parts_ms/overhead_ms instead of any constant. That is why the cold pass is now something you can schedule rather than something that happens to you: catia_get_bounding_box {warm_up: true} runs the same measurement, throws the geometry away and returns what it cost, so the next call that needs the box is warm (session_first_parts: 0). There is no cheaper warm-up: CATIA's first-read setup is the work, and a per-part vertex box cannot be cached from a (size, mtime) the way a mesh box is — the mesh box describes a file, the vertex box describes the live part, which may hold edits that were never saved.

call

time

one part, 24 cells / 16 vertices

~0.3–0.5 s warm, ~0.5–1.5 s cold

one part, 535 cells / 335 vertices (B30)

5.9 s warm / 36.1 s cold

one part, 1 207 cells (acceptance model, B32)

11.4 s warm / 84 s cold

one part, 504 cells (same model, B32)

5.0 s warm / 47 s cold

24 instances / 11 parts (drone), warm

94.4 s

the same call right after opening the assembly

130.0 s (1.38×)

the same call again, same session (mesh boxes reused)

16.0 s (8.1×)

70 instances / 28 parts (acceptance model)

173–183 s cold / 23–26 s warm

Three things dominate, and none of them is "one fixed cost per part":

  • Cold vs warm is the biggest single factor. 173 → 23 s (−87 %) with every work counter unchanged. The gap is per PART and it disappears once that part has been touched: P05 80.0 s cold / 11.4 s warm (7.0×), P01 45.5 / 5.0 s (9.2×), a 12-cell plate 0.33 s warm. It is not "less work" and not the assembly layer — overhead_ms says how much of the call no part owns, and it was identical cold and warm.

  • Cost concentrates in a few parts — in both regimes. Cold: P05 80.0 s (41 %), P01 45.5 s, P07 15.0 s, P02 12.2 s. Warm: P05 11.4 s (27 %), P01 5.0 s, P02 2.9 s, P07 2.2 s. elapsed_ms in parts is what makes that visible, and first_measure is what makes it honest.

  • The mesh fallback costs more than the vertex work when the assembly has curved parts. Parts bounded from STL are in context, so each one is copied to a temp file, opened, updated, exported and closed: 12–17 s per part, unchanged by warming, and independent of the mesh size. They are now reused from that file's identity (path, size, mtime) — that saves one export per curved part (in the drone assembly, 5 parts ≈ 74 s), which is why a second call there costs 16 s instead of 94 s. In the 70-instance model only 2 of 28 parts are curved, so the saving is ~10 s (4.6 %), not a step change. The cache belongs to the copy route, and the copy route is what runs when the part has no window of its own — the test is whether activating it changes ActiveDocument. A part that does change it is exported in place and never cached, because its loaded state may hold unsaved edits that no file identity can describe; catia_open_document cannot put an already-loaded component into that state (it activates it instead). If you only need complete: true, include_curved: false is cheaper still and lists those instances in skipped instead.

  • The "two tiers" were the cold/warm split in disguise. Every part sat at either 17–31 ms/cell or 66–92 ms/cell in one cold pass, and cell count could not separate them (P27 246 cells at 30.5 vs P07 239 at 66.4; P31/P30 both 12 cells at 34.2 vs 64.7). Warm, the same pairs read 9.1 vs 9.4 and 28.1 vs 30.5 — the factor was the first-touch cost, not the geometry. A per-cell figure is therefore only meaningful with the regime it came from.

  • Do not use a small part's ~400 ms as a regression baseline. In that model twelve-cell parts sat at 398–424 ms, but P30 was 776 ms, P20 732 ms and P24 635 ms — per-part jitter of +20 % to +90 % on a field that reads like a constant.

  • The most expensive item may not be in parts at all. Adding up parts[].elapsed_ms to find "the one part that eats the call" was working with 76.4 % of the number: on the 70-instance model the difference was 5 394.6 ms (23.6 % of a warm call), larger than the most expensive part in the table, and it appeared nowhere. Every reply now carries parts_ms, overhead_ms and overhead_breakdown (components_doc_ms / walk_other_ms / report_ms). On a 5-instance product measured here: total 1 308.1 ms, parts_ms 856.8, overhead_ms 451.3, of which components_doc_ms 413.3 — so the residual is dominated by one read of the component tree, not by per-part bookkeeping, which is also why it is identical cold and warm.

  • That read was one COM call per instance, and it is not any more. On the 70-instance model components_doc_ms was 5 036 of 5 563 ms (90.5 % of the whole overhead block) — seventy Evaluate calls for twelve numbers each, 86 ms apiece when timed directly. Since 0.3.34 the unfiltered path reads every child's pose in one script call (batch_positions, with a status byte per instance so a refused pose falls back to the per-component reader instead of reporting zeros). Measured on B30 with 10 instances: components_doc_ms 866 / 851 / 868 ms → 267 / 253 / 205 ms; one instance is unchanged (95 → 101 ms) because there is nothing to batch. A narrowed call (component=...) still reads exactly one pose — batching all of them would bring back the 5.8 s entry fee 0.3.26 removed. poses_route has three values, one per thing that can happen: one_call (the whole assembly in one script call), per_component (this build refused the script, so it fell back to a round trip each) and single (the call was narrowed). The third one exists because "the key is missing" is not an answer a caller can branch on — the same rule sweep_route: mesh follows.

Two documents, one name

document= used to return on the first name match, and its own docstring said why: "A name is unambiguous." It is not, and this server is what makes it ambiguous — catia_new_part(name=…) sets the part number, CATIA names the document after it, so creating a part under a name that is already open leaves two documents called DUP35.CATPart:

catia_new_part({name: "DUP35", file_path: "…/DUP35.CATPart"}) → pad 40 → 16000 mm³, saved
catia_new_part({name: "DUP35"})                              → pad 60 → 24000 mm³, unsaved

catia_get_inertia({})                              → 24000   ✔ the document in front
catia_get_inertia({document: "DUP35.CATPart"})     → 16000   ✘ the other model
                                                     and the reply says document: "DUP35.CATPart"

No error, no hint, a plausible number — the reporter nearly filed it as a regression of the 0.3.28 sub-assembly work. A name that means two models is worse than a name that matches none.

Resolution is one function on the connection (resolve_document) and it collects every match:

step

result

the argument matches one document's full path

that document — the path is the only thing that tells two same-named copies apart (catia_list_documents prints it)

exactly one document matches by name or stem

that document

more than one

DOCUMENT_NAME_AMBIGUOUS, modified: false, and data.candidates with each copy's document, path, active, saved

none

DOCUMENT_NOT_FOUND, listing what is open

{"ok": false, "code": "DOCUMENT_NAME_AMBIGUOUS",
 "message": "'v36_dup.CATPart' matches 2 open documents: 'v36_dup.CATPart' at
             'F:\\cat_bench\\v36_dup.CATPart'; 'v36_dup.CATPart' (never saved - no
             path to name it by) (active). This call did NOT pick one …",
 "data": {"candidates": [
   {"document": "v36_dup.CATPart", "path": "F:\\cat_bench\\v36_dup.CATPart",
    "active": false, "saved": true},
   {"document": "v36_dup.CATPart", "path": null, "active": true, "saved": false}]}}

The tools that can create the situation say so when it happens: catia_new_part / catia_new_product append "⚠ Another open document is already called 'DUP35.CATPart' … document='<name>' is ambiguous and this server refuses it with DOCUMENT_NAME_AMBIGUOUS instead of picking one", catia_activate_document says it activated the first of N, and catia_list_documents marks each duplicated entry with name_shared_by.

A document can be both a component of an open assembly and one of two copies with the same name, and until 0.3.38 that entry carried two notes glued with a space — one advising document='<name>', the next refusing it:

… open it with catia_open_document (or measure it by name with document=) 2 open
documents carry this exact name …

The notes are sentences now, and when the name is shared the component advice is dropped rather than contradicted: for that document only the path works. The two refusals (catia_get_inertia / catia_run_plan) also describe a candidate the same way, through CATIAConnection.format_matches — the planner's copy used to omit "(never saved - no path to name it by)", which is the one fact that decides whether the advice ("pass the path instead") can be followed at all.

A wrong name is one code on every tool. catia_activate_document was the odd one out: it raised COM_ERROR and appended the "CATIA reports most automation failures as a bare 'method failed' … check in_work_object" paragraph, about a call that never touched a body and had already printed the list of open documents. It raises DOCUMENT_NOT_FOUND with two next actions now, like the other three.

That warning only means something if it appears when there IS another copy. 0.3.36 compared the handles with is, which pywin32 never satisfies (Documents.Item(i) mints a new Python wrapper per access: Item(1) is Item(1) is False, Item(1) == Item(1) is True) — so every document was its own twin, every new part was greeted with "Another open document is already called 'SOLO36.CATPart' (1 of them)", and a unique name was reported as "2 open documents carry that name" while the same reply's catia_list_documents said 1. One comparison now (CATIAConnection.same_document, COM equality), used by the listing, the warning and the activation count.

The same name-based restore was hiding in two more places: _tensor_for and _mesh_export borrow the active document (the inertia tensor only exists for the active one) and gave it back by name — with a duplicate pair that restored the wrong twin, so a named catia_get_inertia left the other document in front and the next no-argument call measured the other model. Both keep the document object and re-activate that.

A listing keeps its shape when it is empty

catia_list_features used to answer the bare string "No features in the active body" when a body held sketches and no feature — the one moment its own description says to call it ("this is how to check whether a sketch still exists after a failed pad/pocket/hole"), and the moment a caller doing resp["sketches"] gets a TypeError instead of its sketch. Reported 2026-10-28, along with catia_list_components doing the same on an empty product and catia_list_constraints answering a bare LIST when full and a STRING when empty.

All three always return an object now:

{"body": "零件几何体", "count": 0, "bodies": 1, "features": [],
 "sketches": [{"index": 1, "name": "草图.1"}], "sketch_count": 1,
 "note": "the active body holds no Part Design feature yet, so 'features' is empty
          and 'count' is 0 - this is not an error, and the sketches in it are
          listed below"}

The rule is the one already applied to plan results: a caller must never have to branch on the SHAPE of a reply before it can read it. An empty answer is a value, not a failure, and it says which of the two it is. catia_list_documents follows it too — [] instead of the string "No documents open in CATIA".

An EMPTY PRODUCT is not "every component failed", and the two routes now agree on that: empty_assembly: true, instances 0/0/0, skipped: [], and the reason in incomplete_because naming the document that was measured. The old placeholder in skipped was named after the active window, so an empty EMPTY35.CATProduct measured while F35S.CATPart was in front answered with a skipped component called F35S.CATPart — a document the call never mentioned — and counted it as one instance while catia_get_inertia counted zero for the same document. instances.skipped == len(skipped) holds on both.

One failure, one code — on both paths

0.3.30 gave failures machine-readable codes so a caller could branch on them: NO_MATERIAL means change the offset or profile, SKETCH_NOT_FOUND means read Available. 0.3.33 kept those codes inside catia_run_plan and lost them on the bare path, because the server derived the code from the Python exception type (COM_ERROR) while the planner read the message. The same failed pad answered NO_MATERIAL in a plan and COM_ERROR on its own — two semantics for one failure, in one server.

There is one table now, and it prefers what the raise site declares:

source

example

code

the exception's own code

FeatureRejected after a rolled-back pad

NO_MATERIAL

the exception type

AttributeError on a missing API

UNSUPPORTED_CAPABILITY

the message

CATIA's own refusals

PART_NOT_LOADED, …

the caller's default

nothing matched

COM_ERROR (bare) / STEP_EXCEPTION (plan)

A rejection before execution has one shape, from any tool

Everything this server refuses before its first COM call — an unknown tool, arguments that fail the tool's own schema, a plan with no steps, a step whose arguments are wrong, an expect that cannot be parsed — answers the same envelope: ok: false, data.failure_stage: "validate", effects.modified: false, no recovery block, and data.next_actions describing the argument to fix.

It used to be three shapes. catia_run_plan built its refusal dict by hand (no effects, no failure_stage), a plan with no steps raised ValueError and came back as COM_ERROR with "inspect the CATIA model before retrying", and a writer whose arguments failed the outer schema got a full recovery block — advice to repair a model that had not been opened — while the same failure on a reader got none. catia_mcp.results.reject_validation writes all of them now, so the next refusal cannot ship with two of the three facts.

expect is validated like steps: a value that is not a number is INVALID_EXPECT before the model changes, not ASSERTION_FAILED (a verdict on the model) after it. An expect entry that is not an object used to be skipped silently, so a plan whose assertions were malformed answered PLAN_OK with an empty assertions list — a pass that tested nothing.

recovery follows the same rule — it is filled from facts, not from a template. A pad that measured both directions and rolled back reports rolled_back: true and "change the sketch's offset or profile, then retry", because retrying the same call is the call that failed. A tool that cannot write reports no recovery block at all: catia_get_bounding_box{document: '<a document that is not open>'} used to answer "model changes were not verified — inspect the CATIA model" for a lookup that wrote nothing. What such a tool still publishes are the next actions it can know — they are about the argument, not the model — in data.next_actions beside the candidates they describe. Suppressing the block had been dropping DOCUMENT_NAME_AMBIGUOUS's two sentences on the one code only read-only tools raise.

field

meaning

rolled_back

true the feature this call created was removed again, false it could not be removed, null no feature was ever created

can_request_rollback

always false — no tool takes a rollback argument (it was called rollback_supported until 0.3.35, where a false read as "this failure could not be rolled back" right beside rolled_back: true)

rollback_note

present only when rolled_back is not null: the sentence the constants stand in for

next_actions

what is left to do, written by the raise site when it knows better than the default

A refusal that happens before anything is written says so: CodedError carries modified: false and its own next_actions, so catia_pad{sketch_name: '草图.99'} answers SKETCH_NOT_FOUND / modified: false / "pick a name from Available … this call created nothing" instead of "inspect the CATIA model".

Two follow-ons from the same report. First, that block is absent on every failure of a read-only tool, including argument validation — is_read_only used to be asked only in the except branch, so catia_list_components{names_only: "yes please"} answered INVALID_ARGUMENT with "inspect the CATIA model before retrying" about a call that never opened one. UNKNOWN_TOOL and INVALID_ARGUMENT carry no recovery block at all now.

Second, the message no longer ends with the COM_ERROR sermon when the tool knows what it refused. Session state: … stays (it is a reading); the paragraph telling you to check whether in_work_object is a geometrical set is only added when the failure actually came from COM — otherwise it sat directly below a line saying the sketch name does not exist, or, for DOCUMENT_NOT_FOUND, sent you to inspect a body in a document you do not have open.

A refusal the TOOL makes is the same envelope

The three entry points above are all checks the server makes before dispatch. There is a fourth, and it is the largest: the lookups a tool does for itself once the schema has passed — Edge.99 is out of range: the final shape exposes 12 edge(s), was called without 'feature_name'. Those used to raise a bare RuntimeError, and a bare RuntimeError looks exactly like a COM failure to a server that classifies by what the exception declares.

before:  COM_ERROR · failure_stage "execute" · modified null · recovery block
         + "check whether in_work_object is a geometrical set"
after:   ARGUMENT_REJECTED / ARGUMENT_OUT_OF_RANGE · "validate" · modified false
         · no recovery · data.next_actions

A refusal that never opened a socket is not a communication failure, and the sermon about in_work_object had no business under a message that had just said the edge number was too high. ArgumentRejected carries stage = "validate" with it, so the server stops deciding that from the exception's Python type.

The case that makes it matter: an out-of-range edge (fix the argument) and a fillet CATIA rejected and the tool rolled back (the model needs a look) used to share a code, a stage and a shape. rolled_back: null was the only difference, inside a block that the first case should not have had at all.

An argument the tool does not read is refused, not dropped

catia_get_inertia({component: "NP42.1"}) used to answer 5760.0 mm³ — the active part's volume — for a part of 1536.0 mm³, with isError: false and no warning. component is declared by catia_get_bounding_box, nothing in the measurement module reads it, and a key nobody reads is a key nobody notices. A reply of the wrong number in the right shape is the hardest failure there is.

The server now compares the arguments against the tool's own schema and refuses anything undeclared (UNKNOWN_ARGUMENT, with unknown_arguments and declared_arguments in data). catia_run_plan applies the same rule to every step at validation time, so a dry run reports it too.

Two lessons from the day it shipped, both worth keeping:

A refusal must not name a spelling the tool accepts. catia_measure_distance reads element1, elem1_name, element1_name and elem1 — and its own description said "Aliases accepted: … elem1." The schema declared two of the four, so the new check refused a call the documentation called valid. The list now lives in one place (catia_mcp/arguments.py) and _pick() and the schema both read it. Listing the spellings twice fixes today's tools and re-opens the gap on the next one.

A declared argument nobody reads is the same defect from the other side. catia_coincidence_constraint declared element1 / element2 and read neither — it aligns two whole components — so passing one was accepted, ignored, and answered "constraint created". Those two keys are gone. The check cannot catch this direction (a schema can only lie about what it declares); reading the implementation did.

The unknown key is now reported before a missing one: a caller who typed elem2_name and therefore has no element2 is told about the typo, which is the cause, rather than about the argument it left out, which is its symptom.

A file name CATIA will not write

catia_save_document {file_path: "零件.CATPart"} used to spend six attempts, pop a modal dialog on each, and answer "could not be saved" — which reads like a file lock, and no retry can clear it. Measured on V5-6R2020 B30, same folder, same length, only the name differing:

path

result

…\ascii_probe.CATPart

saved

…\中文探针.CATPart

DISP_E_EXCEPTION, 6 attempts

…\中文目录\probe.CATPart

saved

The limit is the file name — that one is refused before CATIA is asked, at the validate stage, with that table in the message. A non-ASCII folder is a different story and the measurements disagree from machine to machine: nine successes here (2026-11-07/08, five then four path shapes) against eight failures and no success on the 2026-11-09 reporter's machine (0.3.81/0.3.83/0.3.85/0.3.87, empty session and multi-document alike). What is deterministic on both is narrower than the folder: it is the first save of a document that has no path. A catia_save_document into that same folder succeeds every time on both machines, because by then the document has a path.

Since 0.3.89 every first save takes the staged route: SaveAs into %TEMP%, then SaveAs onto the path asked for — the second call is exactly the one measured working on both machines — and the reply says which route ran and whether the folder is ASCII. 0.3.88 gated that on "the folder is non-ASCII", and a report then described a path shape (an ASCII ancestor containing a space, Chinese subfolder) where the route apparently never ran. The test is str(path).isascii() on the whole string — no whitespace split — and that exact path was measured through the staged route here, so the branch is gone instead of defended: there is no folder shape left that can miss it. The cost is one extra SaveAs on a document that has no path to lose. If the staged route fails too, the direct SaveAs is still attempted and the failure names both. A save that fails also says which COM call it was ("this call was a bare doc.Save()" vs "this call was doc.SaveAs(...)"), because those are different problems with different answers — and since 0.3.90 it carries data.save_route, every step in order with the COM answer for each, so "the staged branch never ran" and "its second step failed" are told apart by reading instead of guessing. Since 0.3.91 that route also separates the staged route's two SaveAs calls (staging_save, staging_move) and publishes data.staging_route — tried, temp_file_saved, temp_error, move_error — because one flag set before the call had been read as "the document was saved to %TEMP% and the move was refused too" even when %TEMP% had refused the file. A folder that cannot be created is now SAVE_FAILED with data.call = "os.makedirs" rather than an escaped OSError reported as COM_ERROR. Since 0.3.92 the route also opens with route_decision — the branch taken (bare Save, staged first save, direct SaveAs, refused: …) and the four facts behind it (has_path, same_file, file_exists, holder) — and a document with no path can never be sent to a bare Save, whatever an inconsistent FullName read says: that call cannot give it a path, so it takes the staged route (_same_file and _document_at_path now both require an absolute FullName). The one bare-Save branch left in that path ("the target is this document's own file") compares FullName, not Name: CATIA gives two open documents the same name, and a document with no path can never be "the same file".

A path can only hold one document

catia_new_part(file_path=X) twice on the same path used to leave two documents in the session: one holding the file (an empty part, saved before any geometry existed) and one with no path at all holding everything that was built. After that every document="P05" answered "matches 2 open documents", and the file on disk looked finished while containing nothing.

The second call is refused now — before the document is created, because there is no recovery afterwards. DOCUMENT_ALREADY_OPEN names the document that holds the path and the three ways forward. catia_new_product(file_path=...) gets the same guard, and the same argument, which it had been missing: without it "create an assembly" is two calls, and the one people forget is the one that leaves a product nothing can be added to.

An arc between two points takes the short way

catia_sketch_arc {cx, cy, radius, p1, p2, direction: "ccw"}
-> swept 355.6117° counter-clockwise, arc length 1608.4415 mm

for two endpoints 4.39° apart. ccw from −71.0° to −75.4° is 355.6°; the argument was doing its job on a caller who meant "a little bit the other way". The shaft built on that arc then failed with a bare UpdateObject 失败, and the only clue had been a parenthetical two calls earlier.

major is an argument now, default false: two endpoints give the short arc, and the reply names both sweeps so the choice is visible. major=true keeps the 240-degree arcs that are legitimate. Two endpoints at the same angle are refused rather than swept a full turn, and an exact half circle says that direction decides nothing there.

An arc's endpoint is where the circle puts it

catia_sketch_profile {segments: [73 elements, arc ends 0.004–0.096 mm off their circles]}
-> "every one of the 73 junction(s) meets within 0.0000 mm, so it should close"
catia_close_sketch
-> "not closed - 60 loose endpoint(s) in 30 break(s), smallest gap 0.0043 mm"

Both sentences were true about their own arithmetic. CATIA creates an arc from centre + radius + angles, so the point it draws is the radial projection of your p1 onto that circle; the profile check compared the p1 it was given, while close_sketch compared what was drawn. A fitted arc whose endpoints sit a few hundredths off the circle therefore reads as closed in one tool and as 30 seams in the other — and the pad is the one that decides.

catia_sketch_profile now rebuilds each arc's circle through its two endpoints and its own midpoint, so the ends that get drawn are the ends you asked for. Measured on B30 2026-11-12 with ends 0.05 mm off the circle: junction 0.0000 mm, close_sketch reports closed: true with no breaks, and the pad closes at 5736.1 mm³ against a nominal 5716.8 (+0.34 %, the radius having grown R20.0000 → R20.1720 to take up the error). The reply names every circle it changed; a change over 5 % is refused as bad data and the reply says where the ends will land instead. Arc endpoints that already sit on the circle are left alone and nothing is said.

Measured cost of the change: 0.05 mm of endpoint error costs ~3.4× that in radius (0.05 → 0.172 mm). For a profile that has to close, that is the cheaper of the two errors.

A T junction is not a self-crossing

'line #21' ends on 'line #22' at (52.7619, 20.0000)

That is the crossbar of the I in CATIA meeting its own stem — the third time a report read "THE OUTLINE CROSSES ITSELF" for geometry that crosses nothing. The check now separates three cases: a point in the interior of both segments is crossing (the real defect, and still the hard warning), a point at an endpoint of one is touch (a T junction or a hairline overshoot — nothing is crossed, but the outline is branched, so it is not one closed region for a pad), and a point at an endpoint of both is a corner and not reported at all. describe() gives each its own sentence, and the structured reply carries kind per entry.

Which way a frame normal points

normal = u × v, right-hand rule, and the sketch's H/V axes are u and v exactly as given; origin is in global coordinates. To put material on the other side, reverse v — not the profile, because mirroring the profile changes which side of the sketch the geometry occupies, which is a different operation wearing the same result.

A plan that answers with its own size

A 285-step plan returned 285 step records. The client truncated the reply, and the two failed steps were inside the cut, so locating them cost a re-run of the whole plan. catia_run_plan {summary: true} returns the failed steps only, plus failed_indices, steps_omitted and the totals — executed, measurements and assertions are unchanged. Without summary nothing changes.

Tools run on a single worker thread, so a long box blocks every other MCP call until it finishes. The reply states elapsed_ms (total and per part, parts[]), first_measure (was this part touched for the first time in this session), regime / session_first_parts / parts_total / cold_reasons / after_cold, cells_scanned, sweep_route (one_call / per_cell, or mesh when no vertex sweep ran), poses_route (one_call / per_component / single), parts_ms, overhead_ms and points_route (batched / batched_retried / per_point for a vertex sweep, mesh for an STL extent), so the cost is attributable instead of surprising. catia_get_bounding_box {warm_up: true} pays the cold pass on purpose and returns only the cost. To bound one instance, pass component= — with or without an assembly active: if the active document is a Part, the component is looked up in the open assembly that contains it, and the reply says so (measured_in).

Regime fields: one function, seven routes, one matrix test

regime is cold / warm / not_measured, after_cold is three values and never a missing key: null when THIS call was the cold one, true on the first call after a cold pass, false in the steady state. session_first_parts counts the parts whose first reading by this server happened in this call, out of parts_total, and cold_reasons breaks that count down by job (topology / mesh_export / mass_properties).

All six fields are produced by one function (catia_mcp.regime.SessionBooks.describe) and published by all seven measuring routes: bounding box on an assembly, on an empty product, and on one part; mass properties on an assembly, on an empty product, on a named part and on the active part. Before 0.3.39 they were written out three times and missing from four branches — each acceptance round found one or two of the empty cells, in the release that had just filled another pair by hand. tests/test_route_matrix.py now asserts the set, the arithmetic and the cost closure on every route, and reads the route= literals out of the source so a new branch cannot be added without a row in the matrix.

cold_parts was renamed session_first_parts, and each part carries cold_reason. The old name asserted a cause ("these parts were cold") that the count never measured: in the 2026-10-29 run 26 of 28 parts were no slower at all on their second reading, and the two that read 21×–26× were paying for an STL export, not for a build. The note beside the count no longer claims a cause either — it says what the book counts and points at this call's own fields. parts[].cold_ms and parts[].cold_warm_ratio give the two regimes per part once both have been seen (and parts[].cold_reason says which job the first reading paid for). They are the only numbers to quote: the notes publish no multiplier at all (not even a range), because every range this project wrote down was falsified by the next session — 1.15×–1.81× for 12-cell parts became 1.93×–3.70×, above its own ceiling, one day later. The rule, adopted 2026-10-29: a range is published only if it holds in three independent sessions; otherwise mechanism plus the field. A warm call does still quote this session's own difference against the previous warm call on the same document — only when both call measured the same scope. Until 0.3.39 it quoted one anyway, and a component= narrowing (1 instance) compared against the whole 70-instance assembly read as −67 %, which is the scope, not the machine. Two different scopes get a sentence saying so instead of a number.

component_lookup_ms (narrowed calls only, published beside overhead_breakdown rather than inside it) is the share of components_doc_ms spent matching the name you passed against every component — 303 ms of a 70-instance narrowed call for one pose read, which is three times a single unfiltered read. It sits outside the block on purpose: that block promises "these three add up to overhead_ms", and a fourth key made sum(overhead_breakdown.values()) over-count silently (reported 2026-10-28, P3). catia_get_inertia publishes the same parts_ms / overhead_ms / overhead_breakdown / poses_route vocabulary on the assembly route and on both part routes (measure_ms, tensor_ms, parts_other_ms), so the two aggregators can be compared on one model, and "174 ms here, 2 117 ms there, same tool" is answered inside the reply. On the part routes the three terms add up: measure_ms + tensor_ms + parts_other_ms = parts_ms, where the last one names the remainder (main body, shape count, density arithmetic, building the reply) that used to be 18–19 % of parts_ms with no field to attribute it to.

The assembly inertia route answers the regime question with the same six fields, plus parts[] (per part, with first_measure, cold_reason and, once both regimes have been seen, cold_ms / cold_warm_ratio). Its book is deliberately separate from the bounding box's — mass properties and the vertex sweep do not warm up together — so a part can be warm for one and cold for the other.

A narrowed call (component=) reads the component list once. Until 0.3.38 it re-read the whole list — placements included — to answer "is this part also nested in a sub-assembly?", and did it after elapsed_ms had been stamped, so the reply was short by that time and the narrowed call could cost more than the full scan it replaced. On a 10-instance assembly that was 340–360 ms of component reading against 170–195 ms now, and 247–288 ms of unreported tail against 93–118. So the advice stands, with its true shape: component= saves the per-instance scan, and it no longer pays a hidden second read for it.

catia_list_components filters before reading any placement, and names_only: true skips them entirely (measured: 370 ms → 32 ms on a 5-instance assembly, 94.4 ms for 24 instances) — "are the instances all there" should not cost one pose read each. With names_only, no entry carries a convention: the key describes a rotation, and there is none in the reply. On a Part document it says that no assembly is active (and which ones are) instead of "No components in the active assembly". occurrence there is the ordinal within the part (occurrence_scope: "per_part"), which is what {part_number, occurrence} resolves against; index is the position in the assembly.

Added for looking at the assembly (3 tools)

Tool

Version

Why it exists

catia_colorize_assembly

0.3.79

An assembly built from a text description is checked with a screenshot, and until 0.3.79 every part came out the same grey: catia_set_visibility drove Selection.VisProperties for the show flag and the other half of the interface was never touched. One colour per part by default (instances of one part share it - that is what "colour by part" means), one per instance on request. Colours never wrap around the palette: past 12 parts they are generated, because two parts of one colour is the one outcome this tool must not produce. Measured on B30 2026-11-08: two instances of one part carried (220, 30, 30) and (30, 120, 220) at once, both survived save + close + reopen, and the .CATPart itself still read its default. The colours are visible on screen, not only in the property - which took a screenshot and a pixel count to establish, twice

catia_set_color

0.3.79

One target, two meanings, and the reply always says which: in an assembly it is an instance colour (stored in the CATProduct, the other instances and the part file untouched), in a part it is a part colour (stored in the CATPart, inherited by every feature inside the body). A geometrical set colours its contents too, because a set's colour does not reach them - each element carries its own, exactly like visibility. Selection.Add(part) on the Part node is accepted and reads back (0,0,0): the Part node is not a colour target, the body is

catia_get_color

0.3.79

What colorize_assembly produced, and which instance did not take. It reports hex + RGB and flags a value matching this build's uncoloured default, measured (210, 210, 255) - not white. That flag is evidence, not proof: the interface reports the DISPLAYED colour, so a part deliberately set to that exact value reads the same way, and the reply says so instead of claiming "not set"

Colour: the three signatures that are not the documented ones

Measured on V5-6R2020 B30, 2026-11-08. All three are in catia_mcp/colours.py once, so catia_list_components(include_color=true) and the colour tools cannot drift apart:

  • VisProperties.SetRealColor(R, G, B) is refused with 无效的参数数目 ("invalid number of arguments"). The build wants a fourth value, and it is not a dummy: it is a status flag whose even values (0, 2) store the colour and leave the view untouched, while odd ones (1, 3) apply it. Measured by colouring a component red and sampling the screenshot after each: with 0 the histogram was byte-identical to the uncoloured one, with 1 the solid came out (174, 24, 25). GetRealColor answers the new value in BOTH cases, so a wrong flag reports success on a gray assembly - which is exactly what this feature's first version did, and why the acceptance script now reads pixels instead of trusting the read-back.

  • VisProperties.GetRealColor() returns four values with the RGB last: after SetRealColor(220, 30, 30, 0) it answers (0, 220, 30, 30). Slicing [0:3] reports black for every colour - the same trap as GetShow() returning a 2-tuple whose flag is at [1], which cost a release in 0.3.73.

  • An untouched part reads (0, 210, 210, 255), i.e. RGB (210, 210, 255), not white. A part-level colour is stored in the CATPart; an instance colour in the CATProduct. Nothing is saved by these tools - both need catia_save_document, and the replies say so.

Related MCP server: catia

Requirements

  • Windows (COM automation; pywin32 is a hard dependency)

  • CATIA V5 installed and licensed — verified on V5-6R2020 (B30), Simplified Chinese build. Two known build differences:

    1. catia_gsd_multi_section_surface fails on V5-6R2022 (B32) for every configuration (reported 2026-10-23), while the rest of GSD — including catia_gsd_blend on the same curves — works.

    2. A threaded hole cannot be cut on B32. catia_hole(threaded=true) sets ThreadingMode alone, which threads on B30 and does not on B32: there the update fails, the feature is removed again and the call answers FEATURE_INCOMPLETE / execute with rolled_back and the measured modified (reported on B32, 2026-11-05/06; also stated at the threaded argument itself). B30 reports NO_MATERIAL before the write instead, so the author's machine cannot reproduce it. Registering the limitation is not a fix — a real one needs ThreadStandard/Pitch probing on a B32 seat.

    Everything else in the tool list is B30/B32-agnostic as far as is known.

  • Python 3.10+

Install

git clone https://github.com/wudahua/MCP_CATIA_MechDog.git
cd MCP_CATIA_MechDog
python -m venv .venv
.venv/Scripts/pip install -e .

# offline test suite, no CATIA needed
.venv/Scripts/python -m pytest tests -q

# the smoke check: 139 tool definitions == 139 routes
.venv/Scripts/python tests/test_server.py

Both commands run the same equality assert since 0.3.67. Until then the smoke script kept its own list of nine modules and reported "108 tools defined but 127 in router" while pytest stayed green — the strong check was the one CI never ran. The scan now reads the server's own module list, so a new module is covered the moment it is registered (tests/test_reported_issues_2026_11_05_v0367.py).

Do not install this into the same virtualenv as upstream. Both provide the catia_mcp import package, so one silently shadows the other.

Updating an existing checkout

git pull is the safe route. If you update from a downloaded archive instead, do not copy it over the old directory with cp -r src/* dst/ — a shell glob (and Windows Explorer) skips every dot-prefixed entry, and four of them carry things nothing warns you about:

Path

What its loss costs

.gitattributes

setup.sh is delivered CRLF and dies on line 1

.github/workflows/ci.yml

the only CI, i.e. the tool-count consistency tests

.claude/skills/**

the maintainer / production-operator skill packs

.gitignore

build output starts showing up as untracked

Two ways to stay safe:

# 1. the packaged release (dot-files included, sha256 next to it)
python scripts/package_release.py          # -> dist/MCP_CATIA_MechDog-<ver>.zip[.sha256]
# 2. or copy correctly, from inside the extracted folder
cp -r src/. dst/                           # the "/." is what keeps dot-files

tests/test_release_archive.py fails if the packaging script ever stops including them.

Neither route touches a file that a running server holds — which the launcher .exe cannot say for itself: if pip install -e . dies on Device or resource busy / safe-delete, the client has the old catia-mcp.exe open. Close it first, or switch the client to the python -m catia_mcp form below.

Connect your MCP client

python -m catia_mcp is the recommended entry point: it runs the package and never touches a launcher .exe:

{
  "mcpServers": {
    "MCP_CATIA_MechDog": {
      "command": "C:\\path\\to\\MCP_CATIA_MechDog\\.venv\\Scripts\\python.exe",
      "args": ["-m", "catia_mcp"]
    }
  }
}

The installed catia-mcp.exe is equivalent and works too, with one caveat: on Windows a running executable is locked, so upgrading the package while a client holds the server open fails — pip deletes the old launcher before writing the new one, and the delete is refused (safe-delete / trash-failed / Device or resource busy), which aborts the whole transaction and can leave a ~cp_catia_mechdog-*.dist-info husk behind. Reported 2026-10-23. Either point the client at python -m catia_mcp (the .py files are read into memory at import, so replacing them under a live process is fine) or close the client before upgrading:

{
  "mcpServers": {
    "MCP_CATIA_MechDog": {
      "command": "C:\\path\\to\\MCP_CATIA_MechDog\\.venv\\Scripts\\catia-mcp.exe"
    }
  }
}

catia-mcp --http serves Streamable HTTP at /mcp for a remote agent; see docs/remote-access.md.

Usage: verify while you build

A plan is data, not conversation. This one builds a 40×20×10 block and proves it:

{
  "steps": [
    {"tool": "catia_new_part",         "args": {}},
    {"tool": "catia_create_sketch",    "args": {"plane": "xy"}},
    {"tool": "catia_sketch_rectangle", "args": {"x1": -20, "y1": -10, "x2": 20, "y2": 10}},
    {"tool": "catia_close_sketch",     "args": {}},
    {"tool": "catia_pad",              "args": {"height": 10, "direction": "normal"}}
  ],
  "expect": {
    "volume_mm3": 8000, "area_mm2": 2800,
    "faces": 6, "edges": 12, "vertices": 8,
    "cog": [0, 0, 5], "cog_tolerance_mm": 0.01
  }
}
ok: True | code: PLAN_OK | message: 5 steps executed, 6 assertions passed

Counts are compared exactly; volume and area get a percentage tolerance. The asymmetry is deliberate: a volume-only judge passes a part that grew an isolated boss, cut a pocket the wrong way, or drilled a hole into air — one field case missed a wrong part by −0.24 % while the face count was 30 instead of 26.

Placement from a spec that quotes P_asm = A · P_local + T — pass A row by row, with the default rotation_convention:

{"tool": "catia_set_component_transform",
 "args": {"component_name": "P05_CylinderCap",
          "rotation_rows": [[0, -0.544639, -0.838671],
                            [0, -0.838671,  0.544639],
                            [-1, 0, 0]],
          "translation": [0, 0, 402.5]}}

Specs that instead say "R's three rows are the direction cosines of local X/Y/Z in global" — the form CATIA's own position dialog shows — are P_asm = Rᵀ·p + T, so say so and copy the table verbatim:

{"tool": "catia_set_component_transform",
 "args": {"component_name": "drone stand I015",
          "rotation_rows": [[0, 0, 1], [1, 0, 0], [0, 1, 0]],
          "rotation_convention": "basis",
          "translation": [46.551, 388.598, 543.367]}}

The reply includes max_deviation, det, orthogonality and basis_rows — the last one is the only field that can catch a wrong convention: the two spellings are transposes of each other, both keep det = +1 and both are orthonormal, so a transposed placement passes every other check (max_deviation only proves CATIA stored what you sent). One field case: a quadcopter's landing stands came out lying flat and its motors tilted 60° while every self-check reported success.

A batch is one convention, not one per row: placements=[...] gives every entry the top-level rotation_convention unless the entry sets its own, and the reply says how many inherited it (rotation_convention.inherited_by). Before 0.3.40 the batch branch returned before that argument was read, so a basis table placed as a batch went in transposed and the per-entry reply still said convention=matrix — true, and useless. One acceptance build placed 32 of 80 instances wrong that way; the 48 mirrored ones hid it, because a mirrored rotation is symmetric and its transpose is itself.

Counts, volumes and bounding boxes do not see orientation

Three defects of the last two weeks — a transposed placement (0.3.40), a circular pattern that translated its copies instead of turning them (0.3.42), and a bounding box that measured a GSD surface nothing had cut (0.3.39) — share one property: the number that was checked stayed correct. Volume is invariant under a rigid transpose. A pattern's volume is count × seed whether the copies turn or not. A symmetric seed's envelope is the same whichever way it faces.

So check the invariant the operation is supposed to produce, not the total:

operation

what to assert

why the obvious check fails

catia_circ_pattern

bbox_y == bbox_z and y_max == -y_min for an n-fold pattern

volume is count × seed in both modes

rectangular pattern

x_max − x_min == (n−1)·pitch + seed_w

same

catia_set_component_transform

per-instance centroid, or basis_rows from the reply

48 mirrored instances make a transpose invisible

mirror / symmetry

the mirror plane's coordinate is unchanged, the off-plane one flips

volume and face count are identical

Two rules that follow, and cost nothing to follow:

  1. Use a seed without rotational symmetry. A cube, a cylinder and a disc cannot tell "rotated" from "moved to that angle" apart — their envelope is the same either way. A 400 mm bar can. One acceptance probe measured ±505 on a cube and looked correct for exactly this reason.

  2. Prefer per-instance readings to aggregates. An aggregate averages what it sums and cancels what it mirrors; a centroid per instance does neither.

These are the checks an acceptance round can run in one call each. They are also the reason catia_circ_pattern now says which mode produced its copies: a reply that states its own assumptions can be contradicted by the next measurement, while a reply that only reports success cannot.

From a drawing to a sketch (0.3.128)

catia_read_drawing answers "what is in this file" without touching CATIA: format, the file's own units and the resulting millimetres per unit, contour and element counts, which layers the geometry sits on, how small the smallest contour is (text and arrowheads are tiny), the bounds in mm, and how much text was drawn as TEXT and therefore cannot be read. catia_import_drawing parses the same way and draws the result into a sketch at 1:1, one call, closed at the end.

DXF (ASCII), SVG and vector PDF are read with no external library and no rasterising step. Lines stay lines, arcs stay arcs - DXF bulges included - and circles stay circles, so a pad on the import is not faceted. Measured on B30 2026-11-14: an SVG plate 40 x 20 with a D6 hole padded to 7 717.26 mm³ against the analytic 7 717.26; a DXF stadium with bulge=1 caps padded to 5 570.80 against 5 570.80, where chord-approximating the caps would have given 4 000; a 1 x 0.5 inch rectangle read through $INSUNITS landed at 25.4 x 12.7 mm; the same rectangle with scale=100 (a 1:100 sheet) came out 100 x 50 mm and padded to 10 000.

Two facts about real drawings that this had to accept:

  • A CAD export writes one entity per EDGE, not one per profile. The A3 sample in this workspace parses to 672 contours of which 83 are closed - and all 83 are 3.4-4.6 mm arrowheads. So the parsers stitch edges that share an endpoint; where more than one unused edge meets a joint they leave it open and report the count rather than guess, because a wrong guess is a profile the drawing never had.

  • Text is not converted. A glyph's outline lives in the font program. A drawing exported with its text converted to curves has no text runs and imports whole; one with live text imports the geometry and says how many runs it skipped. DWG and raster images are refused with the way out - re-save as DXF, and a scan has no geometry to read.

Tool reference

Group

Tools

Notes

Document

12

new/open/save/close/list + catia_save_as_copy; catia_save_document retries around dialogs

Sketcher

19

lines, rectangles, circles, constraints; reopen a closed sketch and delete one element; catia_sketch_copy replays a profile onto another plane; catia_sketch_strokes draws a whole letter outline in one call, and catia_text_strokes supplies those outlines from a built-in single-line font (it can hand the points back, and always reports the box the text occupies; its baseline runs along the sketch's own +H); catia_sketch_ellipse draws a slanted ellipse in one curve AND makes both semi-axes dimensional constraints (so a user parameter drives them - set_parameter on 7 undriven ellipses changed nothing, measured 2026-11-14); catia_set_constraint_value changes one sketch dimension and reports what moved; catia_sketch_profile draws a MIXED outline - straight runs and arcs, each with its own primitive - in one call, and checks the junctions on the endpoints that will be DRAWN (it rebuilds each arc's circle through them)

Part Design

26

pad, pocket, groove, fillet, chamfer, hole, patterns, mirror, shell, solid loft, bodies (create / list / delete)…

Generative Shape Design

40

wireframe and surfaces, extract, boundary, heal, fillet_surface, spine, rotate, translate, circ_pattern (rotate+join), plane_normal_to_curve, plane_angle, revolve + close surface, and catia_surface_engrave (a constant-depth groove along a curve on a curved face, with curves=[...] for a whole word in one boolean); catia_solid_tube sweeps one circle along a curve into a closed solid (the six-step bend in one call); catia_gsd_splines builds a whole section chain in one call (17 calls become 1, a mid-chain refusal reports what exists instead of rolling back, and source_file= reads those sections from a JSON or CSV file); catia_solid_sweep_variable sweeps a RADIUS TABLE along a spine - one profile per station, one loft through them, judged against the ruled-cone sum (8 168.2 mm³ against 8 168.1 on a 50 mm cone); since 0.3.126 a station may be an ELLIPSE ([[t, rx, ry], ...], semi-axes), built as a closed spline through 64 points that lie on it because GSD has no ellipse primitive - measured at -0.001 % of the exact perimeter, and 1 099.56 mm³ against the analytic 1 099.56 for a 10x6 -> 5x3 loft; catia_measure_deviation samples a curve along its length and reports how far each sample sits from a body or a face; a GSD element or set is removed with catia_delete_feature

Assembly

14

upstream 9 + get/set component transform, rename, list constraints, copy component, replace component

Appearance

3

catia_colorize_assembly, catia_set_color, catia_get_color - per-instance colour in an assembly, per-node in a part

Measurement

8

inertia, distance, bounding box*, parameters, catia_check_surface, catia_shape_audit

Export

4

STEP / STL / IGES hand-off

Knowledge

6

parameters, formulas, relations, design tables - upstream's five, measured and corrected, plus catia_apply_design_table

Drawing

13

upstream's drafting module, measured, plus catia_drawing_set_sheet and catia_drawing_add_frame: new drawing, generative view (plane / face / body), projection, section, detail, text, table, generated and manual dimensions, paper size, border and title block

Planner

2

run_plan, measure_topology

Diagnostics

2

catia_diagnose (full probe: factories, type libraries, ceilings), catia_server_info (0.3.127 - name, version, tool count, CATIA release; no probes, never connects)

Blueprint

2

catia_read_drawing (0.3.128 - DXF/SVG/vector-PDF to contours, counts, layers, bounds in mm; never connects), catia_import_drawing (0.3.128 - the same, drawn into a sketch at 1:1 and closed)

Knowledgeware (6 tools, 0.3.63)

Upstream's five were dropped when this fork was cut, and came back after each was run on B30 (docs/upstream-diff.md §8 explains what that costs). Four things in upstream's code did not behave as written, so the port is a rewrite:

Tool

What it gives you

What upstream had wrong

catia_create_parameter

Six types; an existing name gets its value set

bool("false") is True in Python, so a boolean sent as text was inverted. Parsed properly now, and unknown text is refused rather than guessed

catia_set_formula

A formula driving a parameter, by name or by the parameter's full path

The target lookup is exact and LOCALIZED: a Chinese build names a pad's height 零件几何体\凸台.1\第一限制\长度, so the English path in upstream's own docstring is not found. The failure now lists near matches

catia_list_relations

Formulas, tables, rules, checks with their bodies

— (a relation's Value really is its expression; measured)

catia_delete_relation

Delete one by name

—

catia_create_design_table

A table from a sheet, associated to parameters by column header

It could create a table and never apply one - see the next row

catia_apply_design_table

Switch the part to row N of a table

New: upstream calls ConfigurationName, which the DesignTable interface does not have. The real member is Configuration, it is writable, and writing 2 on a two-row sheet moved the part (LEN 20 → 30)

A length column without a unit is read in METRES. A sheet row reading 30 set a 20 mm parameter to 30000 mm; 30mm and 0.03m both gave 30. Both the create and the apply reply say so, because the alternative is a part 1000x too large with a success message.

Drafting (13 tools, 0.3.64-0.3.65)

Upstream's module, ported only after all eleven tools were run on B30. Four things did not survive the measurement, so this is a rewrite of those paths:

Tool

What it gives you

What upstream had wrong

catia_new_drawing

A .CATDrawing with the default sheet and its Main/Background views

—

catia_drawing_add_view

Generative front view of an open part: by plane (0.3 s for a plate), by planar face (~30 s), or one body only

Face-driven projection could not work here: it searched "Topology.Face,sel", which is English - a Chinese CATIA raises 方法 Search 失败 and answers 拓扑.面,sel - and its indices were the last FEATURE's, so Face.N meant different geometry to this tool and to catia_list_faces

catia_drawing_projection_view

right/left/top/bottom/rear, placed by the sheet's first- or third-angle method

— (ProjectionMethod reads 0 = first angle on a fresh sheet, as assumed)

catia_drawing_section_view

Section view or cut from a polyline profile, Offset or Aligned

—

catia_drawing_detail_view

Circular detail at a scale

—

catia_drawing_add_text / _add_table

Text and filled tables, incl. cells and per-column widths

—

catia_drawing_generate_dimensions

One associative dimension per 3D sketch constraint, DimStatus 7 = 3d_driven, follows the part

Its "no dimension" note told you to add a length constraint, which catia_sketch_constraint does not have (a length is distance with one element)

catia_drawing_list_dimensions

Values, type codes, basic_2d vs 3d_driven

—

catia_drawing_list_view_geometry

Indexed 2D elements of a view

Index 1 is the view's absolute axis - the same trap as a sketch

catia_drawing_add_dimension

A dimension on hand-drawn 2D geometry

Index 1 reached Dimensions.Add and CATIA answered a bare 方法 Add 失败; a pairing the geometry cannot carry (a horizontal line with a vertical one) was forwarded as that same error

catia_drawing_set_sheet (0.3.65)

A0-A4, landscape/portrait, sheet scale; replies with the size CATIA reports

Paper on a new sheet is catPaperUser (13) and GetPaperWidth raises until a real size is set; the ANSI names in the enum are refused by CATIA

catia_drawing_add_frame (0.3.65)

ISO border (20 mm filing margin) + an 8x5 title-block table with merged fields, plus notes above it; labels in Chinese by default (0.3.66)

New: upstream stops at "frame/title-block generators are the next step". Tables.Add anchors the TOP-left and grows down - the first version put the block below the sheet

Two limits worth knowing before drawing: a generative view cannot be hand-dimensioned at all - its curves are not addressable through automation, only GenerateDimensions reaches them - and Face.N is session-scoped. The same plate gave its hole's cylinder at Face.1 in one build and at Face.7 in another, so list faces and use the index immediately.

0.3.78: the sign my own audit could not see. 0.3.77 fixed dir1_axis and verified it by comparing dimensions — which cannot see a sign, so a mirrored pattern grew the same 200 mm and passed 12/12. The acceptance read the box endpoints and found every label inverted. Measured again on endpoints, the two slots are different: slot 1 takes the plane's H axis, slot 2 its V axis, and both run backwards — so axis x+ now really goes to +X. Also from that report: a new sketch whose name the part already uses is renamed (CATIA's numbering does not check, and every by-name tool then picks one of two), run_plan keeps a warning untruncated in step_warnings and always publishes that field, catia_open_document reads back which file it actually opened, and catia_gsd_create_geoset gained parent, without which 0.3.77's recursive visibility descent could never run. The loft's failure also stopped drifting between codes: it answers LOFT_REJECTED.

0.3.77: dir1_axis was one axis off, and a loft that twists now says so. An 85 m frigate built from a text description turned up two silent geometry defects. catia_rect_pattern mapped x to PlaneYZ on the assumption that a plane reference gives its normal; measured on B30, a plane reference drives the plane's own horizontal axis, so dir1_axis='x' moved the copies along Y — and the ok:true came with a WARNING that had been standing in for a fix since 2026-11-02. The table is the measurement now. And two loft sections holding 59 and 60 points were paired by CATIA by parameter, bulging the stern +49.8% while the volume looked reasonable: catia_solid_loft reads the drawing record, prints every section's curve count, and warns when they disagree. Also: a frame sketch can be re-created after a rollback (the record said base, the resolver read type), pocket(direction='normal') flips like pad instead of failing, hiding a geometrical set reaches a set inside it, and a part that cannot be saved is closed again rather than left pathless.

0.3.109: the outline's self-crossings, at close time. catia_close_sketch now reports where the profile crosses itself, computed from the same drawing record it uses for the outline echo and the break list — data.self_intersections, plus data.self_intersection_checked so an empty list cannot pass for a clean bill of health. Measured on B30, one document per cell: a four-segment figure-eight answered "THE OUTLINE CROSSES ITSELF in 1 place(s): 'line #1' crosses 'line #3' at (5.0000, 5.0000)" and its pad then reported UNVERIFIED rather than a volume; the 0.3.108 stadium answered "does not cross itself (4 segment(s) checked)". Elements are numbered in the order they were drawn, because that is the only handle a closed sketch gives back.

0.3.76: the full gap list, as data. catia_close_sketch takes an optional structured=true and answers data.breaks[] — every loose endpoint pair with its gap in mm — because the sentence carrying them was truncated by clients (1242 chars on a six-arc outline in the 2026-11-06 report). The default is still the unchanged text reply, so no caller has to change. data.breaks.closed is true, false, or null (no coordinates were recorded in this session — a sketch reopened from disk, which is not a closed sketch). Measured on B30: 14/14, the 0.0002 mm break came back intact and the pad still gives 4000.0 mm³.

0.3.75: a non-ASCII file name is refused in both directions. catia_save_as_copy can write 副本.CATPart, but Documents.Open cannot read it — so the "save ASCII, rename afterwards" advice this package had given since 0.3.71 produced files CATIA will never open. It is gone from all five places; the reply now says keep the file name ASCII (the folder may be Chinese) and put the Chinese wording in the part number. catia_open_document refuses such a name before CATIA sees it, because the failed Open leaves a modal incident-report window on the automation thread.

0.3.74: "already open - activated it" was a guess. catia_open_document called Activate() and reported success. Activate() also returns for a document with no window of its own — a part edited in context as a component of an open product — and leaves ActiveDocument where it was, so the next tool answered "ActiveDocument 'Drawing1.CATDrawing' exposes no 'Part'" while catia_activate_document refused the very same part. Both tools now share one read-back, and the advice they print says what works (close the product, or measure by document=) instead of pointing at the call that cannot help.

The frame sketch's datum plane is no longer a white diamond over the solid: it gets a free name, is hidden when created, and catia_set_visibility can find it afterwards — the by-name walk used to skip body.HybridShapes, where these features live, and every frame plane was called Datum_frame (the reporter's P04 holds four). set_visibility sets every node answering to the name and says how many others there were. A CATDrawing also stopped being typed Other in catia_list_documents, and the SaveAs message stopped blaming the folder: only the file name matters, measured in all four combinations.

0.3.73 closed the last two items of the 2026-11-06 batch. matches_request was deviation < 1e-6, which is a bit-equality test: a placement built from 4-decimal unit vectors deviates 1e-5…1e-4 by construction, so 13 of 25 correct placements answered ok: true beside matches_request: false. It is a tolerance now (tolerance, default 1 micron, published in the reply, inherited by every entry of a batch), and a tighter value still reports the same deviation honestly.

catia_set_visibility answered PRECONDITION_FAILED on a Product — "exposes no 'Part'" — so cleaning 11 parts before a screenshot took 33 calls. It walks the component tree now (sub-assemblies included, one entry per part) and applies the change through each part's own Selection, so the assembly stays in front; the reply says how many of how many parts it touched.

0.3.72 made the assembly envelope usable, and made its cache arrive in time. method='mesh' on a Product has worked since 0.3.45; the schema entry for document= still said it was refused, so a 2026-11-06 report read the text and concluded there was no official way to get a precise assembly envelope. The text is fixed and a test holds it to the behaviour.

The reply also stopped being two answers at once: complete: true means every instance got a box, bounds said the boxes came from a vertex sweep — true, and unreadable together. exact and bounds_code (mesh_chord / vertex_sweep / mixed / complete_failed / empty) now say how the numbers can be short. On the acceptance product auto returns x max 205 against a true 220 and says exact: false rather than leaving the caller to reconcile prose.

The per-part mesh cache had existed since 0.3.32 and saved nothing, because the lookup ran after part.Update() and _edited_in_context — 4.2 s of window switching (Activate 2.4 s + restore 1.8 s) around a 10 ms export, paid again on a call that then answered from the cache in 0.6 ms. It is now consulted before any COM call and gated on Document.Saved: a clean part is answered from its file in ~16 ms, a part with unsaved edits is measured and explicitly not cached. Same product, reopened from disk: 32 938 ms → 184 ms.

0.3.71 closed the last two exemptions and fixed four replies that lied. Nine lookups raised inside an except still answered COM_ERROR / execute / modified:null — including "No active document in CATIA", which is every tool call of a cold session — and seven CodedErrors carried DOCUMENT_NOT_FOUND / SKETCH_NOT_FOUND at the execute stage for a typo. Both are validate now, and two static tests hold the line: except-block raises that print a list to choose from must carry a code, and no validate-family code may arrive without stage="validate".

The other four were replies stating a cause the code never measured: seam_hint was popped for a surface patch and added straight back two lines later (so a reply carried "cross_wall is NOT reported" beside "38 creases run THROUGH the wall"); catia_close_sketch called a plate with a hole self-intersecting because it compared bounding boxes; a threaded hole that failed the update left a zombie feature behind and reported modified:null; and document= spent 790 ms computing, then explaining, a tensor that route cannot return (11 ms with one document open). Saving to a non-ASCII path no longer hides inside a successful reply — catia_new_part / catia_new_product raise SAVE_FAILED with modified:true instead of letting catia_run_plan mark the step done.

0.3.70 finished it in the core layer. The same scan over the package rather than its tools directory found eleven more, and they were the ones a caller can do nothing with: "Duplicate tool: X", "unknown guarded operation", "route= is not one of the 7 measuring routes", the "unreachable" branch of the topology enumerator. Reported as COM_ERROR they send someone to inspect a model for a bug that lives here. They carry INTERNAL_ERROR and a fourth stage, internal, whose next action is "report the tool and this message" - no recovery block, because there is nothing to repair.

Two more were states, not failures: "the active document exposes no 'Part'" and "the active body has no solid shape yet" are PRECONDITION_FAILED, and dialogs.save_as/save — six attempts around a modal dialog, then a real failure — is SAVE_FAILED at the execute stage. The structural guard now walks catia_mcp/**, so a core-layer raise RuntimeError fails CI the way a tool's does. Zero left.

0.3.69 finished that sweep: 169 raise sites, no register left. 0.3.68 converted twenty and registered the other 81 in a JSON baseline; 0.3.69 converted all of them, and the 88 more that the old keyword scan could never see. The count went up because the measurement was wrong, not the work: "Pass either 'points' or 'element', not both" and "No active sketch" are refusals too, and they contain none of the words ("must", "unknown", "requires") the scan matched. Every one of the 169 now answers ok:false with failure_stage, modified, and what to fix.

Three classes, because the fix differs: ArgumentRejected (a typo - change the value), PreconditionRejected (the model is not in that state - build or open what the message names, and changing the argument will not help), and ArgumentOutOfRange (enumerate first). A failure that really is CATIA's answer keeps stage:"execute" and gets its own code instead of COM_ERROR: NO_VISIBLE_GEOMETRY, COM_RESULT_UNEXPECTED, FEATURE_INCOMPLETE, ACTIVATION_FAILED, COMPONENT_NOT_ADDED, CLOSE_NOT_CONFIRMED. All three refusal classes subclass ValueError as well as RuntimeError, so the handlers written against the old types keep working.

The guard is structural now, in tests/test_argument_envelopes.py: a raise inside an except re-wraps a CATIA failure (execute stage); a raise anywhere else is a decision the tool made, and a decision carries a code. There is no baseline to add to, because there is nothing left to register.

One thing this release found the hard way: sketcher and document raised PreconditionRejected before importing it, and the caller saw COM_ERROR / execute with the message 'PreconditionRejected' is not defined - a NameError wearing an envelope. pytest was green throughout, because no test walked those two paths; the B30 acceptance script found it. test_every_class_a_tool_raises_ is_reachable_from_that_module now parses the package and fails on any raise whose class is not imported in that module.

0.3.68 closed the refusal-envelope gap the new modules reopened. 0.3.43 gave every pre-execution refusal one shape (ok:false, failure_stage:"validate", modified:false, no recovery, next actions) by converting fifteen raise sites by hand — and missed the very call its own table used as the example. Knowledge (0.3.63) and Drafting (0.3.64–0.3.66) then started from the old shape again. Thirteen sites in Drafting, five in Part Design, one in Document and the knowledge parameter path now raise ArgumentRejected; what schema can express (language enum, exclusiveMinimum on margins and block size) moved there.

The guard is tests/test_argument_envelopes.py: an offline matrix asserting the envelope for every refusal the fix touched, plus a static scan that fails CI when a new bare raise starts talking about an argument. The 81 sites it could not reach were registered in tests/argument_rejection_baseline.json as the honest remaining count — a register 0.3.69 emptied and deleted (see above); the scan matches structure now, not wording.

A server-layer inference ("a bare ValueError means a validate-stage rejection") was tried first and reverted within the hour: test_server_runtime raises ValueError("Wrong sketch") as its ordinary-failure stand-in, and the inference reported that as modified: false. The envelope is declared by the raise site, not guessed at the boundary.

0.3.66 made the sheet furniture Chinese by default. The English was never CATIA: the labels were hard-coded and so were the default view names. Field labels are now 材料 / 比例 / 制图 / 日期 / 审核 / 张次 and views default to 主视图, 右视图, 俯视图, 剖视图, 局部视图 - a view's Name is printed on the sheet, so this is visible output. language: "en" restores the ISO wording, and labels: {"checked": "校对"} changes a single field. Chinese view names work as handles (parent_view: "主视图" resolves on B30).

The odd glyphs (1 looking like н, so 1:1 reads as 1|1) are CATIA's ISO single-stroke font SICH, not a translation problem. Both SetFontName and TextProperties.FontName accept 宋体/FangSong/SimHei and read them back, but the exported PDF renders all of them in SICH - four lines set to four fonts came out identical. Changing it means changing the Drafting standard in CATIA.

0.3.65 added the frame (catia_drawing_set_sheet, catia_drawing_add_frame). Three things had to be measured rather than assumed:

What

What CATIA actually does

A new drawing's paper

It is catPaperUser (13), not A3 - an oversized custom sheet. GetPaperWidth raises until a real ISO size is set, so a frame drawn first lands on an unmeasured page

Paper values

CatPaperSize names Letter/Legal/A..F too, and every one of them raises on this install (values 0, 1, 7-12). Only A0=2 … A4=6 and User=13 write. bestfit orientation is accepted and silently becomes landscape

Tables.Add(x, y)

Anchors the TOP-left corner and grows downward. Passing the bottom margin drew the whole title block below the sheet and still printed a position

The title block is a real table - 8 columns × 5 rows merged into title / drawing number / material / scale / drawn / date / checked / sheet / footer - so it stays editable in CATIA: SetCellString, SetCellAlignment, MergeCells, SetRowSize all confirmed. Border margins are ISO 5457 (20 mm filing margin, 10 mm elsewhere). Zone markings and the ISO corner grid are not drawn, and the tool does not move views - it reports the drawing area (x 20..410) so the caller can place them; one view landed on the border when it was not.

* catia_get_bounding_box measures two ways, and auto takes the STL mesh: exact on planar-faced solids, a lower bound ~0.5-0.7 % small on curved ones. method="vertices" is the cheap sweep, exact only when every face is planar (a revolved face has no vertex at its extremum, which is how an auto box once came out 37.5 % small). On an assembly the two routes differ, and it matters: auto / vertices read each component's vertices and fall back to a mesh only for a part with no vertices at all, so a part whose extremum sits on a curved face is under-reported there — measured 2.392 mm and 8.99 mm on a 20-part bracket, in the two directions its acceptance was being checked in. method="mesh" takes every part's mesh, one per distinct part rather than per instance, which is what you want when a criterion is measuring an envelope: the reply says which route ran per part (source: "mesh") and why (mesh_reason), and the counters part_boxes_by_mesh + part_boxes_by_vertices add up to part_boxes.

Per-part document="<part>.CATPart", method="mesh" still works for one part at a time — the part is opened by path if it exists only as a component, and exported from a temporary copy when CATIA refuses to export it while it is edited in context (that refusal is a modal dialog, and reads as a bare [WinError 2]). Place the result with catia_get_component_transform.

Known limits

  • A curve projected onto a CURVED surface cannot split or fill it — but it CAN engrave it. Measured on B30 2026-11-10, three ways: catia_gsd_split(element=<the extracted bottle skin>, cutter=<projected curve>) fails, the same call with the projection's own extract fails, and a projected CIRCLE fails identically; catia_gsd_fill(boundaries=[<that curve>]) answers FILL_NOT_CLOSED; and catia_gsd_thick_surface on the skin fails at every thickness tried (0.4/0, 0.2/0.2, 0.1/0.1). The same closed curve on a PLANE patch splits cleanly (1486.903 mm² kept), so this is the curved case, not the curve. Note also that gsd_split's "closest approach" number is a MINIMUM - it is 0.0000 mm for a curve that merely touches the surface once, so it cannot tell you whether the contour follows it.

    The route that does work is catia_surface_engrave (0.3.98): a plane normal to the curve, a circle of radius = depth on it, a sweep of that circle along the curve, CloseSurface, and boolean(remove). Because the tube is CENTRED on a curve that lies on the surface, half of it is inside the material, so the groove depth is the radius — measured along the surface normal everywhere. Numbers from B30: a r=4 trace on a r=40 cylinder gave a 5.9657 mm³ tube (2π²Rr² = 5.97) and removed 2.974 mm³, which is the half. The curve has to lie on the body — the tool cannot arrange that — and it has to carry a point-on-curve parameter:

    guide

    works?

    measured 2026-11-11, one fresh document per case

    catia_gsd_line, or its extract

    yes

    50 mm segment on a generator, 0.5 mm deep, removed 19.5829 mm³ (predicted 19.635)

    an OPEN catia_gsd_spline

    yes

    65.682 mm, removed 25.6436 mm³ (predicted 25.793)

    a SKETCH outline projected onto the face

    yes

    the route this tool is built for

    catia_gsd_circle, or its extract

    no

    ENGRAVE_CURVE_UNPARAMETRISABLE

    a CLOSED catia_gsd_spline

    no

    same

    the projection of any of those

    no

    a closed spline's projection refuses too

    An OPEN guide is fine — this used to say the opposite, and that was wrong: CloseSurface closes the two ends of the swept pipe itself. What refuses is a CLOSED curve, because the route needs a point-on-curve and a closed curve will not carry one. Note also that "it came from catia_gsd_project" is NOT the test — 0.3.100's next_actions said it was and sent a reporter down a branch that fails.

    Reopening is NOT a reason to keep the calls in one session. 0.3.101 said it was, from one observation: on a lofted r=40 cylinder closed into a solid, a projected r=6 circle engraved 0.6 mm deep removed 9.991 mm³, and after save/close/reopen the SAME projection (still 17.7831 mm long, still 0.0000 mm from the body) removed nothing. Re-measured for 0.3.105 — five cells, two geometries, a fresh document each time: a 100×100×20 pad with a line on its top face gave 376.9911 mm³ before and 376.9911 mm³ after the reopen; the lofted bottle with a projected 32.111 mm glyph gave 7.9958 mm³ before and 7.9958 mm³ after. part.Update() first changed nothing either. So the advice is withdrawn, NO_MATERIAL on a reopened file means what it says, and the observation is recorded as seen once, not reproduced rather than explained.

  • The first projection in a document answers with a fragment; the second answers with the real curve. Measured on B30 2026-11-11 with eight projections in a row in one document, same source and target: a sketch outline gave 2.200 mm and then 32.111 mm seven times. catia_gsd_project therefore projects twice and keeps the second, and both numbers appear in the reply. The case that makes this a rule rather than a quirk of one bottle: a 20×20 rectangle onto a plane tilted 45° gave 20.000 mm on the first pass and 68.284 mm on the second — and 68.284 is exactly 2·(20 + 20·cos45). A straight cylinder is the one shape where both passes agree (17.783 mm), so the second pass has never measured less than the first.

    What has actually been projected, and onto what (B30, 2026-11-11 unless noted). This table exists because the advice in this file was twice written from memory instead of from a measurement — "extract the face and project onto that" was offered as a fix for a failure it does not fix, and "move the element closer with catia_gsd_translate" makes the result worse. Read a row before advising anyone:

    element

    support

    landed

    carries a point-on-curve parameter

    planar sketch

    xy / yz / zx, or a solid's flat face — including the very face it lies on (0.0000 mm)

    lands: 100 %, measured 2026-11-12 (the 2026-11-10 note said "refused", and four cells could not reproduce it)

    —

    planar sketch

    an edge of a face, extracted as a curve

    refused — PROJECT_FAILED, measured 2026-11-12

    —

    80 mm rectangle

    plane tilted 45° (catia_gsd_plane_angle)

    20.000 mm, then 68.284 mm = 2·(20+20·cos45)

    yes

    63.947 mm glyph outline

    lofted bottle face

    4.000 mm (6.3 %) before 0.3.102, 32.111 mm (48.2 %) after

    yes

    26.164 mm glyph

    padded cylinder, SOLID face Face.3

    13.6 %

    yes

    26.164 mm glyph

    curve extracted from that same face

    13.6 % on 2026-11-11; an extracted edge was refused on 2026-11-12 — the two are not the same curve and the disagreement is unexplained

    —

    26.164 mm glyph

    lofted bottle, sketch on zx offset −40

    16.0 %

    yes

    26.164 mm glyph

    lofted bottle, sketch on a frame plane

    15.1 %

    yes

    catia_gsd_line, its extract, an OPEN spline

    curved face

    lands

    yes

    a CLOSED spline

    curved face

    lands

    no — and neither does its projection

    catia_gsd_circle, its extract

    curved face

    lands

    no — same

    The practical reading: a curved support always accepts the projection and always keeps little of it (13–48 %), so a glyph that has to be legible on a curved face must be COMPUTED on that surface — take the 3D points from the surface's own equation and sweep an OPEN spline through them — not projected onto it. A solid face needs no catia_gsd_extract first; the "surface" the tool wants is whatever _ref can resolve, and a Face.N resolves.

    Do not move the element with catia_gsd_translate before projecting. A translated copy of that sketch landed 3.5 % where the sketch itself landed 48.2 %, and it stayed at 3.5 % through eight tries; it only becomes usable once the sketch it came from has been projected (44.9 %). 0.3.100's low-share warning suggested exactly that translation and was measured to make the curve worse. Why CATIA answers differently the first time is unknown and is not claimed anywhere in the code.

  • What a projection needs is a support with one normal — not an angle. Measured on B30 2026-11-12 on one document, one 40×40 sketch, the file saved, closed and reopened, with InWorkObject on the geometrical set: the face the sketch lies on (0.0000 mm away) took it at 100 %, that same face extracted as a surface at 100 %, and a plane parallel to the sketch at 100 %. An edge extracted as a curve was refused. So the older rule ("parallel or perpendicular is degenerate", 2026-11-10) did not reproduce in any of those four cells, and catia_gsd_project no longer states it: a parallel plane is a plain translation of the element, not a collapse. Reopening the file and pointing InWorkObject at a geometrical set changed nothing either — 84.0 % before, 84.0 % after, 84.0 % with the set. scripts/b30_v0410_audit.py is the acceptance case that separates those variables, and it exists because a 2026-11-11 report blamed a reopen for three PROJECT_FAILEDs it never isolated.

  • A measurable taken on a hybrid surface measures the enclosing SOLID. Measured on B30 2026-11-10: SPAWorkbench.GetMeasurable(<reference to a hybrid shape>) answers Area = 24000 mm² for a 100×100 face of a 100×100×10 block, and its COG is the block's (0, 0, 5) — not the face's. So "compare the centroid before and after" measures the same body twice, and catia_gsd_offset_surface cannot name inside/outside that way. It reports the flag it used, the measured gap (0.4000 mm for a 0.4 mm offset, both directions) and the route that does answer it: cut toward the surface and read the volume.

  • A projection can keep a sixth of the element and still report success — it now reports the number. AddNewProject runs along the support's own normal, so on a curved support part of the element can miss it entirely. Measured on the 2026-11-10 bottle part (a copy, never saved): a 63.947 mm glyph outline onto a lofted body face gave a 4.000 mm curve — 6.3 %, one segment of seven. catia_measure_distance then answered 0.0000 mm, which is a MINIMUM and only says the fragment touches; catia_surface_engrave swept a 2.0106 mm3 tube along those 4 mm and removed nothing, correctly. The reply now begins with WARNING: ONLY PART OF THE ELEMENT LANDED below a 25 % share, and always prints the projected length against the source's. 25 % and not 50 % because a CLOSED curve on a round support legitimately keeps about half (measured: 47.2 % on the 0.3.98 acceptance cylinder, which engraves fine). The other routes to a curve that lies on such a face do not reach the chain either, all measured the same day: an intersection is a real curve on the face (37.699 mm, 0.0000 mm) but all four point-on-curve overloads refuse it, and AddNewSweepCircle + SetRadius(1, r) — a tube with no profile at all — is accepted and never updates.

  • A projection can keep a sixth of the element and still report success — it now reports the number. AddNewProject runs along the support's own normal, so on a curved support part of the element can miss it entirely. Measured on the 2026-11-10 bottle part (a copy, never saved): a 63.947 mm glyph outline onto a lofted body face gave a 4.000 mm curve — 6.3 %, one segment of seven. catia_measure_distance then answered 0.0000 mm, which is a MINIMUM and only says the fragment touches; catia_surface_engrave swept a 2.0106 mm3 tube along those 4 mm and removed nothing, correctly. The reply now begins with WARNING: ONLY PART OF THE ELEMENT LANDED below a 25 % share, and always prints the projected length against the source's. 25 % and not 50 % because a CLOSED curve on a round support legitimately keeps about half (measured: 47.2 % on the 0.3.98 acceptance cylinder, which engraves fine). The other routes to a curve that lies on such a face do not reach the chain either, all measured the same day: an intersection is a real curve on the face (37.699 mm, 0.0000 mm) but all four point-on-curve overloads refuse it, and AddNewSweepCircle + SetRadius(1, r) — a tube with no profile at all — is accepted and never updates.

  • A loft's end conditions are a support, not a strength, and a datum plane is one. HybridShapeLoft exposes 78 members on B30 (read from its own type information, 2026-11-10) and not one of them carries a tension, a factor, a weight or a per-guide influence — so a tutorial's "Normal to Profile, strength 1.5" and "To Next Guide" cannot be set. What can: start_tangent / end_tangent, and a PLANE is a legal support, which the schema used to understate. Measured: two circles r=10 at z=0 and r=14 at z=40 loft to 3 030.971 mm² with no condition, 2 869.025 mm² with start_tangent='xy' and 3 202.807 mm² with end_tangent='xy'. Read that literally — tangency TO the plane, so the surface leaves flat; "normal to profile" asks for the opposite direction. catia_gsd_multi_section_surface accepts the three tutorial names as aliases and refuses each with these numbers (NO_TANGENT_FACTOR, NO_NORMAL_TO_PROFILE, NO_GUIDE_INFLUENCE) instead of leaving JSON Schema to answer for the API.

  • A sketch has no readable coordinates, so it cannot be read back. Point2D exposes no X/Y through automation and a sketch's own frame cannot be read either (2026-11-10). Consequences, stated rather than hidden: catia_sketch_copy replays the coordinates this session recorded when it drew the sketch, so a sketch that came from disk, or one whose constraints solved to other positions, cannot be copied faithfully — the reply says which case it is. Ask CATIA about the built geometry instead: catia_measure_distance, catia_list_edges, catia_get_bounding_box, catia_shape_audit.

  • A mesh measurement is about what is VISIBLE, which is a choice the tool has to make for you. catia_check_surface and catia_shape_audit now hide the geometrical sets, keep visible whatever carries the solid, and restore every flag afterwards — so the numbers describe the SOLID. Measured on B30 2026-11-10: the same solid read 108 triangles and 0 non-manifold edges on its own, and 540 triangles with 162 non-manifold edges with its construction surfaces on screen. include_construction=true gives the second reading back, and scope says which one you got. The screenshot side of the same problem: catia_screenshot(clean=true) does the hiding, but the specification TREE cannot be hidden from automation — Viewer3D has 58 members, no HideTree, and Width/Height are read-only, so the picture is the 3D window's client area whatever size was asked for. Press F3 in the UI for a report image.

  • A loft's end conditions are a support, not a strength, and a datum plane is one. HybridShapeLoft exposes 78 members on B30 (read from its own type information, 2026-11-10) and not one of them carries a tension, a factor, a weight or a per-guide influence — so a tutorial's "Normal to Profile, strength 1.5" and "To Next Guide" cannot be set. What can: start_tangent / end_tangent, and a PLANE is a legal support, which the schema used to understate. Measured: two circles r=10 at z=0 and r=14 at z=40 loft to 3 030.971 mm² with no condition, 2 869.025 mm² with start_tangent='xy' and 3 202.807 mm² with end_tangent='xy'. Read that literally — tangency TO the plane, so the surface leaves flat; "normal to profile" asks for the opposite direction. catia_gsd_multi_section_surface accepts the three tutorial names as aliases and refuses each with these numbers (NO_TANGENT_FACTOR, NO_NORMAL_TO_PROFILE, NO_GUIDE_INFLUENCE) instead of leaving JSON Schema to answer for the API.

  • A sketch has no readable coordinates, so it cannot be read back. Point2D exposes no X/Y through automation and a sketch's own frame cannot be read either (2026-11-10). Consequences, stated rather than hidden: catia_sketch_copy replays the coordinates this session recorded when it drew the sketch, so a sketch that came from disk, or one whose constraints solved to other positions, cannot be copied faithfully — the reply says which case it is. Ask CATIA about the built geometry instead: catia_measure_distance, catia_list_edges, catia_get_bounding_box, catia_shape_audit.

  • A mesh measurement is about what is VISIBLE, which is a choice the tool has to make for you. catia_check_surface and catia_shape_audit now hide the geometrical sets, keep visible whatever carries the solid, and restore every flag afterwards — so the numbers describe the SOLID. Measured on B30 2026-11-10: the same solid read 108 triangles and 0 non-manifold edges on its own, and 540 triangles with 162 non-manifold edges with its construction surfaces on screen. include_construction=true gives the second reading back, and scope says which one you got. The screenshot side of the same problem: catia_screenshot(clean=true) does the hiding, but the specification TREE cannot be hidden from automation — Viewer3D has 58 members, no HideTree, and Width/Height are read-only, so the picture is the 3D window's client area whatever size was asked for. Press F3 in the UI for a report image.

  • AddNewSewSurface is declared, accepts the call, and never updates. Same shape of failure as the GSD pattern above, measured on B30 2026-11-10 before a tool was written for it: ShapeFactory.AddNewSewSurface(iSewingElement, iSewingSide) returns a feature that appears in the tree, and part.Update() then fails in every geometry tried — a 400×400 mm planar wall and a 40×200 mm patch against a 100×100×10 block, a wall standing on the top face, a cap hanging over it, and a datum plane, with iSewingSide as 1/0 and as True/False. Eight attempts, three topologies, both argument types. So the engraving route a 2026-11-10 report asked for does not go through Sew Surface on this build; the two that do work are catia_split_solid (a cutter that divides the body completely) and a solid tool body through catia_boolean(remove). catia_diagnose publishes these conclusions as capability_ceilings — one status per member (UNSUPPORTED_ON_B30, NOT_IN_TYPELIB, DECLARED_NOT_MEASURED, AVAILABLE_WITH_LIMIT) — so "is this declared or does it work" is a lookup instead of a report.

  • There is no text in a V5 sketch. Not a missing feature of this fork: the Factory2D a sketch hands back declares 37 members and none of them creates text (CreatePoint, CreateLine, CreateCircle, CreateSpline, CreateEllipse, CreateParabola, …). Letter outlines have to arrive as geometry — a DXF import, or curves drawn elsewhere and projected — which is why "engrave a label" is a curve-projection problem here and not a text problem. A report in 2026-11-10 described this as a font gap; it is an object-model gap.

  • A single-feature GSD pattern is declared and not callable here. The member exists — ShapeFactory.AddNewGSDCircPattern, 14 arguments, and it returns an object — but part.UpdateObject refuses that object in every shape tried on B30 2026-11-09: iType 0/1/2, completeCrown on and off, a GSD surface and a Part Design pad as the source, with the body set as the in-work object. catia_gsd_circ_pattern therefore builds count−1 rotations plus a join, which is why editing count means calling again and why a join can refuse when the copies share no edge. Two versions of this project stated that wrongly in opposite directions before this was measured: 0.3.81's report said the member was on HybridShapeFactory (it is not — that factory has 160 members, 131 of them AddNew*, none with "Pattern"), and 0.3.82's description concluded from that it was declared nowhere. catia_diagnose now prints both factories' own method tables, so the next question of this kind is a lookup.

  • Capping a hole in a surface: use the hole's own edge, not a circle. One catia_gsd_boundary curve carries every loop, so filling it rebuilds the whole face over the original and the duplicate then breaks a thickening (measured 2026-11-09 on a five-lobe base: 1714 edges shared by more than two triangles; on a cone the same call gave 1570.796 mm² = π·400 + π·100, one flat face per loop stacked, not the lateral area). Three routes exist and they differ by geometry:

    • catia_gsd_boundary(edge=N) returns ONE loop — 62.832 and 125.664 mm on a cone's two circles, against 188.496 for both — and a fill on it covers only that loop (314.159 / 1256.637 mm² measured). This works on a single feature. On a JOIN result (an array) all 30 border edges of a five-patch base failed at update, so pick the loop BEFORE the join. It also refuses on some SINGLE features: a four-sided flat fill and a five-section loft both failed at UpdateObject (2026-11-09), so "it is not a join" does not mean it will work — the reply names both possibilities rather than guessing.

    • catia_gsd_circ_pattern(cap_center=true, cap_curve=<the hole's edge on the source piece>) closes the centre in the same call: that curve is rotated count−1 times, the copies joined into the hole's border, filled, and joined onto the base. Measured 2080.436 + 59.441 = 2139.877 mm² on a five-lobe base — exact, because the cap shares the hole's edge by construction. ON A LOFT THE CURVE HAS TO BE ONE OF THE GUIDES, with the sections' corresponding points on it: a loft's inner border is interpolated, not an element, and a curve that merely touches it (0.0000 mm apart) is not the same edge, so the cap is refused even though the geometry looks right. That is how the 2026-11-09 tutorial build closed its centre after two failures that read like parameter errors.

    • catia_gsd_split with a circle, then fill, then join — 1600.000 → 1486.903 → 113.097 → 1600.000 mm², thickness 0.5 → 800.0 mm³. Works, but a circle has to be big enough to reach the border: a five-sided hole's nearest point to its axis is r·cos36 (4.045 for r=5), so a circle at the "boundary distance" lies wholly inside the hole and cuts nothing (measured 2026-11-09). Splitting twice on the same element does not stack: do both cuts from the original. And the patch in that measurement is a PLANE. On a curved surface a planar circle crosses the surface without lying on it, which cuts nothing: measured 2026-11-09, a circle 3 mm above a patch and a circle on a 45° support both failed. Since 0.3.89 the call measures the closest approach first (SPLIT_CUTTER_MISSES, with the distance) and reports it after a refusal at update too (SPLIT_FAILED, modified: false, both candidate causes named). Where the centre is not flat, cap it with the hole's own edge instead — that is the only cap this build can make, so a five-sided hole gets a five-sided cap.

    catia_gsd_fill measures what it built (0.3.87). Before that, an open boundary answered "Fill surface created" while the Edge.N route was refused for not being closed — two routes, two answers, and the silent one left a zero-area face that only surfaced later as non-manifold edges when the result was thickened. The area is now published ("Its area measures 100.0000 mm2, which is what says the boundary really closed a region"), and below 0.001 mm² the fill is deleted again and the call raises FILL_DEGENERATE with the number: catia_gsd_boundary(edge=N) can return ONE SEGMENT of a border (25.0000 mm of a 91.1450 mm four-sided border), and filling a segment does not fail on this build — it makes that face.

  • A sketch frame cannot be READ back — but a rotated plane's can be reconstructed and checked. Sketch.GetAbsoluteAxisData takes an out-array pywin32's late binding cannot deliver (the same wall as Measurable.GetPoint), and so do HybridShapePlaneAngle's own GetOrigin / GetFirstAxis / GetSecondAxis / GetPosition — measured 2026-11-09 on a 44-member typeinfo dump, every call answers 「方法 … 失败」. So a plane built by catia_gsd_plane_angle in THIS session records what it was built from, and a sketch on it gets a frame reconstructed from that record (the reference plane's frame rotated about the axis by the angle). Because that is a reconstruction and not a reading, one corner is placed as a real point element and its distance to the sketch is measured: 0.0000 mm on B30, i.e. the frame matches CATIA — and a mismatch refuses with SKETCH_FRAME_MISMATCH and the gap. What still has no frame: a plane that came from disk, one rotated about a NAMED element, and any sketch on a FACE. For those, check the placement geometrically — catia_measure_distance between a copy and its original is the chord between the two supports, 2r·sin(A/2) for a copy on a plane rotated by A degrees (measured 7.0534 mm against 7.053 for r=6 at 72°) — or pass the corners explicitly as points.

  • Hiding a geometrical set does not hide the solid — measured twice. One pad in the body and one surface in the set: STL triangle count 63 with the solid alone, 73 with the surface added, 73 after hiding the construction geometry, 63 after hiding the set. It lands exactly on "solid alone". Two reports saw an empty 3D view after hiding a set; the likeliest explanation is a 0.5 mm plate seen edge-on. If the view does go empty, what you were looking at is an element INSIDE that set — show the set again (show=true on the same name), not 零件几何体, which holds none of it.

  • A surface fillet only where the two surfaces form a plain corner, and a spine only from two or more sections. 0.3.53 wrote that neither could be driven; 0.3.56 measured both and that was wrong twice over, in the same way - a declared call was tried in one arrangement and its failure read as a property of the object. catia_gsd_fillet_surface drives AddNewFilletBiTangent across the four orientation pairs and keeps the survivor - always (o1 = −1, o2 = +1) on the corners measured, whichever direction the supports were built in. That pair builds a true rolling-ball fillet (every mesh vertex at exactly r from the axis, area (π − θ)·r·L to 0.001 % on 60/90/120° corners), but it refuses when none survives - two surfaces that do not share an edge, which is what the two disjoint cylinders were - and it refuses when more than one does, because those are different surfaces (a disc with a cylinder standing on it gave 573.4 and 391.3 mm²). catia_gsd_spine drives AddNewSpine with AddSection/AddGuide, which the typelib has always declared: 40 members, and the 0.3.53 probe had only looked at dir(spine). It still needs two sections (UpdateObject fails on one) and its curve follows the sections' NORMALS, not their centres. Where neither applies, round the edge in the geometry you build (an arc in the profile, as the five-petal bottle base ended up doing with its R3), or fillet a SOLID edge with catia_fillet if the wall is thick enough for the radius. Read typeinfo as "declared", never as "usable" - that is how AddNewHealing cost a release (declared with one argument, and unusable until AddBodyToHeal was called) - but read it properly before calling something unusable: a 40-member interface is not absent because dir() showed nothing.

  • A sliver along a seam reads as a fold, and cross_wall is how to tell. On a thin wall the mesh uses its largest triangles where curvature is near zero, and one of them can bridge the two skins; its normal sits between them, so catia_check_surface reports 100 deg+ on a surface that is continuous. Those edges are now split out as fold_deg_by_side.cross_wall (a subset of mixed: the edge itself runs from the outer skin to the inner one), each worst_creases entry carries cross_wall, and seam_hint names the radius band and says what to do. Measured on a synthesised 0.3 mm tube: 0 cross-wall edges on a clean wall - its 64 rim folds at 90.0 deg stay mixed and nothing else - and exactly 1 (169.98 deg) once one triangle was made to bridge. On an OPEN patch (boundary_edges > 0) the split means nothing - one skin, and a border can look like a wall crossing - so cross_wall is withheld there and skin_note says why: a 2026-11-04 report had a surface-only model flagged at its skirt's top edge (r=33, z=110) with a hint to exclude r=33, which on a 66 mm bottle excludes everything. The real case: a five-petal base whose seam fell on the near-flat bottom of its R3 foot read 124.05 deg at r=25 and nothing above 47.59 deg anywhere else, and moving the seam off that radius took the region to 27.42 deg. Keep seams in curved regions on thin walls.

  • A 180 deg dihedral is not a bend, and a duplicated sheet is where it comes from. The curvature estimate divides a length by an angle, so an edge whose two triangles face opposite ways reports a radius of "facet size / pi" - which is how a surface was told its tightest radius was 0.034 mm at the open top border of a skirt, with "widen that feature" attached, where the split was about to cut the border away and there was nothing to widen (reported 2026-11-04). Measuring the mechanism first mattered: a plain open patch does NOT read 180 deg at its border (a 30 mm open tube's worst fold is 22.50 deg, its own chording; a flat patch's is 0.00), so "the surface ends here" was not the cause. Two COINCIDENT patches with opposite orientation do - every edge they share measures exactly 180.00 deg (B30 2026-11-04), which is what a split-and-copied surface leaves visible. catia_check_surface therefore judges fold-back (>= 175 deg) before anything else: those edges, and any shared by more than two triangles, are counted in excluded and kept out of both the radius ranking and the thickness verdict, min_radius_at_open_border and tightest_edges[*].at_open_border flag a real reading that ends on a border, and the thick-surface failure message repeats the count so a number cannot silently vanish either. A genuine zero-radius knife edge reads exactly the same, and the reply says so rather than choosing. region now scopes BOTH blocks - before 0.3.59 it scoped the fold statistics only, so "exclude the skirt and look at the base" changed the angles and left the radii whole-mesh. excluded.unresolvable_edges is the THIRD channel, added 0.3.61: a radius smaller than a tenth of the edge it was measured across is smaller than this mesh can resolve, not a measurement, and the verdict is decided by the 5th percentile rather than by the single smallest edge - a minimum is one triangle, a verdict is about a surface. Both were needed before a surface CATIA had just thickened by 0.3 mm stopped answering "cannot be built": reproduced on B30 2026-11-04 with 0.0888 mm across a 3.3218 mm edge (2.7 %, 7.16 deg of fold, i.e. a bend of ~27 mm). A too-tight reading that is only a MINORITY now says INCONCLUSIVE and names its radius_over_edge instead of prescribing a redesign.

  • A .CATPart written by a newer CATIA may not open on an older one. Measured 2026-11-04: B30 Documents.Open on a file written by V5-6R2022 (B32 SP6) fails with a bare "method Open failed" - no version message, nothing about the release - which reads like a corrupt file. Files do travel between installations in a report chain, so check the writing release before believing that. STL exports of the same part stayed readable on the older release, and re-saving in a release both sides share avoids it. The reverse direction is the same story.

  • set_view works in the ABSOLUTE axis system, not the part's. front means "look along −Y with +Z up" in the document's axes whatever the model's own orientation, so a part rotated in the tree (or a component placed with a transform) will not line up with any of the named views. No automation call reports the current view's right/up vectors the way the UI's "Normal to" does, so the seven names are the whole vocabulary. Build in the axes you want to look at, or take the screenshots before placing the component.

  • CATIA allows two elements to share a name, and this server used to produce them. Assigning Name checks nothing, so building P_04_06 twice left two elements with that name; FindObjectByName returns one of them and a delete removes one of them, which means "I deleted it and rebuilt it" can be true while the old element is still answering to the name. Measured 2026-11-03 — and it is the state a report read as "delete + rebuild breaks the loft topology", where every join failed while the geometry was identical. New elements now take a free name (P_04_06.2) and say so; deleting by a duplicated name is refused with the count; names=[…] removes every element carrying the name. Join itself does not care: two lofts whose facing sections are separate objects built from the same parameters join fine, and so do pieces rebuilt with their shared section also rebuilt (both measured the same day, four rebuild orders, 60/90/120/180/600 point sections).

  • No zebra stripes, curvature comb or Match surface. CATIA V5 does not expose them to automation at all — measured 2026-11-03 on B30: the viewer answers RenderingMode and returns an unresolvable attribute for Zebra, EnvironmentMapping and AnalysisMode, and HybridShapeFactory has AddNewHealing, AddNewBlend, AddNewConnect, AddNewCurveSmooth and AddNewFill but no AddNewMatch. catia_check_surface answers the same question from the mesh (dihedral angle across every interior edge), and it is honest about what that means: the median is the mesh's own chording, so the number to read is the max and the crease list, not the median. AddNewHealing STITCHES, it does not SMOOTH — and the first measurement of it here was invalid, which is worth writing down. HybridShapeHealing exposes AddBodyToHeal(iBody), SetMergingDistance, SetTangencyAngle, SetDistanceObjective, SetSharpnessAngle and ReplaceToHealElement; the 0.3.51 probe set a Continuity and registered NO body, so the feature was a pass-through and every Continuity value gave a byte-identical mesh. Measured again with the body registered on B30 2026-11-03 (every state isolated in its own STL):

    two lofts 0.05 mm apart        112 tris,  0 interior edges, 112 open
    heal C1, merging 0.5           112 tris, 24 interior edges,  64 open
    heal C1, merging 0.001         112 tris,  0 interior edges, 112 open
    two lofts sharing a section   0.52 deg before, 0.52 deg at C1 and C2
    two walls at 90 deg           90.0 deg before and after, 4 tris either way

    So: it closes gaps smaller than the merging distance, and it changes nothing about continuity. catia_gsd_heal ships with those numbers in its description. A seam that is already joined and has to be smooth needs the surfaces rebuilt tangent (catia_gsd_blend, catia_gsd_connect, or sections that meet properly); there is no smoothing operator in this automation.

  • No sheet metal (Generative Sheetmetal Design). This server covers Part Design, GSD, Assembly, Sketcher, Measurement and export — there is no wall, flange, bend, unfold or flat-pattern tool, and no K-factor/bend-radius parameter. A sheet-metal part has to be modelled as extrude + cut with the bend geometry worked out by hand, which is why a bracket that is 57 faces in SolidWorks comes out 76 here (+19 faces, +0.49 % volume — measured 2026-11-03 on a 2 mm bracket with two bends): the extra faces are the prismatic substitutes for the bends. catia_diagnose reports whether the workbench's DLLs are installed beside the running CATIA, but no probe can read the licence, and the automation objects this server drives expose no sheet-metal feature either way.

  • A loft section cannot be a point or a vertex — measured 2026-11-03: a datum point is accepted by AddSectionToLoft and then fails the update, and a solid Vertex.N is refused by the same call with "invalid number of arguments", although the CATIA UI lets you pick a vertex as an end section. Both are now refused before anything is built. End the loft on a small closed section and cap it with catia_gsd_fill instead.

  • A smooth loft refuses to update when the section arrangement folds back — the surface interpolates through the sections and stays inside their hull, so a section that sits past the turn of its neighbours forces a self-intersection. Measured the same day: sections r 2.9 / 3.0 / 8 with the middle one above both neighbours fail as circles, as 12-point closed splines and as 90-point closed splines alike, while the same radii in a monotone stack build. Changing the curve type is not the fix; keep the sections monotone, split the loft at the turn, or use ruled=true, which covers every section in one call.

  • The mesh bounding box is the envelope of the whole visible document, not of the solid: the STL export includes every shown surface and wireframe element, and neither Part nor Body exposes ExportData, so the export cannot be narrowed. A 10 mm pad with a construction surface at z 30..35 measured z max 35 with the set shown and 10 hidden. Since 0.3.50 the reply carries box_scope (no sets / all hidden / some shown) and warns only when a set is actually shown; hide the sets (or use method='vertices') before trusting the numbers.

  • rect_pattern's directions are measured, and they are not the obvious ones. Omit all four axis arguments and this build drives dir1 along −Y and dir2 along −X (the origin planes are PlaneYZ and PlaneZX). dir1_axis='x' does move the copies to +X, and reverse_dir1 / reverse_dir2 flip exactly the slot they name. Measured 2026-11-08 by reading the body's box endpoints - 0.3.77's audit measured the box SIZE, which cannot see a sign, and so passed a wrong table for four releases. If you need to confirm a direction yourself, record min and max, not the difference. circ_pattern and user_pattern were measured the same way and are exact.

  • No lump-count check (upstream has none either) — the only reliable detector for an isolated floating boss.

  • Assembly inertia tensors are not aggregated (volumes, areas and centroids are); a tensor would have to be rotated into world space per component. The tensor is also only computed for the ACTIVE document, and with the part's material density - get_inertia(density=...) does not change it, which is why the reply reports both masses.

  • catia_get_inertia expands sub-assemblies; catia_get_bounding_box does too since 0.3.29. Both compose the two placements and report every leaf with a path (ASM/PART.1) and a depth, so one part is still bounded and measured once however many levels it appears at. component= still matches TOP-LEVEL components only — a nested instance has to be asked for through its sub-assembly, and the refusal says so. When the same part number exists BOTH at the top level and inside a sub-assembly, the narrowed reply reports complete: false, scope: "top_level_only" and the instances it left out in also_nested / skipped, so a partial box cannot pass for the part's whole envelope.

  • A part that CATIA is editing in context (loaded inside an assembly, no window of its own) cannot be exported to STL. The mesh box falls back to a copy of the saved file, so it measures the file, not unsaved edits; the reply says when that happened.

  • catia_add_component runs the insert in a separate process attached to the same CATIA. It is the one call that can raise CATIA's own file prompt on the automation thread, and that prompt is not a #32770 message box the watchdog may answer - one reporter's session sat there for 2 h 14 min. The child is killed after CATIA_MCP_GUARD_TIMEOUT (default 60 s), the prompt is cancelled, and the reply reports the component count so you can see whether the insert happened anyway. CATIA_MCP_GUARD=0 turns the isolation off. Killing the child frees the server; if CATIA itself is wedged behind a native dialog that no button answers, a human still has to look at it.

  • catia_replace_component cannot re-point an instance at a byte copy of a document that is OPEN in the session. This build identifies a document by an internal id, not by its path, so the insert answers with the already-loaded document: the count grows, the pose matches, and the instance still points at the original (measured on B30 2026-11-08 - asked for B\PZ81A.CATPart with A\PZ81A.CATPart open, the instance resolved to A\PZ81A.CATPart). The tool reads ReferenceProduct.Parent.FullName back and refuses with REPLACE_REFERENCE_MISMATCH rather than reporting success, and it removes the instance it just added so the assembly is left as it was. Making an assembly self-contained is therefore TWO steps: copy the files, close the originals (catia_close_document(document=<full path>)), then replace. A copy that is not open in the session resolves to its own path and works in one step.

  • The watchdog answers #32770 message boxes only; CATIA's own CATDlgDialog panels are left alone so a human working alongside the agent is not interrupted.

License and attribution

MIT. Upstream copyright is retained in LICENSE (Adam M'HAMDI, Ondřej Schiller, Chong Zhu). This fork's changes are additions to and modifications of that work; README.md and FORK.md state what they are.

If you send improvements back, upstream is the right home for anything that is not specific to this fork — it accepts contributions.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables building and modifying parametric solid models in a local SOLIDWORKS installation via the COM API.
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables natural-language generation and editing of native CATIA V5 parts and assemblies through MCP tools, converting semantic requests into parametric CATPart/CATProduct models via the CATIA Automation API.
      MIT
    • A
      license
      B
      quality
      B
      maintenance
      An MCP server that drives CATIA V5 on Windows over COM — 161 tools for sketching, solid and surface modelling, assemblies, measurement, drafting and neutral-format export. Requires Windows and your own CATIA installation and licence.
      161
      3
      AGPL 3.0
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables parametric part modeling in SolidWorks via COM, including building box feature trees, verifying volume through mass properties, and saving SLDPRT and exporting STEP files.
      MIT