Repository navigation
Expand file tree
/
Copy pathinventor_mcp_server.py
More file actions
3803 lines (3317 loc) · 129 KB
/
Copy pathinventor_mcp_server.py
File metadata and controls
3803 lines (3317 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Inventor MCP Server
====================
Ein minimaler Model-Context-Protocol-Server, der Autodesk Inventor
ueber die COM-API fernsteuert. Gedacht als Ausgangspunkt zum Erweitern.
Voraussetzungen (nur Windows):
- Autodesk Inventor installiert und GEOEFFNET
- pip install "mcp[cli]" pywin32
Start zum Testen:
python inventor_mcp_server.py
oder mit dem MCP-Inspector:
npx @modelcontextprotocol/inspector python inventor_mcp_server.py
Einbindung in Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"inventor-mcp": {
"command": "python",
"args": ["C:/pfad/zu/inventor_mcp_server.py"]
}
}
}
Wichtige Hinweise:
- Inventor rechnet intern in ZENTIMETERN. Alle Tools nehmen mm entgegen
und rechnen intern um (mm / 10 = cm).
- Ein Vorgang pro Tool-Aufruf. Kein Batching -> sonst Absturzgefahr.
- COM ist nicht thread-sicher: alle Zugriffe laufen ueber denselben Thread.
"""
from __future__ import annotations
import math
import sys
import uuid
# --- MCP SDK ---------------------------------------------------------------
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
sys.stderr.write(
"Fehlt: MCP-SDK. Installiere mit: pip install \"mcp[cli]\"\n"
)
raise
# --- Inventor COM (nur auf Windows verfuegbar) -----------------------------
try:
import pythoncom # type: ignore
import win32com.client # type: ignore
import win32com.client.gencache # type: ignore
_HAS_WIN32 = True
except ImportError:
# Erlaubt Import/Syntaxpruefung auf Nicht-Windows-Systemen.
_HAS_WIN32 = False
# Modulweiter Cache fuer das per gencache erzeugte Konstanten-Modul.
# Wird erst in _get_app() befuellt, weil dafuer eine laufende COM-Verbindung
# noetig ist. WICHTIG: Wir raten hier KEINE Enum-Zahlen mehr (z.B.
# kNewBodyOperation, kPositiveExtentDirection). Stattdessen liest gencache
# die echten Werte direkt aus Inventors Typbibliothek. Das vermeidet genau
# die Klasse von Fehlern, die vorher zu 'DISP_E_EXCEPTION' gefuehrt hat.
_const = None
mcp = FastMCP("inventor-mcp")
# Modulweiter Cache der Inventor-Applikationsinstanz.
_app = None
# ---------------------------------------------------------------------------
# Interne Hilfsfunktionen
# ---------------------------------------------------------------------------
def _mm_to_cm(value_mm: float) -> float:
"""Inventor-interne Einheit ist cm."""
return value_mm / 10.0
def _parse_feature_operation(operation: str):
"""Return an Inventor operation enum and reject ambiguous input.
Older tools silently converted every misspelling to ``new``. That is
dangerous for destructive operations such as a sweep cut, so the newer
feature tools share this strict parser.
"""
normalized = str(operation).strip().lower()
operations = {
"new": _const.kNewBodyOperation,
"join": _const.kJoinOperation,
"cut": _const.kCutOperation,
"intersect": _const.kIntersectOperation,
"surface": _const.kSurfaceOperation,
}
if normalized not in operations:
raise ValueError(
f"Unknown operation '{operation}'. Allowed values: "
"new, join, cut, intersect, surface."
)
return operations[normalized]
def _parse_3d_point_string(points: str, minimum: int = 2) -> list[tuple[float, float, float]]:
"""Parse ``x,y,z;x,y,z`` millimetre coordinates and validate the path."""
parsed: list[tuple[float, float, float]] = []
for raw_point in (part.strip() for part in str(points).split(";")):
if not raw_point:
continue
coordinates = [value.strip() for value in raw_point.split(",")]
if len(coordinates) != 3:
raise ValueError(
f"Invalid 3D point '{raw_point}'. Expected 'x,y,z' in mm."
)
try:
point = tuple(float(value) for value in coordinates)
except ValueError as exc:
raise ValueError(
f"Invalid 3D point '{raw_point}': coordinates must be numbers."
) from exc
if not all(math.isfinite(value) for value in point):
raise ValueError(f"Invalid 3D point '{raw_point}': values must be finite.")
if parsed and all(
abs(point[index] - parsed[-1][index]) < 1e-9 for index in range(3)
):
raise ValueError(
f"Consecutive path points must differ; '{raw_point}' is duplicated."
)
parsed.append(point)
if len(parsed) < minimum:
raise ValueError(f"At least {minimum} 3D points are required.")
return parsed
def _normal_plane_axes(
start: tuple[float, float, float],
end: tuple[float, float, float],
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Build stable, right-handed X/Y axes for a plane normal to a segment."""
normal = tuple(end[index] - start[index] for index in range(3))
length = math.sqrt(sum(value * value for value in normal))
if length < 1e-12:
raise ValueError("The first sweep path segment has zero length.")
normal = tuple(value / length for value in normal)
# Choose a reference axis that is not almost parallel to the path.
reference = (0.0, 0.0, 1.0) if abs(normal[2]) < 0.9 else (0.0, 1.0, 0.0)
x_axis = (
reference[1] * normal[2] - reference[2] * normal[1],
reference[2] * normal[0] - reference[0] * normal[2],
reference[0] * normal[1] - reference[1] * normal[0],
)
x_length = math.sqrt(sum(value * value for value in x_axis))
x_axis = tuple(value / x_length for value in x_axis)
y_axis = (
normal[1] * x_axis[2] - normal[2] * x_axis[1],
normal[2] * x_axis[0] - normal[0] * x_axis[2],
normal[0] * x_axis[1] - normal[1] * x_axis[0],
)
return x_axis, y_axis
def _parse_loft_sections(sections: str) -> list[dict]:
"""Parse legacy circular and structured circle/rectangle loft sections.
Legacy format: ``plane:diameter`` (for example ``XY:50;30:40``).
Structured format uses pipes so offset planes remain unambiguous:
``plane|circle|diameter|cx|cy`` or
``plane|rectangle|width|height|cx|cy``. Centres default to zero.
"""
result: list[dict] = []
for raw_section in (part.strip() for part in str(sections).split(";")):
if not raw_section:
continue
if "|" not in raw_section:
legacy = [value.strip() for value in raw_section.split(":")]
if len(legacy) != 2:
raise ValueError(
f"Invalid loft section '{raw_section}'. Expected "
"'plane:diameter' or the structured pipe format."
)
plane, diameter = legacy
values = {
"plane": plane,
"shape": "circle",
"diameter_mm": float(diameter),
"center_x_mm": 0.0,
"center_y_mm": 0.0,
}
else:
fields = [value.strip() for value in raw_section.split("|")]
if len(fields) < 3:
raise ValueError(f"Invalid structured loft section '{raw_section}'.")
plane, shape = fields[0], fields[1].lower()
if shape == "circle" and len(fields) in (3, 5):
values = {
"plane": plane,
"shape": shape,
"diameter_mm": float(fields[2]),
"center_x_mm": float(fields[3]) if len(fields) == 5 else 0.0,
"center_y_mm": float(fields[4]) if len(fields) == 5 else 0.0,
}
elif shape in ("rectangle", "rect") and len(fields) in (4, 6):
values = {
"plane": plane,
"shape": "rectangle",
"width_mm": float(fields[2]),
"height_mm": float(fields[3]),
"center_x_mm": float(fields[4]) if len(fields) == 6 else 0.0,
"center_y_mm": float(fields[5]) if len(fields) == 6 else 0.0,
}
else:
raise ValueError(
f"Invalid loft section '{raw_section}'. Use "
"plane|circle|diameter[|cx|cy] or "
"plane|rectangle|width|height[|cx|cy]."
)
dimensions = [
value for key, value in values.items()
if key in ("diameter_mm", "width_mm", "height_mm")
]
if not values["plane"]:
raise ValueError("Every loft section needs a plane.")
if not all(math.isfinite(float(value)) and float(value) > 0 for value in dimensions):
raise ValueError(f"Loft section dimensions must be finite and greater than zero: '{raw_section}'.")
if not all(
math.isfinite(float(values[key]))
for key in ("center_x_mm", "center_y_mm")
):
raise ValueError(f"Loft section centres must be finite: '{raw_section}'.")
result.append(values)
if len(result) < 2:
raise ValueError("At least two loft sections are required.")
return result
def _detect_cut_direction(comp_def):
"""
Erkennt die Richtung des ersten Volumenkoerpers anhand der Bounding-Box
und liefert die passende ExtendDirection-Konstante fuer Schnitte/Bohrungen,
die von der XY-Ebene aus gehen.
Rueckgabe:
kNegativeExtentDirection wenn der Koerper ueberwiegend in Z+ liegt
(Schnitt muss nach unten gehen)
kPositiveExtentDirection wenn der Koerper ueberwiegend in Z- liegt
(Schnitt muss nach oben gehen)
kSymmetricExtentDirection wenn der Koerper beidseitig liegt
"""
if comp_def.SurfaceBodies.Count == 0:
return _const.kNegativeExtentDirection
body = comp_def.SurfaceBodies.Item(1)
try:
Box = body.RangeBox
z_min = Box.MinPoint.Z
z_max = Box.MaxPoint.Z
except Exception:
return _const.kNegativeExtentDirection
tol = 1e-6
if z_min >= -tol and z_max > tol:
# Koerper liegt ueberwiegend in Z+ (typisch: direction="positive")
return _const.kNegativeExtentDirection
elif z_max <= tol and z_min < -tol:
# Koerper liegt ueberwiegend in Z- (typisch: direction="negative")
return _const.kPositiveExtentDirection
elif z_min < -tol and z_max > tol:
# Koerper beidseitig (typisch: direction="symmetric")
return _const.kSymmetricExtentDirection
else:
# Koerper liegt auf Z=0 oder ist sehr flach
return _const.kNegativeExtentDirection
def _resolve_work_plane(comp_def, plane: str):
"""
Loest eine Ebenen-Angabe in ein WorkPlane-Objekt auf.
Unterstuetzt:
- "XY", "XZ", "YZ" (Standard-Ebenen)
- "XZ:30" (Ebene mit Offset in mm von der Basisebene)
- "Work Plane1" (benannte Ebene)
"""
plane = plane.strip()
if ":" in plane:
parts = plane.split(":", 1)
base = parts[0].strip().upper()
offset_mm = float(parts[1].strip())
base_map = {"XY": 3, "XZ": 2, "YZ": 1}
if base not in base_map:
raise ValueError(f"Unbekannte Basisebene: '{base}'. Erlaubt: XY, XZ, YZ.")
return comp_def.WorkPlanes.AddByPlaneAndOffset(
comp_def.WorkPlanes.Item(base_map[base]),
_mm_to_cm(offset_mm),
)
plane_upper = plane.upper()
if plane_upper in ("XY", "XZ", "YZ"):
base_map = {"XY": 3, "XZ": 2, "YZ": 1}
return comp_def.WorkPlanes.Item(base_map[plane_upper])
# Als benannte Ebene versuchen.
try:
return comp_def.WorkPlanes.Item(plane)
except Exception:
raise ValueError(
f"Ebene '{plane}' nicht erkannt. "
"Erlaubt: 'XY', 'XZ', 'YZ', 'XZ:30' (Offset), 'Work Plane1'."
)
def _get_app():
"""
Verbindet sich mit einer laufenden Inventor-Instanz und sorgt dafuer,
dass win32com.client.constants mit den ECHTEN Enum-Werten aus Inventors
Typbibliothek befuellt wird (frueher Bindung / gencache).
Faellt zurueck auf das Starten einer neuen Instanz, falls keine laeuft.
"""
global _app, _const
if not _HAS_WIN32:
raise RuntimeError(
"pywin32 nicht verfuegbar - dieser Server laeuft nur auf Windows."
)
if _app is not None:
return _app
pythoncom.CoInitialize()
try:
# Bereits laufende Instanz bevorzugen, aber ueber gencache binden,
# damit die Typbibliothek (und damit die Konstanten) geladen wird.
_app = win32com.client.gencache.EnsureDispatch("Inventor.Application")
except pythoncom.com_error:
# Keine laufende Instanz gefunden -> neu starten.
_app = win32com.client.gencache.EnsureDispatch("Inventor.Application")
_app.Visible = True
# win32com.client.constants wird durch EnsureDispatch global befuellt.
_const = win32com.client.constants
return _app
def _require_part_document(app):
"""
Stellt sicher, dass das aktive Dokument ein Bauteil (Part) ist, UND
castet es explizit auf PartDocument.
Grund: app.ActiveDocument liefert bei frueher Bindung (gencache) ein
generisches 'Document'-Objekt zurueck. Nur ein 'PartDocument' hat die
Eigenschaft 'ComponentDefinition'. Ohne diesen Cast schlaegt jeder
Zugriff auf ComponentDefinition mit
"'...Document instance...' object has no attribute 'ComponentDefinition'"
fehl.
"""
doc = app.ActiveDocument
if doc is None:
raise RuntimeError("Kein aktives Dokument in Inventor geoeffnet.")
if doc.DocumentType != _const.kPartDocumentObject:
raise RuntimeError(
"Aktives Dokument ist kein Bauteil (Part). "
"Bitte zuerst 'create_part' aufrufen oder ein .ipt oeffnen."
)
# Expliziter Cast auf PartDocument - das behebt den fehlenden Zugriff
# auf ComponentDefinition.
part_doc = win32com.client.CastTo(doc, "PartDocument")
if part_doc is None:
raise RuntimeError(
"Cast auf PartDocument fehlgeschlagen. Moeglicherweise ist die "
"gencache-Typbibliothek veraltet - Ordner "
"%TEMP%/gen_py loeschen und Inventor/Skript neu starten."
)
return part_doc
def _get_translator(app, *keywords):
"""
Sucht ein Uebersetzer-Add-in ueber seinen Anzeigenamen, statt eine fest
verdrahtete CLSID zu verwenden. Robuster, weil GUIDs zwischen Versionen
abweichen koennen. 'keywords' sind Teilstrings, die alle (case-insensitiv)
im Namen vorkommen muessen, z. B. _get_translator(app, "STL").
"""
keywords_low = [k.lower() for k in keywords]
for addin in app.ApplicationAddIns:
try:
name = (addin.DisplayName or "").lower()
except Exception:
continue
if all(k in name for k in keywords_low):
return _cast_translator(addin)
raise RuntimeError(
"Kein passendes Uebersetzer-Add-in gefunden fuer: "
+ ", ".join(keywords)
)
def _cast_translator(addin):
"""
Castet ein generisches ApplicationAddIn auf TranslatorAddIn.
Noetig, weil das generische Objekt (z.B. aus einer Iteration ueber
app.ApplicationAddIns oder aus ItemById) keine SaveCopyAs-Methode hat -
die existiert nur auf dem TranslatorAddIn-Interface. Gleiche Fehler-
klasse wie beim PartDocument-Cast weiter oben.
"""
translator = win32com.client.CastTo(addin, "TranslatorAddIn")
if translator is None:
raise RuntimeError(
"Cast auf TranslatorAddIn fehlgeschlagen fuer Add-in "
f"'{getattr(addin, 'DisplayName', '?')}'."
)
return translator
def _export_via_translator(app, doc, addin, file_path):
"""Gemeinsame Export-Logik fuer Translator-Add-ins (STEP/STL/DXF ...)."""
context = app.TransientObjects.CreateTranslationContext()
context.Type = _const.kFileBrowseIOMechanism
options = app.TransientObjects.CreateNameValueMap()
data_medium = app.TransientObjects.CreateDataMedium()
data_medium.FileName = file_path
addin.SaveCopyAs(doc, context, options, data_medium)
def _all_edges(app, comp_def):
"""
Sammelt alle Kanten des ersten Volumenkoerpers in einer EdgeCollection.
Genutzt von Fillet/Chamfer, da wir keine interaktive Kantenauswahl haben.
"""
if comp_def.SurfaceBodies.Count == 0:
raise RuntimeError(
"Kein Volumenkoerper vorhanden. Erst z. B. 'create_box' aufrufen."
)
body = comp_def.SurfaceBodies.Item(1)
edge_coll = app.TransientObjects.CreateEdgeCollection()
for edge in body.Edges:
edge_coll.Add(edge)
return edge_coll
def _filtered_edges(app, comp_def, which):
"""
Liefert eine EdgeCollection, gefiltert nach Lage (statt interaktiver
Auswahl). 'which' ist eines von:
"all" - alle Kanten
"top" - nur Kanten ganz oben (max Z)
"bottom" - nur Kanten ganz unten (min Z)
"vertical" - nur senkrechte Kanten (Z aendert sich entlang der Kante)
Filtert ueber die Z-Koordinaten der Kanten-Endpunkte. Gekruemmte Kanten
ohne Endpunkte (z. B. volle Kreise) landen nur bei "all".
"""
which = (which or "all").lower()
if which == "all":
return _all_edges(app, comp_def)
if comp_def.SurfaceBodies.Count == 0:
raise RuntimeError(
"Kein Volumenkoerper vorhanden. Erst z. B. 'create_box' aufrufen."
)
body = comp_def.SurfaceBodies.Item(1)
def _z_range(edge):
"""(z_start, z_stop) oder None, falls keine Endpunkte vorhanden."""
try:
z1 = edge.StartVertex.Point.Z
z2 = edge.StopVertex.Point.Z
return z1, z2
except Exception:
return None
tol = 1e-4
zs = []
for edge in body.Edges:
zr = _z_range(edge)
if zr:
zs.extend(zr)
if not zs:
raise RuntimeError(
"Keine geraden Kanten mit Endpunkten gefunden - fuer diesen "
"Koerper ist nur 'all' moeglich."
)
z_max, z_min = max(zs), min(zs)
coll = app.TransientObjects.CreateEdgeCollection()
for edge in body.Edges:
zr = _z_range(edge)
if zr is None:
continue
z1, z2 = zr
if which == "top" and abs(z1 - z_max) < tol and abs(z2 - z_max) < tol:
coll.Add(edge)
elif which == "bottom" and abs(z1 - z_min) < tol and abs(z2 - z_min) < tol:
coll.Add(edge)
elif which == "vertical" and abs(z1 - z2) > tol:
coll.Add(edge)
if coll.Count == 0:
raise RuntimeError(
f"Keine Kanten fuer Filter '{which}' gefunden."
)
return coll
# ---------------------------------------------------------------------------
# Skizzen-Bestimmung: Bemassungen (DimensionConstraints) und geometrische
# Abhaengigkeiten (GeometricConstraints), damit Skizzen VOLL BESTIMMT sind.
# ---------------------------------------------------------------------------
def _project_origin(sketch, comp_def):
"""
Projiziert den Modell-Ursprung (WorkPoints.Item(1)) in die Skizze und
liefert den zugehoerigen SketchPoint.
WICHTIG: Wir nutzen NICHT den Rueckgabewert von AddByProjectingEntity
direkt (der ist ein generisches COM-Objekt und mehrfacher Cast-Versuch
darauf schlug fehl). Stattdessen rufen wir die Methode nur fuer ihren
Seiteneffekt auf und holen den neu erzeugten Punkt danach aus der
sketch.SketchPoints-Collection - die ist stark typisiert und liefert
direkt einen echten SketchPoint, ganz ohne Cast. Gleiches Muster wie in
den offiziellen Autodesk-Beispielen (Punkt wird ueber SketchPoints.Item
nach dem Projizieren abgeholt, nicht ueber den Rueckgabewert).
"""
origin_wp = comp_def.WorkPoints.Item(1)
count_before = sketch.SketchPoints.Count
sketch.AddByProjectingEntity(origin_wp)
count_after = sketch.SketchPoints.Count
if count_after > count_before:
# Neuer Punkt wurde angehaengt - das ist unser Ursprungspunkt.
return sketch.SketchPoints.Item(count_after)
# Fallback: nach ReferencedEntity suchen (z. B. falls der Punkt schon
# vorher projiziert war und kein neuer entstand).
for sp in sketch.SketchPoints:
try:
if sp.ReferencedEntity is not None:
return sp
except Exception:
continue
raise RuntimeError("Projizierter Ursprungspunkt nicht gefunden.")
def _as_sketch_line(entity):
"""
Castet ein generisches SketchEntity (z.B. aus einer
SketchEntitiesEnumerator wie dem Rueckgabewert von
AddAsTwoPointRectangle) auf SketchLine. Noetig, weil das generische
Objekt keine StartSketchPoint/EndSketchPoint-Eigenschaften hat - gleiche
Fehlerklasse wie beim PartDocument- und TranslatorAddIn-Cast.
"""
line = win32com.client.CastTo(entity, "SketchLine")
if line is None:
raise RuntimeError("Cast auf SketchLine fehlgeschlagen.")
return line
def _nearest_sketch_point(rect_lines, x_cm, y_cm, tol=1e-6):
"""Sucht unter den Eckpunkten der Rechteck-Linien den Punkt, der (x,y)
am naechsten liegt."""
best, best_d = None, None
for i in range(1, rect_lines.Count + 1):
line = _as_sketch_line(rect_lines.Item(i))
for sp in (line.StartSketchPoint, line.EndSketchPoint):
g = sp.Geometry
d = (g.X - x_cm) ** 2 + (g.Y - y_cm) ** 2
if best_d is None or d < best_d:
best_d, best = d, sp
return best
def _points_already_merged(p1, p2, tol=1e-6):
"""
Prueft, ob zwei SketchPoints geometrisch identisch sind. Inventor
verschmilzt einen projizierten Ursprungspunkt automatisch mit einem
bereits vorhandenen Skizzenpunkt an derselben Stelle - dann sind beide
de facto derselbe Punkt, und eine explizite Koinzidenz-Bedingung
zwischen ihnen ist ungueltig (E_INVALIDARG) und unnoetig.
"""
try:
return (
abs(p1.Geometry.X - p2.Geometry.X) < tol
and abs(p1.Geometry.Y - p2.Geometry.Y) < tol
)
except Exception:
return False
def _try_step(beschreibung, fn):
"""
Fuehrt einen einzelnen Constrain-Schritt aus und haengt bei einem Fehler
die Beschreibung an, damit wir in der Rueckmeldung sehen, welcher
Teilschritt (Ursprung projizieren, Bemassung X, Bemassung Y, ...)
fehlgeschlagen ist - statt nur eines nichtssagenden COM-Fehlercodes.
"""
try:
return fn()
except Exception as exc:
raise RuntimeError(f"[{beschreibung}] {exc}") from exc
def _constrain_rectangle(app, sketch, comp_def, rect_lines,
x_cm, y_cm, w_cm, h_cm):
"""
Versucht, ein Rechteck voll zu bestimmen: Lage der unteren linken Ecke
(Koinzidenz zum Ursprung bei Offset 0, sonst Bemassung) plus Breite/Hoehe.
Rueckgabe: "voll" oder "teilweise" (Best-Effort, Geometrie bleibt intakt).
"""
tg = app.TransientGeometry
geo = sketch.GeometricConstraints
dim = sketch.DimensionConstraints
origin_sp = _try_step(
"Ursprung projizieren", lambda: _project_origin(sketch, comp_def)
)
corner = _try_step(
"Eckpunkt suchen",
lambda: _nearest_sketch_point(rect_lines, x_cm, y_cm),
)
at_origin = abs(x_cm) < 1e-6 and abs(y_cm) < 1e-6
status = "voll"
# --- Lage fixieren ---
if at_origin:
def _do_coincident():
if _points_already_merged(corner, origin_sp):
return # Bereits durch Verschmelzung fixiert - nichts zu tun.
try:
geo.AddCoincident(corner, origin_sp)
except Exception as exc:
raise RuntimeError(
f"{exc!r} | corner_pytype={type(corner).__name__} "
f"origin_pytype={type(origin_sp).__name__}"
) from exc
_try_step("Koinzidenz Ecke-Ursprung", _do_coincident)
else:
# Position ueber Bemassung vom Ursprung; Null-Offsets lassen 1 DOF
# offen -> dann "teilweise".
if abs(x_cm) > 1e-6:
_try_step(
"Bemassung X-Position",
lambda: dim.AddTwoPointDistance(
origin_sp, corner, _const.kHorizontalDim,
tg.CreatePoint2d(x_cm / 2.0, y_cm - 0.5),
),
)
else:
status = "teilweise"
if abs(y_cm) > 1e-6:
_try_step(
"Bemassung Y-Position",
lambda: dim.AddTwoPointDistance(
origin_sp, corner, _const.kVerticalDim,
tg.CreatePoint2d(x_cm - 0.5, y_cm / 2.0),
),
)
else:
status = "teilweise"
# --- Groesse bemassen (Breite + Hoehe) ---
# Untere Kante (horizontal) und eine Seitenkante (vertikal) finden.
horiz_line = vert_line = None
for i in range(1, rect_lines.Count + 1):
line = _as_sketch_line(rect_lines.Item(i))
p1, p2 = line.StartSketchPoint.Geometry, line.EndSketchPoint.Geometry
if abs(p1.Y - p2.Y) < 1e-6 and horiz_line is None:
horiz_line = line
elif abs(p1.X - p2.X) < 1e-6 and vert_line is None:
vert_line = line
if horiz_line is not None:
_try_step(
"Bemassung Breite",
lambda: dim.AddTwoPointDistance(
horiz_line.StartSketchPoint, horiz_line.EndSketchPoint,
_const.kHorizontalDim,
tg.CreatePoint2d(x_cm + w_cm / 2.0, y_cm - 1.0),
),
)
else:
status = "teilweise"
if vert_line is not None:
_try_step(
"Bemassung Hoehe",
lambda: dim.AddTwoPointDistance(
vert_line.StartSketchPoint, vert_line.EndSketchPoint,
_const.kVerticalDim,
tg.CreatePoint2d(x_cm - 1.0, y_cm + h_cm / 2.0),
),
)
else:
status = "teilweise"
return status
def _constrain_circle(app, sketch, comp_def, circle, cx_cm, cy_cm, diameter_cm):
"""
Versucht, einen Kreis voll zu bestimmen: Mittelpunktlage + Durchmesser.
Rueckgabe: "voll" oder "teilweise".
"""
tg = app.TransientGeometry
geo = sketch.GeometricConstraints
dim = sketch.DimensionConstraints
origin_sp = _try_step(
"Ursprung projizieren", lambda: _project_origin(sketch, comp_def)
)
center = circle.CenterSketchPoint
status = "voll"
if abs(cx_cm) < 1e-6 and abs(cy_cm) < 1e-6:
def _do_coincident_circle():
if _points_already_merged(center, origin_sp):
return
geo.AddCoincident(center, origin_sp)
_try_step("Koinzidenz Mittelpunkt-Ursprung", _do_coincident_circle)
else:
if abs(cx_cm) > 1e-6:
_try_step(
"Bemassung X-Position",
lambda: dim.AddTwoPointDistance(
origin_sp, center, _const.kHorizontalDim,
tg.CreatePoint2d(cx_cm / 2.0, cy_cm - 0.5),
),
)
else:
status = "teilweise"
if abs(cy_cm) > 1e-6:
_try_step(
"Bemassung Y-Position",
lambda: dim.AddTwoPointDistance(
origin_sp, center, _const.kVerticalDim,
tg.CreatePoint2d(cx_cm - 0.5, cy_cm / 2.0),
),
)
else:
status = "teilweise"
_try_step(
"Bemassung Durchmesser",
lambda: dim.AddDiameter(
circle, tg.CreatePoint2d(cx_cm + diameter_cm, cy_cm)
),
)
return status
def _safe_constrain(fn):
"""
Fuehrt eine Constrain-Funktion aus, ohne dass ein Fehler die Geometrie-
Erzeugung stoppt. Rueckgabe: Statuswort fuer die Nutzer-Rueckmeldung.
"""
try:
return fn()
except Exception as exc: # bewusst breit: Bemassen ist Best-Effort
return f"nicht bestimmt ({exc})"
# ---------------------------------------------------------------------------
# Stabile Geometrie-Handles
# ---------------------------------------------------------------------------
_GEOMETRY_ATTRIBUTE_SET = "CustomMcpGeometry"
_GEOMETRY_ATTRIBUTE = "GeometryId"
def _get_geometry_id(entity, prefix: str) -> str:
"""Liest oder vergibt eine persistente ID auf einer Inventor-Entitaet.
AttributeSets werden von Inventor an der jeweiligen Face/Edge gespeichert
und sind dadurch deutlich robuster als die laufenden Collection-Indizes.
Bei Topologie-Neuberechnung kann Inventor Attribute trotzdem verwerfen;
der Aufrufer sollte in diesem Fall die Faces erneut auflisten.
"""
try:
sets = entity.AttributeSets
for i in range(1, sets.Count + 1):
attr_set = sets.Item(i)
if attr_set.Name != _GEOMETRY_ATTRIBUTE_SET:
continue
try:
return str(attr_set.Item(_GEOMETRY_ATTRIBUTE).Value)
except Exception:
break
attr_set = sets.Add(_GEOMETRY_ATTRIBUTE_SET)
value = f"{prefix}_{uuid.uuid4().hex}"
attr_set.Add(_GEOMETRY_ATTRIBUTE, _const.kStringType, value)
return value
except Exception as exc:
raise RuntimeError(
"Inventor konnte keinen stabilen Geometrie-Handle speichern. "
f"Entitaet ist moeglicherweise nicht attributierbar: {exc}"
) from exc
def _find_face_by_geometry_id(body, geometry_id: str):
for i in range(1, body.Faces.Count + 1):
face = body.Faces.Item(i)
try:
if _get_geometry_id(face, "face") == geometry_id:
return face
except Exception:
continue
return None
def _find_edge_by_geometry_id(body, geometry_id: str):
for i in range(1, body.Edges.Count + 1):
edge = body.Edges.Item(i)
try:
if _get_geometry_id(edge, "edge") == geometry_id:
return edge
except Exception:
continue
return None
def _resolve_face_reference(body, reference: str):
"""Loest eine stabile Face-ID auf; numerische Indizes bleiben legacy."""
reference = str(reference).strip()
if reference.isdigit():
index = int(reference)
if index < 1 or index > body.Faces.Count:
raise ValueError(f"Flaechen-Index {index} ungueltig.")
return body.Faces.Item(index), "legacy-index"
face = _find_face_by_geometry_id(body, reference)
if face is None:
raise ValueError(f"Flaechen-ID '{reference}' nicht gefunden.")
return face, "geometry-id"
def _resolve_edge_reference(body, reference: str):
"""Loest eine stabile Edge-ID auf; numerische Indizes bleiben legacy."""
reference = str(reference).strip()
if reference.isdigit():
index = int(reference)
if index < 1 or index > body.Edges.Count:
raise ValueError(f"Kanten-Index {index} ungueltig.")
return body.Edges.Item(index), "legacy-index"
edge = _find_edge_by_geometry_id(body, reference)
if edge is None:
raise ValueError(f"Kanten-ID '{reference}' nicht gefunden.")
return edge, "geometry-id"
def _build_face_collection(app, comp_def, face_specs: str):
"""Build a FaceCollection from stable IDs or legacy body-face indices."""
body = comp_def.SurfaceBodies.Item(1)
collection = app.TransientObjects.CreateFaceCollection()
references = [part.strip() for part in str(face_specs).split(",") if part.strip()]
seen: set[str] = set()
for reference in references:
face, _ = _resolve_face_reference(body, reference)
geometry_id = _get_geometry_id(face, "face")
if geometry_id in seen:
continue
collection.Add(face)
seen.add(geometry_id)
return collection
def _resolve_chamfer_face(body, edges, reference_face: str):
"""Resolve or infer the face from which an asymmetric chamfer is measured."""
first_edge = edges.Item(1)
if str(reference_face).strip():
face, reference_kind = _resolve_face_reference(body, reference_face)
else:
try:
face = first_edge.Faces.Item(1)
except Exception as exc:
raise RuntimeError(
"Could not infer the chamfer reference face. Pass reference_face "
"using a geometry_id returned by list_faces."
) from exc
reference_kind = "inferred"
first_edge_id = _get_geometry_id(first_edge, "edge")
belongs_to_face = any(
_get_geometry_id(face.Edges.Item(index), "edge") == first_edge_id
for index in range(1, face.Edges.Count + 1)
)
if not belongs_to_face:
raise ValueError("The first selected edge does not belong to reference_face.")
return face, reference_kind
# ---------------------------------------------------------------------------
# MCP-Tools
# ---------------------------------------------------------------------------
@mcp.tool()
def ping() -> str:
"""Prueft die Verbindung zu Inventor und gibt die Version zurueck."""
app = _get_app()
return f"Verbunden mit Inventor {app.SoftwareVersion.DisplayName}"
@mcp.tool()
def create_part(name: str = "Bauteil1") -> str:
"""
Erstellt ein neues, leeres Bauteil-Dokument (.ipt) und macht es aktiv.
Args:
name: Anzeigename des neuen Bauteils.
"""
app = _get_app()
doc_type = _const.kPartDocumentObject
template = app.FileManager.GetTemplateFile(doc_type)
doc = app.Documents.Add(doc_type, template, True)
try:
doc.DisplayName = name
except Exception:
pass # DisplayName ist nicht immer setzbar vor dem Speichern.
return f"Neues Bauteil '{name}' erstellt."
@mcp.tool()
def create_box(
length_mm: float,
width_mm: float,
height_mm: float,
direction: str = "positive",
) -> str:
"""
Erstellt eine rechteckige Box durch Skizze auf der XY-Ebene + Extrusion.
Args:
length_mm: Laenge in X-Richtung (mm).
width_mm: Breite in Y-Richtung (mm).
height_mm: Hoehe der Extrusion in Z-Richtung (mm).
direction: Extrusionsrichtung: "positive" (Z+, Standard),
"negative" (Z-), "symmetric" (zentriert).
"""
if min(length_mm, width_mm, height_mm) <= 0:
raise ValueError("Alle Masse muessen groesser als 0 sein.")
app = _get_app()
doc = _require_part_document(app)
# Dokument explizit aktivieren - vermeidet Faelle, in denen ein anderes
# Fenster/Dokument in Inventor gerade im Vordergrund ist.
doc.Activate()
comp_def = doc.ComponentDefinition
tg = app.TransientGeometry
length = _mm_to_cm(length_mm)
width = _mm_to_cm(width_mm)
height = _mm_to_cm(height_mm)
def _step(beschreibung, fn):
"""Fuehrt einen einzelnen COM-Aufruf aus und gibt bei Fehlern
genau an, in welchem Schritt es geknallt hat - statt eines
nichtssagenden generischen COM-Fehlers."""
try:
return fn()
except pythoncom.com_error as exc:
raise RuntimeError(
f"Fehler bei Schritt '{beschreibung}': {exc}"
) from exc
# Skizze auf der XY-Ebene (WorkPlanes.Item(3) entspricht in den
# offiziellen Autodesk-Beispielen durchgaengig der XY-Basisebene).
sketch = _step(
"Skizze anlegen",
lambda: comp_def.Sketches.Add(comp_def.WorkPlanes.Item(3)),
)
# Rechteck ueber zwei Diagonalpunkte.
p1 = tg.CreatePoint2d(0, 0)
p2 = tg.CreatePoint2d(length, width)
rect_lines = _step(
"Rechteck zeichnen",
lambda: sketch.SketchLines.AddAsTwoPointRectangle(p1, p2),
)
# Skizze voll bestimmen (Bemassung + Abhaengigkeiten).
constrain_status = _safe_constrain(
lambda: _constrain_rectangle(
app, sketch, comp_def, rect_lines, 0, 0, length, width
)
)
# Profil aus der geschlossenen Skizze.
profile = _step("Profil erzeugen", lambda: sketch.Profiles.AddForSolid())
# Extrusionsdefinition: neuer Koerper, Distanz = height.
# _const liefert die ECHTEN Werte aus Inventors Typbibliothek statt
# geratener Zahlen.
ext_def = _step(
"Extrusionsdefinition erzeugen",
lambda: comp_def.Features.ExtrudeFeatures.CreateExtrudeDefinition(
profile, _const.kNewBodyOperation
),
)
dir_enum = _parse_direction(direction)
_step(