Files
altium-render-engine/backend/app/pcb_diff.py
T
2026-11-09 12:34:50 +03:30

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.'])