135 خطوط
8.2 KiB
Python
135 خطوط
8.2 KiB
Python
"""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.'])
|