abelien-harness 0.4.0 → 0.6.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 (104) hide show
  1. package/.env.example +4 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +10 -5
  11. package/skills/abelien-hardware/domain.json +3 -3
  12. package/skills/abelien-hardware/references/search-index-format.md +13 -1
  13. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  14. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  15. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  16. package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
  17. package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
  18. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
  19. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  20. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  21. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  22. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  23. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
  24. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
  25. package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
  26. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
  28. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  29. package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
  30. package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
  31. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  32. package/skills/abelien-hardware-selection/domain.json +2 -2
  33. package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
  34. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
  35. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  36. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  37. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  38. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  39. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  40. package/skills/abelien-reference/SKILL.md +51 -0
  41. package/skills/abelien-reference/domain.json +92 -0
  42. package/skills/abelien-reference/fixtures/create.final.json +102 -0
  43. package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
  44. package/skills/abelien-reference/fixtures/restore.final.json +267 -0
  45. package/skills/abelien-reference/references/research-contract.md +74 -0
  46. package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
  47. package/skills/abelien-reference/scripts/evidence.mjs +60 -0
  48. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  49. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  50. package/skills/abelien-reference/scripts/png.mjs +86 -0
  51. package/skills/abelien-reference/scripts/research.mjs +179 -0
  52. package/skills/abelien-structure/SKILL.md +2 -2
  53. package/skills/abelien-structure/domain.json +64 -0
  54. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  55. package/skills/abelien-structure/references/handoff.md +1 -1
  56. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  57. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  58. package/skills/abelien-structure/worker/cad.py +1623 -0
  59. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  60. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  61. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  62. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  63. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  64. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  65. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  66. package/skills/abelien-structure/worker/config.mjs +6 -0
  67. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  68. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  69. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  70. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  71. package/skills/abelien-structure/worker/engineering.py +324 -0
  72. package/skills/abelien-structure/worker/identity.mjs +98 -0
  73. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  74. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  75. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  76. package/skills/abelien-structure/worker/main.mjs +102 -0
  77. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  78. package/skills/abelien-structure/worker/materials.mjs +52 -0
  79. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  80. package/skills/abelien-structure/worker/native.mjs +157 -0
  81. package/skills/abelien-structure/worker/representation.mjs +47 -0
  82. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  83. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  84. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  85. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  86. package/skills/abelien-workspace/SKILL.md +11 -0
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  88. package/skills/abelien-workspace/domain.json +25 -2
  89. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  90. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  91. package/skills/abelien-workspace/references/user-journey.md +10 -0
  92. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  93. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  94. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  95. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  96. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  97. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  98. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  99. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  100. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  101. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  102. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
  103. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
  104. package/skills/abelien-workspace/workflows/user-journey-hardware.json +5 -4
