jwwkit 0.2.0__py3-none-any.whl
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.
- jwwkit/__init__.py +14 -0
- jwwkit/__main__.py +4 -0
- jwwkit/_commands/__init__.py +1 -0
- jwwkit/_commands/_integrity.py +55 -0
- jwwkit/_commands/check_installation.py +484 -0
- jwwkit/_commands/compare_drawings.py +29 -0
- jwwkit/_commands/inspect_jwf.py +24 -0
- jwwkit/_commands/recognize_planar_tategu.py +20 -0
- jwwkit/_commands/summarize_drawing.py +16 -0
- jwwkit/_comparison_composites.py +179 -0
- jwwkit/_edit_result_core.py +175 -0
- jwwkit/_ezjww_adapter.py +12 -0
- jwwkit/_geometry_engine.py +204 -0
- jwwkit/_geometry_math.py +93 -0
- jwwkit/_normalization_inspection.py +83 -0
- jwwkit/_replacement_contract.py +161 -0
- jwwkit/_resources.py +23 -0
- jwwkit/_spatial_distance.py +95 -0
- jwwkit/_spatial_query.py +34 -0
- jwwkit/_tategu_features.py +201 -0
- jwwkit/_tategu_geometry.py +108 -0
- jwwkit/alignment.py +85 -0
- jwwkit/cad_model.py +5 -0
- jwwkit/cli.py +40 -0
- jwwkit/drawing_comparison.py +430 -0
- jwwkit/edit_result.py +136 -0
- jwwkit/geometry_view.py +141 -0
- jwwkit/guides/AGENTS.md +47 -0
- jwwkit/guides/docs/api_list.md +1944 -0
- jwwkit/guides/docs/common_edit_result_coverage.md +40 -0
- jwwkit/guides/docs/drawing_comparison.md +79 -0
- jwwkit/guides/docs/edit_results.md +59 -0
- jwwkit/guides/docs/geometry_view.md +64 -0
- jwwkit/guides/docs/jwf_key_catalog.md +244 -0
- jwwkit/guides/docs/jwf_readonly.md +76 -0
- jwwkit/guides/docs/plan_api_coverage.md +75 -0
- jwwkit/guides/docs/read_view.md +89 -0
- jwwkit/guides/docs/reading_to_editing.md +100 -0
- jwwkit/guides/docs/schema_normalization.md +64 -0
- jwwkit/guides/docs/search_queries.md +95 -0
- jwwkit/guides/docs/spatial_search.md +77 -0
- jwwkit/guides/docs/tategu_recognition.md +40 -0
- jwwkit/guides/docs/text_equivalence.md +42 -0
- jwwkit/guides/docs/usage_guide.md +604 -0
- jwwkit/guides/docs/workflow_settings.md +95 -0
- jwwkit/hatch_geometry.py +77 -0
- jwwkit/jwf.py +202 -0
- jwwkit/legacy_document.py +2 -0
- jwwkit/line_document.py +14 -0
- jwwkit/model/__init__.py +1 -0
- jwwkit/model/base.py +15 -0
- jwwkit/model/block_definition.py +32 -0
- jwwkit/model/block_names.py +41 -0
- jwwkit/model/block_operations.py +154 -0
- jwwkit/model/block_reference.py +39 -0
- jwwkit/model/circle.py +44 -0
- jwwkit/model/circular_solid.py +72 -0
- jwwkit/model/common_entities.py +34 -0
- jwwkit/model/dimension.py +49 -0
- jwwkit/model/display_state.py +46 -0
- jwwkit/model/document.py +585 -0
- jwwkit/model/editing.py +270 -0
- jwwkit/model/group_audit.py +91 -0
- jwwkit/model/group_import.py +249 -0
- jwwkit/model/line.py +31 -0
- jwwkit/model/point.py +41 -0
- jwwkit/model/preflight.py +120 -0
- jwwkit/model/preset_rigid.py +42 -0
- jwwkit/model/query.py +270 -0
- jwwkit/model/query_fields.py +189 -0
- jwwkit/model/query_regex_worker.py +20 -0
- jwwkit/model/query_text.py +116 -0
- jwwkit/model/rigid_audit.py +66 -0
- jwwkit/model/solid.py +51 -0
- jwwkit/model/spatial.py +99 -0
- jwwkit/model/summary.py +93 -0
- jwwkit/model/text.py +49 -0
- jwwkit/model/text_metrics.py +78 -0
- jwwkit/model/transformations.py +176 -0
- jwwkit/normalization.py +287 -0
- jwwkit/read_view.py +298 -0
- jwwkit/reader/__init__.py +1 -0
- jwwkit/reader/archive.py +299 -0
- jwwkit/reader/images.py +48 -0
- jwwkit/reader/jws.py +175 -0
- jwwkit/reader/jws_placement.py +78 -0
- jwwkit/reader/sxf.py +51 -0
- jwwkit/reader/template_names.py +22 -0
- jwwkit/reader/text_presets.py +23 -0
- jwwkit/record_reader.py +2 -0
- jwwkit/replacement_workflow.py +39 -0
- jwwkit/resources/README.md +7 -0
- jwwkit/resources/empty_template.jww +0 -0
- jwwkit/resources/halfwidth_kana_composition_v1.json +35 -0
- jwwkit/resources/header.json +3 -0
- jwwkit/resources/jwf_schema.json +925 -0
- jwwkit/resources/jww_workflow.example.toml +14 -0
- jwwkit/resources/text_equivalence_v1.json +1779 -0
- jwwkit/safe_edit.py +454 -0
- jwwkit/spatial_search.py +144 -0
- jwwkit/tategu_recognition.py +181 -0
- jwwkit/workflow_settings.py +140 -0
- jwwkit/writer/__init__.py +1 -0
- jwwkit/writer/header.py +104 -0
- jwwkit/writer/legacy_document.py +62 -0
- jwwkit/writer/mfc.py +110 -0
- jwwkit/writer/preserved.py +265 -0
- jwwkit/writer/strings.py +19 -0
- jwwkit/writer/template_constants.py +9 -0
- jwwkit/writer/template_document.py +390 -0
- jwwkit-0.2.0.dist-info/METADATA +174 -0
- jwwkit-0.2.0.dist-info/RECORD +117 -0
- jwwkit-0.2.0.dist-info/WHEEL +5 -0
- jwwkit-0.2.0.dist-info/entry_points.txt +2 -0
- jwwkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- jwwkit-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +51 -0
- jwwkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Read-only correspondence of independent, strictly readable JWW drawings.
|
|
2
|
+
|
|
3
|
+
LINE/CIRCLE/ARC/POINT/TEXT, dimensions, blocks and solids, plus saved settings.
|
|
4
|
+
Unsupported roots and non-unique correspondences remain explicit; this is not
|
|
5
|
+
the source-index edit audit and does not change its acceptance conditions.
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from collections import defaultdict, Counter
|
|
9
|
+
import copy
|
|
10
|
+
import hashlib
|
|
11
|
+
import itertools
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from jwwkit.cad_model import CadDocument
|
|
15
|
+
|
|
16
|
+
__all__ = ['compare_drawings', 'write_comparison']
|
|
17
|
+
_SUPPORTED = frozenset(('LINE', 'CIRCLE', 'ARC', 'POINT', 'TEXT', 'DIMENSION', 'BLOCK', 'SOLID', 'CIRCLE_SOLID'))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hash(path):
|
|
21
|
+
with Path(path).open('rb') as f:
|
|
22
|
+
return hashlib.file_digest(f, 'sha256').hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json(value):
|
|
26
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, allow_nan=False, separators=(',', ':'))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _finite(value, name):
|
|
30
|
+
if type(value) not in (int, float) or not math.isfinite(value):
|
|
31
|
+
raise ValueError(name + ' must be finite and not bool')
|
|
32
|
+
return float(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scope(value):
|
|
36
|
+
if value is None:
|
|
37
|
+
return dict(groups=list(range(16)), layers=None)
|
|
38
|
+
if not isinstance(value, dict) or set(value) - {'groups', 'layers'}:
|
|
39
|
+
raise ValueError('scope accepts groups and layers only')
|
|
40
|
+
groups = value.get('groups', list(range(16)))
|
|
41
|
+
if not isinstance(groups, (list, tuple)) or any(type(g) is not int or not 0 <= g < 16 for g in groups) or len(set(groups)) != len(groups):
|
|
42
|
+
raise ValueError('groups must be distinct integers 0..15')
|
|
43
|
+
layers = value.get('layers')
|
|
44
|
+
if layers is not None:
|
|
45
|
+
if not isinstance(layers, (list, tuple)):
|
|
46
|
+
raise ValueError('layers must be group/layer pairs')
|
|
47
|
+
pairs = []
|
|
48
|
+
for pair in layers:
|
|
49
|
+
if not isinstance(pair, (list, tuple)) or len(pair) != 2 or any(type(i) is not int or not 0 <= i < 16 for i in pair):
|
|
50
|
+
raise ValueError('layers must contain valid group/layer pairs')
|
|
51
|
+
if pair[0] not in groups:
|
|
52
|
+
raise ValueError('layer group outside groups selection')
|
|
53
|
+
pairs.append(tuple(pair))
|
|
54
|
+
if len(set(pairs)) != len(pairs):
|
|
55
|
+
raise ValueError('duplicate layer selection')
|
|
56
|
+
layers = sorted(pairs)
|
|
57
|
+
return dict(groups=sorted(groups), layers=layers)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _selected(group, layer, scope):
|
|
61
|
+
return group in scope['groups'] and (scope['layers'] is None or (group, layer) in scope['layers'])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _differences(a, b, path='', size_tolerance=0):
|
|
65
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
66
|
+
return [r for k in sorted(a.keys() | b.keys()) for r in _differences(a.get(k), b.get(k), path + '.' + k, size_tolerance)]
|
|
67
|
+
if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)):
|
|
68
|
+
if len(a) != len(b):
|
|
69
|
+
return [dict(field=path, before=a, after=b)]
|
|
70
|
+
return [r for i, (x, y) in enumerate(zip(a, b)) for r in _differences(x, y, path + '[' + str(i) + ']', size_tolerance)]
|
|
71
|
+
if a == b:
|
|
72
|
+
if isinstance(a, float) and isinstance(b, float) and a == 0 and math.copysign(1, a) != math.copysign(1, b):
|
|
73
|
+
return [dict(field=path, before_hex=a.hex(), after_hex=b.hex(), reason='signed_zero_representation')]
|
|
74
|
+
return []
|
|
75
|
+
# Explicit tolerance is restricted to text dimensions, not arbitrary numeric attributes.
|
|
76
|
+
if path.split('.')[-1] in ('size_x', 'size_y', 'spacing') and type(a) in (int, float) and type(b) in (int, float) and abs(a-b) <= size_tolerance:
|
|
77
|
+
return []
|
|
78
|
+
return [dict(field=path, before=a, after=b)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _rows(doc, scope, units, transform, *, _entities=None, _scale=None, _stack=(), _budget=None):
|
|
82
|
+
from jwwkit.model.common_entities import model_entity
|
|
83
|
+
from jwwkit._comparison_composites import enrich
|
|
84
|
+
if _budget is None:_budget=[0]
|
|
85
|
+
angle, dx, dy = transform
|
|
86
|
+
co, si = math.cos(math.radians(angle)), math.sin(math.radians(angle))
|
|
87
|
+
colors, styles = {}, {}
|
|
88
|
+
sx_colors = {r['code']:r for r in doc.get_sxf_colors(include_unnamed=True)}
|
|
89
|
+
sx_styles = {r['code']:r for r in doc.get_sxf_linetypes(include_unnamed=True)}
|
|
90
|
+
rows = []
|
|
91
|
+
for index, entity in enumerate(doc.entities if _entities is None else _entities):
|
|
92
|
+
base = dict(entity.attributes)
|
|
93
|
+
if not _selected(base['layer_group'], base['layer'], scope):
|
|
94
|
+
continue
|
|
95
|
+
_budget[0]+=1
|
|
96
|
+
if _budget[0]>200000:raise ValueError('Composite expansion exceeds 200000 records; narrow scope')
|
|
97
|
+
g = copy.deepcopy(dict(entity.geometry)); kind = entity.entity_type
|
|
98
|
+
row = dict(index=index, source_index=entity.source_index, type=kind, attributes=base,
|
|
99
|
+
geometry_paper_mm=g, raw_record=copy.deepcopy(entity.raw), status='supported')
|
|
100
|
+
rows.append(row)
|
|
101
|
+
if kind not in _SUPPORTED or (kind == 'TEXT' and g['content'].startswith('^@BM')):
|
|
102
|
+
row.update(status='unsupported', reason='Initial comparison excludes this root type or image dependency')
|
|
103
|
+
continue
|
|
104
|
+
scale = _scale if _scale is not None else doc.get_group_scale(base['layer_group']) if units == 'real' else 1.0
|
|
105
|
+
if not math.isfinite(scale) or scale <= 0:
|
|
106
|
+
raise ValueError('Invalid selected group scale')
|
|
107
|
+
def point(x, y):
|
|
108
|
+
x, y = _finite(x, 'coordinate')*scale, _finite(y, 'coordinate')*scale
|
|
109
|
+
return [co*x-si*y+dx, si*x+co*y+dy]
|
|
110
|
+
pts, lengths, angles = [], [], []
|
|
111
|
+
attrs = copy.deepcopy(base); content = None
|
|
112
|
+
if kind in ('DIMENSION','BLOCK','SOLID','CIRCLE_SOLID'):
|
|
113
|
+
def children(records, child_scale, child_transform=transform, stack=_stack):
|
|
114
|
+
return _rows(doc,_scope(None),units,child_transform,_entities=[model_entity(r) for r in records],
|
|
115
|
+
_scale=child_scale,_stack=stack,_budget=_budget)
|
|
116
|
+
try:
|
|
117
|
+
pts,lengths,angles,anchor,extra,content=enrich(doc,row,scale,transform,point,children,_stack)
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
row.update(status='unsupported',reason=str(exc));continue
|
|
120
|
+
attrs.update(extra)
|
|
121
|
+
elif kind == 'LINE':
|
|
122
|
+
pts = sorted([point(g['start_x'],g['start_y']), point(g['end_x'],g['end_y'])])
|
|
123
|
+
anchor = [(pts[0][i]+pts[1][i])/2 for i in (0,1)]
|
|
124
|
+
elif kind in ('CIRCLE','ARC'):
|
|
125
|
+
center = point(g['center_x'],g['center_y'])
|
|
126
|
+
radius = _finite(g['radius'],'radius')*scale; flat = _finite(g['flatness'],'flatness')
|
|
127
|
+
tilt = _finite(g['tilt_angle'],'tilt')+math.radians(angle)
|
|
128
|
+
if radius < 0 or flat <= 0:
|
|
129
|
+
raise ValueError('Invalid circle/ellipse geometry')
|
|
130
|
+
a,b = max(radius,radius*flat),min(radius,radius*flat)
|
|
131
|
+
orient = 0.0 if a == b else (tilt + (math.pi/2 if flat > 1 else 0)) % math.pi
|
|
132
|
+
pts=[center];lengths=[a,b];angles=[(orient,math.pi)];anchor=center
|
|
133
|
+
if kind == 'ARC':
|
|
134
|
+
start = _finite(g['start_angle'],'arc start');sweep=_finite(g['arc_angle'],'arc sweep')
|
|
135
|
+
if abs(sweep)>2*math.pi:
|
|
136
|
+
row.update(status='unsupported',reason='Arc with more than one full turn is unverified');continue
|
|
137
|
+
def on(t):
|
|
138
|
+
x,y=radius*math.cos(t),radius*flat*math.sin(t)
|
|
139
|
+
return [center[0]+math.cos(tilt)*x-math.sin(tilt)*y,center[1]+math.sin(tilt)*x+math.cos(tilt)*y]
|
|
140
|
+
pts += sorted([on(start),on(start+sweep)])+[on(start+sweep/2)]
|
|
141
|
+
angles.append((abs(sweep),0))
|
|
142
|
+
elif kind == 'POINT':
|
|
143
|
+
pts=[point(g['x'],g['y'])];anchor=pts[0]
|
|
144
|
+
attrs.update({k:g[k] for k in ('is_temporary','code','angle','scale')})
|
|
145
|
+
if g['code']:attrs['angle']=(attrs['angle']+angle)%360
|
|
146
|
+
else:
|
|
147
|
+
pts=[point(g['start_x'],g['start_y'])];anchor=pts[0]
|
|
148
|
+
angles=[((math.radians(_finite(g['angle'],'text angle')+angle))%(2*math.pi),2*math.pi)]
|
|
149
|
+
content=g['content']
|
|
150
|
+
attrs.update({k:g[k] for k in ('text_type','size_x','size_y','spacing','font_name')})
|
|
151
|
+
preset=g['text_type']%10000
|
|
152
|
+
if 1<=preset<=10:attrs['text_preset_dependency']=doc.get_text_preset(preset)
|
|
153
|
+
color=base['pen_color']
|
|
154
|
+
if color not in colors:
|
|
155
|
+
if 0<=color<len(doc.header['palette']['pen_colors']):colors[color]=doc.header['palette']['pen_colors'][color]
|
|
156
|
+
elif color in sx_colors:colors[color]=sx_colors[color]
|
|
157
|
+
else:colors[color]={'unresolved_color_definition':color}
|
|
158
|
+
attrs['color_dependency']=({'explicit_RGB':g['color']} if kind in ('SOLID','CIRCLE_SOLID') and color==10 else copy.deepcopy(colors[color]))
|
|
159
|
+
if isinstance(attrs['color_dependency'],dict) and 'unresolved_color_definition' in attrs['color_dependency']:
|
|
160
|
+
row.update(status='unsupported',reason='Selected color number has no verified definition');continue
|
|
161
|
+
if kind not in ('TEXT','POINT','BLOCK','CIRCLE_SOLID') and base['pen_style']>=31:
|
|
162
|
+
style=base['pen_style'];attrs['SXF_style_dependency']=copy.deepcopy(sx_styles.get(style,{'unresolved_style':style}))
|
|
163
|
+
if style not in sx_styles:
|
|
164
|
+
row.update(status='unsupported',reason='Selected line style number has no verified definition');continue
|
|
165
|
+
elif kind not in ('TEXT','POINT','BLOCK','CIRCLE_SOLID') and not 1<=base['pen_style']<=9:
|
|
166
|
+
row.update(status='unsupported',reason='Unverified standard line style number');continue
|
|
167
|
+
row.update(comparison_geometry=dict(points=pts,lengths=lengths,angles=angles),anchor=anchor,
|
|
168
|
+
comparison_attributes=attrs,content=content)
|
|
169
|
+
return rows
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _geometry(a,b,position_tolerance,angle_tolerance):
|
|
173
|
+
if a['type']!=b['type']:return None
|
|
174
|
+
x,y=a['comparison_geometry'],b['comparison_geometry']
|
|
175
|
+
def distance(ps,qs):return max([math.dist(p,q) for p,q in zip(ps,qs)]+[0])
|
|
176
|
+
dp=distance(x['points'],y['points'])
|
|
177
|
+
if a['type']=='LINE':dp=min(dp,distance(x['points'],list(reversed(y['points']))))
|
|
178
|
+
if a['type']=='ARC':dp=min(dp,distance(x['points'],[y['points'][0],y['points'][2],y['points'][1],y['points'][3]]))
|
|
179
|
+
if a['type']=='SOLID':
|
|
180
|
+
if len(x['points'])!=len(y['points']):return None
|
|
181
|
+
ps=y['points'];dp=min(distance(x['points'],seq[n:]+seq[:n]) for seq in (ps,list(reversed(ps))) for n in range(len(ps)))
|
|
182
|
+
if a['type']=='CIRCLE_SOLID' and len(x['points'])==4 and len(y['points'])==4:
|
|
183
|
+
dp=min(dp,distance(x['points'],[y['points'][0],y['points'][2],y['points'][1],y['points'][3]]))
|
|
184
|
+
if len(x['points'])!=len(y['points']) or len(x['lengths'])!=len(y['lengths']) or len(x['angles'])!=len(y['angles']):return None
|
|
185
|
+
dl=max([abs(u-v) for u,v in zip(x['lengths'],y['lengths'])]+[0])
|
|
186
|
+
da=0
|
|
187
|
+
for (u,period),(v,_) in zip(x['angles'],y['angles']):
|
|
188
|
+
diff=abs(u-v)
|
|
189
|
+
if period:diff=min(diff%period,(-diff)%period)
|
|
190
|
+
da=max(da,diff)
|
|
191
|
+
if not (dp<=position_tolerance and dl<=position_tolerance and da<=angle_tolerance):return None
|
|
192
|
+
result=dict(max_position_error=dp,max_length_error=dl,max_angle_error_rad=da)
|
|
193
|
+
if a['type'] in ('DIMENSION','BLOCK'):
|
|
194
|
+
from jwwkit._comparison_composites import child_correspondence
|
|
195
|
+
nested=child_correspondence(a,b,position_tolerance,angle_tolerance)
|
|
196
|
+
if nested is None:return None
|
|
197
|
+
result.update(nested)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _signature(row):
|
|
202
|
+
from jwwkit._comparison_composites import semantic_key
|
|
203
|
+
return _json(semantic_key(row))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _components(edges):
|
|
207
|
+
neighbors=defaultdict(set)
|
|
208
|
+
for a,b in edges:neighbors[('A',a)].add(('B',b));neighbors[('B',b)].add(('A',a))
|
|
209
|
+
seen=set()
|
|
210
|
+
for root in sorted(neighbors):
|
|
211
|
+
if root in seen:continue
|
|
212
|
+
stack=[root];part=[];seen.add(root)
|
|
213
|
+
while stack:
|
|
214
|
+
node=stack.pop();part.append(node)
|
|
215
|
+
for nxt in sorted(neighbors[node]):
|
|
216
|
+
if nxt not in seen:seen.add(nxt);stack.append(nxt)
|
|
217
|
+
yield sorted(n for side,n in part if side=='A'),sorted(n for side,n in part if side=='B')
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _coverage(a,b,tol):
|
|
221
|
+
p,q=a['comparison_geometry']['points'];r,s=b['comparison_geometry']['points']
|
|
222
|
+
length=math.dist(p,q)
|
|
223
|
+
if not length or not math.dist(r,s):return False
|
|
224
|
+
ux,uy=(q[0]-p[0])/length,(q[1]-p[1])/length
|
|
225
|
+
cross=lambda t:abs((t[0]-p[0])*uy-(t[1]-p[1])*ux)
|
|
226
|
+
if max(cross(r),cross(s))>tol:return False
|
|
227
|
+
lo,hi=sorted(((t[0]-p[0])*ux+(t[1]-p[1])*uy for t in (r,s)))
|
|
228
|
+
return min(length,hi)-max(0,lo)>0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _coverage_result(left,right,tol,size_tolerance):
|
|
232
|
+
all_rows=left+right
|
|
233
|
+
longest=max(all_rows,key=lambda r:math.dist(*r['comparison_geometry']['points']))
|
|
234
|
+
p,q=longest['comparison_geometry']['points'];length=math.dist(p,q)
|
|
235
|
+
ux,uy=(q[0]-p[0])/length,(q[1]-p[1])/length
|
|
236
|
+
def intervals(rows):
|
|
237
|
+
out=[]
|
|
238
|
+
for row in rows:
|
|
239
|
+
points=row['comparison_geometry']['points']
|
|
240
|
+
if any(abs((t[0]-p[0])*uy-(t[1]-p[1])*ux)>tol for t in points):return None
|
|
241
|
+
out.append(sorted((t[0]-p[0])*ux+(t[1]-p[1])*uy for t in points))
|
|
242
|
+
return sorted(out)
|
|
243
|
+
a,b=intervals(left),intervals(right)
|
|
244
|
+
if a is None or b is None:return dict(accepted=False,reason='pairwise collinear candidates do not share one common axis')
|
|
245
|
+
def covered(src,dst):
|
|
246
|
+
for lo,hi in src:
|
|
247
|
+
cursor=lo
|
|
248
|
+
for x,y in dst:
|
|
249
|
+
if y<cursor:continue
|
|
250
|
+
if x>cursor+tol:break
|
|
251
|
+
cursor=max(cursor,y)
|
|
252
|
+
if cursor>=hi-tol:break
|
|
253
|
+
if cursor<hi-tol:return False
|
|
254
|
+
return True
|
|
255
|
+
ab,ba=covered(a,b),covered(b,a)
|
|
256
|
+
# Union coverage alone would hide duplicated overlapping lines.
|
|
257
|
+
def overlapping(intervals):
|
|
258
|
+
end=-math.inf
|
|
259
|
+
for lo,hi in intervals:
|
|
260
|
+
if lo<end-tol:return True
|
|
261
|
+
end=max(end,hi)
|
|
262
|
+
return False
|
|
263
|
+
# Equal total length can hide different local multiplicities. Unmatched
|
|
264
|
+
# internally overlapping partitions remain conservative holds.
|
|
265
|
+
multiple=overlapping(a) or overlapping(b) or abs(sum(y-x for x,y in a)-sum(y-x for x,y in b))>tol
|
|
266
|
+
uniform=lambda rows:all(not _differences(rows[0]['comparison_attributes'],r['comparison_attributes'],size_tolerance=size_tolerance) for r in rows[1:])
|
|
267
|
+
ok=ab and ba and not multiple and uniform(left) and uniform(right)
|
|
268
|
+
return dict(accepted=ok,A_fully_covered_by_B=ab,B_fully_covered_by_A=ba,multiplicity_difference=multiple,
|
|
269
|
+
attributes_uniform_A=uniform(left),attributes_uniform_B=uniform(right),
|
|
270
|
+
intervals_A=a,intervals_B=b,reason=None if ok else 'partial coverage, duplicate multiplicity or non-unique/uniform destination attributes')
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _settings(a,b,sa,sb):
|
|
274
|
+
rows=[]
|
|
275
|
+
for group in sorted(set(sa['groups'])&set(sb['groups'])):
|
|
276
|
+
fields=lambda d:dict(name=d.get_group_name(group),scale=d.get_group_scale(group),
|
|
277
|
+
state=d.header['layer_groups'][group]['state'],protect=d.header['layer_groups'][group]['protect'],
|
|
278
|
+
write_layer=d.header['layer_groups'][group]['write_layer'])
|
|
279
|
+
rows+=_differences(fields(a),fields(b),f'groups[{group}]')
|
|
280
|
+
for layer in range(16):
|
|
281
|
+
if not (_selected(group,layer,sa) and _selected(group,layer,sb)):continue
|
|
282
|
+
def layer_fields(d):
|
|
283
|
+
r=copy.deepcopy(d.header['layer_groups'][group]['layers'][layer]);r['name']=d.get_layer_name(group,layer);return r
|
|
284
|
+
rows+=_differences(layer_fields(a),layer_fields(b),f'groups[{group}].layers[{layer}]')
|
|
285
|
+
return rows
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def compare_drawings(left, right, *, left_scope=None, right_scope=None, units='real',
|
|
289
|
+
position_tolerance=0.0, angle_tolerance_degrees=0.0,
|
|
290
|
+
text_size_tolerance=0.0, right_transform=None,
|
|
291
|
+
line_coverage=True, max_candidate_pairs=2000000):
|
|
292
|
+
"""独立JWW2図面を読取り比較し、5分類・保留・群/層設定差をJSON互換辞書へ返す。
|
|
293
|
+
|
|
294
|
+
scopes: {'groups':[0,...], 'layers':[(group,layer),...]}; omitted means all.
|
|
295
|
+
position tolerance/translation use units (real mm by default); text size
|
|
296
|
+
tolerance uses paper mm, angles use degrees. No implicit alignment/scaling.
|
|
297
|
+
right_transform accepts rotation_degrees and translation=(x,y) only.
|
|
298
|
+
Unsupported selected roots make complete=False and are never omitted.
|
|
299
|
+
"""
|
|
300
|
+
if units not in ('real','paper'):raise ValueError('units must be real or paper')
|
|
301
|
+
pt=_finite(position_tolerance,'position_tolerance');at=_finite(angle_tolerance_degrees,'angle tolerance');st=_finite(text_size_tolerance,'text size tolerance')
|
|
302
|
+
if min(pt,at,st)<0 or at>180:raise ValueError('Nonnegative tolerances required; angle <=180')
|
|
303
|
+
if type(line_coverage) is not bool or type(max_candidate_pairs) is not int or max_candidate_pairs<1:raise ValueError('Invalid coverage flag/candidate limit')
|
|
304
|
+
if right_transform is not None and not isinstance(right_transform,dict):raise ValueError('right_transform must be a mapping or None')
|
|
305
|
+
transform=dict(right_transform or {})
|
|
306
|
+
if set(transform)-{'rotation_degrees','translation'}:raise ValueError('Only explicit rigid right transform is supported')
|
|
307
|
+
shift=transform.get('translation',(0,0))
|
|
308
|
+
if not isinstance(shift,(list,tuple)) or len(shift)!=2:raise ValueError('translation requires two coordinates')
|
|
309
|
+
tr=(_finite(transform.get('rotation_degrees',0),'rotation'),_finite(shift[0],'dx'),_finite(shift[1],'dy'))
|
|
310
|
+
sa,sb=_scope(left_scope),_scope(right_scope)
|
|
311
|
+
paths=[Path(left).resolve(),Path(right).resolve()];hashes=[_hash(p) for p in paths]
|
|
312
|
+
docs=[CadDocument.open(p) for p in paths]
|
|
313
|
+
ra,rb=_rows(docs[0],sa,units,(0,0,0)),_rows(docs[1],sb,units,tr)
|
|
314
|
+
aa={r['index']:r for r in ra if r['status']=='supported'};bb={r['index']:r for r in rb if r['status']=='supported'}
|
|
315
|
+
remaining_a=set(aa);remaining_b=set(bb);matches=[];ambiguous=[];partial=[]
|
|
316
|
+
def add(ids,jds,kind='one_to_one',geometry_errors=None):
|
|
317
|
+
la=[aa[i] for i in ids];lb=[bb[j] for j in jds]
|
|
318
|
+
from jwwkit._comparison_composites import changes
|
|
319
|
+
if len(ids)==len(jds)==1 and la[0]['type'] in ('DIMENSION','BLOCK') and geometry_errors is None:
|
|
320
|
+
geometry_errors=_geometry(la[0],lb[0],pt,math.radians(at))
|
|
321
|
+
attrs,content_changes,definition_storage=changes(la[0],lb[0],geometry_errors,st)
|
|
322
|
+
content_changed=bool(content_changes)
|
|
323
|
+
category='text_changed' if content_changed else 'attributes_changed' if attrs else 'unchanged'
|
|
324
|
+
matches.append(dict(category=category,left_indices=ids,right_indices=jds,correspondence=kind,
|
|
325
|
+
attribute_changes=attrs,content_before=la[0]['content'] if content_changed else None,
|
|
326
|
+
content_after=lb[0]['content'] if content_changed else None,geometry_errors=geometry_errors,
|
|
327
|
+
content_changes=content_changes,definition_representation_differences=definition_storage,
|
|
328
|
+
representation_differences=_differences(la[0]['geometry_paper_mm'],lb[0]['geometry_paper_mm']) if len(ids)==len(jds)==1 else [],
|
|
329
|
+
multiplicity_note='indices of identical duplicates are bookkeeping, not persistent identities' if kind=='duplicate_multiplicity' else None))
|
|
330
|
+
remaining_a.difference_update(ids);remaining_b.difference_update(jds)
|
|
331
|
+
# Exact semantic keys resolve repeated unchanged geometry before ambiguous
|
|
332
|
+
# weaker geometric candidates. Signatures do not round coordinates.
|
|
333
|
+
def sig(r):return _signature(r)
|
|
334
|
+
ga,gb=defaultdict(list),defaultdict(list)
|
|
335
|
+
for i,r in aa.items():ga[sig(r)].append(i)
|
|
336
|
+
for j,r in bb.items():gb[sig(r)].append(j)
|
|
337
|
+
for key in sorted(ga.keys()&gb.keys()):
|
|
338
|
+
n=min(len(ga[key]),len(gb[key]))
|
|
339
|
+
for i,j in zip(ga[key][:n],gb[key][:n]):add([i],[j],'duplicate_multiplicity' if max(len(ga[key]),len(gb[key]))>1 else 'one_to_one')
|
|
340
|
+
buckets=defaultdict(list)
|
|
341
|
+
def bucket(r):return (r['type'],*(math.floor(x/pt) for x in r['anchor'])) if pt else (r['type'],*r['anchor'])
|
|
342
|
+
for j in sorted(remaining_b):buckets[bucket(bb[j])].append(j)
|
|
343
|
+
edges={};examined=0
|
|
344
|
+
for i in sorted(remaining_a):
|
|
345
|
+
key=bucket(aa[i]);keys=[key] if not pt else [(key[0],key[1]+x,key[2]+y) for x,y in itertools.product((-1,0,1),repeat=2)]
|
|
346
|
+
for k in keys:
|
|
347
|
+
for j in buckets[k]:
|
|
348
|
+
examined+=1
|
|
349
|
+
if examined>max_candidate_pairs:raise ValueError('Candidate limit exceeded; narrow scopes or raise explicit limit')
|
|
350
|
+
err=_geometry(aa[i],bb[j],pt,math.radians(at))
|
|
351
|
+
if err is not None:edges[i,j]=err
|
|
352
|
+
# Only forced, mutually unique correspondences; no nearest-neighbour guess.
|
|
353
|
+
while edges:
|
|
354
|
+
ca=Counter(i for i,j in edges);cb=Counter(j for i,j in edges)
|
|
355
|
+
forced=[(i,j) for i,j in sorted(edges) if ca[i]==cb[j]==1 and not edges[i,j].get('nested_ambiguous')]
|
|
356
|
+
if not forced:break
|
|
357
|
+
for i,j in forced:add([i],[j],geometry_errors=edges[i,j])
|
|
358
|
+
edges={k:v for k,v in edges.items() if k[0] in remaining_a and k[1] in remaining_b}
|
|
359
|
+
for ids,jds in _components(edges):
|
|
360
|
+
ambiguous.append(dict(reason='non_unique_geometry_or_compound_child_correspondence',left_indices=ids,right_indices=jds))
|
|
361
|
+
remaining_a.difference_update(ids);remaining_b.difference_update(jds)
|
|
362
|
+
if line_coverage:
|
|
363
|
+
le=[i for i in sorted(remaining_a) if aa[i]['type']=='LINE'];re=[j for j in sorted(remaining_b) if bb[j]['type']=='LINE'];ce=[]
|
|
364
|
+
for i in le:
|
|
365
|
+
for j in re:
|
|
366
|
+
examined+=1
|
|
367
|
+
if examined>max_candidate_pairs:raise ValueError('Candidate limit exceeded during line coverage')
|
|
368
|
+
if _coverage(aa[i],bb[j],pt):ce.append((i,j))
|
|
369
|
+
for ids,jds in _components(ce):
|
|
370
|
+
evidence=_coverage_result([aa[i] for i in ids],[bb[j] for j in jds],pt,st)
|
|
371
|
+
if evidence['accepted']:
|
|
372
|
+
add(ids,jds,'line_partition_equivalent',geometry_errors=evidence)
|
|
373
|
+
else:
|
|
374
|
+
partial.append(dict(left_indices=ids,right_indices=jds,evidence=evidence))
|
|
375
|
+
remaining_a.difference_update(ids);remaining_b.difference_update(jds)
|
|
376
|
+
deleted=sorted(remaining_a);added=sorted(remaining_b)
|
|
377
|
+
unsupported=[dict(side=side,index=r['index'],type=r['type'],reason=r['reason']) for side,rs in [('left',ra),('right',rb)] for r in rs if r['status']=='unsupported']
|
|
378
|
+
settings=_settings(docs[0],docs[1],sa,sb)
|
|
379
|
+
if hashes!=[_hash(p) for p in paths]:raise ValueError('Input changed during comparison; discard result')
|
|
380
|
+
counts=Counter(m['category'] for m in matches)
|
|
381
|
+
summary={k:counts[k] for k in ('unchanged','attributes_changed','text_changed')};summary.update(added=len(added),deleted=len(deleted),ambiguous_components=len(ambiguous),partial_line_components=len(partial),unsupported_elements=len(unsupported),settings_changed_fields=len(settings),line_partition_equivalent=sum(m['correspondence']=='line_partition_equivalent' for m in matches))
|
|
382
|
+
differences_a=set(deleted);differences_b=set(added)
|
|
383
|
+
for m in matches:
|
|
384
|
+
if m['category']!='unchanged':differences_a.update(m['left_indices']);differences_b.update(m['right_indices'])
|
|
385
|
+
for part in ambiguous+partial:differences_a.update(part['left_indices']);differences_b.update(part['right_indices'])
|
|
386
|
+
classified_left=Counter();classified_right=Counter()
|
|
387
|
+
for match in matches:
|
|
388
|
+
classified_left[match['category']]+=len(match['left_indices']);classified_right[match['category']]+=len(match['right_indices'])
|
|
389
|
+
classified_left.update(deleted=len(deleted));classified_right.update(added=len(added))
|
|
390
|
+
classified_left.update(unsupported=sum(r['side']=='left' for r in unsupported),ambiguous=sum(len(r['left_indices']) for r in ambiguous),partial=sum(len(r['left_indices']) for r in partial))
|
|
391
|
+
classified_right.update(unsupported=sum(r['side']=='right' for r in unsupported),ambiguous=sum(len(r['right_indices']) for r in ambiguous),partial=sum(len(r['right_indices']) for r in partial))
|
|
392
|
+
assert sum(classified_left.values())==len(ra) and sum(classified_right.values())==len(rb)
|
|
393
|
+
from jwwkit._comparison_composites import definitions_report
|
|
394
|
+
definition_report=definitions_report(ra,rb,pt,math.radians(at),st)
|
|
395
|
+
def outside_references(doc,rows):
|
|
396
|
+
used=set()
|
|
397
|
+
def visit(r):
|
|
398
|
+
if r['status']=='supported' and r['type']=='BLOCK':used.add(r['definition_identity']['number'])
|
|
399
|
+
for child in r.get('children',[]):visit(child)
|
|
400
|
+
for row in rows:visit(row)
|
|
401
|
+
return [copy.deepcopy(d) for d in doc._source_records['block_defs'] if d['number'] not in used]
|
|
402
|
+
definition_report['outside_selected_references']={side:outside_references(doc,rows) for side,doc,rows in zip(('left','right'),docs,(ra,rb))}
|
|
403
|
+
result=dict(schema=1,kind='independent_JWW_comparison',complete=not (unsupported or ambiguous or partial or definition_report['ambiguous']),
|
|
404
|
+
inputs=[dict(side=s,path=str(p),sha256=h,version=d.version) for s,p,h,d in zip(('left','right'),paths,hashes,docs)],
|
|
405
|
+
options=dict(left_scope=sa,right_scope=sb,units=units,position_tolerance=pt,angle_tolerance_degrees=at,text_size_tolerance_paper_mm=st,right_transform=dict(rotation_degrees=tr[0],translation=list(tr[1:])),line_coverage=line_coverage),
|
|
406
|
+
summary=summary,element_accounting=dict(left=dict(classified_left),right=dict(classified_right),left_selected=len(ra),right_selected=len(rb)),matches=sorted(matches,key=lambda r:(r['left_indices'],r['right_indices'])),added=added,deleted=deleted,
|
|
407
|
+
ambiguous=ambiguous,partial_lines=partial,unsupported=unsupported,settings_differences=settings,definitions=definition_report,
|
|
408
|
+
left_entities=ra,right_entities=rb,A_difference_indices=sorted(differences_a),B_difference_indices=sorted(differences_b),
|
|
409
|
+
group_scale_tables=[[dict(group=g,scale=d.get_group_scale(g)) for g in sc['groups']] for d,sc in zip(docs,(sa,sb))],
|
|
410
|
+
settings_scope_unpaired_groups=sorted(set(sa['groups'])^set(sb['groups'])),
|
|
411
|
+
header_observations=[dict(sha256=hashlib.sha256(d._source_bytes[:d._source_records['header_end']]).hexdigest()) for d in docs],
|
|
412
|
+
limitations=['Complete refers only to selected supported scope, not byte/rendering equivalence.',
|
|
413
|
+
'Images, unverified dimension modes/fill representations and unresolved/cyclic block dependencies remain unsupported.',
|
|
414
|
+
'Definitions reachable from selected references are compared structurally in local paper-mm; orphan definitions are outside scope.',
|
|
415
|
+
'Mode0 dimension auxiliary positions are unused storage representation; child attributes are still compared.',
|
|
416
|
+
'Unknown global header settings and standard linetype/print state are not semantically decoded; header hashes only.',
|
|
417
|
+
'Group/layer settings compared at common selected group/layer numbers; no inferred group mapping.',
|
|
418
|
+
'No implicit flag/Unicode normalization; no original drawing changes or review JWW generation.'])
|
|
419
|
+
_json(result)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def write_comparison(result, output):
|
|
424
|
+
"""比較結果を新規.jsonへ保存する。既存ファイル・図面への出力は拒否する。"""
|
|
425
|
+
if not isinstance(result,dict) or result.get('schema')!=1 or result.get('kind')!='independent_JWW_comparison':raise ValueError('Not an independent drawing comparison result')
|
|
426
|
+
path=Path(output)
|
|
427
|
+
if path.suffix.lower()!='.json':raise ValueError('Comparison output must be a new .json file')
|
|
428
|
+
data=json.dumps(result,ensure_ascii=True,sort_keys=True,indent=2,allow_nan=False)+'\n'
|
|
429
|
+
with path.open('x',encoding='utf-8') as f:f.write(data)
|
|
430
|
+
return path
|
jwwkit/edit_result.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Approval-plan entry points for common results; no public direct-edit bypass."""
|
|
2
|
+
import copy,json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from jwwkit._edit_result_core import (NAMES,Rejected,Failed,capture,net_rows,result,present,options,bounded,digest)
|
|
5
|
+
|
|
6
|
+
__all__=['write_edit_plan','preview_plan_result','execute_plan_result','EditExecutionError']
|
|
7
|
+
|
|
8
|
+
class EditExecutionError(RuntimeError):
|
|
9
|
+
"""Unexpected failure: caller must stop; .result preserves diagnostic evidence."""
|
|
10
|
+
def __init__(self,record):self.result=record;super().__init__('Edit result execution failed; inspect exception.result')
|
|
11
|
+
|
|
12
|
+
def _record_path(source,output,plan_path,dependencies,config=None):
|
|
13
|
+
record=Path(output).with_suffix('.edit-result.json').resolve()
|
|
14
|
+
protected={Path(source).resolve(),Path(output).resolve(),Path(plan_path).resolve(),Path(output).with_suffix('.changes.json').resolve(),*(Path(p).resolve()for p in dependencies)}
|
|
15
|
+
if config is not None:protected.add(Path(config).resolve())
|
|
16
|
+
if record in protected:raise ValueError('Common result record overlaps protected input/output')
|
|
17
|
+
if record.exists():raise FileExistsError(record)
|
|
18
|
+
return record
|
|
19
|
+
|
|
20
|
+
def _combine(before,after,records,phase,approval=None):
|
|
21
|
+
counts,resources,changes=net_rows(before,after)
|
|
22
|
+
r=result('plan','applied'if changes else 'unchanged',phase,before,after)
|
|
23
|
+
warnings=[dict(x,operation_index=i)for i,step in enumerate(records)for x in step['warnings']]
|
|
24
|
+
r.update(counts=counts,resource_counts=resources,changes=changes,warnings=warnings,warning_count=len(warnings),approval_plan_sha256=approval,
|
|
25
|
+
operation_count=len(records),operation_totals={k:sum(x['counts'][k]for x in records)for k in ('added','modified','deleted')},
|
|
26
|
+
files_created=[],validation=dict(saved=False,reloaded=False,all_fields_match=False),record_written=False)
|
|
27
|
+
return r
|
|
28
|
+
|
|
29
|
+
def _planned(r):
|
|
30
|
+
r=copy.deepcopy(r);r.update(status='planned',phase='plan',projected_counts=r['counts'],projected_resource_counts=r['resource_counts'],
|
|
31
|
+
projected_after_model_sha256=r['after_model_sha256'],after_model_sha256=r['before_model_sha256'],changes_phase='projected')
|
|
32
|
+
r['counts']=dict(added=0,modified=0,deleted=0,rejected=0,unchanged=r['projected_counts']['modified']+r['projected_counts']['deleted']+r['projected_counts']['unchanged'])
|
|
33
|
+
r['resource_counts']=dict(definitions=0,groups=0,layers=0)
|
|
34
|
+
if 'operation_totals'in r:
|
|
35
|
+
r['projected_operation_totals']=r['operation_totals'];r['operation_totals']=dict(added=0,modified=0,deleted=0)
|
|
36
|
+
return r
|
|
37
|
+
|
|
38
|
+
def _render(r,records,limit,mode):
|
|
39
|
+
r=present(r,limit,mode)
|
|
40
|
+
prepared=[present(_planned(x)if r['status']=='planned'else x,limit,mode)for x in records[:limit]]if mode=='details'else []
|
|
41
|
+
r['operations'],r['operations_page']=bounded(prepared,limit,mode)
|
|
42
|
+
r['operations_page'].update(total=len(records),truncated=len(r['operations'])<len(records),has_more=len(r['operations'])<len(records))
|
|
43
|
+
# operation_data may be a large collection of legacy return models.
|
|
44
|
+
for x in r['operations']:
|
|
45
|
+
if r['status']in ('rejected','failed')and not r.get('transaction_committed',False):x.update(transaction_committed=False,discarded=True)
|
|
46
|
+
if x['operation_data']is not None and len(json.dumps(x['operation_data'],ensure_ascii=True))>1048576:
|
|
47
|
+
x['operation_data']=None;x['operation_data_omitted']=True
|
|
48
|
+
return r
|
|
49
|
+
|
|
50
|
+
def _error(exc,context,records,limit,mode):
|
|
51
|
+
before=context.get('before')
|
|
52
|
+
if isinstance(exc,Rejected):r=copy.deepcopy(exc.record)
|
|
53
|
+
elif context.get('phase')is None and isinstance(exc,(ValueError,TypeError,IndexError,FileExistsError)):
|
|
54
|
+
r=result('plan','rejected','plan',before);r['counts']['rejected']=None;r['rejected_operation_count']=1
|
|
55
|
+
r['rejections']=[dict(code='PLAN_OPERATION_UNSUPPORTED'if str(exc).startswith('PLAN_OPERATION_UNSUPPORTED')else 'PLAN_VALIDATION_REFUSED',exception_type=type(exc).__name__,message=str(exc))]
|
|
56
|
+
else:
|
|
57
|
+
r=result('plan','failed','memory',before);r['rejections']=[dict(code='EXECUTION_FAILED',exception_type=type(exc).__name__,message=str(exc))]
|
|
58
|
+
if isinstance(exc,Failed):r['rejections']=exc.record['rejections']
|
|
59
|
+
r.update(transaction_committed=False,prior_operations_discarded=bool(records),record_written=False,files_created=[],validation=dict(saved=False,reloaded=False,all_fields_match=False))
|
|
60
|
+
r.update(operation_count=len(records)+(1 if isinstance(exc,(Rejected,Failed))else 0),action_count=len(context['plan']['actions'])if context.get('plan')else None,
|
|
61
|
+
operation_totals=dict(added=0,modified=0,deleted=0),staged_operation_totals={k:sum(x['counts'][k]for x in records)for k in ('added','modified','deleted')})
|
|
62
|
+
if before:
|
|
63
|
+
r['before_model_sha256']=digest(before);r['after_model_sha256']=digest(before)
|
|
64
|
+
r['counts'].update(added=0,modified=0,deleted=0,unchanged=len(before['entities']))
|
|
65
|
+
plan=context.get('plan')
|
|
66
|
+
if plan:
|
|
67
|
+
from jwwkit.safe_edit import file_sha256
|
|
68
|
+
for p in (Path(plan['output']),Path(plan['output']).with_suffix('.changes.json')):
|
|
69
|
+
try:
|
|
70
|
+
if p.is_file():r['files_created'].append(dict(path=str(p),sha256=file_sha256(p),retained=True,verified=context.get('phase')=='verified'))
|
|
71
|
+
except OSError as read_error:r['status']='failed';r['files_created'].append(dict(path=str(p),inspection_error=str(read_error),verified=False))
|
|
72
|
+
if r['files_created']:
|
|
73
|
+
verified=context.get('phase')=='verified'and not any('inspection_error'in x for x in r['files_created'])
|
|
74
|
+
r['phase']='verified'if verified else 'saved'
|
|
75
|
+
r['after_model_sha256']=None;r['counts'].update(added=None,modified=None,deleted=None,unchanged=None)
|
|
76
|
+
r['validation']['saved']=True
|
|
77
|
+
if verified and context.get('after'):
|
|
78
|
+
r['counts'],r['resource_counts'],r['changes']=net_rows(before,context['after'])
|
|
79
|
+
r['after_model_sha256']=digest(context['after']);r['validation'].update(reloaded=True,all_fields_match=True)
|
|
80
|
+
r.update(transaction_committed=True,prior_operations_discarded=False)
|
|
81
|
+
r['operation_totals']=r['staged_operation_totals']
|
|
82
|
+
rendered=_render(r,records,limit,mode)
|
|
83
|
+
if rendered['status']=='failed':raise EditExecutionError(rendered)from exc
|
|
84
|
+
return rendered
|
|
85
|
+
|
|
86
|
+
def preview_plan_result(plan_path,*,plan_sha256,limit=100,mode='details'):
|
|
87
|
+
"""Dry-run a SHA-bound plan without saving or granting execution approval."""
|
|
88
|
+
options(limit,mode);records=[];context={}
|
|
89
|
+
try:
|
|
90
|
+
from jwwkit.safe_edit import verify_plan,_apply_edit_actions
|
|
91
|
+
cad,plan=verify_plan(plan_path,approved_sha256=plan_sha256)
|
|
92
|
+
if any(a['op']not in NAMES for a in plan['actions']):raise ValueError('PLAN_OPERATION_UNSUPPORTED: common CadDocument methods only')
|
|
93
|
+
_record_path(plan['source'],plan['output'],plan_path,[d['path']for d in plan['dependencies']],plan['workflow']['settings']['config_snapshot']['config_path']if plan.get('workflow')else None)
|
|
94
|
+
before=capture(cad);context.update(before=before,plan=plan,phase='preview')
|
|
95
|
+
_apply_edit_actions(cad,plan,operation_results=records)
|
|
96
|
+
r=_planned(_combine(before,capture(cad),records,'memory'))
|
|
97
|
+
r['plan']=dict(path=str(Path(plan_path).resolve()),sha256=plan_sha256)
|
|
98
|
+
r['action_count']=len(plan['actions'])
|
|
99
|
+
r['operation_data']=dict(planned_output=plan['output'],planned_result_record=str(Path(plan['output']).with_suffix('.edit-result.json')))
|
|
100
|
+
return _render(r,records,limit,mode)
|
|
101
|
+
except Exception as exc:return _error(exc,context,records,limit,mode)
|
|
102
|
+
|
|
103
|
+
def write_edit_plan(cad,indices,actions,output,plan_path,*,dependencies=(),selection=None,selection_limits=None,limit=100,mode='details'):
|
|
104
|
+
"""Write a schema5 review plan and return projected common results, without saving a drawing."""
|
|
105
|
+
options(limit,mode)
|
|
106
|
+
try:
|
|
107
|
+
from jwwkit.cad_model import CadDocument
|
|
108
|
+
if type(cad)is not CadDocument:raise ValueError('PLAN_OPERATION_UNSUPPORTED: CadDocument only')
|
|
109
|
+
if isinstance(actions,list)and any(isinstance(a,dict)and a.get('op')not in NAMES for a in actions):raise ValueError('PLAN_OPERATION_UNSUPPORTED: common CadDocument methods only')
|
|
110
|
+
dependencies=list(dependencies)
|
|
111
|
+
incoming=[a['kwargs']['path']for a in actions if isinstance(a,dict)and a.get('op')=='import_group'and isinstance(a.get('kwargs'),dict)and 'path'in a['kwargs']]
|
|
112
|
+
_record_path(cad.source,output,plan_path,[*dependencies,*incoming])
|
|
113
|
+
from jwwkit.safe_edit import write_plan
|
|
114
|
+
receipt=write_plan(cad,indices,actions,output,plan_path,dependencies=dependencies,selection=selection,selection_limits=selection_limits,common_result=True)
|
|
115
|
+
except Exception as exc:return _error(exc,{},[],limit,mode)
|
|
116
|
+
return preview_plan_result(receipt['path'],plan_sha256=receipt['sha256'],limit=limit,mode=mode)
|
|
117
|
+
|
|
118
|
+
def execute_plan_result(plan_path,*,approved_sha256,limit=100,mode='details'):
|
|
119
|
+
"""After external chat approval, verify, stage, save/reload and record common results."""
|
|
120
|
+
options(limit,mode);records=[];context={}
|
|
121
|
+
try:
|
|
122
|
+
from jwwkit.safe_edit import verify_plan,_execute_plan,file_sha256
|
|
123
|
+
cad,plan=verify_plan(plan_path,approved_sha256=approved_sha256)
|
|
124
|
+
if any(a['op']not in NAMES for a in plan['actions']):raise ValueError('PLAN_OPERATION_UNSUPPORTED: common CadDocument methods only')
|
|
125
|
+
record_path=_record_path(plan['source'],plan['output'],plan_path,[d['path']for d in plan['dependencies']],plan['workflow']['settings']['config_snapshot']['config_path']if plan.get('workflow')else None)
|
|
126
|
+
saved=_execute_plan(plan_path,approved_sha256=approved_sha256,operation_results=records,execution_context=context)
|
|
127
|
+
r=_combine(context['before'],context['after'],records,'verified',approved_sha256)
|
|
128
|
+
r.update(transaction_committed=True,validation=dict(saved=True,reloaded=True,all_fields_match=True),record_written=True,
|
|
129
|
+
plan=dict(path=str(Path(plan_path).resolve()),sha256=approved_sha256),action_count=len(plan['actions']))
|
|
130
|
+
r['files_created']=[dict(path=str(p),sha256=file_sha256(p),verified=True)for p in (Path(plan['output']),Path(plan['output']).with_suffix('.changes.json'))]
|
|
131
|
+
r['files_created'].append(dict(path=str(record_path),kind='common_result_record'))
|
|
132
|
+
r['operation_data']={k:saved[k]for k in ('source_sha256','output_sha256','saved_all_fields_match')}
|
|
133
|
+
rendered=_render(r,records,limit,mode)
|
|
134
|
+
with record_path.open('x',encoding='utf-8')as stream:json.dump(rendered,stream,ensure_ascii=True,indent=2,allow_nan=False)
|
|
135
|
+
return rendered
|
|
136
|
+
except Exception as exc:return _error(exc,context,records,limit,mode)
|