#!/usr/bin/env python3
# Bone Flow #021 v1.0.96: purpose-designed, monochrome, legible UI pictogram glyphs.
# Old 40 codepoints/glyph outlines are preserved; added 43 map to ui-parts registry order.
# Dependencies: fontTools, shapely.  No network, fonts outside the supplied source, or other components.
"""Usage: python build-shape-font.py original-40/BoneShape-Regular.ttf output-dir
The 43 *font glyphs* are intentionally simplified drawings, not full SVG screen mockups.
For rich colors, editable labels or full UI screenshots, use the SVG BoneShape Runtime.
"""
from pathlib import Path
import json, sys, math
from shapely.geometry import Point, LineString, Polygon, box
from shapely.ops import unary_union
from shapely.affinity import translate
from shapely.geometry.polygon import orient
from fontTools.ttLib import TTFont
from fontTools.pens.ttGlyphPen import TTGlyphPen

if len(sys.argv)!=3: raise SystemExit('Usage: build-shape-font.py EXISTING_40.ttf OUTPUT_DIR')
source=Path(sys.argv[1]); out=Path(sys.argv[2]);out.mkdir(parents=True,exist_ok=True)
registry=json.loads((Path(__file__).resolve().parent.parent/'data/bone-shapes.json').read_text(encoding='utf-8'))
ui=[s for s in registry['shapes'] if s['category']=='ui-parts']
assert len(registry['shapes'])==83 and len(ui)==43
old=TTFont(source); cmap=old.getBestCmap().copy();assert len(cmap)==40 and set(cmap)==set(range(0xE001,0xE029))
font=TTFont(source); order=list(font.getGlyphOrder())

# All drawings use a common 100x100 icon canvas. A stroke is a filled closed
# outline so PDF / Word / Windows rasterizers render it without SVG dependencies.
class Art:
 def __init__(self):self.parts=[]
 def add(self,g):
  if g is not None and not g.is_empty:self.parts.append(g)
  return self
 def line(self,pts,w=5):return self.add(LineString(pts).buffer(w/2,cap_style=1,join_style=1))
 def rect(self,x,y,w,h,stroke=4,r=0,fill=False):
  g=box(x,y,x+w,y+h)
  if r>0 and w>=2*r and h>=2*r:g=box(x+r,y+r,x+w-r,y+h-r).buffer(r,join_style=1)
  return self.add(g if fill else g.boundary.buffer(stroke/2,cap_style=1,join_style=1))
 def circ(self,cx,cy,r,stroke=4,fill=False):
  g=Point(cx,cy).buffer(r,resolution=28)
  return self.add(g if fill else g.boundary.buffer(stroke/2,cap_style=1,join_style=1))
 def arc(self,cx,cy,r,start,end,stroke=4):
  t=[math.radians(start+(end-start)*i/40) for i in range(41)]
  return self.line([(cx+r*math.cos(a),cy+r*math.sin(a)) for a in t],stroke)
 def poly(self,pts,stroke=0,fill=True):
  g=Polygon(pts)
  return self.add(g if fill else g.boundary.buffer(stroke/2,cap_style=1,join_style=1))
 def textlines(self,x,y,widths,spacing=10,stroke=4):
  for i,w in enumerate(widths):self.line([(x,y+i*spacing),(x+w,y+i*spacing)],stroke)
  return self
 def chevron(self,x,y,dir='down',size=10,stroke=5):
  v={'down':[(-size,-size/2),(0,size/2),(size,-size/2)],'up':[(-size,size/2),(0,-size/2),(size,size/2)],'left':[(size/2,-size),( -size/2,0),(size/2,size)],'right':[(-size/2,-size),(size/2,0),(-size/2,size)]}[dir]
  return self.line([(x+dx,y+dy) for dx,dy in v],stroke)
 def cross(self,x,y,s=9,stroke=5):
  self.line([(x-s,y-s),(x+s,y+s)],stroke);self.line([(x+s,y-s),(x-s,y+s)],stroke);return self
 def check(self,x,y,s=1):return self.line([(x-10*s,y),(x-2*s,y+8*s),(x+14*s,y-11*s)],5)
 def star(self,cx,cy,ro=13,ri=6,n=5):
  pts=[]
  for i in range(2*n):
   a=-math.pi/2+i*math.pi/n;r=ro if i%2==0 else ri;pts.append((cx+r*math.cos(a),cy+r*math.sin(a)))
  return self.poly(pts)
 def scene(self):
  if not self.parts:raise ValueError('Empty art')
  return unary_union(self.parts).buffer(0)

