@@ -0,0 +1,134 @@
|
||||
"""PCB object comparison; reuses only the format-independent entity matcher."""
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from .diff import Entity, digest, match_entities
|
||||
|
||||
TOKEN = re.compile(r'[MLAZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?')
|
||||
IGNORE = {'NET_INDEX','COMPONENT_INDEX','POLYGON_INDEX','INDEX','UNIQUEID','SOURCEUNIQUEID',
|
||||
'LOCKED','FLAGS_1','FLAGS_2','OUTLINE_PATH','X','Y','ROTATION','LAYER'}
|
||||
|
||||
|
||||
def number(n):
|
||||
return round(float(n), 7) or 0.0
|
||||
|
||||
|
||||
def point(x, y, origin, rotation=0, mirrored=False):
|
||||
x,y = x-origin[0],y-origin[1]
|
||||
a=math.radians(rotation)
|
||||
x,y=x*math.cos(a)-y*math.sin(a),x*math.sin(a)+y*math.cos(a)
|
||||
return (number(-x if mirrored else x),number(y))
|
||||
|
||||
|
||||
def path_signature(path, origin, rotation=0, mirrored=False):
|
||||
"""Canonical local geometry for the M/L/A/Z vocabulary emitted by the PCB parser."""
|
||||
tokens=TOKEN.findall(path); out=[]; i=0
|
||||
while i<len(tokens):
|
||||
cmd=tokens[i]; i+=1
|
||||
if cmd in ('M','L'):
|
||||
out.append((cmd,*point(float(tokens[i]),float(tokens[i+1]),origin,rotation,mirrored)));i+=2
|
||||
elif cmd=='A':
|
||||
rx,ry,angle,large,sweep,x,y=map(float,tokens[i:i+7]);i+=7
|
||||
angle=(angle+rotation)%180
|
||||
if mirrored: angle=(-angle)%180;sweep=1-sweep
|
||||
out.append(('A',number(rx),number(ry),number(angle),int(large),int(sweep),*point(x,y,origin,rotation,mirrored)))
|
||||
elif cmd=='Z':out.append(('Z',))
|
||||
else: raise ValueError('Unexpected PCB SVG path command')
|
||||
# A straight track has no electrical direction.
|
||||
if len(out)==2 and out[0][0]=='M' and out[1][0]=='L':
|
||||
ends=sorted([out[0][1:],out[1][1:]])
|
||||
out=[('M',*ends[0]),('L',*ends[1])]
|
||||
return out
|
||||
|
||||
|
||||
def entities(doc):
|
||||
primitive_map={p.id:p for p in doc.primitives};children=defaultdict(list)
|
||||
for o in doc.objects:
|
||||
if o.component_id:children[o.component_id].append(o)
|
||||
|
||||
def properties(o,origin,rotation=0,mirrored=False):
|
||||
p={k:v for k,v in o.properties.items() if k not in IGNORE and not k.endswith('_INDEX')}
|
||||
# Region definitions may be invisible; their real retained boundary still participates.
|
||||
if 'OUTLINE_PATH' in o.properties:
|
||||
p['outline']=path_signature(o.properties['OUTLINE_PATH'],origin,rotation,mirrored)
|
||||
if 'ROTATION' in o.properties:p['rotation']=number((float(o.properties['ROTATION'])-rotation)%360)
|
||||
if o.kind=='board_outline':p={}
|
||||
if o.kind=='polygon_definition':
|
||||
p={k:v for k,v in p.items() if not re.fullmatch(r'(?:VX|VY|CX|CY|SA|EA|R|KIND)\d+',k) and k!='NET'}
|
||||
p['net']=next((n.name for n in doc.nets if n.id==o.net_id),None)
|
||||
from .parser.pcb_geometry import property_outline
|
||||
p['outline']=path_signature(property_outline(o.properties)[0],origin,rotation,mirrored)
|
||||
geometry=[]
|
||||
for pid in o.primitive_ids:
|
||||
primitive=primitive_map[pid]
|
||||
item=dict(kind=primitive.kind,layer=primitive.layer_id,layers=getattr(primitive,'layer_ids',[]),
|
||||
width=primitive.width,fill_rule=primitive.fill_rule,role=primitive.role,
|
||||
net=next((n.name for n in doc.nets if n.id==primitive.net_id),None))
|
||||
if primitive.kind=='path':item['path']=path_signature(primitive.path,origin,rotation,mirrored)
|
||||
else:item.update(text=primitive.text,position=point(primitive.position.x,primitive.position.y,origin,rotation,mirrored),
|
||||
rotation=number((primitive.rotation+rotation)%360),mirrored=primitive.mirrored != mirrored,
|
||||
font=primitive.font_family,size=primitive.font_size,bold=primitive.bold,italic=primitive.italic)
|
||||
geometry.append(item)
|
||||
p['geometry']=sorted(geometry,key=digest)
|
||||
p['visible']=o.visible
|
||||
return p
|
||||
|
||||
result=[]
|
||||
for c in doc.components:
|
||||
origin=(c.position.x,c.position.y)
|
||||
p={k:v for k,v in c.parameters.items() if k not in IGNORE}
|
||||
p.update(designator=c.designator,pattern=c.library_reference,value=c.value,
|
||||
rotation=number(c.rotation%360),mirrored=c.mirrored)
|
||||
p['children']=sorted([dict(kind=o.kind,properties=properties(o,origin,c.rotation,c.mirrored))
|
||||
for o in children[c.id]],key=digest)
|
||||
result.append(Entity(c.id,'footprint',c.parameters.get('UNIQUEID',c.parameters.get('SOURCEUNIQUEID','')),
|
||||
c.designator,origin,c.bounds.model_dump(),p,digest(p),c.primitive_ids,0))
|
||||
for o in doc.objects:
|
||||
if o.component_id:continue
|
||||
origin=(o.position.x,o.position.y)
|
||||
if o.kind=='track' and o.connection_points:
|
||||
origin=min((p.x,p.y) for p in o.connection_points)
|
||||
p=properties(o,origin)
|
||||
result.append(Entity(o.id,o.kind,getattr(o,'native_uid',''),'',origin,
|
||||
o.bounds.model_dump() if o.primitive_ids else None,p,digest(p),o.primitive_ids,o.record_type))
|
||||
# The layer stack is independent of source record order and object counts.
|
||||
layer_props={k:v for k,v in doc.sheet.properties.items() if re.fullmatch(r'LAYER\d+(?:NAME|PREV|NEXT)|LAYERV7_\d+(?:NAME|LAYERID|PREV|NEXT)',k)}
|
||||
layer_props['copper_stack']=doc.metadata.get('copper_stack',[])
|
||||
result.append(Entity('pcb-layer-stack','layer_stack','board-layer-stack','',(0,0),None,layer_props,digest(layer_props),[],0))
|
||||
return result
|
||||
|
||||
|
||||
def compare_pcbs(old,new):
|
||||
if old.document_type!='pcb' or new.document_type!='pcb':raise ValueError('PCB documents required')
|
||||
before,after=entities(old),entities(new)
|
||||
pairs,removed,added,ambiguous=match_entities(before,after);changes=[]
|
||||
def change(status,a,b,method='',fields=None):
|
||||
e=b or a
|
||||
changes.append(dict(id=f'change-{len(changes)}',status=status,kind=e.kind,
|
||||
label=e.properties.get('designator') or e.properties.get('PAD_NAME') or e.properties.get('TEXT') or e.kind,
|
||||
old_id=a.id if a else None,new_id=b.id if b else None,
|
||||
old_bounds=a.bounds if a else None,new_bounds=b.bounds if b else None,
|
||||
old_primitive_ids=a.primitive_ids if a else [],new_primitive_ids=b.primitive_ids if b else [],match=method,
|
||||
moved=bool(a and b and a.position!=b.position),
|
||||
movement=dict(dx=number(b.position[0]-a.position[0]),dy=number(b.position[1]-a.position[1])) if a and b else None,
|
||||
rotation_change=number(b.properties.get('rotation',0)-a.properties.get('rotation',0)) if a and b else None,
|
||||
fields=fields or []))
|
||||
for a,b,method in pairs:
|
||||
fields=[dict(property=k,old=a.properties.get(k),new=b.properties.get(k)) for k in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(k)!=b.properties.get(k)]
|
||||
if fields or a.position!=b.position:change('modified' if fields else 'moved',a,b,method,fields)
|
||||
for e in removed:change('removed',e,None)
|
||||
for e in added:change('added',None,e)
|
||||
def info(doc):
|
||||
return dict(filename=doc.filename,sha256=doc.metadata['sha256'],compatibility=doc.metadata['compatibility'],
|
||||
sheet=doc.sheet.bounds.model_dump(),bounds=doc.bounds.model_dump(),layers=[l.model_dump() for l in doc.layers],
|
||||
diagnostics=[d.model_dump() for d in doc.diagnostics])
|
||||
a={n.name for n in old.nets};b={n.name for n in new.nets}
|
||||
return dict(schema_version='1.0',document_type='pcb_comparison',old=info(old),new=info(new),changes=changes,
|
||||
summary={s:sum(c['status']==s for c in changes) for s in ('added','removed','modified','moved')},total_changed=len(changes),
|
||||
nets=dict(added=sorted(b-a),removed=sorted(a-b)),
|
||||
matching=dict(matched=len(pairs),unmatched_old=len(removed),unmatched_new=len(added),ambiguous_groups=ambiguous),
|
||||
limitations=['PCB object/net-assignment comparison; no DRC or proof of electrical equivalence.',
|
||||
'Ambiguous identities remain added/removed; non-UID matches are heuristic.',
|
||||
'Footprint children are compared locally; independent routes remain separate.',
|
||||
'Unsupported native streams are not compared; inspect both parser diagnostic lists.',
|
||||
'Saved pours may be stale; no repouring or negative-plane reconstruction.'])
|
||||
مرجع در شماره جدید
مسدود کردن کاربر