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,93 @@
1
+ """Analytic two-dimensional primitives; no drawing/GUI or external implementation."""
2
+ import math
3
+
4
+ TAU = 2 * math.pi
5
+ IDENTITY = (1., 0., 0., 1., 0., 0.)
6
+
7
+
8
+ def finite(value):
9
+ if type(value) not in (int, float) or not math.isfinite(value): raise ValueError('Nonfinite/non-numeric geometry value')
10
+ return value
11
+
12
+
13
+ def point(matrix, x, y):
14
+ x, y = finite(x), finite(y); a,b,c,d,tx,ty = matrix
15
+ return [finite(a*x+b*y+tx), finite(c*x+d*y+ty)]
16
+
17
+
18
+ def compose(parent, child):
19
+ a,b,c,d,x,y = parent; e,f,g,h,u,v = child
20
+ return tuple(finite(z) for z in (a*e+b*g, a*f+b*h, c*e+d*g, c*f+d*h, a*u+b*v+x, c*u+d*v+y))
21
+
22
+
23
+ def block_matrix(r):
24
+ sx,sy,angle,x,y = [finite(r[k]) for k in ('scale_x','scale_y','rotation','ref_x','ref_y')]
25
+ if sx == 0 or sy == 0: raise ValueError('Zero block scale has unverified geometry')
26
+ cs,sn = math.cos(angle),math.sin(angle)
27
+ return (sx*cs,-sy*sn,sx*sn,sy*cs,x,y), abs(sx) == abs(sy)
28
+
29
+
30
+ def bounds(points):
31
+ for p in points:
32
+ for v in p:finite(v)
33
+ return [min(p[0] for p in points),min(p[1] for p in points),max(p[0] for p in points),max(p[1] for p in points)]
34
+
35
+
36
+ def union(boxes):
37
+ return [min(b[0] for b in boxes),min(b[1] for b in boxes),max(b[2] for b in boxes),max(b[3] for b in boxes)]
38
+
39
+
40
+ def arc_point(center, radius, angle):
41
+ return [finite(center[0]+radius*math.cos(angle)),finite(center[1]+radius*math.sin(angle))]
42
+
43
+
44
+ def on_sweep(angle, start, sweep):
45
+ if abs(sweep) >= TAU: return True
46
+ delta = (angle-start) % TAU if sweep >= 0 else (start-angle) % TAU
47
+ return delta <= abs(sweep) or abs(delta-TAU) <= 8*math.ulp(TAU)
48
+
49
+
50
+ def arc_bounds(center, radius, start, sweep):
51
+ pts = [arc_point(center,radius,start),arc_point(center,radius,start+sweep)]
52
+ pts.extend(arc_point(center,radius,a) for a in (0.,math.pi/2,math.pi,3*math.pi/2) if on_sweep(a,start,sweep))
53
+ return bounds(pts)
54
+
55
+
56
+ def cross(a,b,c):
57
+ return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
58
+
59
+
60
+ def polygon(points):
61
+ clean=[]
62
+ for p in points:
63
+ if not clean or p != clean[-1]: clean.append(p)
64
+ if len(clean)>1 and clean[0]==clean[-1]: clean.pop()
65
+ if len(clean)<3 or len(set(map(tuple,clean)))!=len(clean): raise ValueError('Degenerate solid polygon')
66
+ for i in range(len(clean)):
67
+ a,b=clean[i],clean[(i+1)%len(clean)]
68
+ for j in range(i+1,len(clean)):
69
+ if j==i+1 or i==0 and j==len(clean)-1:continue
70
+ c,d=clean[j],clean[(j+1)%len(clean)]
71
+ if cross(a,b,c)*cross(a,b,d)<=0 and cross(c,d,a)*cross(c,d,b)<=0:
72
+ if max(min(a[0],b[0]),min(c[0],d[0]))<=min(max(a[0],b[0]),max(c[0],d[0])) and max(min(a[1],b[1]),min(c[1],d[1]))<=min(max(a[1],b[1]),max(c[1],d[1])):
73
+ raise ValueError('Self-intersecting/ambiguous solid perimeter')
74
+ weights=[clean[i][0]*clean[(i+1)%len(clean)][1]-clean[(i+1)%len(clean)][0]*clean[i][1] for i in range(len(clean))]
75
+ twice=finite(sum(weights))
76
+ if twice==0:raise ValueError('Zero-area solid polygon')
77
+ centroid=[sum((clean[i][k]+clean[(i+1)%len(clean)][k])*weights[i] for i in range(len(clean)))/(3*twice) for k in (0,1)]
78
+ return clean,[finite(v)for v in centroid],'ccw' if twice>0 else 'cw'
79
+
80
+
81
+ def segment_distance(p,a,b):
82
+ dx,dy=b[0]-a[0],b[1]-a[1];n=dx*dx+dy*dy
83
+ t=max(0.,min(1.,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/n)) if n else 0.
84
+ return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy)
85
+
86
+
87
+ def polygon_distance(p,vertices,boundary=False):
88
+ distance=min(segment_distance(p,a,b) for a,b in zip(vertices,vertices[1:]+vertices[:1]))
89
+ if boundary or distance==0:return distance
90
+ inside=False
91
+ for a,b in zip(vertices,vertices[1:]+vertices[:1]):
92
+ if (a[1]>p[1])!=(b[1]>p[1]) and p[0] < a[0]+(p[1]-a[1])*(b[0]-a[0])/(b[1]-a[1]):inside=not inside
93
+ return 0. if inside else distance
@@ -0,0 +1,83 @@
1
+ """Read-only, bounded schema inspection. No class-byte scanning or file writes."""
2
+ from pathlib import Path
3
+ import hashlib,struct
4
+ from jwwkit.reader.archive import Cursor,_read_records,read_records_bytes
5
+ from jwwkit.writer.preserved import serialize_body
6
+ from jwwkit.model.rigid_audit import exact
7
+
8
+ CLASSES=frozenset(('CDataSen','CDataEnko','CDataMoji'))
9
+ MAX_FILE_BYTES=268435456
10
+ MAX_BATCH_BYTES=536870912
11
+ MAX_FILES=256
12
+
13
+ def sha(data):return hashlib.sha256(data).hexdigest()
14
+
15
+ def _roundtrip(raw,parsed,zero=False):
16
+ for encoding in (('cp932','utf-16le')if parsed['version']==600 else ('utf-16le','cp932')):
17
+ try:
18
+ body=serialize_body(parsed['entities'],parsed['block_defs'],parsed['version'],encoding=encoding,
19
+ raw_trailer=parsed['raw_trailer'],class_declarations=parsed['class_declarations']if zero else None)
20
+ if raw[:parsed['header_end']]+body==raw:return
21
+ except (ValueError,UnicodeError,struct.error):pass
22
+ raise ValueError('Unedited serialization is not byte-identical')
23
+
24
+ def _observe(raw):
25
+ c=Cursor(raw);c.schema_profile='rebro_schema0';c.class_declarations=[];observed=[]
26
+ def observer(name,schema,offset,index):
27
+ observed.append(dict(name=name.decode('ascii',errors='backslashreplace'),name_bytes_hex=name.hex(),schema=schema,
28
+ declaration_offset=offset,schema_offset=offset+2,class_index=index))
29
+ c._class_declaration_observer=observer
30
+ try:return _read_records(c),observed,None,c.pos,c.context
31
+ except (ValueError,UnicodeError,struct.error,OverflowError)as exc:return None,observed,str(exc),c.pos,c.context
32
+
33
+ def inspect_bytes(raw,source):
34
+ version=struct.unpack_from('<I',raw,8)[0]if len(raw)>=12 and raw[:8]==b'JwwData.'else None
35
+ base=dict(source=str(source),source_sha256=sha(raw),size=len(raw),version=version,status='unsupported',
36
+ declarations=[],changed_declarations=[],unsupported_declarations=[],enumeration_complete=False,
37
+ inspection_stopped_offset=None,uninspected_bytes=0,reason=None,expected_output_sha256=None)
38
+ try:
39
+ parsed=read_records_bytes(raw);_roundtrip(raw,parsed)
40
+ base.update(status='not_needed',reason='already_standard',enumeration_complete=True,
41
+ declarations=[dict(name=d['name'],schema=d['schema'],declaration_offset=d['offset'],schema_offset=d['offset']+2,class_index=d['class_index'])for d in parsed['class_declarations']])
42
+ return base,None
43
+ except (ValueError,UnicodeError,struct.error,OverflowError)as strict_error:
44
+ base['strict_reason']=str(strict_error)
45
+ parsed,observed,error,offset,context=_observe(raw)
46
+ base['declarations']=observed
47
+ base['unsupported_declarations']=[dict(d,reason='outside_observed_three_class_schema0_scope')for d in observed if d['name']not in CLASSES or d['schema']!=0]
48
+ if parsed is None:
49
+ base.update(reason=error,inspection_stopped_offset=offset,uninspected_bytes=max(0,len(raw)-offset),context=context)
50
+ return base,None
51
+ try:
52
+ _roundtrip(raw,parsed,True)
53
+ if version not in (600,700)or not observed or any(d['name']not in CLASSES or d['schema']!=0 for d in observed):raise ValueError('Not an observed schema0 input')
54
+ output=bytearray(raw);changed=[]
55
+ for d in observed:
56
+ p=d['schema_offset']
57
+ if raw[p-2:p]!=b'\xff\xff'or struct.unpack_from('<H',raw,p)[0]!=0:raise ValueError('Class declaration identity changed')
58
+ replacement=struct.pack('<H',version);output[p:p+2]=replacement
59
+ changed.append(dict(d,before=0,after=version,before_bytes_hex=raw[p:p+2].hex(),after_bytes_hex=replacement.hex()))
60
+ output=bytes(output);normal=read_records_bytes(output);_roundtrip(output,normal)
61
+ for field in ('entities','block_defs','raw_trailer','version','header_end','memo','layer_names','color_names','line_type_names'):
62
+ if not exact(parsed[field],normal[field]):raise ValueError('Normalization changed parsed field: '+field)
63
+ positions={x['schema_offset']+j for x in changed for j in (0,1)}
64
+ if len(raw)!=len(output)or any(a!=b and i not in positions for i,(a,b)in enumerate(zip(raw,output))):raise ValueError('Changes outside approved schema words')
65
+ if any(x['schema']!=version for x in normal['class_declarations']):raise ValueError('Output schema is not the file version')
66
+ base.update(status='normalize',reason='observed_schema0_only',enumeration_complete=True,changed_declarations=changed,
67
+ expected_output_sha256=sha(output),verification=dict(original_roundtrip=True,strict_output_roundtrip=True,all_element_fields_equal=True,non_schema_bytes_equal=True))
68
+ return base,output
69
+ except (ValueError,UnicodeError,struct.error,OverflowError)as exc:
70
+ base.update(reason=str(exc),enumeration_complete=True)
71
+ return base,None
72
+
73
+ def inspect_path(source):
74
+ path=Path(source).resolve()
75
+ try:
76
+ if not path.is_file():raise ValueError('Source must be a regular file')
77
+ with path.open('rb')as stream:raw=stream.read(MAX_FILE_BYTES+1)
78
+ if len(raw)>MAX_FILE_BYTES:raise ValueError('Source exceeds 256 MiB inspection limit')
79
+ return inspect_bytes(raw,path)
80
+ except (OSError,ValueError)as exc:
81
+ return dict(source=str(path),status='error',reason=str(exc),exception_type=type(exc).__name__,
82
+ source_sha256=None,size=None,version=None,declarations=[],changed_declarations=[],unsupported_declarations=[],
83
+ enumeration_complete=False,inspection_stopped_offset=None,uninspected_bytes=None,expected_output_sha256=None),None
@@ -0,0 +1,161 @@
1
+ """Strict recipe and independent result checks for the observed replacement scope."""
2
+ from pathlib import Path
3
+ import copy,hashlib,math
4
+ from jwwkit.cad_model import CadDocument
5
+ from jwwkit.alignment import solve_two_point_alignment
6
+ from jwwkit.writer.preserved import _document_bytes
7
+ from jwwkit.model.group_import import global_settings_bytes
8
+ from jwwkit.workflow_settings import _verify_resolved,_hash,_copy,_canonical,_assert_live_snapshot
9
+
10
+ def require(ok,message):
11
+ if not ok:raise ValueError(message)
12
+ def group(value):
13
+ require(type(value)is int and 0<=value<16,'Group must be integer 0..15')
14
+ return value
15
+
16
+ def reference_rows(doc):
17
+ from jwwkit.safe_edit import entity_view
18
+ return [dict(index=i,real_mm=list(doc.convert_point((e.geometry['x'],e.geometry['y']),layer_group=3,from_units='paper',to_units='real')),element=entity_view(e))
19
+ for i,e in enumerate(doc.entities)if e.entity_type=='POINT'and e.attributes['layer_group']==3 and e.attributes['layer']==0
20
+ and e.geometry['is_temporary']and e.geometry['code']==0]
21
+
22
+ def points(doc,indices):
23
+ rows={r['index']:r for r in reference_rows(doc)}
24
+ require(type(indices)is list and len(indices)==2 and all(type(i)is int and i in rows for i in indices)and len(set(indices))==2,'Two distinct group3/layer0 temporary-point indices required')
25
+ return [rows[i]['real_mm']for i in indices]
26
+
27
+ def archive_candidates(cad,building):
28
+ counts={g:0 for g in range(16)}
29
+ for r in cad._source_records['entities']:counts[r['base']['layer_group']]+=1
30
+ result=[]
31
+ for g in range(16):
32
+ h=cad.header['layer_groups'][g]
33
+ reason='target_group'if g==building else 'reference_group'if g==3 else 'writing'if h['state']==3 else 'protected'if h['protect']else 'occupied'if counts[g]else None
34
+ result.append(dict(group=g,root_count=counts[g],state=h['state'],protected=h['protect'],eligible=reason is None,reason=reason))
35
+ return result
36
+
37
+ def _contents(cad):
38
+ from jwwkit.safe_edit import snapshot
39
+ snap=snapshot(cad)
40
+ return dict(snap,entities=[r['record']for r in snap['entities']])
41
+
42
+ BASE_CONTEXT={'incoming','building_group','source_reference_indices','target_reference_indices','distance_tolerance','angle_tolerance','angle_units','settings_policy'}
43
+
44
+ def prepare(cad,new,settings,context,*,verification=False):
45
+ """Simulate in memory only. Never select a replacement group during execution."""
46
+ from jwwkit.safe_edit import _targets,_apply_edit_actions
47
+ settings=_verify_resolved(settings);context=_copy(context)
48
+ require(set(context)==BASE_CONTEXT|({'archive_group','archive_selection'}if verification else set()),'Unknown/incomplete replacement context')
49
+ building=group(context['building_group']);require(building!=3,'Building and reference group must differ')
50
+ require(cad.version==new.version==700,'Replacement workflow supports observed v700 scope only')
51
+ require(context['incoming']==str(Path(new.source).resolve()),'Incoming context path mismatch')
52
+ require(context['settings_policy']in ('strict','destination'),'Invalid settings_policy')
53
+ for doc in (cad,new):
54
+ require(doc.get_group_state(building)['raw_value']!=3,'Writing building group cannot be replaced/imported')
55
+ require(not doc.header['layer_groups'][building]['protect'],'Protected building group')
56
+ require(doc.get_group_scale(building)==100,'Replacement requires both original building scales 1/100')
57
+ _document_bytes(doc)
58
+ old=list(cad.find_entities(layer_group=building));added=list(new.find_entities(layer_group=building))
59
+ require(old and added,'Both building groups must be nonempty')
60
+ require(not any(doc.entities[i].entity_type=='BLOCK'for doc,ids in ((cad,old),(new,added))for i in ids),'Building references are outside this replacement workflow scope')
61
+ for doc,indices in ((cad,old),(new,added)):
62
+ for i in indices:
63
+ e=doc.entities[i];text=e.geometry if e.entity_type=='TEXT'else e.geometry.get('text',{})if e.entity_type=='DIMENSION'else {}
64
+ require(not text.get('content','').startswith('^@BM'),'Image-dependent building elements are unsupported, including delete mode')
65
+ kw=dict(source_points=points(new,context['source_reference_indices']),target_points=points(cad,context['target_reference_indices']),
66
+ distance_tolerance=context['distance_tolerance'],angle_tolerance=context['angle_tolerance'],angle_units=context['angle_units'],allow_scale=False)
67
+ fit=solve_two_point_alignment(**kw);require(fit['passed']and fit['applied_scale']==1,'Alignment exceeds explicit tolerances or changes scale')
68
+ fit['measured_endpoint_displacement_mm']=2*fit['source_distance_mm']*math.sin(abs(fit['angle_difference_rad'])/2)
69
+ mode=settings['effective']['old_group_action'];request=settings['effective']['archive_group'];chosen=None;selection=[]
70
+ if mode=='archive':
71
+ require(request is not None,'Archive group must be specified in config, instruction or plan argument')
72
+ candidates=archive_candidates(cad,building);eligible=[r['group']for r in candidates if r['eligible']]
73
+ if verification:
74
+ chosen=group(context['archive_group'])
75
+ require(chosen in eligible,'Fixed archive group is no longer empty/eligible; never select another group')
76
+ require(chosen==(min(eligible)if request=='first_empty'else request),'Archive selection proof changed')
77
+ else:
78
+ require(eligible,'No eligible empty archive group')
79
+ chosen=min(eligible)if request=='first_empty'else group(request)
80
+ require(chosen in eligible,'Specified archive group must be empty, non-writing, unprotected and distinct')
81
+ selection=candidates if request=='first_empty'else [candidates[chosen]]
82
+ if verification:
83
+ require(context['archive_group']==chosen and context['archive_selection']==selection,'Fixed archive context changed')
84
+ else:context.update(archive_group=chosen,archive_selection=selection)
85
+ excluded={building,chosen}if mode=='archive'else{building}
86
+ protected={str(g):cad.snapshot_group(g)for g in range(16)if g not in excluded}
87
+ actions=[]
88
+ if mode=='archive':
89
+ if cad.get_group_scale(chosen)!=100:actions.append(dict(op='set_group_scale',kwargs=dict(layer_group=chosen,scale=100)))
90
+ actions.extend([dict(op='set_entity_attributes',indices=old,kwargs=dict(layer_group=chosen,preserve='paper')),
91
+ dict(op='set_group_state',kwargs=dict(layer_group=chosen,state='hidden'))])
92
+ actions.extend([dict(op='import_group',**{'as':'incoming'},kwargs=dict(path=context['incoming'],source_group=building,target_group=building,
93
+ sync_scale=False,sync_group_name=False,sync_layer_names=False,settings_policy=context['settings_policy'])),
94
+ dict(op='align_two_points',indices=['incoming'],kwargs=kw)])
95
+ if mode=='delete':actions.append(dict(op='delete_entities',indices=old,kwargs={}))
96
+ targets=_targets(cad,old);stage=copy.deepcopy(cad);imports=[]
97
+ _apply_edit_actions(stage,dict(actions=actions,targets=targets),import_results=imports)
98
+ from jwwkit.writer.preserved import entity_record
99
+ from jwwkit.model.rigid_audit import exact,audit_records
100
+ originals=[e for i,e in enumerate(cad.entities)if mode=='archive'or i not in old]
101
+ survivors=[e for e in stage.entities if e.source_index is not None]
102
+ require([e.source_index for e in survivors]==[e.source_index for e in originals],'Original survivor order/population changed')
103
+ old_ids={cad.entities[i].source_index for i in old}
104
+ for before,after in zip(originals,survivors):
105
+ expected=copy.deepcopy(entity_record(before))
106
+ if mode=='archive'and before.source_index in old_ids:expected['base']['layer_group']=chosen
107
+ require(exact(expected,entity_record(after)),'An original element changed beyond the declared archive group')
108
+ imported_records=[entity_record(e)for e in stage.entities if e.source_index is None]
109
+ require(len(imported_records)==len(added)and len(stage.entities)==len(originals)+len(added),'Imported/deleted element counts disagree')
110
+ tr=fit['transform']
111
+ rigid=audit_records([entity_record(new.entities[i])for i in added],imported_records,
112
+ base_point_real=tr['base_point'],translation_real=tr['translation'],rotation=tr['rotation'],group_scale=100,
113
+ definition_mapping=imports[0]['receipt']['definition_mapping'])
114
+ require(rigid['passed'],'Incoming rigid geometry or exact attributes changed')
115
+ require(global_settings_bytes(cad)==global_settings_bytes(stage,_document_bytes(stage)),'Destination global settings bytes changed')
116
+ audits={g:stage.audit_group_unchanged(int(g),snap)for g,snap in protected.items()}
117
+ require(all(a['passed']for a in audits.values()),'A non-target group changed')
118
+ data=_document_bytes(stage)
119
+ review=dict(old_group_action=mode,old_count=len(old),archived_count=len(old)if mode=='archive'else 0,
120
+ deleted_count=len(old)if mode=='delete'else 0,added_count=len(added),expected_entity_count=len(stage.entities),
121
+ old_target_indices=old,old_targets_sha256=_hash(targets),deleted_target_indices=old if mode=='delete'else [],
122
+ expected_contents_sha256=_hash(_contents(stage)),expected_output_sha256=hashlib.sha256(data).hexdigest(),
123
+ global_settings_sha256=hashlib.sha256(global_settings_bytes(cad)).hexdigest(),alignment=fit,incoming_rigid_audit=rigid,
124
+ layer_name_differences=[dict(layer=l,old=cad.get_layer_name(building,l),incoming=new.get_layer_name(building,l))for l in range(16)if cad.get_layer_name(building,l)!=new.get_layer_name(building,l)],
125
+ protected_groups=protected)
126
+ return dict(context=context,actions=actions,targets=targets,review=_copy(review))
127
+
128
+ def validate_plan(cad,plan,*,planning=False):
129
+ w=plan['workflow'];require(type(w)is dict and set(w)=={'schema_version','name','settings','context','review'},'Invalid workflow record keys')
130
+ require(type(w['schema_version'])is int and w['schema_version']==1 and w['name']=='building_replacement','Unsupported workflow')
131
+ require(_hash(w)==plan['workflow_sha256'],'Workflow hash mismatch')
132
+ settings=_verify_resolved(w['settings'])
133
+ if planning:_assert_live_snapshot(settings)
134
+ new=CadDocument.open(w['context']['incoming'])
135
+ proof=prepare(cad,new,settings,w['context'],verification=True)
136
+ require(_canonical(proof['actions'])==_canonical(plan['actions']),'Workflow settings and edit actions disagree')
137
+ require(proof['targets']==plan['targets'],'Workflow old targets changed')
138
+ require(_canonical(proof['review'])==_canonical(w['review']),'Workflow review/evidence changed')
139
+ require(plan['selection']is None or [h['index']for h in plan['selection']['hits']]==proof['review']['old_target_indices'],'Workflow selection mismatch')
140
+ cfg=Path(settings['config_snapshot']['config_path'])
141
+ for p in (plan['output'],str(Path(plan['output']).with_suffix('.changes.json'))):require(Path(p).resolve()!=cfg,'Output overlaps workflow configuration')
142
+ require(cfg.resolve()not in {Path(d['path']).resolve()for d in plan['dependencies']},'Mutable config must be frozen in plan, not a live dependency')
143
+ return proof
144
+
145
+ def audit_result(after,plan):
146
+ from jwwkit.safe_edit import file_sha256
147
+ require(file_sha256(plan['source'])==plan['source_sha256'],'Source changed during workflow')
148
+ for dep in plan['dependencies']:require(file_sha256(dep['path'])==dep['sha256'],'Workflow input dependency changed')
149
+ original=CadDocument.open(plan['source']);proof=validate_plan(original,plan)
150
+ review=proof['review'];context=proof['context'];mode=review['old_group_action']
151
+ require(_hash(_contents(after))==review['expected_contents_sha256'],'Replacement result contents differ from approved projection')
152
+ serialized=_document_bytes(after)
153
+ require(hashlib.sha256(serialized).hexdigest()==review['expected_output_sha256'],'Replacement output bytes differ from approved projection')
154
+ groups={g:after.audit_group_unchanged(int(g),snap)for g,snap in review['protected_groups'].items()}
155
+ require(all(a['passed']for a in groups.values()),'Saved non-target group changed')
156
+ require(hashlib.sha256(global_settings_bytes(after,serialized)).hexdigest()==review['global_settings_sha256'],'Saved global settings changed')
157
+ if mode=='archive':
158
+ chosen=context['archive_group'];require(after.get_group_state(chosen)['raw_value']==0 and after.get_group_scale(chosen)==100,'Archive state/scale mismatch')
159
+ require(len(after.find_entities(layer_group=chosen))==review['old_count'],'Archive count mismatch')
160
+ return dict(passed=True,old_group_action=mode,archive_group=context['archive_group'],archived_count=review['archived_count'],deleted_count=review['deleted_count'],
161
+ added_count=review['added_count'],non_target_groups=groups,global_settings_preserved=True,output_sha256=review['expected_output_sha256'])
jwwkit/_resources.py ADDED
@@ -0,0 +1,23 @@
1
+ """Access data installed alongside jwwkit."""
2
+ from importlib.resources import files
3
+ from pathlib import Path
4
+
5
+
6
+ def _installed_path(*parts: str) -> Path:
7
+ path = files("jwwkit").joinpath(*parts)
8
+ if not isinstance(path, Path):
9
+ raise RuntimeError("Install jwwkit with pip to obtain persistent file paths")
10
+ return path
11
+
12
+
13
+ def template_path() -> Path:
14
+ """Return the installed empty JWW template, for opening read-only."""
15
+ return _installed_path("resources", "empty_template.jww")
16
+
17
+
18
+ def guide_paths() -> dict[str, Path]:
19
+ """Return the installed paths of AGENTS.md and the Japanese usage guide."""
20
+ return {
21
+ "agents": _installed_path("guides", "AGENTS.md"),
22
+ "usage_guide": _installed_path("guides", "docs", "usage_guide.md"),
23
+ }
@@ -0,0 +1,95 @@
1
+ """Analytic distances to qualified U2 logical geometry. Never infer missing shapes."""
2
+ import math
3
+ from jwwkit._geometry_math import finite,arc_point,on_sweep,cross
4
+
5
+
6
+ def segment(p,a,b):
7
+ dx,dy=finite(b[0]-a[0]),finite(b[1]-a[1]);length=finite(math.hypot(dx,dy))
8
+ if length==0:return finite(math.dist(p,a))
9
+ ux,uy=dx/length,dy/length
10
+ along=finite(finite(p[0]-a[0])*ux+finite(p[1]-a[1])*uy)
11
+ if along<=0:return finite(math.dist(p,a))
12
+ if along>=length:return finite(math.dist(p,b))
13
+ return finite(abs(finite(p[0]-a[0])*uy-finite(p[1]-a[1])*ux))
14
+
15
+
16
+ def polygon(p,vertices):
17
+ edges=list(zip(vertices,vertices[1:]+vertices[:1]))
18
+ d=min(segment(p,a,b)for a,b in edges)
19
+ if d==0:return 0.
20
+ inside=False
21
+ for a,b in edges:
22
+ if (a[1]>p[1])!=(b[1]>p[1]):
23
+ x=finite(a[0]+finite(p[1]-a[1])*finite(b[0]-a[0])/finite(b[1]-a[1]))
24
+ if p[0]<x:inside=not inside
25
+ return 0. if inside else d
26
+
27
+
28
+ def arc(p,c):
29
+ center=c['center'];radius=c['radius'];rho=finite(math.dist(p,center))
30
+ if c['kind']=='circle' or rho==0:return abs(rho-radius)
31
+ angle=math.atan2(p[1]-center[1],p[0]-center[0])
32
+ if on_sweep(angle,c['start_angle'],c['sweep_angle']):return abs(rho-radius)
33
+ return min(finite(math.dist(p,arc_point(center,radius,t)))for t in (c['start_angle'],c['start_angle']+c['sweep_angle']))
34
+
35
+
36
+ def contour(p,c):
37
+ if c['kind']=='point':return finite(math.dist(p,c['point']))
38
+ if c['kind']in ('circle','arc'):return finite(arc(p,c))
39
+ if c['kind']=='polyline':
40
+ points=c['points']
41
+ if c.get('filled')or c.get('role')=='text_frame':return polygon(p,points)
42
+ pairs=list(zip(points,points[1:]))
43
+ if c.get('closed'):pairs.append((points[-1],points[0]))
44
+ if not pairs:raise ValueError('Empty contour')
45
+ return min(segment(p,a,b)for a,b in pairs)
46
+ raise ValueError('Unverified distance contour')
47
+
48
+
49
+ def solid_circle(p,contours):
50
+ outer=contours[0];rho=finite(math.dist(p,outer['center']));radius=outer['radius']
51
+ if len(contours)==2 and contours[1].get('role')=='hole':
52
+ inner=contours[1]['radius']
53
+ return inner-rho if rho<inner else rho-radius if rho>radius else 0.
54
+ if outer['kind']=='circle':return max(0.,rho-radius)
55
+ if len(contours)!=2 or contours[1].get('role')!='chord':raise ValueError('Unverified circular fill boundary')
56
+ a,b=contours[1]['points'];middle=arc_point(outer['center'],radius,outer['start_angle']+outer['sweep_angle']/2)
57
+ side=finite(cross(a,b,middle));query_side=finite(cross(a,b,p))
58
+ if side==0:raise ValueError('Circular segment side is numerically indeterminate')
59
+ if rho<=radius and (query_side==0 or (side>0)==(query_side>0)):return 0.
60
+ return min(arc(p,outer),segment(p,a,b))
61
+
62
+
63
+ def unknown(reason,*,upper=None,parts=None):
64
+ return dict(status='undetermined',value=None,lower_bound=0.,upper_bound=upper,reason=str(reason),
65
+ parts=[]if parts is None else parts,unknown_parts=True,distance_kind='shape_distance')
66
+
67
+
68
+ def distance(g,p,text_mode='glyph'):
69
+ """A composite is a union. Known parts provide an upper bound only."""
70
+ parts=g.get('parts',[])
71
+ if parts:
72
+ values=[dict(distance(child,p,text_mode),child_path=child['child_path'],kind=child['kind'])for child in parts]
73
+ exact=[x['value']for x in values if x['status']=='exact']
74
+ unresolved=any(x['status']!='exact' or x['unknown_parts']for x in values)
75
+ bounds=[x['upper_bound']for x in values if x['upper_bound']is not None]
76
+ upper=min(bounds)if bounds else None
77
+ if unresolved and upper!=0:return unknown('A child distance is undetermined; known parts give only an upper bound',upper=upper,parts=values)
78
+ if not exact and upper!=0:return unknown('No exact child distance',parts=values)
79
+ value=0. if upper==0 else min(exact)
80
+ return dict(status='exact',value=value,lower_bound=value,upper_bound=value,reason=None,parts=values,
81
+ unknown_parts=unresolved,distance_kind='composite_shape_and_text_frame_distance'if text_mode=='frame'else 'composite_shape_distance')
82
+ try:
83
+ field=g['contours']
84
+ if field['status']!='exact':
85
+ result=unknown(field.get('reason','No exact contour'))
86
+ if g['kind']=='text'and text_mode=='frame':result['distance_kind']='text_frame_distance'
87
+ return result
88
+ contours=field['value']
89
+ if not contours:raise ValueError('No distance contour')
90
+ value=solid_circle(p,contours)if g['kind']=='circle_solid'else min(contour(p,c)for c in contours)
91
+ value=finite(value)
92
+ if value<0:raise ValueError('Negative geometric distance')
93
+ kind='text_frame_distance'if g['kind']=='text'and text_mode=='frame'else 'shape_distance'
94
+ return dict(status='exact',value=value,lower_bound=value,upper_bound=value,reason=None,parts=[],unknown_parts=False,distance_kind=kind)
95
+ except (ValueError,KeyError,TypeError,OverflowError)as exc:return unknown(exc)
@@ -0,0 +1,34 @@
1
+ """Use the existing JSON query engine on a detached snapshot, without file I/O."""
2
+ from types import SimpleNamespace
3
+ from jwwkit.read_view import _unpack
4
+ from jwwkit.model.common_entities import model_entity
5
+ from jwwkit.model.query import search
6
+
7
+
8
+ class FrozenDocument:
9
+ def __init__(self,view,context):
10
+ self.header=context.header;self.includes_metadata=view.includes_metadata
11
+ self._source_bytes=None;self._header=view._source_header_bytes
12
+ blobs=view._records if context.o['basis']=='current'else view._persisted
13
+ self.missing=[i for i,b in enumerate(blobs)if b is None]
14
+ self.entities=tuple(model_entity(_unpack(b if b is not None else view._records[i]),source_index=view._indices[i])for i,b in enumerate(blobs))
15
+ self.block_definitions=tuple(SimpleNamespace(number=r['number'],name=r['name'],entities=())for r in context.definitions.values())
16
+ self.presets=context.presets
17
+
18
+ def get_group_scale(self,group):return self.header['layer_groups'][group]['scale']
19
+
20
+ def get_text_preset(self,number):
21
+ if self.presets['status']!='exact':raise ValueError('Frozen text presets are undetermined')
22
+ return dict(self.presets['values'][number-1])
23
+
24
+
25
+ def select(view,context,query,remaining_seconds):
26
+ if query is None:return range(view.entity_count),[],None
27
+ doc=FrozenDocument(view,context);missing=set(doc.missing)
28
+ result=search(doc,query,scope='drawing',limits={'total_seconds':min(30.,remaining_seconds),'regex_seconds':min(5.,remaining_seconds)},
29
+ _indices=[i for i in range(view.entity_count)if i not in missing])
30
+ unresolved=[dict(index=r['index'],reason=r['reason'])for r in result['unresolved']]
31
+ unresolved.extend(dict(index=i,reason='No persisted record to evaluate query')for i in doc.missing)
32
+ info={k:result[k]for k in ('input_query','effective_query','examined_count','resolutions','rules')}
33
+ info.update(source_sha256=view.source_sha256,model_sha256=view.model_sha256,basis=context.o['basis'],complete=not unresolved)
34
+ return [x['index']for x in result['hits']],unresolved,info