def window(a):
 a.rect(9,14,82,72,r=8,stroke=4).line([(11,34),(89,34)],4)
 for x in (21,31,41):a.circ(x,24,2.4,fill=True)
 return a

def calendar(a,rows=3,cols=7):
 a.rect(10,12,80,76,r=6).line([(12,33),(88,33)],4)
 for x in (28,72):a.line([(x,7),(x,18)],6)
 for j in range(rows):
  for i in range(cols): a.circ(19+i*(62/(cols-1)),44+j*(34/(max(1,rows-1))),2.6,fill=True)
 return a

def inputbox(a):
 return a.rect(10,28,80,44,stroke=4,r=7)

def clock(a,cx=50,cy=50,r=30):
 a.circ(cx,cy,r).line([(cx,cy-r*0.56),(cx,cy),(cx+r*.45,cy+r*.22)],5)
 return a

def icon(type):
 a=Art();t=type
 if t=='WINDOW_FRAME_UI':
  window(a);a.textlines(23,48,[47,32],14)
 elif t=='WINDOW_CONTROL_MINIMIZE':
  window(a);a.line([(38,56),(62,56)],7)
 elif t=='WINDOW_CONTROL_MAXIMIZE':
  window(a);a.rect(37,44,27,26,5)
 elif t=='WINDOW_CONTROL_RESTORE':
  a.rect(30,24,46,43,4).rect(18,38,48,43,5).line([(18,49),(66,49)],3)
 elif t=='WINDOW_CONTROL_CLOSE':
  window(a);a.cross(50,59,13,6)
 elif t=='BUTTON_RECT':
  a.rect(8,29,84,43,r=10,stroke=5).textlines(30,51,[40],stroke=6)
 elif t=='BUTTON_ICON':
  a.rect(15,14,70,72,r=13).circ(50,50,22,5).line([(50,35),(50,65)],5).line([(35,50),(65,50)],5)
 elif t=='BUTTON_SPLIT':
  a.rect(8,29,84,43,r=9).line([(69,30),(69,71)],4).textlines(24,50,[31],stroke=5).chevron(81,50,'down',6,4)
 elif t=='CHECKBOX_UI':
  a.rect(16,17,67,67,r=9,stroke=6).check(50,52,1.4)
 elif t=='RADIO_BUTTON_UI':
  a.circ(50,50,33,6).circ(50,50,15,fill=True)
 elif t=='TEXTBOX_UI':
  inputbox(a).textlines(22,50,[36],stroke=5).line([(75,39),(75,62)],4)
 elif t=='COMBOBOX_UI':
  inputbox(a).line([(67,30),(67,70)],4).textlines(22,50,[28],stroke=5).chevron(79,49,'down',7)
 elif t=='TOAST_UI':
  a.rect(7,21,86,58,r=8).line([(12,25),(12,75)],7).circ(33,51,12,4).check(33,52,.55).textlines(53,42,[25,19],12,4)
 elif t=='DIALOG_UI':
  a.rect(9,10,82,80,r=9).line([(10,31),(90,31)],4).textlines(19,21,[43],stroke=4).textlines(23,44,[50,38],12,4).rect(51,70,29,11,stroke=3,r=3)
 elif t=='TAB_UI':
  a.rect(12,32,76,52,r=4).rect(12,17,28,18,r=3).rect(43,21,23,11,stroke=3,r=3).textlines(28,52,[43,31],13,4)
 elif t=='MENU_UI':
  a.rect(18,10,64,80,r=6)
  for y in (27,47,67): a.circ(30,y,3,fill=True).line([(41,y),(69,y)],4)
 elif t=='CALENDAR_MONTH':calendar(a,rows=4)
 elif t=='CALENDAR_WEEK':
  calendar(a,rows=2);a.line([(20,39),(20,82)],2)
 elif t=='CALENDAR_DAY_CELL':
  a.rect(15,14,70,72,r=5).line([(27,34),(73,34)],4).circ(37,58,8,fill=True)
 elif t=='CALENDAR_DAY_SELECTED':
  a.rect(15,14,70,72,r=5).line([(27,32),(73,32)],4).rect(31,42,38,35,r=5,stroke=5).check(50,60,.6)
 elif t=='CALENDAR_DAY_TODAY':
  a.rect(15,14,70,72,r=5).circ(50,53,23,5).circ(50,53,8,fill=True)
 elif t=='CALENDAR_DAY_HOLIDAY':
  a.rect(15,14,70,72,r=5).star(50,55,19,9)
 elif t=='CALENDAR_DAY_EVENT':
  a.rect(15,14,70,72,r=5).circ(29,59,6,fill=True).textlines(42,57,[29,20],12,5)
 elif t=='CALENDAR_DAY_TASK':
  a.rect(15,14,70,72,r=5).rect(25,43,22,22,r=3,stroke=4).check(36,54,.47).textlines(55,51,[15,14],12,4)
 elif t=='CALENDAR_HEADER':
  a.rect(7,28,86,44,r=7).chevron(23,50,'left',9).chevron(77,50,'right',9).textlines(40,50,[20],stroke=6)
 elif t=='CALENDAR_NAV_PREV':
  a.circ(50,50,32).chevron(52,50,'left',15,7)
 elif t=='CALENDAR_NAV_NEXT':
  a.circ(50,50,32).chevron(48,50,'right',15,7)
 elif t=='TIMELINE_GRID':
  a.rect(10,15,80,70,r=3)
  for x in (30,50,70): a.line([(x,17),(x,83)],3)
  for y in (34,52,70): a.line([(12,y),(88,y)],3)
 elif t=='TIMELINE_BAR':
  a.line([(12,22),(89,22)],4).line([(12,78),(89,78)],4)
  for x in (20,50,80):a.line([(x,22),(x,31)],3)
  a.rect(23,41,58,22,r=6,fill=True)
 elif t=='TIMELINE_BAR_RANGE':
  a.line([(10,50),(90,50)],3).rect(18,36,64,28,r=5,stroke=5)
  for x in (18,82):a.circ(x,50,6,fill=True)
 elif t=='TIMELINE_BAR_SELECTED':
  a.rect(15,39,70,27,r=6).line([(15,36),(15,69)],7).line([(85,36),(85,69)],7).circ(50,52,6,fill=True)
 elif t=='TIMELINE_VERTICAL':
  a.rect(15,12,70,76,r=3)
  for x in (35,55):a.line([(x,14),(x,86)],3)
  for y in (35,60):a.line([(16,y),(84,y)],3)
  a.rect(39,26,12,46,r=2,fill=True)
 elif t=='TIMELINE_HORIZONTAL':
  a.rect(12,15,76,70,r=3)
  for y in (33,51,69):a.line([(14,y),(86,y)],3)
  a.rect(32,39,50,9,r=3,fill=True)
 elif t=='TIMELINE_LABEL_HOUR':
  clock(a,38,50,26);a.textlines(72,39,[18,18,13],11,5)
 elif t=='TIMELINE_MARKER_NOW':
  a.line([(51,19),(51,84)],4).poly([(39,15),(63,15),(51,32)])
  a.line([(21,43),(79,43)],3)
 elif t=='DATE_PICKER_UI':
  calendar(a,2,4);a.rect(51,54,19,16,r=3,stroke=5)
 elif t=='TIME_PICKER_UI':
  clock(a,48,50,32);a.chevron(79,76,'down',7,4)
 elif t=='DATETIME_PICKER_UI':
  a.rect(8,16,53,67,r=5).line([(10,34),(59,34)],4)
  for x in (21,36,51):a.circ(x,49,3,fill=True)
  clock(a,64,65,21)
 elif t=='COLOR_PICKER_UI':
  a.circ(47,48,31,5)
  for x,y in ((33,34),(51,27),(67,42),(62,61),(42,67)):
   a.circ(x,y,5,fill=True)
  a.circ(26,55,7,stroke=4)
 elif t=='FILE_PICKER_UI':
  a.poly([(12,33),(12,76),(88,76),(88,42),(49,42),(42,33)],stroke=5,fill=False)
  a.line([(52,64),(52,25)],5).chevron(52,26,'up',10,5)
 elif t=='PICKER_POPUP_UI':
  a.rect(12,12,76,24,r=4).rect(19,43,69,46,r=5)
  a.chevron(74,24,'down',6,4)
  a.textlines(32,56,[39,29,37],12,4)
 elif t=='OPTION_LIST_UI':
  a.rect(13,12,74,77,r=7)
  for y in (27,49,71):a.circ(28,y,5,fill=(y==49)).line([(43,y),(74,y)],4)
  a.check(28,49,.3)
 elif t=='LABEL_UI':
  a.line([(22,26),(22,68)],6).line([(15,26),(29,26)],5)
  a.textlines(41,31,[38,29],16,6).star(30,76,9,4)
 else:raise ValueError('UI font icon implementation missing: '+t)
 return a.scene()

