pixalynx/pixal-gps

These files define two separate printed circuit boards: a small, two-layer battery cartridge with protection circuitry and contact pads for a pouch cell, and a larger, two-layer wireless charging dock featuring USB-C input, a resonant coil, and wireless power transmission components.

Version
0.1.2
License
unset
Stars
0

simulator/src/main.js

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/500.css';
import '@fontsource/dm-sans/600.css';
import '@fontsource/ibm-plex-mono/400.css';
import data from './hardware.json';
import './style.css';

const $=s=>document.querySelector(s);
$('#app').innerHTML=`
<header><a class="brand" href="#"><span class="brandmark">p<span>•</span></span> PIXAL<span class="brand-light">GPS</span></a><span class="header-note">PRODUCT STUDIO / REV A</span><button id="about">Design notes ↗</button></header>
<main><section class="intro"><div><p class="eyebrow">SMALL DEVICE. BIG WORLD.</p><h1>Always within reach.</h1><p class="subtitle">A closer look at your tracker, removable battery and wireless dock.</p></div><div class="intro-meta"><span class="status-dot"></span> Mechanical concept <span class="slash">/</span> Actual PCB geometry</div></section>
<div class="workspace"><section class="stage" aria-label="Interactive 3D product"><div class="stage-top"><div class="tabs" role="group" aria-label="Product view"><button data-view="system" class="active">Complete kit</button><button data-view="tracker">Tracker</button><button data-view="battery">Battery</button><button data-view="dock">Dock</button></div><button id="rotate" class="icon-btn" aria-pressed="false" title="Rotate product">↻ <span>Turntable</span></button></div><div id="canvas"></div><div class="stage-caption"><span id="scene-title">01 / THE COMPLETE KIT</span><span>Drag to orbit · Scroll to zoom</span></div><div id="loading">Preparing your product…</div></section>
<aside><div class="panel-label">EXPLORE THE BUILD <span>01—03</span></div><h2 id="panel-title">Made to stay with you.</h2><p id="panel-copy">Three coordinated boards. One pocket-sized tracker with a replaceable power source and a place to recharge.</p><div class="specs" id="specs"></div><div class="divider"></div><div class="row"><h3>Look inside</h3><label class="switch"><input id="cutaway" type="checkbox"><span></span><b>Cutaway</b></label></div><label class="check"><input id="labels" type="checkbox" checked> Part labels</label><label class="check"><input id="measurements" type="checkbox"> Measurements</label><div id="measure-controls" hidden><select id="measure-piece" aria-label="Measured piece"><option value="all">All visible pieces</option><option value="cell">Battery cell</option><option value="pack">Protection / contact PCB</option><option value="pcb">Tracker PCB</option><option value="tray">Cartridge envelope</option><option value="receiver">Receiver coil</option><option value="trackerShell">Tracker enclosure</option><option value="dockPcb">Dock PCB</option><option value="tx">Transmitter coil</option><option value="dockShell">Dock enclosure</option></select><p id="measure-note">Dimensions in mm · Select a piece for a clear view.</p></div><div class="divider"></div><h3>In motion</h3><div class="motion-grid"><button id="assembly">▷ Build assembly</button><button id="explode">↗ Exploded view</button><button id="swap">⇄ Swap battery</button><button id="charge">⌁ Dock & charge</button></div><div class="sequence"><div class="row"><span id="step">Assembled</span><span id="percent">100%</span></div><input id="progress" aria-label="Assembly progress" type="range" min="0" max="1" step="0.001" value="1"><div class="row playback"><button id="pause" disabled>Pause</button><span id="sequence-time">ASSEMBLY / 18 SEC</span></div></div><div class="exports"><button id="png">↓ Save render</button><button id="video">↓ Record animation</button><button id="glb">↓ 3D model</button></div><p class="small-note" id="notice">Enclosure, antenna envelopes and cell fit are inferred. PCB placement is sourced from your tscircuit exports.</p></aside></div>
<section class="parts-strip"><article><span class="number">01</span><div><h3>Tracker</h3><p>nRF9151 · GNSS + LTE-M / NB-IoT</p></div><span class="dimension">34 × 24 mm PCB</span></article><article><span class="number">02</span><div><h3>Battery cartridge</h3><p>503040 cell · Four spring contacts</p></div><span class="dimension">30 × 7 mm PCB</span></article><article><span class="number">03</span><div><h3>Wireless dock</h3><p>USB-C · Qi transmitter + A11 coil</p></div><span class="dimension">56 × 44 mm PCB</span></article></section>
<footer><span>PIXAL / ENGINEERED TO EXPLORE</span><span>Visual study · Not a manufacturing model</span></footer></main>
<dialog><button id="close" aria-label="Close design notes">×</button><p class="eyebrow">MODEL BASIS</p><h2>Real boards. Proposed packaging.</h2><p>The board outlines, fitted component locations, exposed pads, outer-layer traces and drill positions come from the existing three Circuit JSON exports. Component bodies are simplified dimensional envelopes; source land bounds are used where a body drawing is unavailable. DNP parts are omitted.</p><p>The proposed tracker is 42 × 60 × 18 mm. The cartridge is approximately 35 × 57 × 8 mm and is offset 1.5 mm to align its four pads with the tracker pogo row. The dock is 64 × 64 × 12 mm to accommodate the 50 mm coil.</p><p>The 5.4 × 30.5 × 40.5 mm cell and 30 mm receiver coil follow the project notes. Antennas are provisional FPC envelopes, not selected or tuned parts. Rail, latch, spring preload, cell swelling, coil clearance, fasteners and access need mechanical qualification. The exchange animation illustrates an intended service motion.</p><p>The receiver coil stays in the tracker, below the cartridge, with ferrite toward the battery. Dock ferrite faces the dock PCB. Light pulses represent charging activity; this is not an electromagnetic or battery simulation.</p><p>The source says 30 × 7 mm for PCB B; older documentation says 26 × 7 mm. This model follows the source/export. A separate fabrication review is saved beside the project.</p></dialog>`;

