"""ARTISAN ALTO AT-101: editable hard-surface design study. Blender 4.5+.

Coordinates are centimetres, Z up, front toward -Y. The GLB export uses metres.
This is a visual industrial-design proposal, not manufacturing CAD.
"""
import bpy, bmesh, math, os, json, sys
from mathutils import Vector
from pathlib import Path

ROOT = Path(os.environ.get('MEZZO_ROOT', str(Path(__file__).resolve().parent.parent)))
OUT = ROOT / 'public/models'
IMG = ROOT / 'public/images'
OUT.mkdir(parents=True, exist_ok=True)
IMG.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = .01

def linear(v):
    return v/12.92 if v <= .04045 else ((v+.055)/1.055)**2.4
def rgba(h):
    h=h.lstrip('#'); return tuple(linear(int(h[i:i+2],16)/255) for i in (0,2,4))+(1,)
def material(name, color, rough=.35, metal=0, coat=0, emission=0):
    m=bpy.data.materials.new(name); m.use_nodes=True
    bs=m.node_tree.nodes.get('Principled BSDF')
    bs.inputs['Base Color'].default_value=rgba(color)
    bs.inputs['Roughness'].default_value=rough
    bs.inputs['Metallic'].default_value=metal
    bs.inputs['Coat Weight'].default_value=coat
    bs.inputs['Coat Roughness'].default_value=.13
    if emission:
        bs.inputs['Emission Color'].default_value=rgba(color)
        bs.inputs['Emission Strength'].default_value=emission
    m.diffuse_color=rgba(color)
    return m

RED=material('Vermilion / polished moulded polymer','#C5170E',.20,0,.75)
RED_DARK=material('Vermilion / inset satin','#9A211A',.36)
RED_CAP=material('Vermilion / cartridge crown','#CF1C10',.19,0,.68)
GRAPHITE=material('Graphite / fine textured polymer','#292F2F',.48)
BLACK=material('Recess / shadow black','#080C0D',.6)
BASE=material('Underbody / charcoal magnesium','#202727',.39,.35)
CREAM=material('Ivory / sculpted PBT keycaps','#E8E3CE',.36)
MOD=material('Warm grey / modifier PBT','#ABB3AC',.38)
INK=material('Legends / deep graphite','#172621',.5)
WHITE=material('Silkscreen / warm porcelain','#F2EDDD',.42)
ORANGE=material('Function keys / persimmon','#F17B35',.32)
ALUMINUM=material('Collar / brushed titanium','#778C88',.32,.78)
STEEL=material('Fasteners / black stainless','#43504D',.33,.8)
GOLD=material('Contacts / gold','#B69C56',.26,.78)
AMBER=material('Indicator / warm amber','#FFAC48',.23,0,0,2.4)
AMBER_DIM=material('Indicator / inactive amber','#593921',.55)
TOUCH=material('Touchpad / etched graphite glass','#343D39',.50,0,.12)
GLASS=material('Display / smoked glass','#102321',.18,.12,.5)
DISPLAY=material('Display / phosphor ivory','#D9E6BC',.45,0,0,.5)

def select(o):
    bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o
def finish(o, mat, bevel=0, segments=3, smooth=True):
    if mat: o.data.materials.append(mat)
    if o.type=='MESH':
        if bevel:
            mod=o.modifiers.new('Moulded edge radius','BEVEL'); mod.width=bevel; mod.segments=segments
            mod.limit_method='ANGLE'; mod.harden_normals=True
        for p in o.data.polygons: p.use_smooth=smooth
        if smooth:
            mod=o.modifiers.new('Surface normals','WEIGHTED_NORMAL'); mod.keep_sharp=True; mod.weight=50
    return o
def mesh(name, verts, faces, mat=None, bevel=0, smooth=True):
    me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update()
    bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
    ob=bpy.data.objects.new(name,me); bpy.context.collection.objects.link(ob)
    return finish(ob,mat,bevel,smooth=smooth)
def cube(name, loc, dims, mat, bevel=.08, rot=(0,0,0)):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc,rotation=rot)
    o=bpy.context.object; o.name=name; o.dimensions=dims; select(o)
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    return finish(o,mat,bevel)
def cyl(name, loc, radius, depth, mat, vertices=48, rot=(0,0,0), bevel=.035):
    bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=radius,depth=depth,location=loc,rotation=rot)
    o=bpy.context.object; o.name=name; return finish(o,mat,bevel)
def parent(o,g): o.parent=g; return o
def group(name):
    o=bpy.data.objects.new(name,None); bpy.context.collection.objects.link(o); return o
def apply(o):
    select(o)
    for m in list(o.modifiers): bpy.ops.object.modifier_apply(modifier=m.name)
def cut(o,c):
    apply(c); select(o); m=o.modifiers.new('Recess tooling','BOOLEAN');m.operation='DIFFERENCE';m.object=c
    bpy.ops.object.modifier_apply(modifier=m.name); bpy.data.objects.remove(c,do_unlink=True)