def append_glyph(name,g):
 # The Shapely exterior has positive signed area (CCW in image coordinates).
 # Flip Y for TrueType -> exterior clockwise, interior counterclockwise.
 pen=TTGlyphPen(None)
 pieces=([g] if g.geom_type=='Polygon' else list(g.geoms))
 for poly in pieces:
  if poly.geom_type!='Polygon':continue
  poly=orient(poly,sign=1)
  for ring in [poly.exterior,*poly.interiors]:
   pts=[]
   for x,y in list(ring.coords)[:-1]:
    p=(round(95+x*8.1),round(900-y*8.1))
    if not pts or pts[-1]!=p:pts.append(p)
   if len(set(pts))<3:continue
   pen.moveTo(pts[0]);
   for p in pts[1:]:pen.lineTo(p)
   pen.closePath()
 font['glyf'][name]=pen.glyph()
 font['hmtx'].metrics[name]=(1000,0)

newCodes={};stats={}
for i,item in enumerate(ui):
 t=item['type'];code=0xE029+i;name='bf_'+t.lower()
 if name in order:raise ValueError('Duplicate glyph name: '+name)
 g=icon(t)
 if g.is_empty:raise ValueError('Empty geometry: '+t)
 append_glyph(name,g)
 stats[t]={'codepoint':f'U+{code:04X}','area':round(g.area,2),'parts':len(g.geoms) if g.geom_type=='MultiPolygon' else 1}
 newCodes[code]=name;order.append(name)