const renderer=new THREE.WebGLRenderer({antialias:true,alpha:false,preserveDrawingBuffer:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setClearColor('#e6e9e5');renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFShadowMap;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.15;$('#canvas').appendChild(renderer.domElement);renderer.domElement.setAttribute('aria-label','PIXAL GPS interactive 3D render');
const scene=new THREE.Scene(),camera=new THREE.PerspectiveCamera(34,1,.1,1500);camera.position.set(130,120,145);
const controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true;controls.minDistance=35;controls.maxDistance=400;controls.maxPolarAngle=Math.PI*.94;controls.target.set(0,9,0);
const pmrem=new THREE.PMREMGenerator(renderer);scene.environment=pmrem.fromScene(new RoomEnvironment(),.04).texture;
scene.add(new THREE.HemisphereLight('#ffffff','#53675d',2));
const sun=new THREE.DirectionalLight('#fff9ed',3.5);sun.position.set(-70,160,90);sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);Object.assign(sun.shadow.camera,{left:-130,right:130,top:150,bottom:-130,near:1,far:400});sun.shadow.normalBias=.04;scene.add(sun);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(2000,2000),new THREE.MeshStandardMaterial({color:'#e6e9e5',roughness:1}));ground.rotation.x=-Math.PI/2;ground.position.y=-.15;ground.receiveShadow=true;scene.add(ground);
const product=new THREE.Group();scene.add(product);
const mats={shell:new THREE.MeshStandardMaterial({color:'#242c2c',roughness:.47,metalness:.14}),edge:new THREE.MeshStandardMaterial({color:'#455453',roughness:.45,metalness:.2}),teal:new THREE.MeshStandardMaterial({color:'#81e0bd',roughness:.38,metalness:.25}),rubber:new THREE.MeshStandardMaterial({color:'#111a18',roughness:.92}),pcb:new THREE.MeshStandardMaterial({color:'#123e34',roughness:.65}),gold:new THREE.MeshStandardMaterial({color:'#cfad62',metalness:.78,roughness:.26}),silver:new THREE.MeshStandardMaterial({color:'#d1d5d4',metalness:.78,roughness:.31}),chip:new THREE.MeshStandardMaterial({color:'#222729',roughness:.67}),cap:new THREE.MeshStandardMaterial({color:'#8f8060',roughness:.61}),copper:new THREE.MeshStandardMaterial({color:'#b8753d',metalness:.75,roughness:.32}),ferrite:new THREE.MeshStandardMaterial({color:'#444b4e',roughness:.85}),white:new THREE.MeshStandardMaterial({color:'#ede9db',roughness:.5}),trace:new THREE.MeshStandardMaterial({color:'#327261',metalness:.3,roughness:.55})};
function group(parent,name,pos=[0,0,0]){let g=new THREE.Group();g.name=name;g.position.set(...pos);parent.add(g);return g;}
function mesh(parent,geo,mat,pos=[0,0,0]){let m=new THREE.Mesh(geo,mat);m.position.set(...pos);m.castShadow=true;m.receiveShadow=true;parent.add(m);return m;}
function box(parent,w,h,d,mat,pos=[0,0,0],r=.15){return mesh(parent,new RoundedBoxGeometry(w,h,d,2,Math.min(r,w/3,h/3,d/3)),mat,pos);}
function cyl(parent,r,h,mat,pos=[0,0,0],segments=40){return mesh(parent,new THREE.CylinderGeometry(r,r,h,segments),mat,pos);}
function textPlane(parent,text,w,h,pos,fg='#e1ebe5',bg=null){const c=document.createElement('canvas');c.width=1024;c.height=Math.round(1024*h/w);const ctx=c.getContext('2d');if(bg){ctx.fillStyle=bg;ctx.fillRect(0,0,c.width,c.height);}ctx.fillStyle=fg;ctx.textAlign='center';ctx.textBaseline='middle';ctx.font=`500 ${Math.floor(c.height*.55)}px sans-serif`;ctx.fillText(text,c.width/2,c.height/2);const tex=new THREE.CanvasTexture(c);tex.colorSpace=THREE.SRGBColorSpace;const m=mesh(parent,new THREE.PlaneGeometry(w,h),new THREE.MeshBasicMaterial({map:tex,transparent:true,depthWrite:false}),pos);m.rotation.x=-Math.PI/2;return m;}
function wire(parent,pts,r=.16,mat=mats.chip){return mesh(parent,new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts.map(p=>new THREE.Vector3(...p))),40,r,7,false),mat);}
function ringShape(w,d,r){const s=new THREE.Shape();s.moveTo(-w/2+r,-d/2);s.lineTo(w/2-r,-d/2);s.quadraticCurveTo(w/2,-d/2,w/2,-d/2+r);s.lineTo(w/2,d/2-r);s.quadraticCurveTo(w/2,d/2,w/2-r,d/2);s.lineTo(-w/2+r,d/2);s.quadraticCurveTo(-w/2,d/2,-w/2,d/2-r);s.lineTo(-w/2,-d/2+r);s.quadraticCurveTo(-w/2,-d/2,-w/2+r,-d/2);return s;}
function rim(parent,w,d,h,thick,y,mat=mats.shell){const s=ringShape(w,d,4);const inner=ringShape(w-2*thick,d-2*thick,3);s.holes.push(new THREE.Path(inner.getPoints().reverse()));const geo=new THREE.ExtrudeGeometry(s,{depth:h,bevelEnabled:false,curveSegments:20});geo.rotateX(-Math.PI/2);return mesh(parent,geo,mat,[0,y,0]);}
const labels=[];const dimensionSets=[];
function label(parent,txt,pos){const el=document.createElement('div');el.className='part-label';el.textContent=txt;$('#canvas').appendChild(el);const anchor=group(parent,'label',pos);labels.push({el,anchor});}
function board(parent,key){const d=data[key],b=d.find(e=>e.type==='pcb_board'),g=group(parent,key+' PCB');const s=new THREE.Shape(b.outline.map(p=>new THREE.Vector2(p.x,-p.y)));
 for(const h of d.filter(e=>e.type==='pcb_hole'||e.type==='pcb_plated_hole')){let p=new THREE.Path();const radius=(h.hole_diameter||h.hole_width||.4)/2;p.absarc(h.x,-h.y,radius,0,Math.PI*2,true);s.holes.push(p);}
 const geo=new THREE.ExtrudeGeometry(s,{depth:b.thickness,bevelEnabled:false});geo.rotateX(Math.PI/2);geo.translate(0,b.thickness/2,0); // shape y=-PCB.y becomes world z=-PCB.y
 mesh(g,geo,mats.pcb);
 const batched={gold:[],trace:[]};
 for(const p of d.filter(e=>e.type==='pcb_smtpad')){let geo=p.shape==='circle'?new THREE.CylinderGeometry(p.radius,p.radius,.018,16):new THREE.BoxGeometry(p.width,.018,p.height);if(p.ccw_rotation)geo.rotateY(p.ccw_rotation*Math.PI/180);geo.translate(p.x,(p.layer==='bottom'?-1:1)*(b.thickness/2+.015),-p.y);batched.gold.push(geo);}
 for(const t of d.filter(e=>e.type==='pcb_trace'))for(let i=1;i<t.route.length;i++){const a=t.route[i-1],c=t.route[i];if(a.route_type!=='wire'||c.route_type!=='wire'||a.layer!==c.layer||!['top','bottom'].includes(a.layer))continue;const len=Math.hypot(c.x-a.x,c.y-a.y);if(len<.001)continue;const geo=new THREE.BoxGeometry(c.width||a.width,.009,len);geo.rotateY(Math.atan2(c.x-a.x,-(c.y-a.y)));geo.translate((a.x+c.x)/2,(a.layer==='bottom'?-1:1)*(b.thickness/2+.012),-(a.y+c.y)/2);batched.trace.push(geo);}
 for(const [k,list] of Object.entries(batched))if(list.length)mesh(g,mergeGeometries(list),mats[k]);
 for(const v of d.filter(e=>e.type==='pcb_via')){for(const side of [-1,1]){const m=mesh(g,new THREE.RingGeometry((v.hole_diameter||.2)/2,(v.outer_diameter||.4)/2,10),mats.gold,[v.x,side*(b.thickness/2+.025),-v.y]);m.rotation.x=-side*Math.PI/2;}}
 const sources=new Map(d.filter(e=>e.type==='source_component').map(c=>[c.source_component_id,c]));
 for(const c of d.filter(e=>e.type==='pcb_component'&&!e.do_not_place)){const src=sources.get(c.source_component_id);const n=src.name;const part=group(g,n);part.userData={reference:n,part:src.manufacturer_part_number||src.ftype};const bottom=c.layer==='bottom',sign=bottom?-1:1;part.position.set(c.center.x,sign*b.thickness/2,-c.center.y);if(bottom)part.rotation.z=Math.PI;
 // Component extents from Circuit JSON are axis aligned; avoid rotating those bounds twice.
 const w=Math.max(.3,c.width*.76),dpt=Math.max(.3,c.height*.76);
 if(/^TP|^T\d|^P\d/.test(n)){if(key==='tracker'&&/^P[1-4]$/.test(n)){cyl(part,.94,1.35,mats.gold,[0,.67,0]);cyl(part,.52,1.15,mats.gold,[0,1.925,0]);}continue;}
 if(key==='tracker'&&n==='U1'){box(part,12.1,1.05,11.1,mats.silver,[0,.53,0]);textPlane(part,'NORDIC',8,1.6,[0,1.06,-1],'#37443f');textPlane(part,'nRF9151',7,1.4,[0,1.07,1],'#37443f');}
 else if(key==='tracker'&&/^J[12]$/.test(n)){box(part,2.8,.5,2.8,mats.white,[0,.25,0]);cyl(part,1,.7,mats.silver,[0,.8,0]);cyl(part,.3,.75,mats.gold,[0,.82,0]);}
 else if(key==='tracker'&&n==='J3'){box(part,w,1.25,dpt,mats.silver,[0,.625,0]);box(part,w*.85,.3,dpt*.76,mats.chip,[0,1.3,0]);}
 else if(key==='dock'&&n==='J1'){box(part,w,2.8,dpt,mats.silver,[0,1.4,0]);box(part,.15,1.8,6.8,mats.chip,[-w/2-.02,1.4,0]);}
 else{let h=/^U|^Q/.test(n)?.75:/^L|^FB/.test(n)?1:.45;let mat=/^C/.test(n)?mats.cap:/^LED/.test(n)?mats.teal:mats.chip;box(part,w,h,dpt,mat,[0,h/2,0],.05);if(/^R|^C|^L|^FB/.test(n)){box(part,w*.16,h+.02,dpt+.015,mats.silver,[-w*.42,h/2,0],.02);box(part,w*.16,h+.02,dpt+.015,mats.silver,[w*.42,h/2,0],.02);}if(/^U/.test(n))textPlane(part,src.manufacturer_part_number?.split('-')[0]||n,w*.85,Math.min(dpt*.6,1),[0,h+.02,0]);}
 }
 return g;
}
function coil(parent,diameter,y,receiver=false){const g=group(parent,receiver?'Receiver coil':'Transmitter coil',[0,y,0]);if(receiver)box(g,30,.55,29.6,mats.ferrite,[0,.6,0],.4);else cyl(g,25,.8,mats.ferrite,[0,-.9,0]);const pts=[],turns=receiver?11:13;for(let i=0;i<turns*100;i++){const t=i/100*Math.PI*2,r=diameter*.23+(diameter*.25)*i/(turns*100);pts.push(new THREE.Vector3(Math.cos(t)*r,0,Math.sin(t)*r));}mesh(g,new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),turns*100,receiver?.26:.42,7,false),mats.copper);return g;}
const tracker=group(product,'Tracker'),dock=group(product,'Charging dock'),battery=group(tracker,'Removable battery cartridge');
const rear=group(tracker,'Tracker rear shell');box(rear,42,1.2,60,mats.shell,[0,.6,0],.6);const rearDetail=box(rear,30,.06,30,mats.edge,[0,.005,2],.02);
const receiver=coil(tracker,29,2.1,true);receiver.position.z=2;
const frame=group(tracker,'Tracker structural frame');
// Open-ended rails permit the proposed longitudinal cartridge exchange.
box(frame,1.4,15.25,56,mats.shell,[-20.3,9.125,-1],.6);box(frame,1.4,15.25,56,mats.shell,[20.3,9.125,-1],.6);box(frame,40,15.25,1.4,mats.shell,[0,9.125,-29.3],.6);
for(const x of [-18.2,18.2])box(frame,1.2,1,46,mats.edge,[x,3.5,0],.2);
box(frame,40,5.8,1.4,mats.shell,[0,13.85,29.3],.6);
const tray=group(battery,'Cartridge sled');box(tray,35,.65,6,mats.edge,[1.5,3.5,26],.3);box(tray,35,.65,50.8,mats.edge,[1.5,3.5,-2],.3);box(tray,35,7.8,1.6,mats.shell,[1.5,7.1,29.15],.6);box(tray,12,1.4,1,mats.teal,[1.5,7,30.0],.3);
for(const x of [-15.5,18.5])box(tray,.8,5.5,48,mats.edge,[x,6.4,-1],.3);
const cell=group(battery,'600mAh pouch cell',[1.5,6.6,1.7]);box(cell,30.5,5.4,40.5,mats.silver,[0,0,0],.5);box(cell,29.5,.06,36,mats.white,[0,2.73,0],.02);textPlane(cell,'PIXAL  /  600 mAh',25,4,[0,2.79,0],'#34423c');textPlane(cell,'Li-ion 3.7 V · 503040',25,2.3,[0,2.79,5],'#57655e');
const pack=board(battery,'battery-pack');pack.position.set(1.5,9,-23.5);
for(const x of [-11.9,-9.1])box(battery,1.7,.12,6,mats.gold,[x,8.8,-19],.03);
const pcb=board(tracker,'tracker');pcb.position.set(0,12.7,-13);
const antennas=group(tracker,'Provisional LTE and GNSS FPC antennas');box(antennas,30,.2,7,mats.gold,[0,14.1,17],.1);box(antennas,14,.2,8,mats.gold,[9,14.1,5.5],.1);
for(let i=0;i<7;i++)box(antennas,.45,.03,5,mats.chip,[-12+i*3.8,14.22,17],.01);
textPlane(antennas,'LTE',8,1.6,[-2,14.24,17],'#343c35');textPlane(antennas,'GNSS',9,1.7,[9,14.24,5.5],'#343c35');
wire(antennas,[[-3,14,-6],[-15,14.3,0],[-14,14.3,10],[-8,14.3,17]],.23);wire(antennas,[[10,14,-4],[15,14.3,0],[13,14.3,5]],.22);
const lid=group(tracker,'Tracker top cover');box(lid,42,1.25,60,mats.shell,[0,17.375,0],.6);textPlane(lid,'pixal',29,10,[0,18.02,-2]);textPlane(lid,'GPS',12,2.3,[0,18.03,5],'#97aba2');box(lid,7,.12,1.1,mats.teal,[0,18.08,-20],.05);box(lid,3,.7,1.3,mats.rubber,[15,16.5,-27],.3);
const screws=group(tracker,'Cover fasteners');for(const x of [-18,18])for(const z of [-27,27]){cyl(screws,.8,.35,mats.silver,[x,18.04,z],20);box(screws,.85,.04,.18,mats.chip,[x,18.24,z],.01);}
const dockBase=group(dock,'Dock base');box(dockBase,64,1.6,64,mats.shell,[0,.9,0],.75);for(const x of [-24,24])for(const z of [-24,24])cyl(dockBase,3,.5,mats.rubber,[x,.1,z]);
const dockFrame=group(dock,'Dock perimeter'); // left wall split at actual USB-C insertion location z=+6
box(dockFrame,1.5,8.8,32,mats.shell,[-31.25,6.1,-16],.6);box(dockFrame,1.5,8.8,18,mats.shell,[-31.25,6.1,23],.6);box(dockFrame,1.5,3,14,mats.shell,[-31.25,10,7],.5);
box(dockFrame,1.5,8.8,64,mats.shell,[31.25,6.1,0],.6);for(const z of [-31.25,31.25])box(dockFrame,61,8.8,1.5,mats.shell,[0,6.1,z],.6);
const dockPcb=board(dock,'dock');dockPcb.position.y=3.3;const tx=coil(dock,49,8.5,false);
const dockLid=group(dock,'Dock charging surface');box(dockLid,64,1.2,64,mats.shell,[0,11.4,0],.6);box(dockLid,44,.35,61,mats.rubber,[0,12.15,0],.17);textPlane(dockLid,'PIXAL',12,2.8,[25,12.03,1],'#b5c5bc').rotation.z=Math.PI/2;box(dockLid,1,.16,8,mats.teal,[-26,12.07,0],.08);
for(const x of [-22.7,22.7])box(dockLid,1.1,1.6,48,mats.edge,[x,12.7,0],.5);
const dockScrews=group(dock,'Dock screws');for(const x of [-24,24])for(const z of [-18,18]){cyl(dockScrews,1.2,2,mats.silver,[x,4,z]);}
// Lead path is a concept harness; the connector itself remains at its exported PCB location.
wire(dockFrame,[[-27,4.7,6],[-34,4.7,6],[-44,2.6,8],[-52,.8,25],[-70,.8,32]],1.2,mats.rubber);box(dockFrame,9,3.5,7.5,mats.rubber,[-32,4.7,6],.7);
const chargeRings=group(dock,'Charging indicator');for(let i=0;i<3;i++){const m=mesh(chargeRings,new THREE.TorusGeometry(17+i*2.6,.12,6,80),new THREE.MeshBasicMaterial({color:'#58bfa0',transparent:true,opacity:.45}));m.rotation.x=Math.PI/2;}
label(pcb,'01 · TRACKER PCB',[18,1.5,-10]);label(cell,'02 · 600 mAh CELL',[18,1,5]);label(pack,'03 · CONTACT / PROTECTION PCB',[17,1,0]);label(receiver,'04 · Qi RECEIVER',[17,0,5]);label(dockPcb,'05 · DOCK PCB',[30,1,-18]);label(tx,'06 · A11 TRANSMITTER',[27,1,6]);label(antennas,'07 · ANTENNA ENVELOPES',[18,15,17]);