def roundrect(w,d,r,n=12):
    pts=[]
    for cx,cy,a in [(w/2-r,d/2-r,0),(-w/2+r,d/2-r,90),(-w/2+r,-d/2+r,180),(w/2-r,-d/2+r,270)]:
        for i in range(n):
            t=math.radians(a+i*90/(n-1)); pts.append((cx+r*math.cos(t),cy+r*math.sin(t)))
    return pts
def loft(name, contours, mat, bevel=.1, cap_break=None):
    n=len(contours[0]); verts=[v for ring in contours for v in ring]
    faces=[tuple(reversed(range(n)))]
    for j in range(len(contours)-1):
        for i in range(n): faces.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
    top=(len(contours)-1)*n
    if cap_break:
        a,b=sorted(cap_break)
        faces.append(tuple(top+i for i in range(a,b+1)))
        faces.append(tuple(top+i for i in list(range(b,n))+list(range(0,a+1))))
    else:
        faces.append(tuple(top+i for i in range(n)))
    return mesh(name,verts,faces,mat,bevel)
def prism(name, pts, bottom, top, mat, bevel=.06):
    z=lambda a,x,y: a(x,y) if callable(a) else a
    return loft(name,[[(x,y,z(bottom,x,y)) for x,y in pts],[(x,y,z(top,x,y)) for x,y in pts]],mat,bevel)

font_path='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
font_bold_path='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
FONT=bpy.data.fonts.load(font_path) if os.path.exists(font_path) else None
BOLD=bpy.data.fonts.load(font_bold_path) if os.path.exists(font_bold_path) else FONT
LABEL_TARGETS={}
def text(name,body,loc,size,mat=WHITE,rotation=(0,0,0),align='LEFT',bold=False,spacing=1.05):
    cv=bpy.data.curves.new(name,'FONT');cv.body=body;cv.size=size;cv.align_x=align;cv.align_y='CENTER'
    cv.space_character=spacing;cv.resolution_u=4
    if FONT: cv.font=BOLD if bold else FONT
    ob=bpy.data.objects.new(name,cv);bpy.context.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=rotation
    cv.materials.append(mat);return ob
def screw(name,x,y,z,up=True):
    g=group(name)
    parent(cyl(name+' / countersink',(x,y,z),.20,.035,BLACK),g)
    parent(cyl(name+' / captive head',(x,y,z+.025),.137,.05,STEEL,32),g)
    parent(cyl(name+' / hex socket',(x,y,z+.055),.063,.008,BLACK,6,bevel=0),g)
    return g

exec(compile((ROOT/'modeling/cartridge_geometry.py').read_text(),str(ROOT/'modeling/cartridge_geometry.py'),'exec'))

DECK_START=4.12
DECK_HEIGHT=3.8+.083*DECK_START
# The front retains its incline; the complete rear deck is parallel to the base.
topz=lambda y:min(3.8+.083*y,DECK_HEIGHT)
wellz=lambda y:2.50+.063*y
tilt=0.0; keytilt=math.atan(.063)
exec(compile((ROOT/'modeling/alto_enclosure.py').read_text(),str(ROOT/'modeling/alto_enclosure.py'),'exec'))

# Add the two exact fold vertices to every loft ring, then cap the front and
# rear separately. This makes the rear plane geometrically horizontal instead
# of relying on a non-planar polygon's arbitrary triangulation.
outline=roundrect(33.6,24.0,.75)
fold_edges={}
for i,(_,y) in enumerate(outline):
    yy=outline[(i+1)%len(outline)][1]
    if (y+.6-DECK_START)*(yy+.6-DECK_START)<0:
        fold_edges[i]=(DECK_START-.6-y)/(yy-y)
shell_rings=[];fold_indices=[]
for w,d,r,z in [(38.0,25.0,1.1,.62),(40.5,27.6,1.3,1.18),(40.8,27.8,1.20,1.66),(33.6,24.0,.75,None)]:
    points=roundrect(w,d,r);ring=[]
    for i,(x,y) in enumerate(points):
        ring.append((x,y+(.6 if z is None else 0),topz(y+.6) if z is None else z))
        if i in fold_edges:
            t=fold_edges[i];xx,yy=points[(i+1)%len(points)]
            fx=x+(xx-x)*t;fy=y+(yy-y)*t+(.6 if z is None else 0)
            if z is None:fold_indices.append(len(ring))
            ring.append((fx,fy,topz(fy) if z is None else z))
    shell_rings.append(ring)