font.setGlyphOrder(order)
for ct in font['cmap'].tables:
 if ct.isUnicode():ct.cmap.update(newCodes)
font['maxp'].numGlyphs=len(order)
for id,value in ((1,'Bone Shape'),(2,'Regular'),(6,'BoneShape-Regular'),(4,'Bone Shape Regular')):
 font['name'].setName(value,id,3,1,0x409)
font['OS/2'].usFirstCharIndex=min(cmap);font['OS/2'].usLastCharIndex=max(newCodes)
font.save(out/'BoneShape-Regular.ttf')
font.flavor='woff2';font.save(out/'bone-shape.woff2');font.flavor=None
fresh=TTFont(out/'BoneShape-Regular.ttf')
assert len(fresh.getBestCmap())==83
assert set(fresh.getBestCmap())==set(range(0xE001,0xE054))
assert all(fresh['glyf'][name].numberOfContours>0 for name in newCodes.values())
assert all(fresh['glyf'][name].compile(fresh['glyf'])==old['glyf'][name].compile(old['glyf']) for name in cmap.values()),'Existing 40 glyph changed'
(out/'glyph-build-report.json').write_text(json.dumps({'version':'1.0.96','total':83,'preservedOld40':True,'new43':stats},ensure_ascii=False,indent=2),encoding='utf-8')
print('FONT PASS: 83/83 glyphs; old 40 byte-identical; new 43 bespoke UI pictograms.')