function dimensions(parent,key,desc,size,center=[0,0,0],inferred=false){
 const g=group(parent,desc+' dimensions',center),[w,h,d]=size;
 const mat=new THREE.LineBasicMaterial({color:inferred?'#ac773b':'#226657',depthTest:false,transparent:true,opacity:.82});
 const line=(pts)=>{const geo=new THREE.BufferGeometry().setFromPoints(pts.map(p=>new THREE.Vector3(...p)));const l=new THREE.Line(geo,mat);l.renderOrder=10;g.add(l);};
 const tag=(txt,p)=>{const el=document.createElement('div');el.className='dimension-label'+(inferred?' inferred':'');el.textContent=txt;$('#canvas').appendChild(el);const anchor=group(g,'dimension label',p);labels.push({el,anchor,measurement:true});};
 const y=h/2+1.1,z=d/2+5,x=w/2+5;
 line([[-w/2,y,d/2],[-w/2,y,z+1.5]]);line([[w/2,y,d/2],[w/2,y,z+1.5]]);line([[-w/2,y,z],[w/2,y,z]]);
 for(const xx of [-w/2,w/2])line([[xx-1,y,z-1],[xx+1,y,z+1]]);
 tag(`${inferred?'≈ ':''}${w} mm`,[0,y,z]);
 line([[w/2,y,-d/2],[x+1.5,y,-d/2]]);line([[w/2,y,d/2],[x+1.5,y,d/2]]);line([[x,y,-d/2],[x,y,d/2]]);
 for(const zz of [-d/2,d/2])line([[x-1,y,zz-1],[x+1,y,zz+1]]);
 tag(`${inferred?'≈ ':''}${d} mm`,[x,y,0]);
 line([[-w/2-3,-h/2,-d/2],[-w/2-3,h/2,-d/2]]);line([[-w/2-4,-h/2,-d/2],[-w/2,-h/2,-d/2]]);line([[-w/2-4,h/2,-d/2],[-w/2,h/2,-d/2]]);
 tag(`${h} mm`,[-w/2-5,0,-d/2]);dimensionSets.push({g,key,desc,size,inferred});
}
dimensions(cell,'cell','Battery cell',[30.5,5.4,40.5]);
dimensions(pack,'pack','Protection PCB',[30,.8,7]);
dimensions(pcb,'pcb','Tracker PCB',[34,1.6,24]);
dimensions(tray,'tray','Cartridge envelope',[35,8,57],[1.5,7.3,1],true);
dimensions(receiver,'receiver','Receiver coil',[30,1,29.6],[0,.3,0]);
dimensions(lid,'trackerShell','Tracker enclosure',[42,18,60],[0,9,0],true);
dimensions(dockPcb,'dockPcb','Dock PCB',[56,1.6,44]);
dimensions(tx,'tx','Transmitter coil',[50,3,50],[0,-.5,0]);
dimensions(dockLid,'dockShell','Dock enclosure',[64,12,64],[0,6,0],true);
const moving=[{g:dockBase,axis:'y',delta:0,start:0},{g:dockFrame,axis:'y',delta:5,start:0},{g:dockPcb,axis:'y',delta:14,start:.06},{g:dockScrews,axis:'y',delta:18,start:.12},{g:tx,axis:'y',delta:27,start:.17},{g:dockLid,axis:'y',delta:38,start:.24},{g:rear,axis:'y',delta:0,start:0},{g:receiver,axis:'y',delta:13,start:.32},{g:frame,axis:'y',delta:17,start:.37},{g:tray,axis:'y',delta:24,start:.42},{g:cell,axis:'y',delta:36,start:.49},{g:pack,axis:'y',delta:44,start:.55},{g:pcb,axis:'y',delta:59,start:.63},{g:antennas,axis:'y',delta:70,start:.7},{g:lid,axis:'y',delta:82,start:.79},{g:screws,axis:'y',delta:90,start:.87}];moving.forEach(x=>x.base=x.g.position.clone());
const state={view:'system',progress:1,cutaway:false,labels:true,motion:null,time:0,paused:false,turntable:false,swap:0,dock:0,recording:false,measurements:false,measurePiece:"all"};
const views={system:{title:'Made to stay with you.',copy:'Three coordinated boards. One pocket-sized tracker with a replaceable power source and a place to recharge.',specs:[['Connectivity','LTE-M / NB-IoT + GNSS'],['Power','600 mAh · Qi charging'],['Product envelope','42 × 60 × 18 mm¹']],eye:[132,112,150],target:[0,10,0]},tracker:{title:'Small. Connected. Yours.',copy:'Nordic nRF9151 handles location and cellular connectivity. The nPM1300 manages charging and the replaceable battery.',specs:[['Main board','34 × 24 mm · 4 layers'],['Motion','LIS2DW12 accelerometer'],['Antennas','Separate LTE + GNSS¹']],eye:[70,76,94],target:[0,10,0]},battery:{title:'A fresh start, in seconds.',copy:'A protected pouch cell and compact interface board form a removable cartridge with BAT+, GND, NTC and ID contacts.',specs:[['Interface PCB','30 × 7 × 0.8 mm'],['Cell envelope','30.5 × 40.5 × 5.4 mm'],['Interface','4 contacts · 3 mm pitch']],eye:[56,68,78],target:[0,7,0]},dock:{title:'Set it down. Power up.',copy:'USB-C supplies the IP6829 transmitter. Its A11 coil sits above the board, with ferrite shielding facing the electronics.',specs:[['Dock PCB','56 × 44 mm · 2 layers'],['Transmitter coil','Ø50 mm A11'],['Dock envelope','64 × 64 × 12 mm¹']],eye:[90,100,106],target:[0,7,0]}};
let cameraGoal=null;
function fit(exploded=false){const v=views[state.view];cameraGoal={eye:new THREE.Vector3(...v.eye),target:new THREE.Vector3(...v.target)};cameraGoal.eye.multiplyScalar(Math.max(1,1.18/camera.aspect));if(exploded){cameraGoal.eye.multiplyScalar(1.38);cameraGoal.target.y=40;}}
function setView(view){state.view=view;state.motion=null;state.swap=0;state.dock=0;state.progress=1;state.paused=false;$('#progress').value=1;$('.tabs .active')?.classList.remove('active');$(`[data-view="${view}"]`).classList.add('active');const v=views[view];$('#panel-title').textContent=v.title;$('#panel-copy').textContent=v.copy;$('#specs').innerHTML=v.specs.map(s=>`<div><span>${s[0]}</span><b>${s[1]}</b></div>`).join('');$('#scene-title').textContent={system:'01 / THE COMPLETE KIT',tracker:'02 / TRACKER',battery:'03 / BATTERY CARTRIDGE',dock:'04 / WIRELESS DOCK'}[view];$('#swap').disabled=view==='dock';$('#charge').disabled=view==='battery';fit();updateUI();}
function smooth(x){x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);}
function apply(){const p=state.progress;moving.forEach(m=>{m.g.position.copy(m.base);m.g.position[m.axis]+=m.delta*(1-smooth((p-m.start)/.13));});
 tracker.position.set(state.view==='system'?-32:0,0,state.view==='system'?2:0);dock.position.set(state.view==='system'?33:0,0,state.view==='system'?-5:0);tracker.visible=state.view!=='dock';dock.visible=state.view==='system'||state.view==='dock';battery.visible=state.view!=='dock';
 for(const g of [rear,receiver,frame,pcb,antennas,lid,screws])g.visible=state.view!=='battery';
 if(state.view==='battery'){tracker.position.set(-1.5,0,0);}
 const interior=state.cutaway||p<.995||state.view==='battery';lid.visible=lid.visible&&!state.cutaway;frame.visible=frame.visible&&!state.cutaway;dockFrame.visible=!state.cutaway;dockLid.visible=!state.cutaway; screws.visible=screws.visible&&!state.cutaway;
 battery.position.set(0,0,state.swap*65);
 if(state.dock>0){tracker.position.x=THREE.MathUtils.lerp(-32,33,state.dock);tracker.position.z=THREE.MathUtils.lerp(2,-3,state.dock);tracker.position.y=13.3*state.dock+Math.sin(state.dock*Math.PI)*35;}
 chargeRings.visible=state.motion==='charge'&&state.time>4;chargeRings.children.forEach((m,i)=>{m.position.y=13+(state.time*7+i*4)%16;m.material.opacity=.28*(1-((state.time*7+i*4)%16)/16);});
 dimensionSets.forEach(m=>m.g.visible=state.measurements&&(state.measurePiece==='all'||state.measurePiece===m.key));
 for(const {el,anchor,measurement} of labels){let visible=measurement?state.measurements:state.labels&&interior&&!state.measurements;let a=anchor;while(a){if(!a.visible)visible=false;a=a.parent;}el.style.display=visible?'block':'none';if(visible){const v=anchor.getWorldPosition(new THREE.Vector3()).project(camera);el.style.left=`${(v.x*.5+.5)*$('#canvas').clientWidth}px`;el.style.top=`${(-v.y*.5+.5)*$('#canvas').clientHeight}px`;if(v.z>1||v.z<0)el.style.display='none';}}
}
const steps=['Dock foundation','Dock PCB & fasteners','Transmitter & dock lid','Receiver coil & frame','Cartridge sled','Pouch cell & protection','Tracker PCB & contacts','LTE / GNSS antennas','Cover & fasteners','Assembly complete'];
function updateUI(){ $('#progress').value=state.progress;$('#percent').textContent=Math.round(state.progress*100)+'%';$('#step').textContent=state.motion==='swap'?(state.time<4?'Release & withdraw cartridge':state.time<7?'Inspect four-contact interface':'Reinsert & latch'):state.motion==='charge'?(state.time<4?'Align receiver over dock':'Charging · illustrative'):steps[Math.min(9,Math.floor(state.progress*10))];$('#pause').disabled=!state.motion;$('#pause').textContent=state.paused?'Resume':'Pause';$('#sequence-time').textContent=state.motion?`${state.motion.toUpperCase()} / ${state.time.toFixed(1)} SEC`:'ASSEMBLY / 18 SEC'; }
function motion(type){if(type==='swap'&&state.view==='dock')setView('system');if(type==='charge'&&state.view!=='system')setView('system');state.motion=type;state.time=0;state.paused=false;state.swap=0;state.dock=0;state.progress=type==='assembly'?0:1;if(type==='assembly'){state.cutaway=false;$('#cutaway').checked=false;fit(true);}else if(type==='swap'){state.cutaway=true;$('#cutaway').checked=true;fit();}else if(type==='charge'){state.cutaway=false;$('#cutaway').checked=false;fit();}updateUI();}
let last=performance.now();function tick(now){const dt=Math.min(.05,(now-last)/1000);last=now;if(state.motion&&!state.paused){state.time+=dt;if(state.motion==='assembly'){state.progress=Math.min(1,state.time/18);if(state.time>=18){state.motion=null;fit();}}else if(state.motion==='swap'){state.swap=state.time<4?smooth(state.time/4):state.time<7?1:1-smooth((state.time-7)/4);if(state.time>=11)state.motion=null;}else if(state.motion==='charge'){state.dock=smooth(state.time/4);if(state.time>12)state.time=4;}updateUI();}apply();if(cameraGoal){camera.position.lerp(cameraGoal.eye,.065);controls.target.lerp(cameraGoal.target,.065);if(camera.position.distanceTo(cameraGoal.eye)<.1)cameraGoal=null;}controls.autoRotate=state.turntable;controls.autoRotateSpeed=.75;controls.update();renderer.render(scene,camera);requestAnimationFrame(tick);}requestAnimationFrame(tick);
controls.addEventListener('start',()=>cameraGoal=null);
new ResizeObserver(()=>{const w=$('#canvas').clientWidth,h=$('#canvas').clientHeight;renderer.setSize(w,h);camera.aspect=w/h;camera.updateProjectionMatrix();}).observe($('#canvas'));
for(const b of document.querySelectorAll('[data-view]'))b.onclick=()=>setView(b.dataset.view);
$('#cutaway').onchange=e=>state.cutaway=e.target.checked;$('#labels').onchange=e=>state.labels=e.target.checked;$('#rotate').onclick=()=>{state.turntable=!state.turntable;$('#rotate').setAttribute('aria-pressed',state.turntable);};$('#assembly').onclick=()=>motion('assembly');$('#swap').onclick=()=>motion('swap');$('#charge').onclick=()=>motion('charge');$('#explode').onclick=()=>{state.motion=null;state.swap=0;state.dock=0;state.progress=state.progress<.5?1:0;fit(state.progress===0);updateUI();};$('#pause').onclick=()=>{state.paused=!state.paused;updateUI();};$('#progress').oninput=e=>{state.motion=null;state.progress=+e.target.value;state.swap=0;state.dock=0;fit(state.progress<.85);updateUI();};