shell=loft('shell_upper',shell_rings,RED,0,cap_break=fold_indices)
# The recess is cut beyond the upper deck and out through both sloping cheeks.
# Its floor meets the frustum flank: the keyboard now interrupts the silhouette.
opening=[(-21.0,-8.12),(-18.0,-8.87),(-13.45,-8.87),(-12.8,-9.38),(9.7,-9.38),(10.4,-8.87),(18.0,-8.87),(21.0,-8.12),(21.0,1.92),(18.1,2.65),(16.35,4.12),(-16.35,4.12),(-18.1,2.65),(-21.0,1.92)]
cut(shell,prism('Keyboard through-cut tool',opening,lambda x,y:wellz(y)-.10,8,None,.16))
cut(shell,cube('Cooling opening tool',(-8.835,10.11,5),(14.5,4.20,3.0),None,.25,rot=(tilt,0,0)))
for bay_y in (7.30,11.10):
    cut(shell,cube('Expansion socket tooling',(9.4*.9,bay_y,4.30),(13.95*.9,2.0,4.4),None,.11))
cut(shell,cube('Side cartridge tool',(20.15,6.65,1.75),(4.5,6.0,1.08),None,.20))
cut(shell,cube('Secondary mini card tool',(-20.15,6.65,1.75),(4.5,6.0,1.45),None,.20))
rear_angle=-math.atan(1.30/(DECK_HEIGHT-1.66))
cut(shell,cube('Rear / connector panel tooling',(0,13.36,2.43),(34.0,1.6,1.63),None,.17,rot=(rear_angle,0,math.pi)))
finish(shell,None,.14,4)

base=cube('shell_lower',(0,0,.63),(38.8,26.3,.72),BASE,.7)
seam=cube('Perimeter / recessed shadow joint',(0,0,1.03),(39.70,27.00,.15),BLACK,.45)
tray_pts=[(-19.18,-8.08),(-18.0,-8.83),(-13.42,-8.83),(-12.78,-9.33),(9.68,-9.33),(10.38,-8.83),(18.0,-8.83),(19.18,-8.08),(18.65,1.93),(17.9,2.60),(16.32,4.08),(-16.32,4.08),(-17.9,2.60),(-18.65,1.93)]
tray=prism('Keyboard / continuous through-cut deck',tray_pts,lambda x,y:wellz(y)-.1,lambda x,y:wellz(y)+.10,GRAPHITE,.12)

# The top louvres return; the two jet outlets now face aft beside the I/O strip.
top_grille=build_top_grille()

# Display beneath the grille: actual inset glass, a flush red lip and legible type.
cube('Display / shadow gasket',(-9.55,6.05,topz(6.05)+.013),(14.50,2.16,.11),BLACK,.25,rot=(tilt,0,0))
screen=cube('status_screen',(-9.55,6.03,topz(6.03)+.07),(14.18,1.90,.045),GLASS,.18,rot=(tilt,0,0))
text('Display / system','ARTISAN OS',(-15.75,6.40,topz(6.40)+.12),.36,DISPLAY,rotation=(tilt,0,0),bold=True)
text('Display / task','READY TO MAKE',(-15.75,5.76,topz(5.76)+.12),.28,DISPLAY,rotation=(tilt,0,0),spacing=1.2)
text('Display / local','LOCAL',(-3.17,6.40,topz(6.40)+.12),.29,DISPLAY,rotation=(tilt,0,0),align='RIGHT')
for i in range(12):
    cube('Display / progress segment',(-6.3+i*.28,5.77,topz(5.77)+.13),(.17,.18,.014),DISPLAY if i<4 else GRAPHITE,.01,rot=(tilt,0,0))

# Shaped keycaps. Each has a rounded skirt and a shallow concave touch surface.
def keycap(name,x,y,w,label,mat=CREAM,secondary=None):
    d=1.65; h=.72; b=wellz(y)+.15
    rings=[]
    for ww,dd,zz,r in [(w,d,b,.16),(w,d,b+.15,.16),(w-.17,d-.17,b+h-.13,.19),(w-.24,d-.24,b+h,.19)]:
        rings.append([(xx+x,yy+y,zz+.063*yy) for xx,yy in roundrect(ww,dd,r,6)])
    for scale,drop in [(.82,.035),(.55,.075),(.20,.09)]:
        ww=(w-.24)*scale;dd=(d-.24)*scale
        rings.append([(xx+x,yy+y,b+h-drop+.063*yy) for xx,yy in roundrect(ww,dd,min(.19*scale,dd/3),6)])
    o=loft(name,rings,mat,.025)
    # A visibly recessed, gently dished centre is formed from a rounded smaller patch.
    # Subtle top dish keeps the larger modifier keys calm in the studio reflections.
    size=.46 if len(label)<3 else .255
    label_obj=text(name+' / legend',label,(x-(w-.40)/2,y+.29,b+h+.025+.063*.29),size,INK,rotation=(keytilt,0,0),bold=len(label)==1)
    LABEL_TARGETS[label_obj.name]=o.name
    if secondary:
        secondary_obj=text(name+' / secondary',secondary,(x+(w-.40)/2,y-.34,b+h+.025-.063*.34),.205,INK,rotation=(keytilt,0,0),align='RIGHT')
        LABEL_TARGETS[secondary_obj.name]=o.name
    if label in ('F','J'):
        cube('Keyboard / tactile home bar '+label,(x,y-.46,b+h-.025),(.35,.065,.035),INK,.015,rot=(keytilt,0,0))
    return o

