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
jwwkit/_geometry_math.py
ADDED
|
@@ -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)
|
jwwkit/_spatial_query.py
ADDED
|
@@ -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
|