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.
Files changed (117) hide show
  1. jwwkit/__init__.py +14 -0
  2. jwwkit/__main__.py +4 -0
  3. jwwkit/_commands/__init__.py +1 -0
  4. jwwkit/_commands/_integrity.py +55 -0
  5. jwwkit/_commands/check_installation.py +484 -0
  6. jwwkit/_commands/compare_drawings.py +29 -0
  7. jwwkit/_commands/inspect_jwf.py +24 -0
  8. jwwkit/_commands/recognize_planar_tategu.py +20 -0
  9. jwwkit/_commands/summarize_drawing.py +16 -0
  10. jwwkit/_comparison_composites.py +179 -0
  11. jwwkit/_edit_result_core.py +175 -0
  12. jwwkit/_ezjww_adapter.py +12 -0
  13. jwwkit/_geometry_engine.py +204 -0
  14. jwwkit/_geometry_math.py +93 -0
  15. jwwkit/_normalization_inspection.py +83 -0
  16. jwwkit/_replacement_contract.py +161 -0
  17. jwwkit/_resources.py +23 -0
  18. jwwkit/_spatial_distance.py +95 -0
  19. jwwkit/_spatial_query.py +34 -0
  20. jwwkit/_tategu_features.py +201 -0
  21. jwwkit/_tategu_geometry.py +108 -0
  22. jwwkit/alignment.py +85 -0
  23. jwwkit/cad_model.py +5 -0
  24. jwwkit/cli.py +40 -0
  25. jwwkit/drawing_comparison.py +430 -0
  26. jwwkit/edit_result.py +136 -0
  27. jwwkit/geometry_view.py +141 -0
  28. jwwkit/guides/AGENTS.md +47 -0
  29. jwwkit/guides/docs/api_list.md +1944 -0
  30. jwwkit/guides/docs/common_edit_result_coverage.md +40 -0
  31. jwwkit/guides/docs/drawing_comparison.md +79 -0
  32. jwwkit/guides/docs/edit_results.md +59 -0
  33. jwwkit/guides/docs/geometry_view.md +64 -0
  34. jwwkit/guides/docs/jwf_key_catalog.md +244 -0
  35. jwwkit/guides/docs/jwf_readonly.md +76 -0
  36. jwwkit/guides/docs/plan_api_coverage.md +75 -0
  37. jwwkit/guides/docs/read_view.md +89 -0
  38. jwwkit/guides/docs/reading_to_editing.md +100 -0
  39. jwwkit/guides/docs/schema_normalization.md +64 -0
  40. jwwkit/guides/docs/search_queries.md +95 -0
  41. jwwkit/guides/docs/spatial_search.md +77 -0
  42. jwwkit/guides/docs/tategu_recognition.md +40 -0
  43. jwwkit/guides/docs/text_equivalence.md +42 -0
  44. jwwkit/guides/docs/usage_guide.md +604 -0
  45. jwwkit/guides/docs/workflow_settings.md +95 -0
  46. jwwkit/hatch_geometry.py +77 -0
  47. jwwkit/jwf.py +202 -0
  48. jwwkit/legacy_document.py +2 -0
  49. jwwkit/line_document.py +14 -0
  50. jwwkit/model/__init__.py +1 -0
  51. jwwkit/model/base.py +15 -0
  52. jwwkit/model/block_definition.py +32 -0
  53. jwwkit/model/block_names.py +41 -0
  54. jwwkit/model/block_operations.py +154 -0
  55. jwwkit/model/block_reference.py +39 -0
  56. jwwkit/model/circle.py +44 -0
  57. jwwkit/model/circular_solid.py +72 -0
  58. jwwkit/model/common_entities.py +34 -0
  59. jwwkit/model/dimension.py +49 -0
  60. jwwkit/model/display_state.py +46 -0
  61. jwwkit/model/document.py +585 -0
  62. jwwkit/model/editing.py +270 -0
  63. jwwkit/model/group_audit.py +91 -0
  64. jwwkit/model/group_import.py +249 -0
  65. jwwkit/model/line.py +31 -0
  66. jwwkit/model/point.py +41 -0
  67. jwwkit/model/preflight.py +120 -0
  68. jwwkit/model/preset_rigid.py +42 -0
  69. jwwkit/model/query.py +270 -0
  70. jwwkit/model/query_fields.py +189 -0
  71. jwwkit/model/query_regex_worker.py +20 -0
  72. jwwkit/model/query_text.py +116 -0
  73. jwwkit/model/rigid_audit.py +66 -0
  74. jwwkit/model/solid.py +51 -0
  75. jwwkit/model/spatial.py +99 -0
  76. jwwkit/model/summary.py +93 -0
  77. jwwkit/model/text.py +49 -0
  78. jwwkit/model/text_metrics.py +78 -0
  79. jwwkit/model/transformations.py +176 -0
  80. jwwkit/normalization.py +287 -0
  81. jwwkit/read_view.py +298 -0
  82. jwwkit/reader/__init__.py +1 -0
  83. jwwkit/reader/archive.py +299 -0
  84. jwwkit/reader/images.py +48 -0
  85. jwwkit/reader/jws.py +175 -0
  86. jwwkit/reader/jws_placement.py +78 -0
  87. jwwkit/reader/sxf.py +51 -0
  88. jwwkit/reader/template_names.py +22 -0
  89. jwwkit/reader/text_presets.py +23 -0
  90. jwwkit/record_reader.py +2 -0
  91. jwwkit/replacement_workflow.py +39 -0
  92. jwwkit/resources/README.md +7 -0
  93. jwwkit/resources/empty_template.jww +0 -0
  94. jwwkit/resources/halfwidth_kana_composition_v1.json +35 -0
  95. jwwkit/resources/header.json +3 -0
  96. jwwkit/resources/jwf_schema.json +925 -0
  97. jwwkit/resources/jww_workflow.example.toml +14 -0
  98. jwwkit/resources/text_equivalence_v1.json +1779 -0
  99. jwwkit/safe_edit.py +454 -0
  100. jwwkit/spatial_search.py +144 -0
  101. jwwkit/tategu_recognition.py +181 -0
  102. jwwkit/workflow_settings.py +140 -0
  103. jwwkit/writer/__init__.py +1 -0
  104. jwwkit/writer/header.py +104 -0
  105. jwwkit/writer/legacy_document.py +62 -0
  106. jwwkit/writer/mfc.py +110 -0
  107. jwwkit/writer/preserved.py +265 -0
  108. jwwkit/writer/strings.py +19 -0
  109. jwwkit/writer/template_constants.py +9 -0
  110. jwwkit/writer/template_document.py +390 -0
  111. jwwkit-0.2.0.dist-info/METADATA +174 -0
  112. jwwkit-0.2.0.dist-info/RECORD +117 -0
  113. jwwkit-0.2.0.dist-info/WHEEL +5 -0
  114. jwwkit-0.2.0.dist-info/entry_points.txt +2 -0
  115. jwwkit-0.2.0.dist-info/licenses/LICENSE +21 -0
  116. jwwkit-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +51 -0
  117. 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)