rows=[
    [('`',1,'~'),('1',1,'!'),('2',1,'@'),('3',1,'#'),('4',1,'$'),('5',1,'%'),('6',1,'^'),('7',1,'&'),('8',1,'*'),('9',1,'('),('0',1,')'),('-',1,'_'),('=',1,'+'),('BACK',1.65,None)],
    [('TAB',1.35,None)]+[(s,1,None) for s in 'QWERTYUIOP']+[('[',1,'{'),(']',1,'}'),('\\',1.30,'|')],
    [('CTRL',1.65,None)]+[(s,1,None) for s in 'ASDFGHJKL']+[(';',1,':'),("'",1,'"'),('RETURN',2,None)],
    [('SHIFT',2.10,None)]+[(s,1,None) for s in 'ZXCVBNM']+[(',',1,'<'),('.',1,'>'),('/',1,'?'),('SHIFT',2.55,None)],
    [('FN',1.10,None),('ALT',1.10,None),('META',1.10,None),('',6.35,None),('ALT',1.10,None),('MENU',1.10,None),('CTRL',1.65,None)]
]
for ri,row in enumerate(rows):
    y=.82-ri*1.91; unit=1.78; x=-16.72
    for ci,(label,u,sec) in enumerate(row):
        w=u*unit-.13
        keycap(f'Key / {ri+1:01}.{ci+1:02} {label or "SPACE"}',x+w/2,y,w,label,CREAM if len(label)<=1 else MOD,sec)
        x+=u*unit

# Five three-actuation orange capsules: left function / centre action / right
# function. Fine grooves divide the press areas while keeping one tactile bar.
for i,label in enumerate(['MAKE','PLAN','BUILD','REVIEW','RUN']):
    x=-15.22+i*3.57;y=3.02;z=wellz(y)+.40
    cube('Function / socket',(x,y,z-.20),(3.22,1.14,.19),BLACK,.25,rot=(keytilt,0,0))
    cap=cube('Function / three-way '+label,(x,y,z),(3.04,.97,.32),ORANGE,.22,rot=(keytilt,0,0))
    for dx in (-.71,.71):
        cube('Function / tactile division',(x+dx,y,z+.163),(.018,.53,.013),RED_DARK,.008,rot=(keytilt,0,0))
    for name,body,dx,size,bold in [('left',f'F{i*2+1}',-1.07,.225,False),('centre',label,0,.24 if label=='REVIEW' else .265,True),('right',f'F{i*2+2}',1.07,.225,False)]:
        legend=text('Function / '+label+' / '+name,body,(x+dx,y,z+.18),size,INK,rotation=(keytilt,0,0),align='CENTER',bold=bold)
        LABEL_TARGETS[legend.name]=cap.name
for x,label in [(4.88,'HOME'),(7.94,'STOP')]:
    z=wellz(3.02)+.37
    cube('Function / '+label,(x,3.02,z),(2.42,.82,.29),MOD,.2,rot=(keytilt,0,0))
    text('Function / legend '+label,label,(x,3.02,z+.16),.23,INK,rotation=(keytilt,0,0),align='CENTER')

# A single graphite monolith: broad pad and four flush tessellated cursor keys.
cx=14.17;cy=-4.40;b=wellz(cy)+.17
cube('Control / monolith gasket',(cx,cy,b),(5.76,7.23,.23),BLACK,.28,rot=(keytilt,0,0))
cube('Control / monolith rim',(cx,cy,b-.023),(5.57,7.04,.15),ALUMINUM,.20,rot=(keytilt,0,0))
control_z=lambda x,y:wellz(y)+.30
pad=[(cx-2.65,-5.42),(cx+2.65,-5.42),(cx+2.65,-1.04),(cx-2.65,-1.04)]
prism('touchpad_surface',pad,lambda x,y:control_z(x,y)-.10,control_z,TOUCH,.075)
# Four individual moving keys, coplanar at rest; the dark cuts are physical gaps.
keys={
 'up':[(-1.40,-5.48),(1.40,-5.48),(.81,-6.61),(-.81,-6.61)],
 'down':[(-.81,-6.69),(.81,-6.69),(1.40,-7.80),(-1.40,-7.80)],
 'left':[(-2.65,-5.48),(-1.49,-5.48),(-.89,-6.65),(-1.49,-7.80),(-2.65,-7.80)],
 'right':[(2.65,-5.48),(1.49,-5.48),(.89,-6.65),(1.49,-7.80),(2.65,-7.80)]}
for kind,pts in keys.items():
    key=group('cursor_'+kind)
    parent(prism('Cursor / '+kind,[(cx+x,y) for x,y in pts],lambda x,y:control_z(x,y)-.11,control_z,TOUCH,.027),key)
    xx,yy={'up':(cx,-5.98),'down':(cx,-7.33),'left':(cx-2.02,-6.63),'right':(cx+2.02,-6.63)}[kind]
    parent(text('Cursor / glyph',{'up':'▲','down':'▼','left':'◀','right':'▶'}[kind],(xx,yy,control_z(xx,yy)+.012),.24,MOD,rotation=(keytilt,0,0),align='CENTER'),key)
