"""Decode actual symbol geometry and document-defined electrical marker styles.""" import math import textwrap from app.models.schematic import Primitive, Point from .geometry import number, integer, coordinate, point, flag, bounds, color, poly_path, transform_point KINDS={1:'component',2:'pin',3:'ieee_symbol',4:'label',5:'bezier',6:'polyline',7:'polygon',8:'ellipse',9:'pie',10:'round_rectangle',11:'elliptical_arc',12:'arc',13:'line',14:'rectangle',15:'sheet_symbol',16:'sheet_entry',17:'power_port',18:'port',22:'no_erc',25:'net_label',26:'bus',27:'wire',28:'text_frame',29:'junction',30:'image',31:'sheet',32:'sheet_name',33:'sheet_filename',34:'designator',37:'bus_entry',39:'template',41:'parameter',43:'parameter_set',44:'implementation_list',45:'implementation',46:'pin_map',47:'pin_map',48:'implementation_parameter',209:'text_frame',225:'dashed_rectangle'} SUPPORTED=set(KINDS)-{3,30,43} def decode(record, component_id, sheet, warn, records): p=record.properties t=integer(p,'RECORD') pos=point(p) output=[] stroke=color(p.get('COLOR','8388608' if t in (25,26,27) else '128')) fill=color(p.get('AREACOLOR','16777215')) if flag(p,'ISSOLID') and not flag(p,'TRANSPARENT') else 'none' width=(0.4,1,2,4)[min(3,max(0,integer(p,'LINEWIDTH')))] dash=[4,2] if integer(p,'LINESTYLE') or t==225 else [] if integer(p,'LINESTYLE'): warn('dash_metrics','Non-solid line dash spacing requires Altium reference validation',record) def path(points=None,d=None,closed=False,fill_=None,stroke_=None,width_=None,box=None): output.append(Primitive(id=f'{record.id}:p{len(output)}',kind='path', object_id=record.id,component_id=component_id,path=d if d is not None else poly_path(points or [],closed), bounds=box or bounds(points or [],width+1),stroke=stroke_ or stroke,fill=fill if fill_ is None else fill_, width=width_ or width,dash=dash)) def text(value,at=pos,angle=None,font=None,anchor=None,baseline=None,frame=None,wrap_width=None): if not value: return fid=font or integer(p,'FONTID',integer(sheet,'SYSTEMFONT',1)) # Browser em size approximation: Altium font size is not a CSS point size. # This yields glyph cap heights close to the KiCad importer's size/2 stroke text. size=max(1,min(300,number(sheet,f'SIZE{fid}',10)))*0.9 just=integer(p,'JUSTIFICATION') a=anchor or ('start','middle','end')[just%3] base=baseline or ('alphabetic','middle','hanging')[min(2,max(0,just//3))] rotation=-90*integer(p,'ORIENTATION') if angle is None else angle raw=value.replace('~1','\n') # Altium backslash suffix marks an overbar on the preceding character. clean=raw.replace('\\','') if raw.startswith('='): key=raw[1:].upper() resolved=p.get('_RESOLVED_TEXT') if resolved is not None: clean=resolved else: warn('unresolved_text',f'Unresolved parameter reference {raw}',record) lines=clean.splitlines() or [''] if wrap_width is not None: columns=max(1,int(wrap_width/(size*0.55))) lines=[line for paragraph in lines for line in (textwrap.wrap(paragraph,width=columns,break_long_words=True,break_on_hyphens=False,replace_whitespace=False) or [''])] for line_i,line in enumerate(lines): loc=Point(x=at.x,y=at.y+line_i*size*1.2) w=max(size*.6,len(line)*size*.8) x={'start':0,'middle':-w/2,'end':-w}[a] y={'alphabetic':-size,'middle':-size/2,'hanging':0}[base] corners=[transform_point(Point(x=cx,y=cy),loc,-rotation,False) for cx,cy in [(x,y),(x+w,y),(x+w,y+size*1.3),(x,y+size*1.3)]] box=bounds(corners,2) if frame: x0,y0=max(box.x,frame.x),max(box.y,frame.y) x1,y1=min(box.x+box.width,frame.x+frame.width),min(box.y+box.height,frame.y+frame.height) if x1<=x0 or y1<=y0: continue from app.models.schematic import Bounds box=Bounds(x=x0,y=y0,width=x1-x0,height=y1-y0) output.append(Primitive(id=f'{record.id}:p{len(output)}',kind='text',object_id=record.id, component_id=component_id,text=line,position=loc,rotation=rotation,mirrored=False, bounds=box,clip=frame,stroke=color(p.get('TEXTCOLOR',p.get('COLOR','128'))),fill='none', font_size=size,font_family=sheet.get(f'FONTNAME{fid}','Arial'),bold=flag(sheet,f'BOLD{fid}'), italic=flag(sheet,f'ITALIC{fid}'),underline=flag(sheet,f'UNDERLINE{fid}'),anchor=a,baseline=base)) if '\\' in raw: warn('text_overbar','Overbar text is displayed without its per-character overline',record) def local(points,origin=pos,rotation=None,closed=False,fill_=None): pts=[transform_point(Point(x=x,y=y),origin,90*integer(p,'ORIENTATION') if rotation is None else rotation) for x,y in points] path(pts,closed=closed,fill_=fill_) def ellipse(cx,cy,rx,ry,solid='none'): if rx<=0 or ry<=0: return d=f'M {cx-rx:.15g},{cy:.15g} A {rx:.15g},{ry:.15g} 0 1 0 {cx+rx:.15g},{cy:.15g} A {rx:.15g},{ry:.15g} 0 1 0 {cx-rx:.15g},{cy:.15g} Z' path(d=d,fill_=solid,box=bounds([Point(x=cx-rx,y=cy-ry),Point(x=cx+rx,y=cy+ry)],width)) if t in (5,6,7,26,27): n=integer(p,'LOCATIONCOUNT') extra=integer(p,'EXTRALOCATIONCOUNT') if n<0 or extra<0 or n+extra>20000: raise ValueError('Invalid point count') pts=[Point(x=coordinate(p,f'X{i}'),y=-coordinate(p,f'Y{i}')) for i in range(1,n+1)] pts += [Point(x=coordinate(p,f'EX{i}'),y=-coordinate(p,f'EY{i}')) for i in range(n+1,n+extra+1)] if t==5: if len(pts)<4 or (len(pts)-1)%3: warn('bezier_points','Non-cubic Bezier point sequence is unsupported',record) else: d=f'M{pts[0].x:.15g},{pts[0].y:.15g} ' for i in range(1,len(pts),3): d+='C'+' '.join(f'{q.x:.15g},{q.y:.15g}' for q in pts[i:i+3])+' ' path(pts,d=d,fill_='none') elif len(pts)>1: path(pts,closed=t==7,fill_=fill if t==7 else 'none') if integer(p,'STARTLINESHAPE') or integer(p,'ENDLINESHAPE'): warn('line_decoration','Line end decorations are not rendered',record) elif t in (13,37): path([pos,point(p,'CORNER')],fill_='none') elif t in (10,14,15,28,209,225): corner=point(p,'CORNER') if t==15: corner=Point(x=pos.x+coordinate(p,'XSIZE'),y=pos.y+coordinate(p,'YSIZE')) x,y=min(pos.x,corner.x),min(pos.y,corner.y); w,h=abs(pos.x-corner.x),abs(pos.y-corner.y) pts=[Point(x=x,y=y),Point(x=x+w,y=y),Point(x=x+w,y=y+h),Point(x=x,y=y+h)] if t==10: rx=min(w/2,abs(coordinate(p,'CORNERXRADIUS'))); ry=min(h/2,abs(coordinate(p,'CORNERYRADIUS'))) if rx and ry: d=f'M{x+rx},{y} H{x+w-rx} A{rx},{ry} 0 0 1 {x+w},{y+ry} V{y+h-ry} A{rx},{ry} 0 0 1 {x+w-rx},{y+h} H{x+rx} A{rx},{ry} 0 0 1 {x},{y+h-ry} V{y+ry} A{rx},{ry} 0 0 1 {x+rx},{y} Z' path(pts,d=d) else: path(pts,closed=True) elif t not in (28,209) or flag(p,'SHOWBORDER') or fill!='none': path(pts,closed=True,fill_=fill,stroke_=stroke if t not in (28,209) or flag(p,'SHOWBORDER') else fill) if t in (28,209): frame=bounds(pts) if flag(p,'CLIPTORECT') else None text(p.get('TEXT',''),Point(x=x+2,y=y+2),angle=0,baseline='hanging',frame=frame,wrap_width=max(1,w-4) if flag(p,'WORDWRAP') else None) if flag(p,'WORDWRAP'): warn('text_frame_metrics','Text frame wrapping uses estimated font advances; clipping is preserved',record) if flag(p,'COLLAPSED'): warn('collapsed_note','Collapsed note is rendered as a clipped text frame',record) elif t in (8,9,11,12): rx=abs(coordinate(p,'RADIUS')); ry=abs(coordinate(p,'SECONDARYRADIUS')) or rx start=number(p,'STARTANGLE'); end=number(p,'ENDANGLE',360) if t==8 or abs(end-start)>=359.999 or start==end: ellipse(pos.x,pos.y,rx,ry,fill if t in (8,9) else 'none') elif rx and ry: def pt(deg): a=math.radians(deg); a=math.atan2(rx*math.sin(a),ry*math.cos(a)) return Point(x=pos.x+rx*math.cos(a),y=pos.y-ry*math.sin(a)) a,b=pt(start),pt(end); sweep=(end-start)%360 d=f'M{a.x:.15g},{a.y:.15g} A{rx:.15g},{ry:.15g} 0 {1 if sweep>180 else 0} 0 {b.x:.15g},{b.y:.15g}' if t==9: d+=f' L{pos.x},{pos.y} Z' path(d=d,fill_=fill if t==9 else 'none',box=bounds([Point(x=pos.x-rx,y=pos.y-ry),Point(x=pos.x+rx,y=pos.y+ry)],width)) elif t==2: bits=integer(p,'PINCONGLOMERATE'); rot=(bits&3)*90 length=coordinate(p,'PINLENGTH') end=transform_point(Point(x=length,y=0),pos,rot) path([pos,end],fill_='none') # Name extends into body, number lies over the pin. Keep labels upright. horizontal=(bits&3)%2==0 direction=1 if (bits&3) in (0,3) else -1 if bits&8: at=Point(x=pos.x-(4*direction if horizontal else 0),y=pos.y+(0 if horizontal else -4*direction)) text(p.get('NAME',''),at,angle=0 if horizontal else -90,font=integer(p,'NAME_CUSTOMFONTID',1),anchor='end' if direction>0 else 'start',baseline='middle') if bits&16: at=Point(x=(pos.x+end.x)/2-(0 if horizontal else 3),y=(pos.y+end.y)/2-(3 if horizontal else 0)) text(p.get('DESIGNATOR',''),at,angle=0 if horizontal else -90,font=integer(p,'DESIGNATOR_CUSTOMFONTID',1),anchor='middle') if integer(p,'SYMBOL_OUTEREDGE')==1: ellipse(pos.x,pos.y,2,2) elif integer(p,'SYMBOL_OUTEREDGE'): warn('pin_decoration','Unsupported outer-edge pin symbol',record) if integer(p,'SYMBOL_INNEREDGE')==3: local([(0,-3),(-4,0),(0,3)],rotation=rot,fill_='none') elif integer(p,'SYMBOL_INNEREDGE'): warn('pin_decoration','Unsupported inner-edge pin symbol',record) if integer(p,'SYMBOL_OUTER') or integer(p,'SYMBOL_INNER'): warn('pin_decoration','Additional pin decorations are not rendered',record) if any(k in p for k in ('NAME_CUSTOMPOSITION_MARGIN','DESIGNATOR_CUSTOMPOSITION_MARGIN')) or integer(p,'PINNAME_POSITIONCONGLOMERATE') or integer(p,'PINDESIGNATOR_POSITIONCONGLOMERATE'): warn('pin_text_position','Custom pin text positions use standard inferred offsets',record) elif t in (4,25,32,33,34,41): value=p.get('TEXT','') if t==34 and component_id: parent=records.get((record.stream,int(component_id.rsplit(':',1)[-1]))) if parent and integer(parent.properties,'PARTCOUNT')>2: unit=integer(parent.properties,'CURRENTPARTID',1) if 1<=unit<=26: value+=chr(64+unit) else: warn('unit_suffix','Unit suffix above Z is unsupported',record) if t==41 and flag(p,'SHOWNAME'): value=p.get('NAME','')+': '+value # Parameter labels use upright text with opposite anchors at orientations 2/3. if t in (34,41) and integer(p,'ORIENTATION') in (2,3): text(value,angle=0 if integer(p,'ORIENTATION')==2 else -90,anchor='end',baseline='hanging') elif t==4 and integer(p,'ORIENTATION')==2: just=max(0,min(8,integer(p,'JUSTIFICATION'))) text(value,angle=0,anchor=('end','middle','start')[just%3],baseline=('hanging','middle','alphabetic')[just//3]) else: text(value) elif t==29: ellipse(pos.x,pos.y,1.5,1.5,stroke) elif t==22: path([Point(x=pos.x-2,y=pos.y-2),Point(x=pos.x+2,y=pos.y+2)],fill_='none') path([Point(x=pos.x-2,y=pos.y+2),Point(x=pos.x+2,y=pos.y-2)],fill_='none') elif t==17: style=integer(p,'STYLE'); rot=90*integer(p,'ORIENTATION') if flag(p,'ISCROSSSHEETCONNECTOR'): local([(8,-3),(4,0),(8,3)],fill_='none');local([(4,-3),(0,0),(4,3)],fill_='none') elif style in (0,1,2,4): local([(0,0),(10,0)],fill_='none') if style==0: c=transform_point(Point(x=10,y=0),pos,rot);ellipse(c.x,c.y,2.5,2.5) elif style==1: local([(6,-3),(10,0),(6,3)],fill_='none') elif style==2: local([(10,-7),(10,7)],fill_='none') elif style==4: for dx,span in [(10,7),(13,4),(16,1)]: local([(dx,-span),(dx,span)],fill_='none') else: warn('power_style',f'Unsupported power port style {style}; marker omitted rather than substituted',record) if flag(p,'SHOWNETNAME'): offset = 20 if style==4 else 14 if flag(p,'ISCROSSSHEETCONNECTOR') else 12 at=transform_point(Point(x=offset,y=0),pos,rot) orient=integer(p,'ORIENTATION') text(p.get('TEXT',''),at,angle=0,anchor=('start','middle','end','middle')[orient%4],baseline=('middle','alphabetic','middle','hanging')[orient%4]) elif t==18: style=integer(p,'STYLE'); w=coordinate(p,'WIDTH'); h=coordinate(p,'HEIGHT') or 10 # Style 0..3 horizontal and 4..7 vertical; bit0 left/top, bit1 right/bottom. tip=min(h/2,w/3); left=bool(style&1); right=bool(style&2) pts=[(0,0),(tip if left else 0,-h/2),(w-tip if right else w,-h/2),(w,0),(w-tip if right else w,h/2),(tip if left else 0,h/2)] local(pts,rotation=90 if style>=4 else 0,closed=True,fill_=color(p.get('AREACOLOR','8454143'))) at=transform_point(Point(x=w/2,y=0),pos,90 if style>=4 else 0) text(p.get('NAME',''),at,angle=-90 if style>=4 else 0,anchor='middle',baseline='middle') if p.get('HARNESSTYPE'): warn('harness','Harness semantics are not resolved',record) elif t==16: parent=records.get((record.stream,integer(p,'OWNERINDEX',-1))) if parent and integer(parent.properties,'RECORD')==15: q=parent.properties; origin=point(q);side=integer(p,'SIDE');distance=number(p,'DISTANCEFROMTOP')*10+number(p,'DISTANCEFROMTOP_FRAC1')/100000 at=Point(x=origin.x+(coordinate(q,'XSIZE') if side==1 else distance if side>=2 else 0),y=origin.y+(coordinate(q,'YSIZE') if side==3 else distance if side<2 else 0)) text(p.get('NAME',''),at,anchor='end' if side==1 else 'start',baseline='middle') path([Point(x=at.x-2,y=at.y),Point(x=at.x+2,y=at.y)],fill_='none') warn('sheet_entry_shape','Sheet entry location and name are rendered; entry arrow style is unsupported',record) else: warn('orphan_sheet_entry','Sheet entry owner not found',record) return output