@@ -0,0 +1,1623 @@
1
+ """Bounded sandbox invocation: trusted API export, non-executing AST precheck, independent build and inspection."""
2
+ import base64
3
+ import contextlib
4
+ import hashlib
5
+ from io import BytesIO
6
+ import itertools
7
+ import json
8
+ import math
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import struct
13
+ import sys
14
+
15
+ # OCCT also writes through native fd 1; Python redirect_stdout alone cannot isolate
16
+ # those messages from the JSON protocol. Only the explicit response uses this fd.
17
+ response_stream = os.fdopen(os.dup(1), 'w')
18
+
19
+
20
+ def failure_diagnostic(stage, exc):
21
+ """Bounded, model-facing reason a build failed: no paths, addresses, worker code or control chars."""
22
+ line = None
23
+ if isinstance(exc, SyntaxError) and exc.filename == 'model.py':
24
+ line = exc.lineno
25
+ tb = exc.__traceback__
26
+ while tb is not None: # innermost frame of the submitted source; worker frames never count
27
+ if tb.tb_frame.f_code.co_filename == 'model.py':
28
+ line = tb.tb_lineno
29
+ tb = tb.tb_next
30
+ message = re.sub(r'(?:[A-Za-z]:)?(?:/|\\)[^\s\'",)]+', '<path>', str(exc))
31
+ message = re.sub(r'0x[0-9a-fA-F]+', '0x?', message)
32
+ message = ''.join(ch if ch.isprintable() else ' ' for ch in message).strip()[:200]
33
+ name = type(exc).__name__
34
+ return {'stage': stage, 'exceptionType': name if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', name) else 'Error',
35
+ 'message': message, 'line': line if isinstance(line, int) and 0 < line <= 32768 else None}
36
+
37
+
38
+ def fail_with_diagnostic(stage, exc):
39
+ import traceback
40
+ traceback.print_exc(file=sys.stderr)
41
+ print(json.dumps({'diagnostic': failure_diagnostic(stage, exc)}, ensure_ascii=False), file=response_stream, flush=True)
42
+ raise SystemExit(0)
43
+ os.dup2(2, 1)
44
+
45
+ VIEWS = ('normal', 'internal', 'exploded')
46
+ DEFERRED = ('voltage', 'peak_current', 'port_occupancy', 'logic_level', 'concurrent_resources', 'actuator_load')
47
+ STABLE = re.compile(r'^[a-z0-9][a-z0-9._:-]{0,127}$')
48
+ TARGET_ID = re.compile(r'^[A-Za-z0-9][A-Za-z0-9:_.-]{0,127}$')
49
+ NODE_ID = re.compile(r'^[a-z0-9][a-z0-9_-]{0,127}$')
50
+ HEX = re.compile(r'^[a-f0-9]{64}$')
51
+ REVISION_CHANGES = ('geometry', 'transform', 'interfaces', 'appearance_mapping')
52
+
53
+ def load_cad_library(*, reset=False):
54
+ """Load the trusted adjacent library under isolated Python without changing sys.path."""
55
+ import importlib.util
56
+ library_path = Path(__file__).with_name('abelien_cad.py')
57
+ spec = importlib.util.spec_from_file_location('abelien_cad', library_path)
58
+ if spec is None or spec.loader is None:
59
+ raise RuntimeError('CAD library unavailable')
60
+ module = importlib.util.module_from_spec(spec)
61
+ sys.modules['abelien_cad'] = module
62
+ spec.loader.exec_module(module)
63
+ if reset:
64
+ module._RECORDS.clear()
65
+ return module
66
+
67
+
68
+ def load_source_call_stats():
69
+ """Load the trusted adjacent pure counter under isolated Python without changing sys.path."""
70
+ import importlib.util
71
+ helper_path = Path(__file__).with_name('source_call_stats.py')
72
+ spec = importlib.util.spec_from_file_location('_abelien_source_call_stats', helper_path)
73
+ if spec is None or spec.loader is None:
74
+ raise RuntimeError('source call statistics unavailable')
75
+ module = importlib.util.module_from_spec(spec)
76
+ spec.loader.exec_module(module)
77
+ return module.source_call_stats
78
+
79
+
80
+ source_call_stats = load_source_call_stats()
81
+
82
+
83
+ def library_signature(node):
84
+ """Python-style signature of one abelien_cad function from its own AST; omitted-sentinel defaults read as '...'."""
85
+ import ast
86
+ args = node.args
87
+ leading = [argument.arg for argument in args.posonlyargs + args.args]
88
+ keywords = [argument.arg if default is None else
89
+ f"{argument.arg}={'...' if isinstance(default, ast.Name) and default.id.startswith('_') else ast.unparse(default)}"
90
+ for argument, default in zip(args.kwonlyargs, args.kw_defaults)]
91
+ text = f"{node.name}({', '.join(leading + (['*'] + keywords if keywords else []))})"
92
+ if len(text) > 240 or not text.isprintable():
93
+ raise RuntimeError('CAD library signature invalid')
94
+ return text
95
+
96
+
97
+ def library_preflight(tree, request):
98
+ """Statically validate imports and trusted-library/runtime names; never execute model source."""
99
+ import ast
100
+ allowed_imports = {'build123d', 'math', 'abelien_cad', 'typing', 'dataclasses', 'itertools',
101
+ 'functools', 'collections', 'copy', 'enum'}
102
+ nodes = sorted(ast.walk(tree), key=lambda node: (getattr(node, 'lineno', 0), getattr(node, 'col_offset', 0)))
103
+ library_aliases, build123d_aliases, library_direct = set(), set(), {}
104
+ build123d_names = request.get('build123dNames')
105
+ dynamic_names = any(isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in {'exec', 'eval', 'globals', 'locals'} for node in nodes)
106
+ star_import = any(isinstance(node, ast.ImportFrom) and any(alias.name == '*' for alias in node.names) for node in nodes)
107
+ runtime_names = set(build123d_names) if not dynamic_names and not star_import and isinstance(build123d_names, list) and all(isinstance(name, str) for name in build123d_names) else None
108
+ for node in nodes:
109
+ if isinstance(node, ast.Import):
110
+ for alias in node.names:
111
+ if alias.name not in allowed_imports:
112
+ return {'code': 'source_unsupported', 'feature': 'import_unavailable',
113
+ 'line': min(max(node.lineno, 1), 32768)}
114
+ if alias.name == 'abelien_cad':
115
+ library_aliases.add(alias.asname or alias.name)
116
+ if alias.name == 'build123d':
117
+ build123d_aliases.add(alias.asname or alias.name)
118
+ elif isinstance(node, ast.ImportFrom):
119
+ module = node.module or ''
120
+ if node.level or module not in allowed_imports:
121
+ return {'code': 'source_unsupported', 'feature': 'import_unavailable',
122
+ 'line': min(max(node.lineno, 1), 32768)}
123
+
124
+ library_tree = ast.parse(Path(__file__).with_name('abelien_cad.py').read_text(encoding='utf8'), filename='abelien_cad.py')
125
+ public = set()
126
+ functions, positional, signatures = {}, {}, {}
127
+ for node in library_tree.body:
128
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
129
+ functions[node.name] = {argument.arg for argument in node.args.kwonlyargs}
130
+ positional[node.name] = len(node.args.posonlyargs) + len(node.args.args)
131
+ signatures[node.name] = library_signature(node)
132
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
133
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
134
+ if any(isinstance(target, ast.Name) and target.id == '__all__' for target in targets):
135
+ value = node.value
136
+ if isinstance(value, (ast.List, ast.Tuple)) and all(isinstance(item, ast.Constant) and isinstance(item.value, str) for item in value.elts):
137
+ public.update(item.value for item in value.elts)
138
+ if not public or any(name not in functions for name in public):
139
+ raise RuntimeError('CAD library index invalid')
140
+
141
+ library_unknown, argument_unknown, positional_excess, build123d_unknown = set(), set(), set(), set()
142
+ for node in nodes:
143
+ if isinstance(node, ast.ImportFrom) and node.module == 'abelien_cad':
144
+ for alias in node.names:
145
+ if alias.name == '*':
146
+ continue
147
+ if alias.name not in public:
148
+ library_unknown.add(alias.name)
149
+ else:
150
+ library_direct[alias.asname or alias.name] = alias.name
151
+ if isinstance(node, ast.ImportFrom) and node.module == 'build123d' and runtime_names is not None:
152
+ build123d_unknown.update(alias.name for alias in node.names if alias.name != '*' and alias.name not in runtime_names)
153
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
154
+ if node.value.id in library_aliases and node.attr not in public:
155
+ library_unknown.add(node.attr)
156
+ if node.value.id in build123d_aliases and runtime_names is not None and node.attr not in runtime_names:
157
+ build123d_unknown.add(node.attr)
158
+ if isinstance(node, ast.Call):
159
+ function = node.func.attr if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id in library_aliases \
160
+ else library_direct.get(node.func.id) if isinstance(node.func, ast.Name) else None
161
+ if function in public:
162
+ argument_unknown.update(f'{function}.{keyword.arg}' for keyword in node.keywords
163
+ if keyword.arg is not None and keyword.arg not in functions[function])
164
+ # Library parameters after the leading shape/profile/body are keyword-only; a starred call stays dynamic.
165
+ if not any(isinstance(argument, ast.Starred) for argument in node.args) and len(node.args) > positional[function]:
166
+ positional_excess.add(function)
167
+ bounded = lambda values: sorted(name for name in values if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', name))[:5]
168
+ bounded_arguments = lambda values: sorted(name for name in values if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}\.[A-Za-z_][A-Za-z0-9_]{0,63}', name))[:5]
169
+ unknown_names = bounded(library_unknown)
170
+ unknown_arguments = bounded_arguments(argument_unknown)
171
+ excess_positional = bounded(positional_excess)
172
+ runtime_unknown = bounded(build123d_unknown)
173
+ # Argument errors carry the exact signature of each affected function so the next attempt can call it correctly.
174
+ signatures_for = lambda names: [signatures[name] for name in sorted({name.split('.', 1)[0] for name in names})]
175
+ if unknown_names:
176
+ return {'code': 'library_unknown', 'names': unknown_names}
177
+ if unknown_arguments:
178
+ return {'code': 'library_argument_unknown', 'names': unknown_arguments, 'signatures': signatures_for(unknown_arguments)}
179
+ if excess_positional:
180
+ return {'code': 'library_argument_positional', 'names': excess_positional, 'signatures': signatures_for(excess_positional)}
181
+ if runtime_unknown:
182
+ return {'code': 'build123d_unknown', 'names': runtime_unknown}
183
+ return None
184
+
185
+
186
+ def representation_text(value):
187
+ if not isinstance(value, str) or not value.strip() or value != value.strip() or len(value) > 200:
188
+ raise ValueError('invalid representation text')
189
+ return value
190
+
191
+
192
+ def validate_representation(value, assembly):
193
+ """Strictly bind optional representation evidence to the exact occurrence assembly."""
194
+ if not isinstance(value, dict) or set(value) != {'schema', 'occurrences', 'stats'} or \
195
+ value.get('schema') != 'abelien.structure-representation.v1' or assembly.get('mode') != 'occurrence_assembly':
196
+ raise ValueError('invalid representation')
197
+ occurrences, stats = value.get('occurrences'), value.get('stats')
198
+ owners = assembly.get('occurrences')
199
+ if not isinstance(occurrences, list) or not isinstance(owners, list) or len(occurrences) != len(owners) or \
200
+ not isinstance(stats, dict) or set(stats) != {'libraryCalls', 'build123dCalls'} or \
201
+ any(not isinstance(stats.get(name), int) or isinstance(stats.get(name), bool) or stats[name] < 0
202
+ for name in ('libraryCalls', 'build123dCalls')):
203
+ raise ValueError('invalid representation')
204
+ for item, owner in zip(occurrences, owners):
205
+ if not isinstance(item, dict) or set(item) != {'occurrenceId', 'representation', 'omitted'} or \
206
+ item.get('occurrenceId') != owner.get('occurrenceId') or item.get('representation') not in ('detailed', 'simplified', 'envelope') or \
207
+ not isinstance(item.get('omitted'), list) or len(item['omitted']) > 16:
208
+ raise ValueError('invalid representation occurrence')
209
+ precision = owner.get('geometryPrecision')
210
+ if precision == 'envelope':
211
+ if item['representation'] != 'envelope':
212
+ raise ValueError('envelope representation mismatch')
213
+ elif precision == 'custom_brep':
214
+ if item['representation'] == 'envelope':
215
+ raise ValueError('custom representation mismatch')
216
+ else:
217
+ raise ValueError('unsupported representation precision')
218
+ if item['representation'] == 'detailed' and item['omitted'] or item['representation'] == 'simplified' and not item['omitted']:
219
+ raise ValueError('representation gaps mismatch')
220
+ for omission in item['omitted']:
221
+ if not isinstance(omission, dict) or set(omission) != {'feature', 'reason', 'affects'} or \
222
+ not isinstance(omission.get('affects'), list) or not omission['affects'] or len(omission['affects']) > 32:
223
+ raise ValueError('invalid representation omission')
224
+ representation_text(omission.get('feature')); representation_text(omission.get('reason'))
225
+ for affect in omission['affects']:
226
+ representation_text(affect)
227
+ return value
228
+
229
+
230
+ def build_representation(source, records, assembly, library_names, build123d_names):
231
+ """Convert trusted-library records to the report contract after assembly identities exist."""
232
+ if not isinstance(records, list) or len(records) > 512:
233
+ raise ValueError('invalid representation records')
234
+ if assembly.get('mode') != 'occurrence_assembly':
235
+ if records:
236
+ raise ValueError('representation record requires occurrence assembly')
237
+ return None
238
+ owners = {item['occurrenceId']: item for item in assembly['occurrences']}
239
+ rows = {item['occurrenceId']: {'occurrenceId': item['occurrenceId'],
240
+ 'representation': 'envelope' if item['geometryPrecision'] == 'envelope' else 'detailed', 'omitted': []}
241
+ for item in assembly['occurrences']}
242
+ for record in records:
243
+ if not isinstance(record, dict) or record.get('kind') not in ('simplified', 'envelope'):
244
+ raise ValueError('invalid representation record')
245
+ kind, expected = record['kind'], {'kind', 'occurrence_id', 'reason', 'omitted', 'affects'} if record['kind'] == 'simplified' \
246
+ else {'kind', 'occurrence_id', 'reason'}
247
+ if set(record) != expected:
248
+ raise ValueError('invalid representation record')
249
+ occurrence_id = record.get('occurrence_id')
250
+ owner, row = owners.get(occurrence_id), rows.get(occurrence_id)
251
+ if owner is None or row is None:
252
+ raise ValueError('representation missing occurrence')
253
+ reason = representation_text(record.get('reason'))
254
+ if kind == 'envelope':
255
+ if owner['geometryPrecision'] != 'envelope':
256
+ raise ValueError('envelope representation mismatch')
257
+ row['omitted'].append({'feature': 'detailed_geometry', 'reason': reason,
258
+ 'affects': ['geometry_fidelity']})
259
+ else:
260
+ omitted, affects = record.get('omitted'), record.get('affects')
261
+ if owner['geometryPrecision'] != 'custom_brep' or not isinstance(omitted, list) or not omitted or \
262
+ not isinstance(affects, list) or not affects or len(affects) > 32:
263
+ raise ValueError('invalid simplified representation')
264
+ affects = [representation_text(item) for item in affects]
265
+ row['omitted'].extend({'feature': representation_text(feature), 'reason': reason, 'affects': list(affects)}
266
+ for feature in omitted)
267
+ row['representation'] = 'simplified'
268
+ if len(row['omitted']) > 16:
269
+ raise ValueError('representation omissions exceed supported bound')
270
+ value = {'schema': 'abelien.structure-representation.v1',
271
+ 'occurrences': [rows[item['occurrenceId']] for item in assembly['occurrences']],
272
+ 'stats': source_call_stats(source, library_names, build123d_names)}
273
+ return validate_representation(value, assembly)
274
+
275
+ def encode(path):
276
+ return base64.b64encode(Path(path).read_bytes()).decode('ascii')
277
+
278
+ def canonical_manifest(value):
279
+ """Language-neutral manifest hash input; every number is its IEEE-754 value."""
280
+ if value is None:
281
+ return 'null'
282
+ if isinstance(value, bool):
283
+ return 'true' if value else 'false'
284
+ if isinstance(value, (int, float)):
285
+ number = float(value)
286
+ if not math.isfinite(number):
287
+ raise ValueError('non-finite manifest number')
288
+ return '#' + struct.pack('>d', number).hex()
289
+ if isinstance(value, str):
290
+ return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
291
+ if isinstance(value, (list, tuple)):
292
+ return '[' + ','.join(canonical_manifest(item) for item in value) + ']'
293
+ if isinstance(value, dict) and all(isinstance(key, str) for key in value):
294
+ return '{' + ','.join(json.dumps(key, ensure_ascii=False) + ':' + canonical_manifest(value[key]) for key in sorted(value)) + '}'
295
+ raise ValueError('unsupported manifest value')
296
+
297
+ def digest(value):
298
+ if isinstance(value, bytes):
299
+ body = value
300
+ else:
301
+ body = canonical_manifest(value).encode()
302
+ return hashlib.sha256(body).hexdigest()
303
+
304
+ def vector(value):
305
+ if not isinstance(value, (list, tuple)) or len(value) != 3 or not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(item) for item in value):
306
+ raise ValueError('invalid assembly transform')
307
+ result = [float(item) for item in value]
308
+ if any(abs(item) > 1_000_000 for item in result):
309
+ raise ValueError('assembly transform outside bounds')
310
+ return result
311
+
312
+ def transform(value, *, normal=False):
313
+ if not isinstance(value, dict) or set(value) != {'translation_mm', 'rotation_deg', 'visible'} or not isinstance(value['visible'], bool):
314
+ raise ValueError('invalid assembly transform')
315
+ result = {'translationMm': vector(value['translation_mm']), 'rotationDeg': vector(value['rotation_deg']), 'visible': value['visible']}
316
+ if normal and not result['visible']:
317
+ raise ValueError('normal assembly occurrence must be visible')
318
+ return result
319
+
320
+ def shape_digest(shape):
321
+ stream = BytesIO()
322
+ bd.export_brep(shape, stream)
323
+ return digest(stream.getvalue())
324
+
325
+ def normalize_mount_frames(value):
326
+ if value is None:
327
+ return []
328
+ if not isinstance(value, list) or len(value) > 32:
329
+ raise ValueError('invalid mount frames')
330
+ rows, identifiers = [], set()
331
+ for item in value:
332
+ if not isinstance(item, dict) or set(item) != {'frame_id', 'translation_mm', 'rotation_deg'} or not isinstance(item['frame_id'], str) or not NODE_ID.fullmatch(item['frame_id']) or item['frame_id'] in identifiers:
333
+ raise ValueError('invalid or duplicate mount frame')
334
+ identifiers.add(item['frame_id'])
335
+ rows.append({'frameId': item['frame_id'], 'translationMm': vector(item['translation_mm']), 'rotationDeg': vector(item['rotation_deg'])})
336
+ return rows
337
+
338
+ def normalize_interfaces(value, frames):
339
+ if value is None:
340
+ return []
341
+ if not isinstance(value, list) or len(value) > 32:
342
+ raise ValueError('invalid interfaces')
343
+ frame_ids = {item['frameId'] for item in frames}; rows, identifiers = [], set()
344
+ for item in value:
345
+ if not isinstance(item, dict) or set(item) != {'interface_id', 'frame_id', 'compatibility_key'} or \
346
+ not isinstance(item['interface_id'], str) or not NODE_ID.fullmatch(item['interface_id']) or item['interface_id'] in identifiers or \
347
+ not isinstance(item['frame_id'], str) or item['frame_id'] not in frame_ids or \
348
+ not isinstance(item['compatibility_key'], str) or not STABLE.fullmatch(item['compatibility_key']):
349
+ raise ValueError('invalid or duplicate mount interface')
350
+ identifiers.add(item['interface_id'])
351
+ rows.append({'interfaceId': item['interface_id'], 'frameId': item['frame_id'], 'compatibilityKey': item['compatibility_key']})
352
+ return rows
353
+
354
+ def normalize_appearance_bindings(value):
355
+ if value is None:
356
+ return []
357
+ if not isinstance(value, list) or len(value) > 32:
358
+ raise ValueError('invalid appearance bindings')
359
+ rows, identifiers = [], set()
360
+ for item in value:
361
+ if not isinstance(item, dict) or set(item) != {'binding_id', 'material_slot'} or \
362
+ not isinstance(item['binding_id'], str) or not TARGET_ID.fullmatch(item['binding_id']) or item['binding_id'] in identifiers or \
363
+ not isinstance(item['material_slot'], str) or not STABLE.fullmatch(item['material_slot']):
364
+ raise ValueError('invalid or duplicate appearance binding')
365
+ identifiers.add(item['binding_id']); rows.append({'bindingId': item['binding_id'], 'materialSlot': item['material_slot']})
366
+ return rows
367
+
368
+ def normalize_lineage(value):
369
+ if value is None:
370
+ return None
371
+ if not isinstance(value, dict) or set(value) != {'kind', 'prior_assembly_sha256', 'previous_occurrence_ids'} or value['kind'] not in ('preserved', 'replaced', 'split', 'merged') or \
372
+ not isinstance(value['prior_assembly_sha256'], str) or not HEX.fullmatch(value['prior_assembly_sha256']) or \
373
+ not isinstance(value['previous_occurrence_ids'], list) or not value['previous_occurrence_ids'] or len(value['previous_occurrence_ids']) > 128 or len(set(value['previous_occurrence_ids'])) != len(value['previous_occurrence_ids']) or \
374
+ any(not isinstance(item, str) or not NODE_ID.fullmatch(item) for item in value['previous_occurrence_ids']):
375
+ raise ValueError('invalid occurrence lineage')
376
+ return {'kind': value['kind'], 'priorAssemblySha256': value['prior_assembly_sha256'], 'previousOccurrenceIds': list(value['previous_occurrence_ids'])}
377
+
378
+ def component_counts(occurrences):
379
+ counts = {}
380
+ for occurrence in occurrences:
381
+ counts[occurrence['componentId']] = counts.get(occurrence['componentId'], 0) + 1
382
+ return [{'componentId': key, 'count': counts[key]} for key in sorted(counts)]
383
+
384
+ def occurrence_identity(occurrences):
385
+ return [{key: occurrence[key] for key in ('occurrenceId', 'componentId', 'bomItemId', 'source', 'geometryPrecision',
386
+ 'geometrySha256', 'transformSha256', 'interfaceSha256', 'appearanceSha256',
387
+ 'mountFrames', 'interfaces', 'appearanceBindings', 'lineage', 'material') if key in occurrence} for occurrence in occurrences]
388
+
389
+ def normalize_material(value):
390
+ if not isinstance(value, dict) or set(value) != {'base_color_srgb', 'roughness', 'metallic'} or \
391
+ not isinstance(value['base_color_srgb'], str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', value['base_color_srgb']) or \
392
+ any(not isinstance(value[key], (int, float)) or isinstance(value[key], bool) or \
393
+ not math.isfinite(value[key]) or not 0 <= value[key] <= 1 for key in ('roughness', 'metallic')):
394
+ raise ValueError('invalid occurrence material')
395
+ return {'baseColorSrgb': value['base_color_srgb'].lower(), 'roughness': float(value['roughness']), 'metallic': float(value['metallic'])}
396
+
397
+ def appearance_identity(bindings, material):
398
+ return bindings if material is None else {'appearanceBindings': bindings, 'material': material}
399
+
400
+ def material_linear_color(material):
401
+ channels = [int(material['baseColorSrgb'][index:index+2], 16) / 255 for index in (1, 3, 5)]
402
+ return [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels] + [1.0]
403
+
404
+ def apply_materials_to_glb(raw, assembly):
405
+ if not any('material' in occurrence for occurrence in assembly['occurrences']):
406
+ return raw
407
+ document = gltf_document(raw)
408
+ validate_occurrence_nodes(document, assembly)
409
+ materials = document.setdefault('materials', [])
410
+ for occurrence in assembly['occurrences']:
411
+ material = occurrence.get('material')
412
+ if material is None:
413
+ continue
414
+ index = len(materials)
415
+ materials.append({'name': 'occurrence-material:' + occurrence['occurrenceId'],
416
+ 'pbrMetallicRoughness': {'baseColorFactor': material_linear_color(material),
417
+ 'roughnessFactor': material['roughness'], 'metallicFactor': material['metallic']},
418
+ 'alphaMode': 'OPAQUE', 'doubleSided': True})
419
+ nodes = document['nodes']
420
+ root_index = next(index for index, node in enumerate(nodes) if node.get('name') == occurrence['occurrenceId'])
421
+ def assign(node_index):
422
+ node = nodes[node_index]
423
+ if 'mesh' in node:
424
+ mesh = json.loads(json.dumps(document['meshes'][node['mesh']]))
425
+ for primitive in mesh['primitives']:
426
+ primitive['material'] = index
427
+ primitive.get('attributes', {}).pop('COLOR_0', None)
428
+ node['mesh'] = len(document['meshes'])
429
+ document['meshes'].append(mesh)
430
+ for child in node.get('children', []):
431
+ assign(child)
432
+ assign(root_index)
433
+ encoded = json.dumps(document, separators=(',', ':'), ensure_ascii=False).encode()
434
+ encoded += b' ' * ((-len(encoded)) % 4)
435
+ tail = raw[20 + int.from_bytes(raw[12:16], 'little'):]
436
+ return b'glTF' + struct.pack('<II', 2, 20 + len(encoded) + len(tail)) + struct.pack('<II', len(encoded), 0x4E4F534A) + encoded + tail
437
+
438
+ def inspection_number(value):
439
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value < 0 or value > 1_000_000_000_000:
440
+ raise ValueError('invalid inspection threshold')
441
+ return float(value)
442
+
443
+ def inspection_ids(value, identifiers, *, minimum=1):
444
+ if not isinstance(value, list) or not minimum <= len(value) <= 128 or len(set(value)) != len(value) or any(not isinstance(item, str) or item not in identifiers for item in value):
445
+ raise ValueError('invalid inspection occurrence list')
446
+ return list(value)
447
+
448
+ def not_applicable(value):
449
+ if not isinstance(value, dict) or set(value) != {'applicable', 'reason'} or value['applicable'] is not False or not isinstance(value['reason'], str) or not value['reason'].strip() or len(value['reason']) > 300:
450
+ return None
451
+ return {'kind': 'not_applicable', 'reason': value['reason'].strip()}
452
+
453
+ def normalize_inspection_geometry(raw, occurrences, source_sha256):
454
+ """Inspection bodies travel separately; they are never product occurrences."""
455
+ if raw is None:
456
+ return None, None
457
+ if not isinstance(raw, list) or not 1 <= len(raw) <= 32:
458
+ raise ValueError('invalid inspection geometry')
459
+ owners = {item['occurrenceId']: item for item in occurrences}
460
+ identifiers, shell_ids, regions, shells, breps = set(owners), set(), [], [], {}
461
+ for item in raw:
462
+ if not isinstance(item, dict) or set(item) != {'shell', 'outer', 'cavity', 'openings'} or item['shell'] not in owners or item['shell'] in shell_ids:
463
+ raise ValueError('invalid inspection shell')
464
+ owner = owners[item['shell']]
465
+ if owner['geometryPrecision'] != 'custom_brep':
466
+ raise ValueError('inspection shell must be actual custom BRep')
467
+ shell_ids.add(item['shell'])
468
+ if not isinstance(item['openings'], list) or len(item['openings']) > 32:
469
+ raise ValueError('invalid inspection openings')
470
+
471
+ def register(region, kind):
472
+ if not isinstance(region, dict) or set(region) != {'region_id', 'shape'} or not isinstance(region['region_id'], str) or not NODE_ID.fullmatch(region['region_id']) or region['region_id'] in identifiers or not isinstance(region['shape'], bd.Shape):
473
+ raise ValueError('invalid or duplicate inspection region')
474
+ region_id = region['region_id']; identifiers.add(region_id)
475
+ stream = BytesIO(); bd.export_brep(region['shape'], stream); body = stream.getvalue()
476
+ if not body:
477
+ raise ValueError('empty inspection BRep')
478
+ breps[region_id] = base64.b64encode(body).decode('ascii')
479
+ regions.append({'regionId': region_id, 'kind': kind, 'ownerOccurrenceId': owner['occurrenceId'], 'brepSha256': digest(body)})
480
+ return region_id
481
+
482
+ outer_id, cavity_id = register(item['outer'], 'outer'), register(item['cavity'], 'cavity')
483
+ opening_ids = [register(region, 'opening') for region in item['openings']]
484
+ shells.append({'occurrenceId': owner['occurrenceId'], 'geometrySha256': owner['geometrySha256'],
485
+ 'transformSha256': owner['transformSha256'], 'outerRegionId': outer_id,
486
+ 'cavityRegionId': cavity_id, 'openingRegionIds': opening_ids})
487
+ if len(regions) > 128 or sum(len(value) for value in breps.values()) > 12 * 1024 * 1024:
488
+ raise ValueError('inspection geometry exceeds bound')
489
+ return {'schema': 'abelien.structure-inspection-geometry.v1', 'sourceSha256': source_sha256,
490
+ 'regions': regions, 'shells': shells}, breps
491
+
492
+ def normalize_inspection(raw, occurrences, geometry=None):
493
+ identifiers = {item['occurrenceId'] for item in occurrences}
494
+ regions = {item['regionId']: item['kind'] for item in geometry['regions']} if geometry else {}
495
+ keys = {'collision', 'containment', 'openings', 'motion_envelopes'}
496
+ if raw is not None and (not isinstance(raw, dict) or not set(raw).issubset(keys)):
497
+ raise ValueError('invalid inspection plan')
498
+ raw = raw or {}
499
+
500
+ collision = raw.get('collision')
501
+ if collision is not None:
502
+ omitted = not_applicable(collision)
503
+ if omitted:
504
+ collision = omitted
505
+ else:
506
+ if not isinstance(collision, dict) or set(collision) != {'occurrence_ids', 'allowed_pairs', 'maximum_overlap_mm3'}:
507
+ raise ValueError('invalid collision inspection')
508
+ occurrence_ids = inspection_ids(collision['occurrence_ids'], identifiers, minimum=2)
509
+ pairs, seen = [], set()
510
+ if not isinstance(collision['allowed_pairs'], list) or len(collision['allowed_pairs']) > 8128:
511
+ raise ValueError('invalid collision allowed pairs')
512
+ for pair in collision['allowed_pairs']:
513
+ if not isinstance(pair, list) or len(pair) != 2 or pair[0] == pair[1] or any(item not in occurrence_ids for item in pair):
514
+ raise ValueError('invalid collision allowed pair')
515
+ normalized = tuple(sorted(pair))
516
+ if normalized in seen:
517
+ raise ValueError('duplicate collision allowed pair')
518
+ seen.add(normalized); pairs.append(list(normalized))
519
+ collision = {'kind': 'rules', 'occurrenceIds': occurrence_ids, 'allowedPairs': sorted(pairs),
520
+ 'maximumOverlapMm3': inspection_number(collision['maximum_overlap_mm3'])}
521
+
522
+ def normalize_rules(name, output_name, required, normalize):
523
+ value = raw.get(name)
524
+ if value is None:
525
+ return None
526
+ omitted = not_applicable(value)
527
+ if omitted:
528
+ return omitted
529
+ if not isinstance(value, list) or not value or len(value) > 128:
530
+ raise ValueError(f'invalid {name} inspection')
531
+ rows = []
532
+ for item in value:
533
+ if not isinstance(item, dict) or set(item) != required:
534
+ raise ValueError(f'invalid {name} inspection rule')
535
+ rows.append(normalize(item))
536
+ return {'kind': 'rules', output_name: rows}
537
+
538
+ def containment_rule(item):
539
+ container = item['container']
540
+ if not isinstance(container, str) or (container not in identifiers and regions.get(container) != 'cavity'):
541
+ raise ValueError('invalid containment container')
542
+ targets = inspection_ids(item['targets'], identifiers)
543
+ if container in targets:
544
+ raise ValueError('containment target cannot be its container')
545
+ return {'container': container, 'targets': targets, 'maximumOutsideMm3': inspection_number(item['maximum_outside_mm3'])}
546
+
547
+ def opening_rule(item):
548
+ opening, target = item['opening'], item['target']
549
+ if not isinstance(opening, str) or not isinstance(target, str) or (opening not in identifiers and regions.get(opening) != 'opening') or target not in identifiers or opening == target:
550
+ raise ValueError('invalid opening inspection identities')
551
+ if opening in regions and inspection_number(item['minimum_overlap_mm3']) <= 0:
552
+ raise ValueError('inspection opening needs positive target overlap')
553
+ return {'opening': opening, 'target': target, 'maximumDistanceMm': inspection_number(item['maximum_distance_mm']),
554
+ 'minimumOverlapMm3': inspection_number(item['minimum_overlap_mm3'])}
555
+
556
+ def motion_rule(item):
557
+ envelope = item['envelope']
558
+ if not isinstance(envelope, str) or envelope not in identifiers:
559
+ raise ValueError('invalid motion envelope')
560
+ obstacles = inspection_ids(item['obstacles'], identifiers)
561
+ if envelope in obstacles:
562
+ raise ValueError('motion envelope cannot obstruct itself')
563
+ return {'envelope': envelope, 'obstacles': obstacles, 'maximumOverlapMm3': inspection_number(item['maximum_overlap_mm3'])}
564
+
565
+ containment = normalize_rules('containment', 'rules', {'container', 'targets', 'maximum_outside_mm3'}, containment_rule)
566
+ openings = normalize_rules('openings', 'rules', {'opening', 'target', 'maximum_distance_mm', 'minimum_overlap_mm3'}, opening_rule)
567
+ motion = normalize_rules('motion_envelopes', 'rules', {'envelope', 'obstacles', 'maximum_overlap_mm3'}, motion_rule)
568
+ return {'schema': 'abelien.structure-inspection-plan.v2' if geometry else 'abelien.structure-inspection-plan.v1',
569
+ 'collision': collision, 'containment': containment, 'openings': openings, 'motionEnvelopes': motion,
570
+ **({'inspectionGeometry': geometry} if geometry else {})}
571
+
572
+ def revision_not_applicable():
573
+ return {name: {'status': 'not_applicable', 'method': 'declared_not_applicable', 'reason': 'initial_build', 'findings': []}
574
+ for name in ('delta', 'preservation', 'mount')}
575
+
576
+ def rotation_matrix(degrees):
577
+ x, y, z = (math.radians(value) for value in degrees)
578
+ cx, sx, cy, sy, cz, sz = math.cos(x), math.sin(x), math.cos(y), math.sin(y), math.cos(z), math.sin(z)
579
+ return [[cy * cz, -cy * sz, sy],
580
+ [sx * sy * cz + cx * sz, -sx * sy * sz + cx * cz, -sx * cy],
581
+ [-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]]
582
+
583
+ def matrix_multiply(left, right):
584
+ return [[sum(left[row][axis] * right[axis][column] for axis in range(3)) for column in range(3)] for row in range(3)]
585
+
586
+ def matrix_vector(matrix, vector):
587
+ return [sum(matrix[row][axis] * vector[axis] for axis in range(3)) for row in range(3)]
588
+
589
+ def matrix_transpose(matrix):
590
+ return [[matrix[column][row] for column in range(3)] for row in range(3)]
591
+
592
+ def vector_add(left, right):
593
+ return [left[index] + right[index] for index in range(3)]
594
+
595
+ def vector_subtract(left, right):
596
+ return [left[index] - right[index] for index in range(3)]
597
+
598
+ def frame_matrix(occurrence, frame_id):
599
+ frame = next((item for item in occurrence['mountFrames'] if item['frameId'] == frame_id), None)
600
+ if frame is None:
601
+ raise ValueError('mount alignment frame missing')
602
+ normal = occurrence['views']['normal']
603
+ normal_rotation = rotation_matrix(normal['rotationDeg'])
604
+ frame_rotation = rotation_matrix(frame['rotationDeg'])
605
+ rotation = matrix_multiply(normal_rotation, frame_rotation)
606
+ position = vector_add(normal['translationMm'], matrix_vector(normal_rotation, frame['translationMm']))
607
+ return [rotation[row] + [position[row]] for row in range(3)]
608
+
609
+ def euler_degrees(rotation):
610
+ # Inverse of rotation_matrix for the same Rx * Ry * Rz convention. The
611
+ # singular branch is deterministic; mount checks reject any residual error.
612
+ y = math.asin(max(-1.0, min(1.0, rotation[0][2])))
613
+ cy = math.cos(y)
614
+ if abs(cy) > 1e-9:
615
+ x = math.atan2(-rotation[1][2], rotation[2][2])
616
+ z = math.atan2(-rotation[0][1], rotation[0][0])
617
+ else:
618
+ x = math.atan2(rotation[2][1], rotation[1][1])
619
+ z = 0.0
620
+ return [0.0 if abs(value) < 1e-12 else math.degrees(value) for value in (x, y, z)]
621
+
622
+ def apply_rigid_transform(view, correction_rotation, correction_translation):
623
+ view_rotation = rotation_matrix(view['rotationDeg'])
624
+ rotation = matrix_multiply(correction_rotation, view_rotation)
625
+ position = [0.0 if abs(value) < 1e-12 else value
626
+ for value in vector_add(matrix_vector(correction_rotation, view['translationMm']), correction_translation)]
627
+ return {'translationMm': position, 'rotationDeg': euler_degrees(rotation), 'visible': view['visible']}
628
+
629
+ def auto_align_mounts(previous, current, alignments):
630
+ """Move each replaced occurrence onto its prior mount frame without scaling.
631
+
632
+ The correction is a rigid transform in assembly space and is applied to all
633
+ views, so STEP, GLB and rendered views are generated from the same corrected
634
+ assembly. Ambiguous many-to-one mount requests fail closed.
635
+ """
636
+ aligned_targets = set()
637
+ for alignment in alignments:
638
+ previous_id, next_id = alignment['previousOccurrenceId'], alignment['nextOccurrenceId']
639
+ if next_id in aligned_targets:
640
+ raise ValueError('mount alignment target is ambiguous')
641
+ aligned_targets.add(next_id)
642
+ before, after = previous[previous_id], current[next_id]
643
+ compatible_before = any(item['frameId'] == alignment['previousFrameId'] and item['compatibilityKey'] == alignment['compatibilityKey'] for item in before['interfaces'])
644
+ compatible_after = any(item['frameId'] == alignment['nextFrameId'] and item['compatibilityKey'] == alignment['compatibilityKey'] for item in after['interfaces'])
645
+ if not compatible_before or not compatible_after:
646
+ raise ValueError('mount interface incompatible or misaligned')
647
+ before_frame = frame_matrix(before, alignment['previousFrameId'])
648
+ after_frame = frame_matrix(after, alignment['nextFrameId'])
649
+ before_rotation = [row[:3] for row in before_frame]
650
+ after_rotation = [row[:3] for row in after_frame]
651
+ before_position = [row[3] for row in before_frame]
652
+ after_position = [row[3] for row in after_frame]
653
+ correction_rotation = matrix_multiply(before_rotation, matrix_transpose(after_rotation))
654
+ correction_translation = vector_subtract(before_position, matrix_vector(correction_rotation, after_position))
655
+ for view_name in VIEWS:
656
+ after['views'][view_name] = apply_rigid_transform(after['views'][view_name], correction_rotation, correction_translation)
657
+ after['transformSha256'] = digest(after['views'])
658
+ return aligned_targets
659
+
660
+ def frame_errors(previous, next_value):
661
+ translation = math.sqrt(sum((previous[row][3] - next_value[row][3]) ** 2 for row in range(3)))
662
+ relative_trace = sum(sum(previous[row][axis] * next_value[row][axis] for row in range(3)) for axis in range(3))
663
+ angle = math.degrees(math.acos(max(-1.0, min(1.0, (relative_trace - 1.0) / 2.0))))
664
+ return translation, angle
665
+
666
+ def validate_revision(prior, occurrences, revision):
667
+ if revision is None:
668
+ if any(item['lineage'] is not None for item in occurrences):
669
+ raise ValueError('initial assembly cannot claim revision lineage')
670
+ return None, revision_not_applicable()
671
+ if not isinstance(prior, dict) or prior.get('schema') != 'abelien.structure-assembly.v2' or prior.get('mode') != 'occurrence_assembly':
672
+ raise ValueError('revision prior assembly unavailable')
673
+ public_keys = {'schema', 'prior', 'targetOccurrenceIds', 'allowedChanges', 'preserveOccurrenceIds',
674
+ 'preserveAppearanceTargetIds', 'appearanceTargetBindings', 'replacementMappings', 'mountAlignments', 'requestSha256'}
675
+ if not isinstance(revision, dict) or set(revision) != public_keys | {'priorAssembly'} or revision.get('schema') != 'abelien.structure-revision.v1' or revision.get('priorAssembly') != prior:
676
+ raise ValueError('invalid revision request')
677
+ summary = {key: revision[key] for key in public_keys}
678
+ unsigned = dict(summary); request_sha256 = unsigned.pop('requestSha256', None)
679
+ if not isinstance(request_sha256, str) or request_sha256 != digest(unsigned):
680
+ raise ValueError('revision request hash mismatch')
681
+ prior_subject = summary['prior']
682
+ if not isinstance(prior_subject, dict) or set(prior_subject) != {'jobId', 'sourceSha256', 'assemblySha256', 'occurrenceSetSha256'} or \
683
+ prior_subject['sourceSha256'] != prior.get('sourceSha256') or prior_subject['assemblySha256'] != prior.get('assemblySha256') or prior_subject['occurrenceSetSha256'] != prior.get('occurrenceSetSha256'):
684
+ raise ValueError('revision prior subject mismatch')
685
+ previous = {item['occurrenceId']: item for item in prior['occurrences']}; current = {item['occurrenceId']: item for item in occurrences}
686
+ targets, preserved = summary['targetOccurrenceIds'], summary['preserveOccurrenceIds']
687
+ if not isinstance(targets, list) or not targets or not isinstance(preserved, list) or len(set(targets)) != len(targets) or len(set(preserved)) != len(preserved) or set(targets) & set(preserved) or set(targets) | set(preserved) != set(previous):
688
+ raise ValueError('revision target/preserve partition invalid')
689
+ if not isinstance(summary['allowedChanges'], list) or not summary['allowedChanges'] or len(set(summary['allowedChanges'])) != len(summary['allowedChanges']) or any(item not in REVISION_CHANGES for item in summary['allowedChanges']):
690
+ raise ValueError('revision allowed changes invalid')
691
+ alignments = summary['mountAlignments']
692
+ if not isinstance(alignments, list) or len(alignments) > 128:
693
+ raise ValueError('invalid mount alignments')
694
+ aligned_targets = auto_align_mounts(previous, current, alignments)
695
+ mappings = summary['replacementMappings']
696
+ if not isinstance(mappings, list) or not mappings or len(mappings) > 128:
697
+ raise ValueError('revision replacement mappings missing')
698
+ previous_seen, next_seen, delta_findings = set(), set(), []
699
+ digest_fields = {'geometry': 'geometrySha256', 'transform': 'transformSha256', 'interfaces': 'interfaceSha256', 'appearance_mapping': 'appearanceSha256'}
700
+ for mapping in mappings:
701
+ if not isinstance(mapping, dict) or set(mapping) != {'kind', 'previousOccurrenceIds', 'nextOccurrenceIds'} or mapping['kind'] not in ('preserved', 'replaced', 'split', 'merged'):
702
+ raise ValueError('invalid replacement mapping')
703
+ old_ids, new_ids = mapping['previousOccurrenceIds'], mapping['nextOccurrenceIds']
704
+ if not isinstance(old_ids, list) or not old_ids or not isinstance(new_ids, list) or not new_ids or len(set(old_ids)) != len(old_ids) or len(set(new_ids)) != len(new_ids) or any(item not in previous for item in old_ids) or any(item not in current for item in new_ids) or previous_seen.intersection(old_ids) or next_seen.intersection(new_ids):
705
+ raise ValueError('invalid replacement mapping identity')
706
+ if mapping['kind'] in ('preserved', 'replaced') and (len(old_ids) != 1 or len(new_ids) != 1) or mapping['kind'] == 'split' and (len(old_ids) != 1 or len(new_ids) < 2) or mapping['kind'] == 'merged' and (len(old_ids) < 2 or len(new_ids) != 1):
707
+ raise ValueError('replacement mapping cardinality mismatch')
708
+ previous_seen.update(old_ids); next_seen.update(new_ids)
709
+ changed = []
710
+ for change, field in digest_fields.items():
711
+ before = sorted(previous[item][field] for item in old_ids); after = sorted(current[item][field] for item in new_ids)
712
+ # A mount correction is a server-derived rigid transform, not an
713
+ # independently authorized user change. The corrected pose is
714
+ # still recorded in the manifest and checked below.
715
+ derived_transform = change == 'transform' and all(item in aligned_targets for item in new_ids)
716
+ if before != after and not derived_transform:
717
+ changed.append(change)
718
+ if any(change not in summary['allowedChanges'] for change in changed):
719
+ raise ValueError('revision changed a field outside allowed changes')
720
+ delta_findings.append({'kind': mapping['kind'], 'previousOccurrenceIds': old_ids, 'nextOccurrenceIds': new_ids, 'changes': changed})
721
+ for next_id in new_ids:
722
+ lineage = current[next_id]['lineage']
723
+ if lineage != {'kind': mapping['kind'], 'priorAssemblySha256': prior['assemblySha256'], 'previousOccurrenceIds': old_ids}:
724
+ raise ValueError('occurrence lineage does not match replacement mapping')
725
+ if previous_seen != set(previous) or next_seen != set(current):
726
+ raise ValueError('revision mapping does not cover complete assemblies')
727
+ # Source/report versions, presentation views and interface labels alone
728
+ # cannot turn an unchanged physical target into a repaired candidate.
729
+ physical_change = False
730
+ for mapping in mappings:
731
+ old_ids, new_ids = mapping['previousOccurrenceIds'], mapping['nextOccurrenceIds']
732
+ if not set(old_ids).intersection(targets):
733
+ continue
734
+ for change, identity in (
735
+ ('geometry', lambda item: item['geometrySha256']),
736
+ ('transform', lambda item: digest(item['views']['normal'])),
737
+ ('appearance_mapping', lambda item: digest(item.get('material')))):
738
+ if change in summary['allowedChanges'] and sorted(identity(previous[item]) for item in old_ids) != sorted(identity(current[item]) for item in new_ids):
739
+ physical_change = True
740
+ if not physical_change:
741
+ raise ValueError('revision target has no authorized physical change')
742
+ preservation_findings = []
743
+ for occurrence_id in preserved:
744
+ mapping = next((item for item in mappings if item['previousOccurrenceIds'] == [occurrence_id]), None)
745
+ if not mapping or mapping['kind'] != 'preserved' or mapping['nextOccurrenceIds'] != [occurrence_id]:
746
+ raise ValueError('preserved occurrence mapping invalid')
747
+ before, after = previous[occurrence_id], current[occurrence_id]
748
+ result = {'previousOccurrenceId': occurrence_id, 'nextOccurrenceId': occurrence_id,
749
+ 'geometryPreserved': before['geometrySha256'] == after['geometrySha256'],
750
+ 'transformPreserved': before['transformSha256'] == after['transformSha256'],
751
+ 'interfacesPreserved': before['interfaceSha256'] == after['interfaceSha256'],
752
+ 'appearancePreserved': before['appearanceSha256'] == after['appearanceSha256']}
753
+ if not all(value for key, value in result.items() if key.endswith('Preserved')):
754
+ raise ValueError('preserved occurrence changed')
755
+ preservation_findings.append(result)
756
+ appearance_targets = summary['preserveAppearanceTargetIds']
757
+ appearance_bindings = summary['appearanceTargetBindings']
758
+ if not isinstance(appearance_targets, list) or len(appearance_targets) > 512 or len(set(appearance_targets)) != len(appearance_targets) or any(not isinstance(item, str) or not TARGET_ID.fullmatch(item) for item in appearance_targets) or \
759
+ not isinstance(appearance_bindings, list) or len(appearance_bindings) != len(appearance_targets):
760
+ raise ValueError('appearance preservation target invalid')
761
+ seen_appearance_targets = set()
762
+ for index, binding in enumerate(appearance_bindings):
763
+ if not isinstance(binding, dict) or set(binding) != {'targetId', 'occurrenceId'} or binding['targetId'] != appearance_targets[index] or \
764
+ not isinstance(binding['targetId'], str) or not TARGET_ID.fullmatch(binding['targetId']) or binding['targetId'] in seen_appearance_targets or \
765
+ not isinstance(binding['occurrenceId'], str) or binding['occurrenceId'] not in previous:
766
+ raise ValueError('appearance target binding invalid')
767
+ seen_appearance_targets.add(binding['targetId'])
768
+ mapping = next((item for item in mappings if binding['occurrenceId'] in item['previousOccurrenceIds']), None)
769
+ if mapping is None or len(mapping['previousOccurrenceIds']) != 1 or len(mapping['nextOccurrenceIds']) != 1 or \
770
+ previous[binding['occurrenceId']]['appearanceSha256'] != current[mapping['nextOccurrenceIds'][0]]['appearanceSha256']:
771
+ raise ValueError('appearance binding was not preserved')
772
+ mount_findings = []
773
+ for alignment in alignments:
774
+ required = {'previousOccurrenceId', 'previousFrameId', 'nextOccurrenceId', 'nextFrameId', 'compatibilityKey'}
775
+ if not isinstance(alignment, dict) or set(alignment) != required or alignment['previousOccurrenceId'] not in previous or alignment['nextOccurrenceId'] not in current or not isinstance(alignment['compatibilityKey'], str) or not STABLE.fullmatch(alignment['compatibilityKey']):
776
+ raise ValueError('invalid mount alignment')
777
+ before, after = previous[alignment['previousOccurrenceId']], current[alignment['nextOccurrenceId']]
778
+ compatible_before = any(item['frameId'] == alignment['previousFrameId'] and item['compatibilityKey'] == alignment['compatibilityKey'] for item in before['interfaces'])
779
+ compatible_after = any(item['frameId'] == alignment['nextFrameId'] and item['compatibilityKey'] == alignment['compatibilityKey'] for item in after['interfaces'])
780
+ translation_error, rotation_error = frame_errors(frame_matrix(before, alignment['previousFrameId']), frame_matrix(after, alignment['nextFrameId']))
781
+ finding = {**alignment, 'translationErrorMm': translation_error, 'rotationErrorDeg': rotation_error,
782
+ 'compatible': compatible_before and compatible_after, 'aligned': translation_error <= 1e-6 and rotation_error <= 1e-6}
783
+ if not finding['compatible'] or not finding['aligned']:
784
+ raise ValueError('mount interface incompatible or misaligned')
785
+ mount_findings.append(finding)
786
+ checks = {
787
+ 'delta': {'status': 'pass', 'method': 'manifest_digest', 'reason': None, 'findings': delta_findings},
788
+ 'preservation': {'status': 'pass', 'method': 'manifest_digest', 'reason': None, 'findings': preservation_findings},
789
+ 'mount': {'status': 'pass', 'method': 'frame_alignment', 'reason': None, 'findings': mount_findings},
790
+ }
791
+ return summary, checks
792
+
793
+ def local_geometry(shape):
794
+ """Bake the source Shape's own rigid location, leaving the occurrence pose separate."""
795
+ if shape.location.wrapped.IsIdentity():
796
+ return shape
797
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
798
+ unlocated = shape.located(bd.Location())
799
+ # Copy the geometry so OCCT does not merely return another top-level Location.
800
+ # Unlike a general affine transform this preserves analytic planes/cylinders.
801
+ return bd.Compound.cast(BRepBuilderAPI_Transform(
802
+ unlocated.wrapped, shape.location.wrapped.Transformation(), True).Shape())
803
+
804
+
805
+ def draft_manifest(scope, source_sha256, revision=None):
806
+ raw = scope.get('assembly')
807
+ if raw is None:
808
+ if scope.get('inspection_geometry') is not None:
809
+ raise ValueError('inspection geometry requires occurrence assembly')
810
+ model = scope.get('result')
811
+ if not isinstance(model, bd.Shape):
812
+ raise ValueError('source must assign assembly occurrences or a legacy Shape to result')
813
+ model.label = 'legacy-shape'
814
+ identity = {'translationMm': [0.0, 0.0, 0.0], 'rotationDeg': [0.0, 0.0, 0.0], 'visible': True}
815
+ views = {name: dict(identity) for name in VIEWS}
816
+ occurrence = {'occurrenceId': 'legacy-shape', 'componentId': 'legacy:unverified-shape', 'bomItemId': None,
817
+ 'label': 'Legacy single shape', 'source': {'kind': 'legacy_unverified', 'sha256': source_sha256},
818
+ 'geometryPrecision': 'legacy_unverified', 'geometrySha256': shape_digest(model), 'transformSha256': digest(views),
819
+ 'interfaceSha256': digest({'mountFrames': [], 'interfaces': []}), 'appearanceSha256': digest([]),
820
+ 'mountFrames': [], 'interfaces': [], 'appearanceBindings': [], 'lineage': None, 'views': views}
821
+ inspection = normalize_inspection(scope.get('inspection'), [occurrence])
822
+ summary, revision_checks = validate_revision(None, [occurrence], revision)
823
+ return model, {'schema': 'abelien.structure-assembly.v2', 'mode': 'legacy_shape', 'status': 'legacy_unverified',
824
+ 'units': 'mm', 'coordinateSystem': 'z_up', 'transformSpace': 'assembly_root_mm', 'sourceSha256': source_sha256,
825
+ 'revision': summary,
826
+ 'inspectionPlanSha256': digest(inspection), 'occurrenceCount': 1, 'componentCounts': component_counts([occurrence]), 'occurrences': [occurrence],
827
+ 'deferredChecks': {name: {'status': 'unverified', 'gating': False} for name in DEFERRED}}, inspection, revision_checks, None
828
+ if not isinstance(raw, list) or not 2 <= len(raw) <= 128:
829
+ raise ValueError('assembly must contain 2..128 occurrences')
830
+ occurrences, shapes, shapes_by_id, identifiers = [], [], {}, set()
831
+ required = {'occurrence_id', 'component_id', 'bom_item_id', 'label', 'shape', 'geometry_precision', *VIEWS}
832
+ optional = {'mount_frames', 'interfaces', 'appearance_bindings', 'lineage', 'material'}
833
+ for item in raw:
834
+ if not isinstance(item, dict) or not required.issubset(item) or not set(item).issubset(required | optional):
835
+ raise ValueError('invalid assembly occurrence')
836
+ occurrence_id, component_id, bom_item_id = item['occurrence_id'], item['component_id'], item['bom_item_id']
837
+ if not isinstance(occurrence_id, str) or not NODE_ID.fullmatch(occurrence_id) or occurrence_id in identifiers or not isinstance(component_id, str) or not STABLE.fullmatch(component_id) or not isinstance(bom_item_id, str) or not STABLE.fullmatch(bom_item_id):
838
+ raise ValueError('invalid or duplicate assembly identity')
839
+ # Separate reasons: a merged message once led the model to rewrite a valid Shape for an over-long label.
840
+ if not isinstance(item['label'], str) or not item['label'].strip():
841
+ raise ValueError(f'assembly occurrence {occurrence_id} label must be a non-empty string')
842
+ if len(item['label']) > 128:
843
+ raise ValueError(f'assembly occurrence {occurrence_id} label has {len(item["label"])} characters; maximum is 128')
844
+ if not isinstance(item['shape'], bd.Shape):
845
+ raise ValueError(f'assembly occurrence {occurrence_id} shape must be a build123d Shape, not {type(item["shape"]).__name__}')
846
+ if item['geometry_precision'] not in ('custom_brep', 'envelope'):
847
+ raise ValueError('unsupported geometry precision')
848
+ views = {name: transform(item[name], normal=name == 'normal') for name in VIEWS}
849
+ mount_frames = normalize_mount_frames(item.get('mount_frames')); interfaces = normalize_interfaces(item.get('interfaces'), mount_frames)
850
+ appearance_bindings = normalize_appearance_bindings(item.get('appearance_bindings')); lineage = normalize_lineage(item.get('lineage'))
851
+ material = normalize_material(item['material']) if 'material' in item else None
852
+ shapes_by_id[occurrence_id] = item['shape']
853
+ identifiers.add(occurrence_id)
854
+ occurrences.append({'occurrenceId': occurrence_id, 'componentId': component_id, 'bomItemId': bom_item_id,
855
+ 'label': item['label'].strip(),
856
+ 'source': {'kind': 'generated_envelope' if item['geometry_precision'] == 'envelope' else 'generated_custom', 'sha256': source_sha256},
857
+ 'geometryPrecision': item['geometry_precision'], 'geometrySha256': shape_digest(item['shape']),
858
+ 'transformSha256': digest(views), 'interfaceSha256': digest({'mountFrames': mount_frames, 'interfaces': interfaces}),
859
+ 'appearanceSha256': digest(appearance_identity(appearance_bindings, material)), 'mountFrames': mount_frames, 'interfaces': interfaces,
860
+ 'appearanceBindings': appearance_bindings, 'lineage': lineage, 'views': views,
861
+ **({'material': material} if material is not None else {})})
862
+ if not any(occurrence['views']['internal'] != occurrence['views']['normal'] for occurrence in occurrences):
863
+ raise ValueError('internal view must differ from normal assembly')
864
+ if not any(occurrence['views']['exploded'] != occurrence['views']['normal'] for occurrence in occurrences):
865
+ raise ValueError('exploded view must differ from normal assembly')
866
+ if any(not occurrence['views']['exploded']['visible'] for occurrence in occurrences):
867
+ raise ValueError('exploded view must keep every occurrence visible')
868
+ if any(not any(occurrence['views'][name]['visible'] for occurrence in occurrences) for name in VIEWS):
869
+ raise ValueError('every assembly view must keep at least one visible occurrence')
870
+ prior = revision.get('priorAssembly') if isinstance(revision, dict) else None
871
+ summary, revision_checks = validate_revision(prior, occurrences, revision)
872
+ if summary is not None:
873
+ previous = {item['occurrenceId']: item for item in prior['occurrences']}
874
+ for occurrence in occurrences:
875
+ if occurrence['lineage']['kind'] == 'preserved':
876
+ occurrence['source'] = previous[occurrence['lineage']['previousOccurrenceIds'][0]]['source']
877
+ geometry, breps = normalize_inspection_geometry(scope.get('inspection_geometry'), occurrences, source_sha256)
878
+ inspection = normalize_inspection(scope.get('inspection'), occurrences, geometry)
879
+ for occurrence in occurrences:
880
+ normal = occurrence['views']['normal']
881
+ placed = local_geometry(shapes_by_id[occurrence['occurrenceId']]).moved(bd.Location(tuple(normal['translationMm']), tuple(normal['rotationDeg'])))
882
+ placed.label = occurrence['occurrenceId']
883
+ shapes.append(placed)
884
+ model = bd.Compound(children=shapes); model.label = 'assembly'
885
+ return model, {'schema': 'abelien.structure-assembly.v2', 'mode': 'occurrence_assembly', 'status': 'candidate',
886
+ 'units': 'mm', 'coordinateSystem': 'z_up', 'transformSpace': 'assembly_root_mm', 'sourceSha256': source_sha256,
887
+ 'revision': summary,
888
+ 'inspectionPlanSha256': digest(inspection),
889
+ 'occurrenceCount': len(occurrences), 'componentCounts': component_counts(occurrences), 'occurrences': occurrences,
890
+ 'deferredChecks': {name: {'status': 'unverified', 'gating': False} for name in DEFERRED}}, inspection, revision_checks, breps
891
+
892
+ def transform_set(occurrences, view):
893
+ return [{'occurrenceId': occurrence['occurrenceId'], 'transform': occurrence['views'][view]} for occurrence in occurrences]
894
+
895
+ def final_manifest(draft, step_bytes, glb_bytes, view_images):
896
+ result = dict(draft)
897
+ result['occurrenceSetSha256'] = digest(occurrence_identity(result['occurrences']))
898
+ result['artifacts'] = {'modelStepSha256': digest(step_bytes), 'modelGlbSha256': digest(glb_bytes)}
899
+ reset = digest(transform_set(result['occurrences'], 'normal'))
900
+ result['views'] = {name: {'modelGlbSha256': result['artifacts']['modelGlbSha256'],
901
+ 'occurrenceSetSha256': result['occurrenceSetSha256'],
902
+ 'artifactName': f'{name}.png', 'imageSha256': digest(view_images[name]),
903
+ 'transformSetSha256': digest(transform_set(result['occurrences'], name)),
904
+ 'resetTransformSetSha256': reset, 'scale': 1.0,
905
+ 'labels': ([{'occurrenceId': occurrence['occurrenceId'], 'bomItemId': occurrence['bomItemId']}
906
+ for occurrence in result['occurrences'] if occurrence['bomItemId'] is not None]
907
+ if name == 'exploded' else [])} for name in VIEWS}
908
+ result['assemblySha256'] = digest(result)
909
+ return result
910
+
911
+ def gltf_document(raw):
912
+ if len(raw) < 20 or raw[:4] != b'glTF' or int.from_bytes(raw[8:12], 'little') != len(raw) or int.from_bytes(raw[16:20], 'little') != 0x4E4F534A:
913
+ raise ValueError('invalid GLB')
914
+ length = int.from_bytes(raw[12:16], 'little')
915
+ document = json.loads(raw[20:20 + length].decode().rstrip('\0 '))
916
+ if not document.get('meshes') or not isinstance(document.get('nodes'), list):
917
+ raise ValueError('GLB contains no addressable meshes')
918
+ return document
919
+
920
+ def validate_occurrence_nodes(document, manifest):
921
+ if manifest['mode'] != 'occurrence_assembly':
922
+ return
923
+ nodes = document['nodes']
924
+ scenes = document.get('scenes'); scene_index = document.get('scene', 0)
925
+ if not isinstance(scenes, list) or not isinstance(scene_index, int) or scene_index < 0 or scene_index >= len(scenes) or not isinstance(scenes[scene_index].get('nodes'), list) or not scenes[scene_index]['nodes']:
926
+ raise ValueError('GLB has no active assembly scene')
927
+ parents = {}
928
+ for index, node in enumerate(nodes):
929
+ for child in node.get('children', []):
930
+ if not isinstance(child, int) or child in parents:
931
+ raise ValueError('invalid GLB hierarchy')
932
+ parents[child] = index
933
+ reachable = set()
934
+ def visit(index):
935
+ if not isinstance(index, int) or index < 0 or index >= len(nodes) or index in reachable:
936
+ return
937
+ reachable.add(index)
938
+ for child in nodes[index].get('children', []):
939
+ visit(child)
940
+ for root_index in scenes[scene_index]['nodes']:
941
+ visit(root_index)
942
+ near = lambda left, right: isinstance(left, (int, float)) and math.isfinite(left) and abs(left - right) < 1e-9
943
+ def quaternion(rotation_deg):
944
+ x, y, z = (math.radians(value) / 2 for value in rotation_deg)
945
+ sx, cx, sy, cy, sz, cz = math.sin(x), math.cos(x), math.sin(y), math.cos(y), math.sin(z), math.cos(z)
946
+ return [sx*cy*cz + cx*sy*sz, cx*sy*cz - sx*cy*sz, cx*cy*sz + sx*sy*cz, cx*cy*cz - sx*sy*sz]
947
+ def same_quaternion(actual, expected):
948
+ return isinstance(actual, list) and len(actual) == 4 and (all(near(actual[index], expected[index]) for index in range(4)) or all(near(actual[index], -expected[index]) for index in range(4)))
949
+ def occurrence_pose(node, normal):
950
+ translation = node.get('translation', [0, 0, 0]); rotation = node.get('rotation', [0, 0, 0, 1]); scale = node.get('scale', [1, 1, 1])
951
+ return node.get('matrix') is None and isinstance(translation, list) and len(translation) == 3 and all(near(translation[index], normal['translationMm'][index] / 1000) for index in range(3)) and \
952
+ same_quaternion(rotation, quaternion(normal['rotationDeg'])) and isinstance(scale, list) and len(scale) == 3 and all(near(item, 1) for item in scale)
953
+ def assembly_root(node):
954
+ expected = [-math.sqrt(0.5), 0, 0, math.sqrt(0.5)]
955
+ return node.get('name') == 'assembly-root' and node.get('matrix') is None and node.get('translation') is None and node.get('scale') is None and same_quaternion(node.get('rotation'), expected) and not isinstance(node.get('mesh'), int)
956
+ def has_mesh(index, seen):
957
+ if not isinstance(index, int) or index < 0 or index >= len(nodes) or index in seen:
958
+ return False
959
+ seen.add(index); node = nodes[index]
960
+ return isinstance(node.get('mesh'), int) or any(has_mesh(child, seen) for child in node.get('children', []))
961
+ mapped = []
962
+ for occurrence in manifest['occurrences']:
963
+ matching = [index for index, node in enumerate(nodes) if node.get('name') == occurrence['occurrenceId']]
964
+ if len(matching) != 1 or matching[0] not in reachable or not has_mesh(matching[0], set()) or not occurrence_pose(nodes[matching[0]], occurrence['views']['normal']):
965
+ raise ValueError('GLB occurrence mapping lost during export')
966
+ mapped.append(matching[0])
967
+ root_indices = {parents.get(index) for index in mapped}
968
+ if len(root_indices) != 1 or None in root_indices:
969
+ raise ValueError('GLB occurrences do not share one assembly root')
970
+ root_index = next(iter(root_indices)); root_node = nodes[root_index]
971
+ if root_index not in scenes[scene_index]['nodes'] or root_index in parents or scenes[scene_index]['nodes'] != [root_index] or not assembly_root(root_node) or root_node.get('children') != mapped:
972
+ raise ValueError('GLB assembly root transform is not the fixed Build123d basis')
973
+
974
+ def scene_rows(children, manifest, view):
975
+ if manifest['mode'] == 'legacy_shape':
976
+ return [(manifest['occurrences'][0], children[0])]
977
+ rows = []
978
+ for occurrence, child in zip(manifest['occurrences'], children):
979
+ pose = occurrence['views'][view]
980
+ if pose['visible']:
981
+ rows.append((occurrence, child.located(bd.Location(tuple(pose['translationMm']), tuple(pose['rotationDeg'])))))
982
+ return rows
983
+
984
+ def scene_mesh(rows, tolerance):
985
+ meshes, total = [], 0
986
+ for occurrence, shape in rows:
987
+ vertices, faces = shape.tessellate(tolerance); total += len(faces)
988
+ if total > 200000:
989
+ raise ValueError('render complexity budget exceeded')
990
+ points = np.asarray([tuple(point) for point in vertices]); triangles = points[np.asarray(faces)]
991
+ meshes.append((occurrence, triangles, tuple(shape.bounding_box().center())))
992
+ if not meshes:
993
+ raise ValueError('assembly view has no visible geometry')
994
+ return meshes
995
+
996
+ def render_scene(root, name, mesh, elevation, azimuth, *, labels=False):
997
+ # Painter-order collections cannot resolve an enclosed component against a
998
+ # hollow shell. Orthographic per-pixel depth uses the actual readback triangles.
999
+ colors = ('#96a3b5', '#82aab8', '#c3a67e', '#9f96bd', '#7fa88e', '#bd8f98')
1000
+ points = np.concatenate([triangles.reshape(-1, 3) for _, triangles, _ in mesh], axis=0)
1001
+ middle = (points.min(axis=0) + points.max(axis=0)) / 2
1002
+ elev, azim = math.radians(elevation), math.radians(azimuth)
1003
+ toward_camera = np.array([math.cos(elev)*math.cos(azim), math.cos(elev)*math.sin(azim), math.sin(elev)])
1004
+ right = np.array([-math.sin(azim), math.cos(azim), 0.0])
1005
+ up = np.cross(toward_camera, right)
1006
+ basis = np.stack((right, up, toward_camera), axis=1)
1007
+ projected = (points-middle) @ basis
1008
+ extent = max(float(np.abs(projected[:, :2]).max()) * 1.16, 1.0)
1009
+ size = 640; scale = (size-1) / (extent*2)
1010
+ def project(values):
1011
+ result = (values-middle) @ basis
1012
+ result[..., 0] = size/2 + result[..., 0]*scale
1013
+ result[..., 1] = size/2 - result[..., 1]*scale
1014
+ return result
1015
+ depth = np.full((size, size), -np.inf)
1016
+ pixels = np.ones((size, size, 3), dtype=np.float64)
1017
+ light = np.array([-0.3, -0.5, 0.8]); light /= np.linalg.norm(light)
1018
+ processed_pixels = 0
1019
+ for occurrence, triangles, _ in mesh:
1020
+ fallback = colors[int(hashlib.sha256(occurrence['occurrenceId'].encode()).hexdigest()[:8], 16) % len(colors)]
1021
+ color = occurrence.get('material', {}).get('baseColorSrgb', fallback)
1022
+ # PNG is a bounded diffuse preview of the declared sRGB base color. The
1023
+ # GLB stores the actual PBR roughness/metallic; this is no reflectance test.
1024
+ base = np.array([int(color[index:index+2], 16)/255 for index in (1, 3, 5)])
1025
+ normals = np.cross(triangles[:, 1]-triangles[:, 0], triangles[:, 2]-triangles[:, 0])
1026
+ lengths = np.linalg.norm(normals, axis=1)
1027
+ normals /= np.maximum(lengths[:, None], 1e-20)
1028
+ shades = 0.65 + 0.35*np.abs(normals @ light)
1029
+ for triangle, shade in zip(project(triangles), shades):
1030
+ x0, y0, z0 = triangle[0]; x1, y1, z1 = triangle[1]; x2, y2, z2 = triangle[2]
1031
+ denominator = (y1-y2)*(x0-x2) + (x2-x1)*(y0-y2)
1032
+ if abs(denominator) < 1e-12:
1033
+ continue
1034
+ left = max(0, math.floor(min(x0, x1, x2))); top = max(0, math.floor(min(y0, y1, y2)))
1035
+ right_edge = min(size-1, math.ceil(max(x0, x1, x2))); bottom = min(size-1, math.ceil(max(y0, y1, y2)))
1036
+ if left > right_edge or top > bottom:
1037
+ continue
1038
+ processed_pixels += (right_edge-left+1)*(bottom-top+1)
1039
+ if processed_pixels > 200_000_000:
1040
+ raise ValueError('render raster budget exceeded')
1041
+ yy, xx = np.mgrid[top:bottom+1, left:right_edge+1]
1042
+ xx = xx+0.5; yy = yy+0.5
1043
+ w0 = ((y1-y2)*(xx-x2) + (x2-x1)*(yy-y2)) / denominator
1044
+ w1 = ((y2-y0)*(xx-x2) + (x0-x2)*(yy-y2)) / denominator
1045
+ w2 = 1-w0-w1
1046
+ candidate_depth = w0*z0 + w1*z1 + w2*z2
1047
+ previous = depth[top:bottom+1, left:right_edge+1]
1048
+ visible = (w0 >= -1e-9) & (w1 >= -1e-9) & (w2 >= -1e-9) & (candidate_depth > previous)
1049
+ previous[visible] = candidate_depth[visible]
1050
+ pixels[top:bottom+1, left:right_edge+1][visible] = base*shade
1051
+ fig = plt.figure(figsize=(size/120, size/120), dpi=120)
1052
+ axis = fig.add_axes((0, 0, 1, 1)); axis.imshow(pixels, interpolation='nearest')
1053
+ if labels:
1054
+ for occurrence, _, center in mesh:
1055
+ if occurrence['bomItemId'] is not None:
1056
+ x, y, _ = project(np.asarray(center))
1057
+ axis.text(x, y, f"{occurrence['occurrenceId']}\n{occurrence['bomItemId']}", fontsize=7, color='#111827',
1058
+ horizontalalignment='center', verticalalignment='bottom')
1059
+ axis.set_axis_off()
1060
+ image = root / f'{name}.png'; fig.savefig(image, facecolor='white'); plt.close(fig)
1061
+ return image.read_bytes()
1062
+
1063
+ def render_assembly_views(root, children, manifest, tolerance):
1064
+ normal = scene_mesh(scene_rows(children, manifest, 'normal'), tolerance)
1065
+ internal = scene_mesh(scene_rows(children, manifest, 'internal'), tolerance)
1066
+ exploded = scene_mesh(scene_rows(children, manifest, 'exploded'), tolerance)
1067
+ images = {
1068
+ 'normal': render_scene(root, 'normal', normal, 25, -55),
1069
+ 'internal': render_scene(root, 'internal', internal, 25, -55),
1070
+ 'exploded': render_scene(root, 'exploded', exploded, 25, -55, labels=manifest['mode'] == 'occurrence_assembly'),
1071
+ 'front': render_scene(root, 'front', normal, 0, -90),
1072
+ 'top': render_scene(root, 'top', normal, 90, -90),
1073
+ 'iso': render_scene(root, 'iso', normal, 25, -55),
1074
+ }
1075
+ return images
1076
+
1077
+ def shape_result_volume(value):
1078
+ if value is None:
1079
+ return 0.0
1080
+ if isinstance(value, bd.Shape):
1081
+ return max(float(value.volume), 0.0)
1082
+ return sum(max(float(shape.volume), 0.0) for shape in value)
1083
+
1084
+ def boolean_shape(value):
1085
+ """One Shape (or None) from a build123d 0.11.1 boolean: intersect/cut may return a ShapeList even for one piece."""
1086
+ if value is None or isinstance(value, bd.Shape):
1087
+ return value
1088
+ items = list(value)
1089
+ if not items:
1090
+ return None
1091
+ return items[0] if len(items) == 1 else bd.Compound(items)
1092
+
1093
+ def overlap_volume(left, right):
1094
+ return shape_result_volume(left.intersect(right))
1095
+
1096
+ def geometry_finding(subjects, metric, measured, operator, threshold):
1097
+ measured = float(measured); threshold = float(threshold)
1098
+ if not math.isfinite(measured) or measured < 0:
1099
+ raise ValueError('non-finite inspection measurement')
1100
+ passed = measured <= threshold if operator == '<=' else measured >= threshold
1101
+ return {'subjectIds': list(subjects), 'metric': metric, 'measured': measured, 'operator': operator, 'threshold': threshold, 'passed': passed}
1102
+
1103
+ def evaluate_rule_set(spec, plan_sha256, callback):
1104
+ if spec is None:
1105
+ return {'status': 'unknown', 'method': 'unavailable', 'planSha256': plan_sha256,
1106
+ 'reason': 'inspection_not_declared', 'findings': []}
1107
+ if spec['kind'] == 'not_applicable':
1108
+ return {'status': 'not_applicable', 'method': 'declared_not_applicable', 'planSha256': plan_sha256,
1109
+ 'reason': spec['reason'], 'findings': []}
1110
+ try:
1111
+ findings = callback(spec)
1112
+ except Exception:
1113
+ return {'status': 'unknown', 'method': 'unavailable', 'planSha256': plan_sha256,
1114
+ 'reason': 'brep_operation_failed', 'findings': []}
1115
+ return {'status': 'pass' if all(item['passed'] for item in findings) else 'conflict', 'method': 'occt_brep',
1116
+ 'planSha256': plan_sha256, 'reason': None, 'findings': findings}
1117
+
1118
+ def read_inspection_geometry(children, assembly, plan, encoded):
1119
+ """Independently read BRep witnesses and prove their relation to actual STEP."""
1120
+ metadata = plan.get('inspectionGeometry')
1121
+ if metadata is None:
1122
+ if plan.get('schema') != 'abelien.structure-inspection-plan.v1' or encoded is not None:
1123
+ raise ValueError('unexpected inspection geometry')
1124
+ return {}, None
1125
+ if plan.get('schema') != 'abelien.structure-inspection-plan.v2' or not isinstance(metadata, dict) or set(metadata) != {'schema', 'sourceSha256', 'regions', 'shells'} or metadata['schema'] != 'abelien.structure-inspection-geometry.v1' or metadata['sourceSha256'] != assembly['sourceSha256']:
1126
+ raise ValueError('inspection geometry identity mismatch')
1127
+ if not isinstance(metadata['regions'], list) or not 2 <= len(metadata['regions']) <= 128 or not isinstance(metadata['shells'], list) or not 1 <= len(metadata['shells']) <= 32 or not isinstance(encoded, dict):
1128
+ raise ValueError('invalid inspection geometry transport')
1129
+ owners = {item['occurrenceId']: item for item in assembly['occurrences']}
1130
+ product_shapes = {item['occurrenceId']: shape for item, shape in zip(assembly['occurrences'], children)}
1131
+ region_specs, shapes, total = {}, {}, 0
1132
+ from OCP.BRep import BRep_Builder
1133
+ from OCP.BRepTools import BRepTools
1134
+ from OCP.TopoDS import TopoDS_Shape
1135
+ for region in metadata['regions']:
1136
+ if not isinstance(region, dict) or set(region) != {'regionId', 'kind', 'ownerOccurrenceId', 'brepSha256'} or not isinstance(region['regionId'], str) or not NODE_ID.fullmatch(region['regionId']) or region['regionId'] in owners or region['regionId'] in region_specs or region['kind'] not in ('outer', 'cavity', 'opening') or region['ownerOccurrenceId'] not in owners:
1137
+ raise ValueError('invalid inspection region binding')
1138
+ region_id = region['regionId']; region_specs[region_id] = region
1139
+ body = base64.b64decode(encoded[region_id], validate=True); total += len(body)
1140
+ if not body or total > 8 * 1024 * 1024 or digest(body) != region['brepSha256']:
1141
+ raise ValueError('inspection BRep hash or size mismatch')
1142
+ wrapped = TopoDS_Shape(); BRepTools.Read_s(wrapped, BytesIO(body), BRep_Builder())
1143
+ if wrapped.IsNull():
1144
+ raise ValueError('inspection BRep readback failed')
1145
+ shape = bd.Compound.cast(wrapped)
1146
+ if not shape.is_valid or len(shape.solids()) != 1 or not math.isfinite(shape.volume) or shape.volume <= 0:
1147
+ raise ValueError('inspection region must be a valid single solid')
1148
+ normal = owners[region['ownerOccurrenceId']]['views']['normal']
1149
+ shapes[region_id] = shape.moved(bd.Location(tuple(normal['translationMm']), tuple(normal['rotationDeg'])))
1150
+ if set(encoded) != set(region_specs):
1151
+ raise ValueError('inspection BRep set mismatch')
1152
+ # Numerical equivalence tolerance, never a model-selected business clearance.
1153
+ epsilon = 1e-6
1154
+ used, shell_ids, checks = set(), set(), []
1155
+ for shell in metadata['shells']:
1156
+ if not isinstance(shell, dict) or set(shell) != {'occurrenceId', 'geometrySha256', 'transformSha256', 'outerRegionId', 'cavityRegionId', 'openingRegionIds'} or shell['occurrenceId'] not in owners or shell['occurrenceId'] in shell_ids or not isinstance(shell['openingRegionIds'], list) or len(shell['openingRegionIds']) > 32:
1157
+ raise ValueError('invalid inspection shell binding')
1158
+ owner = owners[shell['occurrenceId']]; shell_ids.add(owner['occurrenceId'])
1159
+ if owner['geometryPrecision'] != 'custom_brep' or shell['geometrySha256'] != owner['geometrySha256'] or shell['transformSha256'] != owner['transformSha256']:
1160
+ raise ValueError('inspection shell version mismatch')
1161
+ expected_regions = [(shell['outerRegionId'], 'outer'), (shell['cavityRegionId'], 'cavity'), *[(item, 'opening') for item in shell['openingRegionIds']]]
1162
+ for region_id, kind in expected_regions:
1163
+ spec = region_specs.get(region_id)
1164
+ if not spec or region_id in used or spec['kind'] != kind or spec['ownerOccurrenceId'] != owner['occurrenceId']:
1165
+ raise ValueError('inspection region owner mismatch')
1166
+ used.add(region_id)
1167
+ outer, cavity = shapes[shell['outerRegionId']], shapes[shell['cavityRegionId']]
1168
+ if len(outer.shells()) != 1 or len(cavity.shells()) != 1:
1169
+ raise ValueError('inspection outer and cavity must be closed filled bodies')
1170
+ outside = shape_result_volume(cavity.cut(outer))
1171
+ wall_distance = float(outer.shells()[0].distance_to(cavity))
1172
+ if not math.isfinite(wall_distance) or outside > epsilon or wall_distance <= epsilon:
1173
+ raise ValueError('inspection cavity is not strictly inside outer')
1174
+ wall = boolean_shape(outer.cut(cavity)); expected_shell = wall; opening_checks = []
1175
+ if wall is None:
1176
+ raise ValueError('inspection outer and cavity leave no wall')
1177
+ for region_id in shell['openingRegionIds']:
1178
+ opening = shapes[region_id]
1179
+ cavity_overlap = overlap_volume(opening, cavity)
1180
+ outside_outer = shape_result_volume(opening.cut(outer))
1181
+ unique_cut = boolean_shape(opening.intersect(wall))
1182
+ for other in shell['openingRegionIds']:
1183
+ if other != region_id and unique_cut is not None:
1184
+ unique_cut = boolean_shape(unique_cut.cut(shapes[other]))
1185
+ wall_cut = shape_result_volume(unique_cut)
1186
+ if any(not math.isfinite(value) or value <= epsilon for value in (cavity_overlap, outside_outer, wall_cut)):
1187
+ raise ValueError('inspection opening must cut a unique passage through actual wall')
1188
+ expected_shell = boolean_shape(expected_shell.cut(opening))
1189
+ if expected_shell is None:
1190
+ raise ValueError('inspection openings remove the whole wall')
1191
+ opening_checks.append({'regionId': region_id, 'cavityOverlapMm3': cavity_overlap,
1192
+ 'outsideOuterVolumeMm3': outside_outer, 'uniqueWallCutVolumeMm3': wall_cut})
1193
+ actual = product_shapes[owner['occurrenceId']]
1194
+ difference = shape_result_volume(actual.cut(expected_shell)) + shape_result_volume(expected_shell.cut(actual))
1195
+ if not math.isfinite(difference) or difference > epsilon:
1196
+ raise ValueError('inspection shell reconstruction differs from actual STEP')
1197
+ checks.append({'shellOccurrenceId': owner['occurrenceId'], 'cavityOutsideMm3': outside,
1198
+ 'minimumWallMm': wall_distance, 'shellSymmetricDifferenceMm3': difference, 'openings': opening_checks})
1199
+ if used != set(region_specs):
1200
+ raise ValueError('unbound inspection region')
1201
+ return {key: shape for key, shape in shapes.items() if region_specs[key]['kind'] != 'outer'}, {
1202
+ 'schema': 'abelien.structure-inspection-geometry-evidence.v1', 'sourceSha256': assembly['sourceSha256'],
1203
+ 'inspectionPlanSha256': assembly['inspectionPlanSha256'], 'plan': metadata,
1204
+ 'status': 'pass', 'method': 'occt_brep_boolean_reconstruction', 'toleranceMm3': epsilon, 'checks': checks}
1205
+
1206
+ def inspect_geometry(children, manifest, plan, inspection_shapes=None):
1207
+ by_id = {occurrence['occurrenceId']: child for occurrence, child in zip(manifest['occurrences'], children)}
1208
+ by_id.update(inspection_shapes or {})
1209
+ plan_sha256 = digest(plan)
1210
+ if plan_sha256 != manifest['inspectionPlanSha256']:
1211
+ raise ValueError('inspection plan hash mismatch')
1212
+
1213
+ def collisions(spec):
1214
+ allowed = {tuple(pair) for pair in spec['allowedPairs']}; findings = []
1215
+ for left, right in itertools.combinations(spec['occurrenceIds'], 2):
1216
+ pair = tuple(sorted((left, right)))
1217
+ if pair not in allowed:
1218
+ findings.append(geometry_finding(pair, 'overlap_volume_mm3', overlap_volume(by_id[left], by_id[right]), '<=', spec['maximumOverlapMm3']))
1219
+ return findings
1220
+
1221
+ def containment(spec):
1222
+ findings = []
1223
+ for rule in spec['rules']:
1224
+ for target in rule['targets']:
1225
+ outside = shape_result_volume(by_id[target].cut(by_id[rule['container']]))
1226
+ findings.append(geometry_finding((rule['container'], target), 'outside_volume_mm3', outside, '<=', rule['maximumOutsideMm3']))
1227
+ return findings
1228
+
1229
+ def openings(spec):
1230
+ findings = []
1231
+ for rule in spec['rules']:
1232
+ opening, target = by_id[rule['opening']], by_id[rule['target']]
1233
+ findings.append(geometry_finding((rule['opening'], rule['target']), 'distance_mm', opening.distance_to(target), '<=', rule['maximumDistanceMm']))
1234
+ findings.append(geometry_finding((rule['opening'], rule['target']), 'overlap_volume_mm3', overlap_volume(opening, target), '>=', rule['minimumOverlapMm3']))
1235
+ return findings
1236
+
1237
+ def motion(spec):
1238
+ findings = []
1239
+ for rule in spec['rules']:
1240
+ for obstacle in rule['obstacles']:
1241
+ findings.append(geometry_finding((rule['envelope'], obstacle), 'overlap_volume_mm3', overlap_volume(by_id[rule['envelope']], by_id[obstacle]), '<=', rule['maximumOverlapMm3']))
1242
+ return findings
1243
+
1244
+ return {
1245
+ 'collision': evaluate_rule_set(plan['collision'], plan_sha256, collisions),
1246
+ 'containment': evaluate_rule_set(plan['containment'], plan_sha256, containment),
1247
+ 'opening_alignment': evaluate_rule_set(plan['openings'], plan_sha256, openings),
1248
+ 'motion_envelope': evaluate_rule_set(plan['motionEnvelopes'], plan_sha256, motion),
1249
+ }
1250
+
1251
+ def export_source_api():
1252
+ """Trusted startup only: inspect the installed module, never a submitted program."""
1253
+ import build123d
1254
+ import inspect
1255
+ import importlib.metadata
1256
+ classes = ('Part', 'Solid', 'Compound', 'Vertex', 'Edge', 'Face', 'Sketch', 'BuildPart', 'BuildSketch', 'Location')
1257
+ def signature(value):
1258
+ try:
1259
+ sig = inspect.signature(value)
1260
+ except (ValueError, TypeError):
1261
+ return None
1262
+ def annotation(value):
1263
+ return None if value is inspect.Signature.empty else value if isinstance(value, str) else str(value)
1264
+ return {'text': str(sig), 'parameters': [
1265
+ {'name': p.name, 'kind': p.kind.name, 'annotation': annotation(p.annotation)} for p in sig.parameters.values()],
1266
+ 'returns': annotation(sig.return_annotation)}
1267
+ names = sorted(name for name in dir(build123d) if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', name))
1268
+ result = {'schema': 'abelien.build123d-api.v1', 'build123dVersion': importlib.metadata.version('build123d'),
1269
+ 'moduleNames': names, 'callables': {}, 'classes': {}}
1270
+ for name in names:
1271
+ value = getattr(build123d, name)
1272
+ if callable(value):
1273
+ result['callables'][name] = signature(value)
1274
+ for name in classes:
1275
+ cls = getattr(build123d, name)
1276
+ attrs = sorted(attr for attr in dir(cls) if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', attr))
1277
+ members = {}
1278
+ for attr in attrs:
1279
+ raw = inspect.getattr_static(cls, attr)
1280
+ value = raw.fget if isinstance(raw, property) else getattr(cls, attr)
1281
+ if callable(value):
1282
+ members[attr] = {'property': isinstance(raw, property), 'signature': signature(value)}
1283
+ result['classes'][name] = {'attributes': attrs, 'members': members, 'signature': signature(cls),
1284
+ 'dynamicAttributes': any('__getattr__' in base.__dict__ for base in cls.__mro__)}
1285
+ library = load_cad_library()
1286
+ result['library'] = {'version': library.LIBRARY_VERSION,
1287
+ 'functions': [{'name': name, 'params': list(inspect.signature(getattr(library, name)).parameters)}
1288
+ for name in library.__all__]}
1289
+ return result
1290
+
1291
+
1292
+ def source_symbol_precheck(tree, manifest):
1293
+ """Conservative straight-line inference. Unknown/dynamic receivers are left to the sandbox runtime."""
1294
+ import ast
1295
+ import builtins
1296
+ module_names, classes = set(manifest['moduleNames']), manifest['classes']
1297
+ callables = manifest['callables']
1298
+ env, star, generation = {}, False, 0
1299
+ declared = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)}
1300
+ declared.update(node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)))
1301
+ declared.update(dir(builtins))
1302
+ for node in ast.walk(tree):
1303
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
1304
+ declared.update(alias.asname or alias.name.split('.')[0] for alias in node.names)
1305
+ class InvalidSymbol(Exception):
1306
+ def __init__(self, node, symbol):
1307
+ self.diagnostic = {'code': 'source_symbol', 'stage': 'source_precheck',
1308
+ 'line': min(max(node.lineno, 1), 32768), 'symbol': symbol}
1309
+ def invalid(node, symbol):
1310
+ raise InvalidSymbol(node, symbol)
1311
+ # These are headless-contract restrictions, independent of inferred CAD types.
1312
+ for node in ast.walk(tree):
1313
+ desktop = isinstance(node, ast.Import) and any(alias.name.split('.')[0] == 'ocp_vscode' for alias in node.names) or \
1314
+ isinstance(node, ast.ImportFrom) and (node.module or '').split('.')[0] == 'ocp_vscode'
1315
+ display = isinstance(node, ast.Call) and (isinstance(node.func, ast.Name) and node.func.id == 'show_object' or
1316
+ isinstance(node.func, ast.Attribute) and node.func.attr == 'show_object')
1317
+ if desktop or display:
1318
+ return {'code': 'source_symbol', 'stage': 'source_precheck', 'line': min(max(node.lineno, 1), 32768),
1319
+ 'symbol': 'ocp_vscode' if desktop else 'show_object'}
1320
+ if any(isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in
1321
+ {'exec', 'eval', 'globals', 'locals', 'setattr', 'delattr'} for node in ast.walk(tree)):
1322
+ return None
1323
+ def binding(name):
1324
+ return ('class', name) if name in classes else ('function', name) if name in callables else None
1325
+ def result_type(sig, owner=None):
1326
+ annotation = (sig or {}).get('returns')
1327
+ if annotation in classes:
1328
+ return ('instance', annotation)
1329
+ if annotation in ('Self', 'typing.Self') and owner:
1330
+ return ('instance', owner)
1331
+ return None
1332
+ def forget():
1333
+ nonlocal star, generation
1334
+ env.clear(); star = False; generation += 1
1335
+ def expression(node):
1336
+ if isinstance(node, ast.Name):
1337
+ if node.id in env:
1338
+ return env[node.id]
1339
+ if star and node.id in module_names:
1340
+ return binding(node.id)
1341
+ if star and node.id not in declared:
1342
+ invalid(node, 'build123d.' + node.id)
1343
+ return None
1344
+ if isinstance(node, ast.Attribute):
1345
+ base = expression(node.value)
1346
+ if base and base[0] == 'module':
1347
+ if node.attr not in module_names:
1348
+ invalid(node, 'build123d.' + node.attr)
1349
+ return binding(node.attr)
1350
+ if base and base[0] in ('class', 'instance'):
1351
+ cls = classes[base[1]]
1352
+ # Instance attributes may be set in __init__, so only reject a missing method when called.
1353
+ member = cls['members'].get(node.attr)
1354
+ if member and member['property']:
1355
+ return result_type(member['signature'], base[1])
1356
+ return ('member', base[1], node.attr)
1357
+ return None
1358
+ if isinstance(node, ast.Call):
1359
+ fn = expression(node.func)
1360
+ proof_generation = generation
1361
+ args = [expression(arg) for arg in node.args]
1362
+ kwargs = {item.arg: expression(item.value) for item in node.keywords}
1363
+ if not fn or proof_generation != generation or any(isinstance(arg, ast.Starred) for arg in node.args) or None in kwargs:
1364
+ # User callbacks can rebind or mutate any global/argument; discard earlier proofs.
1365
+ forget(); return None
1366
+ if fn[0] == 'class':
1367
+ sig, symbol = classes[fn[1]]['signature'], fn[1]
1368
+ elif fn[0] == 'function':
1369
+ sig, symbol = callables[fn[1]], fn[1]
1370
+ elif fn[0] == 'member':
1371
+ cls = classes[fn[1]]
1372
+ if fn[2] not in cls['attributes'] and not cls['dynamicAttributes']:
1373
+ invalid(node, fn[1] + '.' + fn[2])
1374
+ sig, symbol = (cls['members'].get(fn[2]) or {}).get('signature'), fn[1] + '.' + fn[2]
1375
+ else:
1376
+ forget(); return None
1377
+ if sig:
1378
+ parameters = sig['parameters']
1379
+ if not any(p['kind'] == 'VAR_KEYWORD' for p in parameters):
1380
+ accepted = {p['name'] for p in parameters if p['kind'] in ('POSITIONAL_OR_KEYWORD', 'KEYWORD_ONLY')}
1381
+ for keyword in node.keywords:
1382
+ if keyword.arg is not None and keyword.arg not in accepted:
1383
+ invalid(node, symbol + '.' + keyword.arg)
1384
+ if fn == ('function', 'extrude'):
1385
+ first = kwargs.get('to_extrude', args[0] if args else None)
1386
+ accepted = next((p['annotation'] for p in parameters if p['name'] == 'to_extrude'), None)
1387
+ # Only BuildSketch is a proven builder/shape mismatch; no general annotation type checker.
1388
+ if first == ('instance', 'BuildSketch') and accepted and 'BuildSketch' not in accepted:
1389
+ invalid(node, 'extrude.to_extrude.BuildSketch')
1390
+ return ('instance', fn[1]) if fn[0] == 'class' else result_type(sig, fn[1] if fn[0] == 'member' else None)
1391
+ if isinstance(node, ast.NamedExpr):
1392
+ value = expression(node.value); assign(node.target, value); return value
1393
+ if isinstance(node, (ast.Lambda, ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.BoolOp, ast.IfExp)):
1394
+ # Local scopes, deferred callbacks, short-circuited branches and iterator types are not inferred.
1395
+ forget(); return None
1396
+ for child in ast.iter_child_nodes(node):
1397
+ if isinstance(child, ast.expr):
1398
+ expression(child)
1399
+ return None
1400
+ def assign(target, value):
1401
+ if isinstance(target, ast.Name):
1402
+ env[target.id] = value
1403
+ else:
1404
+ # Attribute mutation, unpacking and indexed aliases are intentionally not inferred.
1405
+ forget()
1406
+ def statements(nodes):
1407
+ nonlocal star
1408
+ for node in nodes:
1409
+ # Re-importing a cached Python module cannot restore proof after a possible monkey patch.
1410
+ if generation:
1411
+ return
1412
+ if isinstance(node, ast.Import):
1413
+ for alias in node.names:
1414
+ if alias.name not in ('build123d', 'math'):
1415
+ forget()
1416
+ env[alias.asname or alias.name.split('.')[0]] = ('module',) if alias.name == 'build123d' else None
1417
+ elif isinstance(node, ast.ImportFrom):
1418
+ for alias in node.names:
1419
+ if node.module == 'build123d':
1420
+ if alias.name == '*':
1421
+ star = True
1422
+ elif alias.name not in module_names:
1423
+ invalid(node, 'build123d.' + alias.name)
1424
+ else:
1425
+ env[alias.asname or alias.name] = binding(alias.name)
1426
+ elif alias.name == '*':
1427
+ forget()
1428
+ else:
1429
+ if node.module != 'math':
1430
+ forget()
1431
+ env[alias.asname or alias.name] = None
1432
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
1433
+ value = expression(node.value) if node.value else None
1434
+ for target in node.targets if isinstance(node, ast.Assign) else [node.target]:
1435
+ assign(target, value)
1436
+ elif isinstance(node, ast.Expr):
1437
+ expression(node.value)
1438
+ elif isinstance(node, ast.With):
1439
+ for item in node.items:
1440
+ value = expression(item.context_expr)
1441
+ if item.optional_vars:
1442
+ assign(item.optional_vars, value)
1443
+ statements(node.body)
1444
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
1445
+ parameters = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs,
1446
+ *([node.args.vararg] if node.args.vararg else []), *([node.args.kwarg] if node.args.kwarg else [])]
1447
+ if node.decorator_list or node.args.defaults or node.args.kw_defaults or node.returns or any(arg.annotation for arg in parameters):
1448
+ forget()
1449
+ env[node.name] = None
1450
+ else:
1451
+ # Control flow, deletion and augmented writes lose certainty; do not reject based on stale types.
1452
+ forget()
1453
+ try:
1454
+ statements(tree.body)
1455
+ except InvalidSymbol as exc:
1456
+ return exc.diagnostic
1457
+ return None
1458
+
1459
+
1460
+ request = json.load(sys.stdin)
1461
+ if request.get('mode') == 'api_manifest':
1462
+ print(json.dumps(export_source_api()), file=response_stream, flush=True)
1463
+ raise SystemExit(0)
1464
+ if request.get('mode') == 'preflight':
1465
+ # Never import or execute generated source here. This is a conservative
1466
+ # syntax/global-name lint, not a proof of execution or geometric validity.
1467
+ import ast
1468
+ import builtins
1469
+ import symtable
1470
+ try:
1471
+ source = request['source']
1472
+ tree = ast.parse(source, filename='model.py')
1473
+ table = symtable.symtable(source, 'model.py', 'exec')
1474
+ declared = {symbol.get_name() for symbol in table.get_symbols() if symbol.is_assigned() or symbol.is_imported() or symbol.is_namespace()}
1475
+ declared.update(dir(builtins)); declared.update({'__name__', '__builtins__'})
1476
+ star = any(isinstance(node, ast.ImportFrom) and any(alias.name == '*' for alias in node.names) for node in ast.walk(tree))
1477
+ dynamic = any(isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in {'exec', 'eval', 'globals', 'locals'} for node in ast.walk(tree))
1478
+ missing = set()
1479
+ def names(scope):
1480
+ for symbol in scope.get_symbols():
1481
+ if symbol.is_referenced() and symbol.is_global() and symbol.get_name() not in declared:
1482
+ missing.add(symbol.get_name())
1483
+ for child in scope.get_children():
1484
+ names(child)
1485
+ if not star and not dynamic:
1486
+ names(table)
1487
+ safe = sorted(name for name in missing if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}', name))[:5]
1488
+ unsupported = library_preflight(tree, request)
1489
+ for node in ast.walk(tree):
1490
+ desktop = isinstance(node, ast.Import) and any(alias.name.split('.')[0] == 'ocp_vscode' for alias in node.names) or \
1491
+ isinstance(node, ast.ImportFrom) and (node.module or '').split('.')[0] == 'ocp_vscode'
1492
+ manual_export = isinstance(node, ast.Call) and (isinstance(node.func, ast.Attribute) and node.func.attr == 'export_step' or \
1493
+ isinstance(node.func, ast.Name) and node.func.id == 'export_step')
1494
+ if desktop or manual_export:
1495
+ unsupported = {'code': 'source_unsupported', 'feature': 'desktop_import' if desktop else 'manual_export',
1496
+ 'line': min(max(node.lineno, 1), 32768)}
1497
+ break
1498
+ assigned = {symbol.get_name() for symbol in table.get_symbols() if symbol.is_assigned()}
1499
+ if unsupported is None and not dynamic and not {'assembly', 'result'}.intersection(assigned):
1500
+ unsupported = {'code': 'source_unsupported', 'feature': 'missing_output', 'line': 1}
1501
+ # An initializer is not the final assembly: append/extend, loops and
1502
+ # later assignments may populate it. Only a final non-unpacked literal
1503
+ # has a statically known count; runtime validates every actual result.
1504
+ for node in tree.body[-1:]:
1505
+ target = node.targets[0] if isinstance(node, ast.Assign) and len(node.targets) == 1 else node.target if isinstance(node, ast.AnnAssign) else None
1506
+ if unsupported is None and isinstance(target, ast.Name) and target.id == 'assembly' and isinstance(node.value, (ast.List, ast.Tuple)) and \
1507
+ not any(isinstance(item, ast.Starred) for item in node.value.elts) and not 2 <= len(node.value.elts) <= 128:
1508
+ unsupported = {'code': 'source_unsupported', 'feature': 'assembly_shape', 'line': min(max(node.lineno, 1), 32768)}
1509
+ symbol_diagnostic = source_symbol_precheck(tree, request['sourceApi']) if request.get('sourceApi') else None
1510
+ response = {'ok': False, 'diagnostic': symbol_diagnostic} if symbol_diagnostic else \
1511
+ {'ok': False, 'diagnostic': {'code': 'source_unbound', 'names': safe}} if missing else \
1512
+ {'ok': False, 'diagnostic': unsupported} if unsupported else {'ok': True}
1513
+ except SyntaxError as exc:
1514
+ response = {'ok': False, 'diagnostic': {'code': 'source_syntax', 'line': min(max(exc.lineno or 1, 1), 32768)}}
1515
+ print(json.dumps(response), file=response_stream, flush=True)
1516
+ raise SystemExit(0)
1517
+
1518
+ import build123d as bd
1519
+ import runpy
1520
+ engineering = runpy.run_path(str(Path(__file__).with_name('engineering.py')))
1521
+ root = Path(os.environ.get('STRUCTURE_TASK_DIR', '/tmp/structure'))
1522
+ root.mkdir(exist_ok=True)
1523
+ step = root / 'model.step'
1524
+ if request['mode'] in ('component_fit_build', 'component_fit_inspect'):
1525
+ try:
1526
+ if request['mode'] == 'component_fit_inspect':
1527
+ import matplotlib
1528
+ matplotlib.use('Agg')
1529
+ import matplotlib.pyplot as plt
1530
+ import numpy as np
1531
+ result = engineering['construct' if request['mode'] == 'component_fit_build' else 'verify_final'](request, globals())
1532
+ print(json.dumps(result), file=response_stream, flush=True)
1533
+ except Exception as exc:
1534
+ fail_with_diagnostic('geometry_validation', exc)
1535
+ raise SystemExit(0)
1536
+ if request['mode'] == 'build':
1537
+ source = request['source']; source_sha256 = request.get('sourceSha256')
1538
+ if not isinstance(source_sha256, str) or source_sha256 != digest(source.encode()):
1539
+ raise ValueError('source hash mismatch')
1540
+ scope = {'__name__': '__main__'}
1541
+ stage = 'source_exec'
1542
+ try:
1543
+ abelien_cad = load_cad_library(reset=True)
1544
+ with contextlib.redirect_stdout(sys.stderr):
1545
+ exec(compile(source, 'model.py', 'exec'), scope)
1546
+ representation_records = abelien_cad._RECORDS
1547
+ stage = 'assembly_contract'
1548
+ model, assembly, inspection, revision_checks, inspection_breps = draft_manifest(scope, source_sha256, request.get('revision'))
1549
+ representation = build_representation(source, representation_records, assembly, abelien_cad.__all__, dir(bd))
1550
+ attachment_proposal = engineering['proposal'](scope['engineering_attachment']) if 'engineering_attachment' in scope else None
1551
+ stage = 'step_export'
1552
+ bd.export_step(model, str(step))
1553
+ except Exception as exc: # The model's own source failed: return a bounded reason it can act on.
1554
+ fail_with_diagnostic(stage, exc)
1555
+ print(json.dumps({'step': encode(step), 'assembly': assembly, 'inspection': inspection, 'revisionChecks': revision_checks,
1556
+ **({'representation': representation} if representation is not None else {}),
1557
+ **({'inspectionGeometryBreps': inspection_breps} if inspection_breps is not None else {}),
1558
+ **({'attachmentProposal': attachment_proposal} if attachment_proposal is not None else {})}), file=response_stream, flush=True)
1559
+ else:
1560
+ source_sha256, assembly, inspection, revision_checks = request.get('sourceSha256'), request.get('assembly'), request.get('inspection'), request.get('revisionChecks')
1561
+ if not isinstance(source_sha256, str) or not isinstance(assembly, dict) or assembly.get('sourceSha256') != source_sha256:
1562
+ raise ValueError('assembly source mismatch')
1563
+ if not isinstance(inspection, dict) or digest(inspection) != assembly.get('inspectionPlanSha256'):
1564
+ raise ValueError('inspection plan mismatch')
1565
+ if not isinstance(revision_checks, dict) or set(revision_checks) != {'delta', 'preservation', 'mount'}:
1566
+ raise ValueError('revision checks missing')
1567
+ representation = validate_representation(request['representation'], assembly) if 'representation' in request else None
1568
+ try:
1569
+ step_bytes = base64.b64decode(request['step'], validate=True); step.write_bytes(step_bytes)
1570
+ model = bd.import_step(str(step))
1571
+ dimensions = list(model.bounding_box().size); volume = model.volume; solids = len(model.solids())
1572
+ if not model.is_valid or not solids or not math.isfinite(volume) or volume <= 0:
1573
+ raise ValueError('invalid or empty solid')
1574
+ if not all(math.isfinite(value) and value > 0 for value in dimensions):
1575
+ raise ValueError('invalid dimensions')
1576
+ glb = root / 'model.glb'
1577
+ if assembly.get('mode') == 'legacy_shape':
1578
+ display_model = bd.Part(model.wrapped); display_model.label = 'legacy-shape'
1579
+ children = [display_model]
1580
+ elif assembly.get('mode') == 'occurrence_assembly':
1581
+ children = list(model.children)
1582
+ expected = [occurrence['occurrenceId'] for occurrence in assembly['occurrences']]
1583
+ if len(children) != len(expected) or [child.label for child in children] != expected:
1584
+ raise ValueError('STEP occurrence hierarchy lost during readback')
1585
+ display_model = bd.Compound(children=children); display_model.label = 'assembly-root'
1586
+ else:
1587
+ raise ValueError('unknown assembly mode')
1588
+ inspection_shapes, inspection_evidence = read_inspection_geometry(children, assembly, inspection, request.get('inspectionGeometryBreps'))
1589
+ if not bd.export_gltf(display_model, str(glb), binary=True, linear_deflection=0.2 if request['pass'] == 'preview' else 0.05):
1590
+ raise ValueError('GLB export failed')
1591
+ raw = apply_materials_to_glb(glb.read_bytes(), assembly); glb.write_bytes(raw)
1592
+ document = gltf_document(raw); validate_occurrence_nodes(document, assembly)
1593
+ import matplotlib
1594
+ matplotlib.use('Agg')
1595
+ import matplotlib.pyplot as plt
1596
+ import numpy as np
1597
+ view_images = render_assembly_views(root, children, assembly, 0.3)
1598
+ manifest = final_manifest(assembly, step_bytes, raw, view_images)
1599
+ geometric_checks = inspect_geometry(children, manifest, inspection, inspection_shapes)
1600
+ unknown = [name for name, check in geometric_checks.items() if check['status'] == 'unknown']
1601
+ not_checked = ['reference_match', 'wall_thickness', 'manufacturability', 'physical_performance', *unknown, *DEFERRED]
1602
+ if representation is not None and any(item['omitted'] for item in representation['occurrences']):
1603
+ not_checked.append('representation_gaps')
1604
+ report = {'schema': 'abelien.structure-inspection.v2', 'units': 'mm', 'build123d': bd.__version__,
1605
+ 'assembly': {'mode': manifest['mode'], 'sha256': manifest['assemblySha256'], 'occurrence_count': manifest['occurrenceCount'],
1606
+ 'node_mapping': manifest['mode'] == 'occurrence_assembly', 'inspection_plan_sha256': manifest['inspectionPlanSha256']},
1607
+ 'checks': {'step_readback': True, 'solid_validity': True, 'solid_count': solids,
1608
+ 'dimensions_mm': dimensions, 'volume_mm3': volume, 'geometry': geometric_checks, 'revision': revision_checks},
1609
+ 'visual_review': 'review_required',
1610
+ 'not_checked': not_checked,
1611
+ **({'representation': representation} if representation is not None else {})}
1612
+ if inspection_evidence is not None:
1613
+ report['inspectionGeometry'] = {**inspection_evidence, 'assemblySha256': manifest['assemblySha256']}
1614
+ images = {f'{name}.png': base64.b64encode(body).decode('ascii') for name, body in view_images.items()}
1615
+ artifacts = {'model.step': encode(step), 'model.glb': encode(glb), **images,
1616
+ 'geometry.json': base64.b64encode(json.dumps(report, separators=(',', ':')).encode()).decode('ascii'),
1617
+ 'assembly.json': base64.b64encode(json.dumps(manifest, separators=(',', ':'), ensure_ascii=False).encode()).decode('ascii')}
1618
+ if 'attachmentProposal' in request:
1619
+ attachment = engineering['baseline'](children, manifest, request['attachmentProposal'], step_bytes, globals())
1620
+ artifacts['attachment.json'] = base64.b64encode(json.dumps(attachment, separators=(',', ':')).encode()).decode()
1621
+ print(json.dumps({'artifacts': artifacts}), file=response_stream, flush=True)
1622
+ except Exception as exc: # Geometry of the built model failed validation; integrity checks above stay fatal.
1623
+ fail_with_diagnostic('geometry_validation', exc)