for sign in (-1,1):
    cube('Control / touch register',(cx+sign*2.12,-5.14,control_z(cx,-5.14)+.005),(.24,.021,.006),MOD,.003,rot=(keytilt,0,0))
text('Control / monolith label','GESTURE / DIRECTION',(cx,-.72,wellz(-.72)+.14),.18,WHITE,rotation=(keytilt,0,0),align='CENTER')

# Three media keys share the orange row's centreline above the PAUSE rocker.
media_groups=[]
for mx,kind in [(12.40,'previous'),(14.15,'play'),(15.90,'next')]:
    my=3.02;mz=wellz(my)+.37
    cube('Media / socket '+kind,(mx,my,mz-.13),(1.47,1.13,.18),BLACK,.21,rot=(keytilt,0,0))
    g=group('media_'+kind);media_groups.append(g)
    parent(cube('Media / key '+kind,(mx,my,mz),(1.29,.94,.27),MOD,.18,rot=(keytilt,0,0)),g)
    iz=mz+.145
    def icon_bar(name,dx,width,height):
        parent(cube(name,(mx+dx,my,iz),(width,height,.009),INK,.009,rot=(keytilt,0,0)),g)
    def icon_triangle(name,dx,direction=1):
        pts=[(mx+dx+direction*.13,my),(mx+dx-direction*.11,my+.16),(mx+dx-direction*.11,my-.16)]
        parent(prism(name,pts,lambda x,y:iz+.063*(y-my),lambda x,y:iz+.063*(y-my)+.006,INK,.004),g)
    if kind=='previous':icon_triangle('Media / previous triangle',.03,-1);icon_bar('Media / previous stop',-.18,.038,.32)
    elif kind=='next':icon_triangle('Media / next triangle',-.03,1);icon_bar('Media / next stop',.18,.038,.32)
    else:
        icon_triangle('Media / play triangle',-.17,1)
        icon_bar('Media / pause bar one',.13,.041,.29);icon_bar('Media / pause bar two',.24,.041,.29)
    g.location=(mx,my,mz)
    for child in g.children:child.location-=g.location

rx=14.15;ry=.75;rz=wellz(ry)+.37
cube('Pause / sculpted pocket',(rx,ry,rz),(5.65,2.37,.35),BLACK,.55,rot=(keytilt,0,0))
cube('Pause / titanium cradle',(rx,ry,rz+.12),(5.18,2.06,.18),ALUMINUM,.42,rot=(keytilt,0,0))
rocker=group('pause_rocker')
cap=cube('Pause / thumb saddle',(rx,ry,rz+.38),(4.92,1.77,.52),GRAPHITE,.39,rot=(keytilt,0,0));parent(cap,rocker)
apply(cap)
bpy.ops.mesh.primitive_uv_sphere_add(segments=96,ring_count=48,radius=15,location=(rx,ry,rz+15.52))
cut(cap,bpy.context.object)
parent(text('Pause / PAUSE','PAUSE',(rx,ry+.12,rz+.653),.35,WHITE,rotation=(keytilt,0,0),align='CENTER',bold=True,spacing=1.2),rocker)
for x,s in [(rx-1.75,'<'),(rx+1.75,'>')]: parent(text('Pause / direction',s,(x,ry+.12,rz+.66),.34,WHITE,rotation=(keytilt,0,0),align='CENTER'),rocker)
for i in range(9):
    cube('pause_led_'+str(i),(rx+(i-4)*.40,ry-1.03,rz+.23),(.28,.08,.065),AMBER if i==4 else AMBER_DIM,.024,rot=(keytilt,0,0))
# Set the hinge origin at its mechanical axis while preserving child transforms.
rocker.location=(rx,ry,rz+.20)
for ch in rocker.children: ch.location-=rocker.location
text('Pause / transport subtitle','PROPORTIONAL TRANSPORT',(rx,ry+1.33,wellz(ry+1.33)+.12),.17,WHITE,rotation=(keytilt,0,0),align='CENTER',spacing=1.3)

