dsh-engineering-ui 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +64 -4
@@ -0,0 +1,509 @@
1
+ """Unwired, bounded part-export adapter. Importing this module never opens COM.
2
+
3
+ Caller must supply an already authorized, exclusively owned SW part document.
4
+ No application discovery, locking, configuration switching, saving or deployment.
5
+ Production MCP wiring remains outside this adapter; captures never authorize physics.
6
+ """
7
+ from copy import deepcopy
8
+ import hashlib
9
+ import math
10
+ from pathlib import Path
11
+ import xml.etree.ElementTree as ET
12
+
13
+ from .physics import (mass_properties, sw_inertia_tensor, sw_transform, transform_mass,
14
+ merge_rigid_bodies, validate_rotation, _checked, _numbers)
15
+
16
+
17
+ def _require_clean(model):
18
+ rebuild = model.Extension.NeedsRebuild2
19
+ if model.GetSaveFlag() is not False or type(rebuild) not in (int, bool) or rebuild != 0:
20
+ raise ValueError('DIRTY_REBUILD_REQUIRED_OR_UNKNOWN')
21
+
22
+
23
+ def _source_state(model, expected, doc_type=1):
24
+ if model.GetType() != doc_type:
25
+ raise ValueError('DOCUMENT_TYPE_MISMATCH')
26
+ path = Path(model.GetPathName())
27
+ if not path.is_absolute() or not path.is_file() or path.resolve() != Path(expected['path']).resolve():
28
+ raise ValueError('SOURCE_PATH_MISMATCH')
29
+ _require_clean(model)
30
+ config = model.ConfigurationManager.ActiveConfiguration.Name
31
+ if not config or config != expected['configuration']:
32
+ raise ValueError('CONFIGURATION_MISMATCH')
33
+ with path.open('rb') as stream:
34
+ digest = hashlib.file_digest(stream, 'sha256').hexdigest()
35
+ if digest != expected['sha256']:
36
+ raise ValueError('SOURCE_HASH_MISMATCH')
37
+ return {'path': str(path.resolve()), 'configuration': config, 'sha256': digest}
38
+
39
+
40
+ def _component_state(component):
41
+ return {'instance_path': component.Name2, 'configuration': component.ReferencedConfiguration,
42
+ 'source_file': component.GetPathName(), 'suppression_raw': component.GetSuppression2(),
43
+ 'transform_raw': list(component.Transform2.ArrayData)}
44
+
45
+
46
+ def _mass_readout(mp, frame):
47
+ if mp is None: raise ValueError('MASS_PROPERTIES_UNAVAILABLE')
48
+ mp.UseSystemUnits = True
49
+ if mp.UseSystemUnits is not True: raise ValueError('SYSTEM_UNITS_NOT_CONFIRMED')
50
+ raw = {'mass': mp.Mass, 'center_of_mass': list(mp.CenterOfMass),
51
+ 'moment': list(mp.GetMomentOfInertia(0)), 'user_assigned': mp.UserAssigned}
52
+ body = mass_properties(raw['mass'], raw['center_of_mass'], sw_inertia_tensor(raw['moment']),
53
+ length_unit='m', mass_unit='kg', frame=frame)
54
+ return raw, body
55
+
56
+
57
+ def capture_context_component(model, component, *, expected_source, expected_component_source,
58
+ sw_version, provenance='unknown', max_triangles=100000,
59
+ invoke_bodies=None):
60
+ """Read actual assembly-context solids (including assembly-cut user bodies).
61
+
62
+ AddBodies on the assembly mass object already accounts for component poses.
63
+ Face mesh coordinates are retained raw with UNKNOWN frame until real native
64
+ calibration; they cannot be fed to the part-local export path by accident.
65
+ invoke_bodies is the offline seam for GetBodies3's COM out argument only.
66
+ """
67
+ if type(max_triangles) is not int or not 1 <= max_triangles <= 100000:
68
+ raise ValueError('triangle budget must be 1..100000')
69
+ if provenance not in ('unknown', 'fixture', 'real-sw-export') or not isinstance(sw_version, str) or not sw_version.strip():
70
+ raise ValueError('explicit version/provenance required')
71
+ source = _source_state(model, expected_source, 2)
72
+ before = _component_state(component)
73
+ if type(before['suppression_raw']) is not int or before['suppression_raw'] not in (2,3):
74
+ raise ValueError('COMPONENT_NOT_RESOLVED')
75
+ sw_transform(before['transform_raw'])
76
+ if model.SelectionManager.GetSelectedObjectCount2(-1) != 0:
77
+ raise ValueError('SELECTION_MUST_BE_EMPTY')
78
+ path = Path(before['source_file'])
79
+ if not path.is_absolute() or path.resolve() != Path(expected_component_source['path']).resolve() or before['configuration'] != expected_component_source['configuration']:
80
+ raise ValueError('COMPONENT_SOURCE_MISMATCH')
81
+ child_doc = component.GetModelDoc2()
82
+ if child_doc is None or child_doc.GetType() != 1 or Path(child_doc.GetPathName()).resolve() != path.resolve():
83
+ raise ValueError('COMPONENT_DOCUMENT_UNAVAILABLE_OR_MISMATCH')
84
+ # Referenced assembly configuration may differ from the part's active one;
85
+ # do not switch it, but never attribute unsaved geometry to a saved file hash.
86
+ _require_clean(child_doc)
87
+ def check_child_file():
88
+ with path.open('rb') as stream:
89
+ if hashlib.file_digest(stream, 'sha256').hexdigest() != expected_component_source['sha256']:
90
+ raise ValueError('COMPONENT_SOURCE_HASH_MISMATCH')
91
+ check_child_file()
92
+ if invoke_bodies is None:
93
+ def invoke_bodies(comp, kind):
94
+ import pythoncom
95
+ from win32com.client import VARIANT
96
+ info = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None)
97
+ bodies = comp.GetBodies3(kind, info)
98
+ return bodies, info.value
99
+ solids, info = invoke_bodies(component, 0)
100
+ surfaces, _ = invoke_bodies(component, 1)
101
+ if not isinstance(surfaces, (list, tuple)):
102
+ raise ValueError('UNKNOWN_CONTEXT_SURFACE_QUERY')
103
+ if surfaces or not isinstance(solids, (list,tuple)) or not 1 <= len(solids) <= 10000:
104
+ raise ValueError('MISSING_SURFACE_OR_OVER_BUDGET_CONTEXT_BODIES')
105
+ if not isinstance(info, (list,tuple)) or len(info) != len(solids) or any(type(x) is not int for x in info):
106
+ raise ValueError('UNKNOWN_CONTEXT_BODY_INFO')
107
+ mp = model.Extension.CreateMassProperty()
108
+ if mp is None or mp.AddBodies(tuple(solids)) is not True:
109
+ raise ValueError('CONTEXT_BODIES_NOT_ACCEPTED')
110
+ raw, physics = _mass_readout(mp, 'root-assembly')
111
+ vertices, face_count = [], 0
112
+ for body in solids:
113
+ faces = body.GetFaces()
114
+ if not isinstance(faces, (list,tuple)) or not faces:
115
+ raise ValueError('MISSING_CONTEXT_FACES')
116
+ face_count += len(faces)
117
+ if face_count > 10000: raise ValueError('CONTEXT_FACE_BUDGET')
118
+ for face in faces:
119
+ points = _triangles(face.GetTessTriangles(True), max_triangles)
120
+ if len(vertices)+len(points) > 9*max_triangles:
121
+ raise ValueError('CONTEXT_TESSELLATION_BUDGET')
122
+ vertices.extend(points)
123
+ check_child_file()
124
+ _require_clean(child_doc)
125
+ if _component_state(component) != before or _source_state(model, expected_source, 2) != source:
126
+ raise ValueError('CONTEXT_CHANGED_DURING_CAPTURE')
127
+ return {'source':source, 'component_source':deepcopy(expected_component_source), 'component':before,
128
+ 'source_revision':source['sha256'], 'provenance':provenance, 'sw_version':sw_version,
129
+ 'physics':physics, 'raw_mass_properties':raw, 'body_info_raw':list(info),
130
+ 'vertices_raw_system_units':vertices, 'mesh_frame':'unknown', 'ready_for_physics':False,
131
+ 'methods':{'bodies':'IComponent2.GetBodies3', 'mass':'IMassProperty.AddBodies/GetMomentOfInertia(0)',
132
+ 'mesh':'IFace2.GetTessTriangles(True)', 'component_transform_applied_to_mass':False},
133
+ 'missing_information':['installed COM marshaling and user-body density calibration',
134
+ 'context face mesh frame and geometric tolerance', 'collision coverage']}
135
+
136
+
137
+ def capture_assembly(model, *, expected_source, sw_version, provenance='unknown',
138
+ max_components=256, max_triangles=100000, generation=None,
139
+ capture_mode='standalone', invoke_bodies=None):
140
+ """Capture occurrence poses and either standalone or assembly-context evidence.
141
+
142
+ All occurrences, including failed/unsupported ones, are retained. Containers
143
+ carry no mass to avoid double counting. Source-file deduplication is forbidden.
144
+ Context mode binds AddBodies mass to the same native occurrence and A02
145
+ reference. Its mesh frame remains unknown; standalone equivalence is unproved.
146
+ Neither capture mode can authorize a physics run or CAD write.
147
+ """
148
+ if type(max_components) is not int or not 1 <= max_components <= 1000:
149
+ raise ValueError('component budget must be 1..1000')
150
+ if type(max_triangles) is not int or not 1 <= max_triangles <= 100000:
151
+ raise ValueError('triangle budget must be 1..100000')
152
+ if provenance not in ('unknown', 'fixture', 'real-sw-export') or not isinstance(sw_version, str) or not sw_version.strip():
153
+ raise ValueError('explicit version/provenance required')
154
+ if capture_mode not in ('standalone', 'context'):
155
+ raise ValueError('INVALID_CAPTURE_MODE')
156
+ if capture_mode == 'context' and generation is None:
157
+ raise ValueError('CONTEXT_CAPTURE_REQUIRES_GENERATION')
158
+ source = _source_state(model, expected_source, 2)
159
+ snapshot_context = {'source_revision':source['sha256'], 'configuration':source['configuration'],
160
+ 'generation':generation}
161
+ native = model.GetComponents(False)
162
+ if not isinstance(native, (list, tuple)) or not 1 <= len(native) <= max_components:
163
+ raise ValueError('MISSING_OR_OVER_BUDGET_COMPONENTS')
164
+ references, root_mates = None, None
165
+ if generation is not None:
166
+ if type(generation) is not int or generation < 0:
167
+ raise ValueError('INVALID_GENERATION')
168
+ from .assembly import capture_reference, capture_mate_features
169
+ references = [capture_reference(model.Extension, component) for component in native]
170
+ if len(set(references)) != len(references):
171
+ raise ValueError('DUPLICATE_COMPONENT_REFERENCE')
172
+ root_mates = capture_mate_features(model, owning_frame='root-assembly',
173
+ configuration=source['configuration'])
174
+ records, captured_docs, captured_components, total = [], [], [], 0
175
+ for index, component in enumerate(native):
176
+ record = {'id': f'occurrence-{index}', 'identity_status': 'snapshot-local-only',
177
+ 'persistent_reference': None, 'status': 'unknown'}
178
+ if references is not None:
179
+ record.update(persistent_reference=references[index], identity_status='native-reference-unresolved',
180
+ owner_document=source['path'], owner_configuration=source['configuration'],
181
+ snapshot_context=deepcopy(snapshot_context))
182
+ try:
183
+ record.update(instance_path=component.Name2, configuration=component.ReferencedConfiguration,
184
+ source_file=component.GetPathName(), suppression_raw=component.GetSuppression2())
185
+ if type(record['suppression_raw']) is not int or record['suppression_raw'] not in (2, 3):
186
+ raise ValueError('COMPONENT_NOT_RESOLVED')
187
+ raw_transform = list(component.Transform2.ArrayData)
188
+ r, t = sw_transform(raw_transform)
189
+ record.update(rotation=r, translation_m=t, transform_raw=raw_transform,
190
+ transform_frame='root-assembly', transform_method='IComponent2.Transform2')
191
+ captured_components.append((component, {key:deepcopy(record[key]) for key in
192
+ ('instance_path','configuration','source_file','suppression_raw','transform_raw')}))
193
+ doc = component.GetModelDoc2()
194
+ if doc is None: raise ValueError('COMPONENT_DOCUMENT_UNAVAILABLE')
195
+ if doc.GetType() == 2:
196
+ record['status'] = 'container-no-mass'
197
+ elif doc.GetType() == 1:
198
+ child_path = Path(record['source_file'])
199
+ if not child_path.is_absolute(): raise ValueError('UNSAVED_OR_VIRTUAL_COMPONENT')
200
+ with child_path.open('rb') as stream:
201
+ digest = hashlib.file_digest(stream, 'sha256').hexdigest()
202
+ expected = {'path': str(child_path), 'sha256': digest, 'configuration': record['configuration']}
203
+ if capture_mode == 'context':
204
+ # Referenced configuration belongs to the occurrence, not the active part document.
205
+ active_expected = dict(expected, configuration=doc.ConfigurationManager.ActiveConfiguration.Name)
206
+ _source_state(doc, active_expected)
207
+ snap = capture_context_component(model, component, expected_source=expected_source,
208
+ expected_component_source=expected, sw_version=sw_version, provenance=provenance,
209
+ max_triangles=max_triangles, invoke_bodies=invoke_bodies)
210
+ total += len(snap['vertices_raw_system_units'])//9
211
+ record.update(capture=snap, status='captured-context-mesh-frame-unknown')
212
+ captured_docs.append((doc, active_expected))
213
+ else:
214
+ snap = capture_part(doc, expected_source=expected, sw_version=sw_version,
215
+ provenance=provenance, max_triangles=max_triangles)
216
+ total += len(snap['vertices_m'])//9
217
+ record.update(capture=snap, status='captured-standalone-part-not-context-validated')
218
+ captured_docs.append((doc, expected))
219
+ if total > max_triangles: raise ValueError('ASSEMBLY_TESSELLATION_BUDGET')
220
+ else:
221
+ raise ValueError('UNSUPPORTED_COMPONENT_DOCUMENT_TYPE')
222
+ except Exception as error:
223
+ record['status'] = 'unsupported'
224
+ record['error'] = type(error).__name__ + ': ' + str(error)
225
+ records.append(record)
226
+ if total > max_triangles:
227
+ raise ValueError('ASSEMBLY_TESSELLATION_BUDGET')
228
+ for doc, expected in captured_docs:
229
+ _source_state(doc, expected)
230
+ for component, expected in captured_components:
231
+ if _component_state(component) != expected:
232
+ raise ValueError('COMPONENT_CHANGED_DURING_CAPTURE')
233
+ if references is not None:
234
+ # Re-enumerate: checking only the original objects misses inserted/removed occurrences.
235
+ after = model.GetComponents(False)
236
+ if not isinstance(after, (list, tuple)) or len(after) != len(native):
237
+ raise ValueError('COMPONENT_SET_CHANGED')
238
+ after_refs = [capture_reference(model.Extension, component) for component in after]
239
+ if len(set(after_refs)) != len(after_refs) or set(after_refs) != set(references):
240
+ raise ValueError('COMPONENT_SET_CHANGED')
241
+ if [capture_reference(model.Extension, component) for component in native] != references:
242
+ raise ValueError('COMPONENT_REFERENCE_CHANGED')
243
+ if capture_mate_features(model, owning_frame='root-assembly', configuration=source['configuration']) != root_mates:
244
+ raise ValueError('MATES_CHANGED_DURING_CAPTURE')
245
+ if _source_state(model, expected_source, 2) != source:
246
+ raise ValueError('ASSEMBLY_CHANGED_DURING_CAPTURE')
247
+ return {'source': source, 'source_revision': source['sha256'], 'sw_version': sw_version,
248
+ 'provenance': provenance, 'components': records, 'ready_for_physics': False,
249
+ 'snapshot_context':snapshot_context, 'capture_mode':capture_mode,
250
+ 'root_mates':root_mates, 'mates_complete':False,
251
+ 'missing_information': ['A02 persistent occurrence identity and complete mate solution',
252
+ 'assembly-context geometry equivalence', 'validated collisions',
253
+ 'real SW and Webots calibration']}
254
+
255
+
256
+ def _triangles(raw, max_triangles):
257
+ if not isinstance(raw, (list, tuple)) or not raw or len(raw) % 9 or len(raw)//9 > max_triangles:
258
+ raise ValueError('MISSING_INVALID_OR_OVER_BUDGET_TESSELLATION')
259
+ vertices = _numbers(raw, len(raw))
260
+ for i in range(0, len(vertices), 9):
261
+ a, b, c = (vertices[i+k:i+k+3] for k in (0,3,6))
262
+ u, v = ([b[k]-a[k] for k in range(3)], [c[k]-a[k] for k in range(3)])
263
+ normal = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]]
264
+ if not all(math.isfinite(x) for x in normal) or not any(normal):
265
+ raise ValueError('DEGENERATE_TESSELLATION')
266
+ return vertices
267
+
268
+
269
+ def capture_part(model, *, expected_source, sw_version, provenance='unknown', max_triangles=100000):
270
+ """Capture current saved part via named APIs, with source checks on both sides.
271
+
272
+ Tests use fake objects. Real SW call signatures/geometry must be calibrated
273
+ before this adapter is connected to a live host. No fallback to old arrays.
274
+ """
275
+ if type(max_triangles) is not int or not 1 <= max_triangles <= 100000:
276
+ raise ValueError('triangle budget must be 1..100000')
277
+ if provenance not in ('unknown', 'fixture', 'real-sw-export') or not isinstance(sw_version, str) or not sw_version.strip():
278
+ raise ValueError('explicit version/provenance required')
279
+ source = _source_state(model, expected_source)
280
+ if model.SelectionManager.GetSelectedObjectCount2(-1) != 0:
281
+ raise ValueError('SELECTION_MUST_BE_EMPTY: will not clear user selection')
282
+ solids = model.GetBodies2(0, False)
283
+ visible = model.GetBodies2(0, True)
284
+ surfaces = model.GetBodies2(1, False)
285
+ if any(not isinstance(value, (list, tuple)) for value in (solids, visible, surfaces)):
286
+ raise ValueError('UNKNOWN_PART_BODY_QUERY')
287
+ if not solids or len(solids) != len(visible) or surfaces:
288
+ raise ValueError('HIDDEN_SURFACE_OR_MISSING_BODIES_UNSUPPORTED')
289
+ count = model.GetTessTriangleCount()
290
+ if type(count) is not int or not 1 <= count <= max_triangles:
291
+ raise ValueError('TESSELLATION_BUDGET')
292
+ mp = model.Extension.CreateMassProperty()
293
+ raw, body = _mass_readout(mp, 'part-local')
294
+ vertices = _triangles(model.GetTessTriangles(True), max_triangles)
295
+ if len(vertices)//9 != count: raise ValueError('TESSELLATION_COUNT_CHANGED')
296
+ if _source_state(model, expected_source) != source:
297
+ raise ValueError('SOURCE_CHANGED_DURING_CAPTURE')
298
+ return {'source': source, 'source_revision': source['sha256'], 'sw_version': sw_version,
299
+ 'provenance': provenance, 'physics': body, 'vertices_m': vertices,
300
+ 'raw_mass_properties': raw,
301
+ 'methods': {'mass': 'IModelDocExtension.CreateMassProperty/IMassProperty',
302
+ 'inertia': 'IMassProperty.GetMomentOfInertia(0)',
303
+ 'mesh': 'IPartDoc.GetTessTriangles(True)', 'UseSystemUnits': True}}
304
+
305
+
306
+ def part_bundle(snapshot):
307
+ """Return URDF + local-metre OBJ and S04 data payloads, without writing files.
308
+
309
+ Visual tessellation is never silently promoted to a validated collision body.
310
+ This bundle alone is therefore NOT ready for physical simulation.
311
+ """
312
+ body = _checked(snapshot['physics'], snapshot['physics']['frame'])
313
+ vertices = _triangles(snapshot['vertices_m'], 100000)
314
+ number = lambda x: format(x, '.17g')
315
+ xyz = lambda v: ' '.join(map(number, v))
316
+ root = ET.Element('robot', name='sw_part')
317
+ link = ET.SubElement(root, 'link', name='part')
318
+ inertial = ET.SubElement(link, 'inertial')
319
+ ET.SubElement(inertial, 'origin', xyz=xyz(body['com_m']), rpy='0 0 0')
320
+ ET.SubElement(inertial, 'mass', value=number(body['mass_kg']))
321
+ a = body['inertia_kg_m2']
322
+ ET.SubElement(inertial, 'inertia', **{name: number(a[i][j]) for name,i,j in
323
+ [('ixx',0,0),('ixy',0,1),('ixz',0,2),('iyy',1,1),('iyz',1,2),('izz',2,2)]})
324
+ visual = ET.SubElement(link, 'visual')
325
+ ET.SubElement(visual, 'origin', xyz='0 0 0', rpy='0 0 0')
326
+ ET.SubElement(ET.SubElement(visual, 'geometry'), 'mesh', filename='visual.obj', scale='1 1 1')
327
+ obj = ['# SW display tessellation, metres, part-local; collision unvalidated']
328
+ obj += ['v '+xyz(vertices[i:i+3]) for i in range(0,len(vertices),3)]
329
+ obj += [f'f {i+1} {i+2} {i+3}' for i in range(0,len(vertices)//3,3)]
330
+ missing = ['real SW API calibration', 'validated collision geometry and coverage',
331
+ 'urdf2webots conversion and precision audit', 'real Webots physics']
332
+ return {'robot.urdf': ET.tostring(root, encoding='unicode'), 'visual.obj': '\n'.join(obj)+'\n',
333
+ 'vertices_m': vertices,
334
+ 'manifest_data': {'source': deepcopy(snapshot['source']),
335
+ 'capture_provenance': snapshot.get('provenance', 'unknown'),
336
+ 'capture_methods': deepcopy(snapshot.get('methods', {})),
337
+ 'raw_mass_properties': deepcopy(snapshot['raw_mass_properties']),
338
+ 'components': [{'id': 'part', 'persistent_reference': None,
339
+ 'identity_status': 'unknown'}],
340
+ 'rigid_bodies': [body], 'joints': []},
341
+ 'conversion_data': {'inputs': [deepcopy(snapshot['source'])],
342
+ 'tools': [{'name': 'SW', 'version': snapshot['sw_version']}],
343
+ 'mappings': [{'target': {'kind': 'urdf_link', 'id': 'part'}, 'component_ids': ['part']}],
344
+ 'approximations': ['display tessellation; error not calibrated'],
345
+ 'omissions': ['collision geometry'], 'support': 'approximate',
346
+ 'physical_validation': 'not_run', 'missing_information': missing}}
347
+
348
+
349
+ def rigid_group_bundle(components, *, frame, source_frame='part-local'):
350
+ """Export one explicitly fixed group; input captures are component-local.
351
+
352
+ Each member: id, capture, rotation, translation_m (source frame to output link).
353
+ root-assembly mode requires independently established mesh_frame evidence;
354
+ native context captures currently emit unknown and are rejected.
355
+ Caller must establish fixed membership and assembly-context geometry
356
+ equivalence. This pure function does not infer either from part filenames.
357
+ """
358
+ if source_frame not in ('part-local', 'root-assembly'):
359
+ raise ValueError('UNSUPPORTED_GROUP_SOURCE_FRAME')
360
+ members = list(components)
361
+ bodies, vertices, sources = [], [], []
362
+ ids = [m['id'] for m in members]
363
+ if not ids or any(not isinstance(i, str) or not i.strip() for i in ids) or len(set(ids)) != len(ids):
364
+ raise ValueError('unique component ids required')
365
+ for member in members:
366
+ snap = member['capture']
367
+ if member.get('transform_frame', frame) != frame:
368
+ raise ValueError('COMPONENT_OUTPUT_FRAME_MISMATCH')
369
+ r, t = validate_rotation(member['rotation']), _numbers(member['translation_m'], 3)
370
+ body = transform_mass(snap['physics'], r, t, source_frame=source_frame, target_frame=frame)
371
+ if source_frame == 'root-assembly':
372
+ if snap.get('mesh_frame') != 'root-assembly':
373
+ raise ValueError('CONTEXT_MESH_FRAME_UNPROVEN')
374
+ raw = _triangles(snap['vertices_raw_system_units'], 100000)
375
+ else:
376
+ raw = _triangles(snap['vertices_m'], 100000)
377
+ bodies.append((member['id'], body))
378
+ if len(vertices) + len(raw) > 900000:
379
+ raise ValueError('GROUP_TESSELLATION_BUDGET')
380
+ for offset in range(0, len(raw), 3):
381
+ vertices.extend(sum(r[i][j]*raw[offset+j] for j in range(3))+t[i] for i in range(3))
382
+ sources.append(deepcopy(snap['source']))
383
+ merged = merge_rigid_bodies(bodies, frame=frame)
384
+ combined = {'physics': merged, 'vertices_m': vertices, 'source': {'members': sources},
385
+ 'raw_mass_properties': [deepcopy(m['capture']['raw_mass_properties']) for m in members],
386
+ 'sw_version': members[0]['capture']['sw_version']}
387
+ bundle = part_bundle(combined)
388
+ bundle['manifest_data']['rigid_bodies'] = [merged]
389
+ bundle['manifest_data']['components'] = [
390
+ {'id': m['id'], 'source': deepcopy(m['capture']['source']),
391
+ 'provenance': m['capture']['provenance'], 'methods': deepcopy(m['capture']['methods']),
392
+ 'rotation': deepcopy(m['rotation']), 'translation_m': deepcopy(m['translation_m']),
393
+ 'source_transform': deepcopy(m.get('source_transform')),
394
+ 'persistent_reference': None, 'identity_status': 'unknown'} for m in members]
395
+ for exported, member in zip(bundle['manifest_data']['components'], members):
396
+ for key in ('persistent_reference', 'identity_status', 'owner_document', 'owner_configuration',
397
+ 'instance_path', 'configuration', 'snapshot_context'):
398
+ if key in member:
399
+ exported[key] = deepcopy(member[key])
400
+ bundle['conversion_data']['inputs'] = sources
401
+ bundle['conversion_data']['tools'] = [{'name': 'SW', 'version': v} for v in
402
+ sorted({m['capture']['sw_version'] for m in members})]
403
+ bundle['conversion_data']['mappings'] = [{'target': {'kind': 'urdf_link', 'id': 'part'},
404
+ 'component_ids': ids}]
405
+ bundle['conversion_data']['missing_information'].append('fixed-group proof and assembly-context geometry equivalence')
406
+ return bundle
407
+
408
+
409
+ def reframe_component(component, *, link_rotation, link_translation_m, owning_frame, link_frame):
410
+ """Root-relative component and link poses -> component pose in link frame.
411
+
412
+ Both input poses must refer to the same captured assembly-root frame. Mesh
413
+ and physics remain untouched until rigid_group_bundle applies this pose once.
414
+ """
415
+ if (not isinstance(owning_frame, str) or not owning_frame.strip() or
416
+ component.get('transform_frame') != owning_frame or
417
+ not isinstance(link_frame, str) or not link_frame.startswith('link:') or len(link_frame) == 5):
418
+ raise ValueError('COMPONENT_LINK_FRAME_MISMATCH')
419
+ rc = validate_rotation(component['rotation'])
420
+ tc = _numbers(component['translation_m'], 3)
421
+ rl = validate_rotation(link_rotation)
422
+ tl = _numbers(link_translation_m, 3)
423
+ result = dict(component)
424
+ result['source_transform'] = {'frame': owning_frame, 'rotation': deepcopy(rc), 'translation_m': list(tc),
425
+ 'raw': deepcopy(result.pop('transform_raw', None))}
426
+ result['transform_frame'] = link_frame
427
+ result['rotation'] = [[sum(rl[k][i]*rc[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
428
+ result['translation_m'] = [sum(rl[k][i]*(tc[k]-tl[k]) for k in range(3)) for i in range(3)]
429
+ return result
430
+
431
+
432
+ def jointed_bundle(links, joints):
433
+ """Serialize A02-validated joints and link-local rigid-group bundles.
434
+
435
+ A02 remains the single tree/joint validator. Before I01 its module must be
436
+ supplied from that worktree for source-only seam tests. Never reads external
437
+ XML/OBJ: regenerates mesh and inertial XML from the validated numeric payload.
438
+ """
439
+ from .assembly import validate_joint, validate_tree
440
+ if not isinstance(links, (list, tuple)) or not 1 <= len(links) <= 256:
441
+ raise ValueError('LINK_BUDGET')
442
+ ids = [link['id'] for link in links]
443
+ checked_joints = [validate_joint(j) for j in joints]
444
+ root_id = validate_tree(ids, checked_joints)
445
+ robot = ET.Element('robot', name='sw_assembly')
446
+ files, components, bodies, mappings, inputs, raw_mass, seen = {}, [], [], [], [], [], set()
447
+ audit = {key: [] for key in ('tools', 'omissions', 'approximations', 'missing_information')}
448
+ vertex_count = 0
449
+ for index, link in enumerate(links):
450
+ name, bundle = link['id'], link['bundle']
451
+ data = bundle['manifest_data']
452
+ if len(data['rigid_bodies']) != 1:
453
+ raise ValueError('ONE_RIGID_BODY_PER_LINK_REQUIRED')
454
+ original = data['rigid_bodies'][0]
455
+ body = _checked(original, 'link:'+name)
456
+ member_ids = original.get('component_ids')
457
+ member_records = data['components']
458
+ if (not isinstance(member_ids, list) or not member_ids or
459
+ any(not isinstance(cid, str) or not cid.strip() for cid in member_ids) or
460
+ len(set(member_ids)) != len(member_ids) or set(member_ids) & seen or
461
+ [c['id'] for c in member_records] != member_ids):
462
+ raise ValueError('INVALID_OR_REUSED_RIGID_MEMBERS')
463
+ seen.update(member_ids)
464
+ body['component_ids'] = list(member_ids)
465
+ body['id'] = name
466
+ vertices = _triangles(bundle['vertices_m'], 100000)
467
+ vertex_count += len(vertices)
468
+ if vertex_count > 900000:
469
+ raise ValueError('ASSEMBLY_TESSELLATION_BUDGET')
470
+ fresh = part_bundle({'physics':body, 'vertices_m':vertices, 'source':data['source'],
471
+ 'raw_mass_properties':data['raw_mass_properties'], 'sw_version':'see-inputs'})
472
+ element = ET.fromstring(fresh['robot.urdf']).find('link')
473
+ element.set('name', name)
474
+ mesh_path = f'meshes/link_{index}.obj'
475
+ element.find('visual/geometry/mesh').set('filename', mesh_path)
476
+ robot.append(element)
477
+ files[mesh_path] = fresh['visual.obj']
478
+ components.extend(deepcopy(member_records)); bodies.append(body)
479
+ inputs.extend(deepcopy(bundle['conversion_data']['inputs']))
480
+ for key, values in audit.items():
481
+ # ponytail: bounded 256-link audit lists; use canonical keys if this budget grows.
482
+ for value in bundle['conversion_data'].get(key, []):
483
+ if value not in values: values.append(deepcopy(value))
484
+ raw_mass.append(deepcopy(data['raw_mass_properties']))
485
+ mappings.append({'target':{'kind':'urdf_link','id':name}, 'component_ids':list(member_ids)})
486
+ number = lambda x: format(x, '.17g')
487
+ xyz = lambda v: ' '.join(map(number, v))
488
+ for j in checked_joints:
489
+ element = ET.SubElement(robot, 'joint', name=j['id'], type=j['type'])
490
+ ET.SubElement(element, 'parent', link=j['parent'])
491
+ ET.SubElement(element, 'child', link=j['child'])
492
+ ET.SubElement(element, 'origin', xyz=xyz(j['origin_xyz_m']), rpy=xyz(j['origin_rpy_rad']))
493
+ if j['type'] != 'fixed':
494
+ ET.SubElement(element, 'axis', xyz=xyz(j['axis']))
495
+ ET.SubElement(element, 'limit', **{k:number(j['limits'][k]) for k in ('lower','upper','effort','velocity')})
496
+ files['robot.urdf'] = ET.tostring(robot, encoding='unicode')
497
+ files['manifest_data'] = {'source':{'inputs':inputs}, 'components':components,
498
+ 'rigid_bodies':bodies, 'joints':checked_joints, 'raw_mass_properties':raw_mass}
499
+ files['conversion_data'] = {'inputs':inputs, 'root_link':root_id, 'mappings':mappings,
500
+ 'support':'approximate', 'approximations':['display tessellation; error not calibrated'],
501
+ 'omissions':['collision geometry'], 'physical_validation':'not_run',
502
+ 'missing_information':['trusted source snapshot and full mate completeness',
503
+ 'assembly-context geometry calibration', 'collision coverage',
504
+ 'converter precision and real SW/Webots validation']}
505
+ for key, values in audit.items():
506
+ target = files['conversion_data'].setdefault(key, [])
507
+ for value in values:
508
+ if value not in target: target.append(value)
509
+ return files