$('#measurements').onchange=e=>{state.measurements=e.target.checked;$('#measure-controls').hidden=!state.measurements;if(state.measurements&&state.measurePiece==='all'){state.cutaway=true;$('#cutaway').checked=true;}apply();};
$('#measure-piece').onchange=e=>{const key=e.target.value;state.measurePiece=key;const meta=dimensionSets.find(m=>m.key===key);if(key!=='all'){const view=['dockPcb','tx','dockShell'].includes(key)?'dock':['cell','pack','tray'].includes(key)?'battery':'tracker';setView(view);state.cutaway=!['trackerShell','dockShell'].includes(key);$('#cutaway').checked=state.cutaway;$('#measure-note').textContent=`${meta.desc}: ${meta.size[0]} × ${meta.size[2]} × ${meta.size[1]} mm (W × L × H). ${meta.inferred?'Inferred concept envelope.':key==='cell'||key==='receiver'||key==='tx'?'Nominal component envelope from project notes.':'PCB dimensions from Circuit JSON.'}`;}else $('#measure-note').textContent='Dimensions in mm · Select a piece for a clear view.';};
$('#about').onclick=()=>$('dialog').showModal();$('#close').onclick=()=>$('dialog').close();$('dialog').onclick=e=>{if(e.target===$('dialog'))$('dialog').close();};
function download(blob,name){const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);}
async function savePng(){const ratio=renderer.getPixelRatio();renderer.setPixelRatio(2.5);renderer.render(scene,camera);const c=document.createElement('canvas');c.width=renderer.domElement.width;c.height=renderer.domElement.height;const ctx=c.getContext('2d');ctx.drawImage(renderer.domElement,0,0);const cr=$('#canvas').getBoundingClientRect(),sx=c.width/cr.width,sy=c.height/cr.height;for(const {el} of labels){if(el.style.display==='none')continue;const r=el.getBoundingClientRect(),x=(r.left-cr.left)*sx,y=(r.top-cr.top)*sy;ctx.fillStyle='#f7fff0ed';ctx.fillRect(x,y,r.width*sx,r.height*sy);ctx.strokeStyle='#8ca992';ctx.lineWidth=sx*.5;ctx.strokeRect(x,y,r.width*sx,r.height*sy);ctx.fillStyle='#28583f';ctx.font=`${parseFloat(getComputedStyle(el).fontSize)*sx}px monospace`;ctx.textBaseline='middle';ctx.fillText(el.textContent,x+5*sx,y+r.height*sy/2);}ctx.textBaseline='alphabetic';ctx.fillStyle='#243b32';ctx.font=`${c.width*.012}px sans-serif`;ctx.fillText('PIXAL GPS / REV A · MECHANICAL CONCEPT',c.width*.025,c.height-c.width*.025);renderer.setPixelRatio(ratio);c.toBlob(b=>download(b,`pixal-gps-${state.view}-${state.progress<.9?'exploded':'assembled'}.png`));}$('#png').onclick=savePng;
$('#glb').onclick=async()=>{try{apply();const glb=await new GLTFExporter().parseAsync(product,{binary:true,onlyVisible:true});download(new Blob([glb],{type:'model/gltf-binary'}),'pixal-gps-concept.glb');$('#notice').textContent='3D model exported in millimetres. Procedural animation remains in this viewer.';}catch(e){$('#notice').textContent='Could not export model: '+e.message;}};
async function record(){if(state.recording)return;const mime=['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm'].find(t=>MediaRecorder.isTypeSupported(t));if(!mime){$('#notice').textContent='WebM recording is unavailable in this browser. Try Chrome.';return;}state.recording=true;$('#video').disabled=true;$('#video').textContent='Recording…';const kind=state.motion|| (state.turntable?'turntable':'assembly');if(kind!=='turntable')motion(kind);const stream=renderer.domElement.captureStream(30),chunks=[];const rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:8000000});rec.ondataavailable=e=>{if(e.data.size)chunks.push(e.data);};rec.onstop=()=>{download(new Blob(chunks,{type:mime}),`pixal-gps-${kind}.webm`);stream.getTracks().forEach(t=>t.stop());state.recording=false;$('#video').disabled=false;$('#video').textContent='↓ Record animation';};rec.start();setTimeout(()=>rec.stop(),kind==='assembly'?22500:kind==='swap'?14000:14000);}$('#video').onclick=record;
// Inspection API for deterministic render capture and verifying geometric relationships.
window.pixalStudio={state,setView,motion,apply,fit,renderer,scene,camera,controls,product,parts:{pcb,pack,cell,receiver,tx,tracker,dock},ready:true};
setView('system');$('#loading').remove();