# Deep cartridge wells accept the protective sleeves; paired doors close flush.
for i,y in enumerate([7.30,11.10]):
    x=9.40;z=topz(y)
    bezel=cube(f'Bay {i+1} / recessed bezel',(x,y,z-.010),(15.10,2.80,.17),RED_DARK,.26,rot=(tilt,0,0))
    cut(bezel,cube('Bezel through aperture',(x,y,z),(13.97,1.99,1.2),None,.09))
    mouth=cube(f'Bay {i+1} / mouth',(x,y,z-.019),(14.54,2.20,.17),BLACK,.14,rot=(tilt,0,0))
    cut(mouth,cube('Socket through aperture',(x,y,z+.12),(13.94,1.90,1.2),None,.08))
    cube(f'Bay {i+1} / socket floor',(x,y,z-1.82),(13.85,1.94,.18),BLACK,.10)
    cube(f'Bay {i+1} / edge connector',(x,y,z-.66),(10.60,.50,.36),GRAPHITE,.06)
    for pin in range(82):
        for side in (-1,1):
            cube('Bay / socket contact',(x-4.88+pin*.118,y+side*.23,z-.44),(.072,.055,.16),GOLD,.007)
    text('Bay / numbered index',f'0{i+1}',(17.15,y,topz(y)+.09),.31,WHITE,rotation=(tilt,0,0),align='CENTER',bold=True)
    for dx,dy,w,d in [(0,-.35,.65,.025),(0,.35,.65,.025),(-.325,0,.025,.7),(.325,0,.025,.7)]:
        cube('Bay / index frame',(17.15+dx,y+dy,topz(y+dy)+.06),(w,d,.008),WHITE,0,rot=(tilt,0,0))
    cube('Bay / eject pocket',(17.52,y-1.02,topz(y-1.02)+.02),(.81,.48,.13),BLACK,.15,rot=(tilt,0,0))
    cube('Bay / eject lever',(17.52,y-1.02,topz(y-1.02)+.13),(.53,.31,.16),MOD,.08,rot=(tilt,0,0))

cart=finalize_cartridge(make_vector());cart.name='cartridge_compute'
cart.location=(8.46,7.30,DECK_HEIGHT-.10)
project=finalize_cartridge(make_world());project.name='cartridge_project'
project.location=(18.32,6.65,1.73)
audio_card=finalize_cartridge(make_audio());audio_card.name='cartridge_audio_mini'
audio_card.location=(-18.32,6.65,1.73);audio_card.rotation_euler.z=math.pi
cube('Project / dark sleeve',(19.35,6.65,1.72),(1.60,5.85,1.10),BLACK,.14)
cube('Project / eject recess',(20.05,10.71,1.74),(.18,.94,.59),BLACK,.12)
cube('Project / eject button',(20.13,10.71,1.74),(.12,.63,.34),MOD,.07)

# Rear assemblies respect the narrower upper face of the frustum.
for ob in list(scene.objects):
    if ob.type in ('MESH','FONT') and (ob.name.startswith(('Display /','Bay ','Bay /')) or ob.name=='status_screen'):
        ob.location.x*=.9;ob.scale.x*=.9
bay_hinges=build_bay_doors()

# Product graphics use the physical surfaces as their layout grid. The surface
# projection below transfers these meshes onto the front and side facets.
def shell_type(name,body,x,y,size,mat=WHITE,align='LEFT',bold=False,angle=0,spacing=1.05):
    ob=text(name,body,(x,y,8),size,mat,rotation=(0,0,angle),align=align,bold=bold,spacing=spacing)
    LABEL_TARGETS[ob.name]=shell.name
    return ob
def shell_rule(name,x,y,w,d,mat=WHITE):
    ob=mesh(name,[(x-w/2,y-d/2,8),(x+w/2,y-d/2,8),(x+w/2,y+d/2,8),(x-w/2,y+d/2,8)],[(0,1,2,3)],mat,0,False)
    LABEL_TARGETS[ob.name]=shell.name
    return ob

# A confident model designation, like the large drive labels on Sony's MSX2 era.
shell_type('Shell / family','ARTISAN',-15.9,-11.74,.86,bold=True,spacing=1.10)
shell_type('Shell / family caption','PERSONAL COMPUTER SYSTEM',-15.87,-12.39,.19,spacing=1.12)
shell_type('Shell / product name','ALTO',0,-11.89,1.55,align='CENTER',spacing=1.08)
shell_type('Shell / model line','AT-101',15.90,-11.72,1.03,align='RIGHT',bold=True,spacing=1.02)
shell_type('Shell / edition','2026  /  OPEN ARCHITECTURE',15.85,-12.40,.18,align='RIGHT',spacing=1.1)
shell_rule('Shell / front registration line',0,-12.77,30.9,.018)
for x in (-15.45,15.45):shell_rule('Shell / front registration end',x,-12.70,.018,.16)

shell_type('Hood / family','ARTISAN',-15.08,4.60,.50,bold=True,spacing=1.07)
shell_type('Hood / system class','PERSONAL / AGENTIC / OPEN',-10.65,4.63,.175,spacing=1.07)
shell_type('Hood / model','AT-101 ASX',-2.17,4.60,.49,align='RIGHT',bold=True)

shell_type('Hood / function caption','LOCAL INTELLIGENCE',-14.0,7.50,.19,spacing=1.16)
shell_type('Hood / version caption','AT-101 / 2026',-2.15,7.50,.18,align='RIGHT',spacing=1.1)
# A restrained dot-matrix maker's emblem, recalling the printed marks in the references.
for row in range(4):
    for col in range(4):
        if row+col>=2:
            ob=cyl('Hood / matrix mark',(-15.30+col*.16,7.30+row*.16,topz(7.30+row*.16)+.025),.041,.007,WHITE,12,bevel=0)
