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.
- package/.env.example +4 -0
- package/README.md +11 -18
- package/package.json +2 -3
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +12 -2
- package/skills/abelien-clarify/references/examples.md +22 -0
- package/skills/abelien-clarify/scripts/check_result.py +38 -3
- package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +10 -5
- package/skills/abelien-hardware/domain.json +3 -3
- package/skills/abelien-hardware/references/search-index-format.md +13 -1
- package/skills/abelien-hardware/references/search-index-release.md +4 -2
- package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
- package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
- package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
- package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
- package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
- package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
- package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
- package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
- package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
- package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
- package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
- package/skills/abelien-hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
- package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
- package/skills/abelien-harness/scripts/ab.mjs +24 -2
- package/skills/abelien-harness/scripts/web/common.mjs +20 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
- package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
- package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
- package/skills/abelien-reference/SKILL.md +51 -0
- package/skills/abelien-reference/domain.json +92 -0
- package/skills/abelien-reference/fixtures/create.final.json +102 -0
- package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
- package/skills/abelien-reference/fixtures/restore.final.json +267 -0
- package/skills/abelien-reference/references/research-contract.md +74 -0
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
- package/skills/abelien-reference/scripts/evidence.mjs +60 -0
- package/skills/abelien-reference/scripts/finalize.mjs +116 -0
- package/skills/abelien-reference/scripts/measure.mjs +62 -0
- package/skills/abelien-reference/scripts/png.mjs +86 -0
- package/skills/abelien-reference/scripts/research.mjs +179 -0
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +64 -0
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/abelien_cad.py +585 -0
- package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
- package/skills/abelien-structure/worker/cad.py +1623 -0
- package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
- package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
- package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
- package/skills/abelien-structure/worker/component-fit.mjs +167 -0
- package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
- package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
- package/skills/abelien-structure/worker/component-target-render.py +58 -0
- package/skills/abelien-structure/worker/config.mjs +6 -0
- package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
- package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
- package/skills/abelien-structure/worker/engineering-policy.json +16 -0
- package/skills/abelien-structure/worker/engineering-repair.py +147 -0
- package/skills/abelien-structure/worker/engineering.py +324 -0
- package/skills/abelien-structure/worker/identity.mjs +98 -0
- package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
- package/skills/abelien-structure/worker/jobs.mjs +451 -0
- package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
- package/skills/abelien-structure/worker/main.mjs +102 -0
- package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
- package/skills/abelien-structure/worker/materials.mjs +52 -0
- package/skills/abelien-structure/worker/native-launcher.py +13 -0
- package/skills/abelien-structure/worker/native.mjs +157 -0
- package/skills/abelien-structure/worker/representation.mjs +47 -0
- package/skills/abelien-structure/worker/sandbox.mjs +41 -0
- package/skills/abelien-structure/worker/source-contract.mjs +143 -0
- package/skills/abelien-structure/worker/source_call_stats.py +59 -0
- package/skills/abelien-structure/worker/supervisor.mjs +38 -0
- package/skills/abelien-workspace/SKILL.md +11 -0
- package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
- package/skills/abelien-workspace/domain.json +25 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +2 -2
- package/skills/abelien-workspace/references/user-journey.md +10 -0
- package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
- package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
- package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
- package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
- 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)
|