# The drive title deliberately lives on the sloping right shoulder.
shell_type('Shoulder / drive title','SSD',18.45,5.25,1.16,bold=True,angle=math.pi/2)
shell_type('Shoulder / drive subtitle','M.2 / PROJECT STORAGE',17.51,5.24,.19,angle=math.pi/2,spacing=1.12)
shell_rule('Shoulder / drive rule',17.16,6.45,.025,2.50)
shell_type('Shoulder / drive index','03',18.60,9.42,.30,bold=True,angle=math.pi/2)

shell_type('Shoulder / IO title','I/O',-18.48,5.80,.93,bold=True,angle=-math.pi/2)
shell_type('Shoulder / IO detail','ASX MINI / 04',-17.53,5.80,.18,angle=-math.pi/2)

# Small control markings belong to the controls they explain.
text('Keyboard / workstation label','ALTO / CONTROL SURFACE',(11.52,-8.15,wellz(-8.15)+.15),.19,WHITE,rotation=(keytilt,0,0),spacing=1.1)
for x in (-18.12,18.12):
    for j in range(3):
        cube('Keyboard / deck register',(x+j*.08,1.02,wellz(1.02)+.115),(.032,.31,.006),MOD,0,rot=(keytilt,0,0))

# All full-size connections sit between the two rear-facing exhausts.
rear=build_rear_panel()

# Service assemblies remain distinct for the interactive release sequence.
exec(compile((ROOT/'modeling/alto_service.py').read_text(),str(ROOT/'modeling/alto_service.py'),'exec'))
service_lid,lid_clamp,heels,service_internals=build_service(shell,base,seam)

# Convert type to geometry; save a clean reusable model with real named components.
for o in list(scene.objects):
    if o.type=='FONT': select(o);bpy.ops.object.convert(target='MESH')
for o in list(scene.objects):
    if o.type=='MESH' and o.modifiers:apply(o)
# Edge blending can lift the boundary of a large n-gon by a few microns.
# Restore the rear datum on the broad deck faces while preserving the radii.
deck_vertices=set()
for face in shell.data.polygons:
    verts=[shell.data.vertices[i] for i in face.vertices]
    if face.area>.3 and face.normal.z>.9999 and all(v.co.y>=DECK_START-.01 and abs(v.co.z-DECK_HEIGHT)<.01 for v in verts):
        deck_vertices.update(face.vertices)
for i in deck_vertices:shell.data.vertices[i].co.z=DECK_HEIGHT
shell.data.update()
normals=shell.modifiers.new('Planar deck normals','WEIGHTED_NORMAL')
normals.keep_sharp=True;normals.weight=50
apply(shell)
bpy.context.view_layer.update()
for label_name,target_name in LABEL_TARGETS.items():
    label=bpy.data.objects.get(label_name);target=bpy.data.objects.get(target_name)
    if not label or not target or label.type!='MESH':continue
    inv=target.matrix_world.inverted();li=label.matrix_world.inverted()
    direction=(inv.to_3x3()@Vector((0,0,-1))).normalized()
    for v in label.data.vertices:
        world=label.matrix_world@v.co
        origin=inv@Vector((world.x,world.y,30))
        hit,loc,normal,_=target.ray_cast(origin,direction)
        if hit:
            p=target.matrix_world@loc;p.z+=.008;v.co=li@p

# Group most static geometry by material to make the browser scene efficient.
# Keep the animated parts and indicator meshes separate and named.
def merge_static():
    buckets={}
    for ob in list(scene.objects):
        if ob.type!='MESH' or ob.parent or ob.name.startswith('pause_led_') or ob.name in ['status_screen','touchpad_surface']:
            continue
        key=ob.data.materials[0].name if ob.data.materials else 'unassigned'
        buckets.setdefault(key,[]).append(ob)
    for key,obs in buckets.items():
        if len(obs)<2: continue
        bpy.ops.object.select_all(action='DESELECT')
        for o in obs:o.select_set(True)
        bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join()
        bpy.context.object.name='Assembly / '+key
merge_static()
# Merge the moving shells by material too, preserving the assembly hinges and LEDs.
for assembly in (cart,project,audio_card,rocker,*media_groups,service_lid,lid_clamp,*heels,service_internals,bpy.data.objects['service_electronics'],top_grille,*bay_hinges):
    buckets={}
    for ob in list(assembly.children):
        if ob.type=='MESH' and not ob.name.startswith('activity_led_'):
            buckets.setdefault(ob.data.materials[0].name,[]).append(ob)
    for material_name,objects in buckets.items():
        if len(objects)<2:continue
        bpy.ops.object.select_all(action='DESELECT')
        for ob in objects:ob.select_set(True)
        bpy.context.view_layer.objects.active=objects[0]
        bpy.ops.object.join()
        bpy.context.object.name=assembly.name+' / '+material_name

model_objects=list(scene.objects)
scene.world.color=(.1,.1,.1)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'modeling/mezzo-master.blend'))
bpy.ops.object.select_all(action='SELECT')
# glTF uses metres; the editable Blender master retains centimetre working units.
for o in model_objects:
    o.location *= .01
    if o.type == 'MESH':
        for v in o.data.vertices: v.co *= .01
bpy.ops.export_scene.gltf(filepath=str(OUT/'mezzo.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_materials='EXPORT',export_extras=True)
for o in model_objects:
    o.location *= 100
    if o.type == 'MESH':
        for v in o.data.vertices: v.co *= 100
bpy.context.view_layer.update()

stats={'objects':len(model_objects),'vertices':sum(len(o.data.vertices) for o in model_objects if o.type=='MESH'),'polygons':sum(len(o.data.polygons) for o in model_objects if o.type=='MESH'),'dimensions_cm':{'width':40.8,'depth':27.8,'body_height':round(DECK_HEIGHT,3)},'design':'ARTISAN ALTO AT-101 — revision 08 / protected cartridges, flush doors and rear jet exhausts','cartridges':['VECTOR','COMPANION','SOUND','ORIGIN','BATT','WORLD','AUDIO PROFESSIONAL'],'accessories':['MIX'],'service':{'lid':'single captive screw and pull clamp','memory':['LPCAMM2 A','LPCAMM2 B'],'heel_positions':['stowed','low','high']},'control_surface':'continuous touchpad and four flush cursor keys','cartridge_interface':{'seated_datum_cm':DECK_HEIGHT-.10,'protective_sleeve_depth_cm':1.50,'paired_flush_doors':True},'cooling':'red top louvres / twin rear-facing jet exhausts','rear_ports':['POWER / USB-C','CARTRIDGE 1 / USB-C','CARTRIDGE 2 / USB-C','USB-C 01','USB-C 02','LAN','HDMI','USB-A','AUDIO / 3.5 mm'],'rear_deck':{'height_cm':DECK_HEIGHT,'start_y_cm':DECK_START,'parallel_to_base':True},'function_keys':[[f'F{i*2+1}',label,f'F{i*2+2}'] for i,label in enumerate(['MAKE','PLAN','BUILD','REVIEW','RUN'])],'references':['https://www.sony.com/en/SonyInfo/design/gallery/HB-101/','https://www.sony.com/en/SonyInfo/design/gallery/WM-20/','https://www.sony.com/en/SonyInfo/design/gallery/WM-2/']}
(ROOT/'modeling/model-info.json').write_text(json.dumps(stats,indent=2))
print('MODEL_EXPORTED',json.dumps(stats),flush=True)

# A separate, reproducible studio scene renders the exact same geometry.
world=bpy.data.worlds.new('Studio / charcoal')
scene.world=world;world.use_nodes=True
world.node_tree.nodes['Background'].inputs['Color'].default_value=(.10,.115,.12,1)
world.node_tree.nodes['Background'].inputs['Strength'].default_value=.3
floor=cube('Studio / floor',(0,0,-.20),(2000,2000,.10),material('Studio / floor finish','#242D25',.44),.0)
def area(name,location,power,size,color,target=(0,0,2),size_y=None):
    data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=color
    data.shape='RECTANGLE';data.size=size;data.size_y=size_y or size
    o=bpy.data.objects.new(name,data);bpy.context.collection.objects.link(o);o.location=location;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();return o
area('Studio / key softbox',(-24,-16,42),21000,24,(1,.95,.89),size_y=10)
area('Studio / long strip',(25,8,32),18000,4,(.91,.96,1),size_y=30)
area('Studio / rear rim',(-10,28,23),23000,28,(1,.97,.92),size_y=4)
area('Studio / front fill',(8,-32,16),4300,20,(.93,.97,1),size_y=12)
cam_data=bpy.data.cameras.new('Studio camera');cam=bpy.data.objects.new('Studio camera',cam_data);bpy.context.collection.objects.link(cam)
scene.camera=cam
cam_data.type='ORTHO';cam_data.ortho_scale=57
def camera(loc,target=(0,0,2.5),scale=57):
    cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam_data.ortho_scale=scale
camera((39,-51,43))
scene.render.engine='CYCLES';scene.cycles.samples=48;scene.cycles.use_denoising=True
scene.render.resolution_x=1600;scene.render.resolution_y=1200;scene.render.resolution_percentage=100
scene.render.image_settings.file_format='PNG';scene.render.film_transparent=False
scene.view_settings.view_transform='AgX'
scene.view_settings.look='AgX - Medium High Contrast'
scene.view_settings.exposure=-.6
scene.render.filepath=str(IMG/'mezzo-studio.png')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'modeling/mezzo-studio.blend'))
if '--no-render' not in sys.argv:
    bpy.ops.render.render(write_still=True)
    print('HERO_RENDERED',flush=True)
