@@ -535,6 +535,20 @@ <h2>Configure canvas</h2>
535535 </ button >
536536 </ div >
537537
538+ < div class ="btn-row ">
539+ < button class ="ghost " onclick ="exportConfig() "> Export config</ button >
540+ < button class ="ghost " onclick ="document.getElementById('importFile').click() ">
541+ Import config
542+ </ button >
543+ < input
544+ type ="file "
545+ id ="importFile "
546+ accept ="application/json,.json "
547+ style ="display: none "
548+ onchange ="importConfig(this) "
549+ />
550+ </ div >
551+
538552 < div class ="btn-row ">
539553 < button class ="gold " onclick ="saveCanvas() "> Save canvas</ button >
540554 < button class ="danger " onclick ="clearDrawings() "> Clear drawings</ button >
@@ -845,6 +859,11 @@ <h2>Enqueue test drawing</h2>
845859 } ) ;
846860 // bots that live on this canvas (matched by region id)
847861 const regionIds = new Set ( ( c . regions || [ ] ) . map ( ( r ) => r . id ) ) ;
862+ // Robot body geometry, all measured in mm from the pen (= reported pose).
863+ const NOSE_AHEAD = 76 ; // nose is 76mm in front of the pen
864+ const CAM_AHEAD = 51 ; // camera sits between pen and nose
865+ const TAIL_BEHIND = 226 - NOSE_AHEAD ; // total length 226mm → 150mm behind pen
866+ const HALF_WHEEL = 149 / 2 ; // wheel axle spans 149mm across the pen
848867 ( bots || [ ] )
849868 . filter ( ( b ) => b . region && regionIds . has ( b . region ) )
850869 . forEach ( ( b ) => {
@@ -856,20 +875,40 @@ <h2>Enqueue test drawing</h2>
856875 : b . status === "ready"
857876 ? "#2d7d46"
858877 : "#c79a2e" ;
859- // Arrow for heading
860878 const rad = ( b . pose . headingDegrees * Math . PI ) / 180 ;
861- const arrowLen = fs * 1.4 ;
862- const ax = b . pose . x + Math . cos ( rad ) * arrowLen ;
863- const ay = b . pose . y + Math . sin ( rad ) * arrowLen ;
879+ const fx = Math . cos ( rad ) , fy = Math . sin ( rad ) ; // forward (heading) unit
880+ const rx = - Math . sin ( rad ) , ry = Math . cos ( rad ) ; // rightward unit
881+ const pen = { x : b . pose . x , y : b . pose . y } ;
882+ const along = ( d ) => ( { x : pen . x + fx * d , y : pen . y + fy * d } ) ;
883+ const nose = along ( NOSE_AHEAD ) ;
884+ const tail = along ( - TAIL_BEHIND ) ;
885+ const cam = along ( CAM_AHEAD ) ;
886+ const wheelL = { x : pen . x + rx * HALF_WHEEL , y : pen . y + ry * HALF_WHEEL } ;
887+ const wheelR = { x : pen . x - rx * HALF_WHEEL , y : pen . y - ry * HALF_WHEEL } ;
888+
889+ // Spine (tail → nose) and wheel axle (across the pen) — the "t".
890+ parts . push (
891+ `<line x1="${ tail . x } " y1="${ tail . y } " x2="${ nose . x } " y2="${ nose . y } " stroke="${ color } " stroke-width="${ sw * 1.5 } " stroke-linecap="round"/>` ,
892+ ) ;
893+ parts . push (
894+ `<line x1="${ wheelL . x } " y1="${ wheelL . y } " x2="${ wheelR . x } " y2="${ wheelR . y } " stroke="${ color } " stroke-width="${ sw * 1.5 } " stroke-linecap="round"/>` ,
895+ ) ;
864896
897+ // Camera as a triangle pointing along the heading.
898+ const TRI = 34 ; // mm
899+ const apex = { x : cam . x + fx * TRI * 0.6 , y : cam . y + fy * TRI * 0.6 } ;
900+ const cL = { x : cam . x - fx * TRI * 0.4 + rx * TRI * 0.5 , y : cam . y - fy * TRI * 0.4 + ry * TRI * 0.5 } ;
901+ const cR = { x : cam . x - fx * TRI * 0.4 - rx * TRI * 0.5 , y : cam . y - fy * TRI * 0.4 - ry * TRI * 0.5 } ;
865902 parts . push (
866- `<line x1 ="${ b . pose . x } " y1=" ${ b . pose . y } " x2=" ${ ax } " y2 ="${ ay } " stroke="${ color } " stroke-width="${ sw * 1 .5} " marker-end="url(#arrowhead) "/>` ,
903+ `<polygon points ="${ apex . x } , ${ apex . y } ${ cL . x } , ${ cL . y } ${ cR . x } , ${ cR . y } " fill ="${ color } " stroke="#fff " stroke-width="${ sw * 0 .5} "/>` ,
867904 ) ;
905+
906+ // Pen location (the reported pose) as a small dot.
868907 parts . push (
869- `<circle cx="${ b . pose . x } " cy="${ b . pose . y } " r="${ fs * 0.7 } " fill="${ color } " stroke="#fff " stroke-width="${ sw } "/>` ,
908+ `<circle cx="${ pen . x } " cy="${ pen . y } " r="${ sw * 2.2 } " fill="#fff " stroke="${ color } " stroke-width="${ sw } "/>` ,
870909 ) ;
871910 parts . push (
872- `<text x="${ b . pose . x + fs * 0.9 } " y="${ b . pose . y + fs * 0.4 } " font-size="${ fs * 0.8 } " font-family="monospace" fill="#222">${ escSvg ( b . name ) } </text>` ,
911+ `<text x="${ pen . x + fs * 0.9 } " y="${ pen . y + fs * 0.4 } " font-size="${ fs * 0.8 } " font-family="monospace" fill="#222">${ escSvg ( b . name ) } </text>` ,
873912 ) ;
874913 } ) ;
875914 parts . push ( "</svg>" ) ;
@@ -961,26 +1000,102 @@ <h2>Enqueue test drawing</h2>
9611000 editorToast ( `Generated ${ rows * cols } regions` , true ) ;
9621001 }
9631002
964- /** Populate the editor from a canvas returned by the server. */
965- function loadSelected ( ) {
966- const id = document . getElementById ( "loadSelect" ) . value ;
967- const c = ( window . _canvases || [ ] ) . find ( ( x ) => x . id === id ) ;
968- if ( ! c ) return ;
969- document . getElementById ( "cId" ) . value = c . id ;
970- document . getElementById ( "cW" ) . value = c . width ;
971- document . getElementById ( "cH" ) . value = c . height ;
1003+ /**
1004+ * Populate the whole editor from a canvas config object. Accepts both the
1005+ * server shape (from GET /api/robots/canvases, no `placement`) and the
1006+ * exported payload shape (from buildCanvasPayload, with `placement`).
1007+ * Placement fields are only touched when the config carries them, so
1008+ * loading a server canvas leaves the current placement settings intact.
1009+ * @param {any } cfg
1010+ */
1011+ function applyCanvasConfig ( cfg ) {
1012+ if ( cfg . id != null ) document . getElementById ( "cId" ) . value = cfg . id ;
1013+ if ( cfg . width != null ) document . getElementById ( "cW" ) . value = cfg . width ;
1014+ if ( cfg . height != null ) document . getElementById ( "cH" ) . value = cfg . height ;
1015+
1016+ const p = cfg . placement ;
1017+ if ( p ) {
1018+ const set = ( id , v ) => {
1019+ if ( v != null ) document . getElementById ( id ) . value = v ;
1020+ } ;
1021+ set ( "pCell" , p . cellMm ) ;
1022+ set ( "pPen" , p . penMm ) ;
1023+ set ( "pClear" , p . clearanceMm ) ;
1024+ set ( "pStep" , p . searchStepCells ) ;
1025+ set ( "pAngle" , p . angleStepDeg ) ;
1026+ if ( p . strategy != null )
1027+ document . getElementById ( "pStrategy" ) . value = p . strategy ;
1028+ set ( "pTargetFootprint" , p . targetFootprintMm ) ;
1029+ set ( "pMinScale" , p . minFootprintScale ) ;
1030+ }
1031+
9721032 document . getElementById ( "markersList" ) . innerHTML = "" ;
973- ( c . markers || [ ] ) . forEach ( ( m ) =>
1033+ ( cfg . markers || [ ] ) . forEach ( ( m ) =>
9741034 addMarkerRow ( {
9751035 id : m . id ,
976- x : m . position . x ,
977- y : m . position . y ,
1036+ x : m . position ? m . position . x : m . x ,
1037+ y : m . position ? m . position . y : m . y ,
9781038 sizeMm : m . sizeMm ,
9791039 } ) ,
9801040 ) ;
9811041 document . getElementById ( "regionsList" ) . innerHTML = "" ;
982- ( c . regions || [ ] ) . forEach ( ( r ) => addRegionRow ( r ) ) ;
983- editorToast ( `Loaded "${ c . id } " (placement defaults apply)` , true ) ;
1042+ ( cfg . regions || [ ] ) . forEach ( ( r ) => addRegionRow ( r ) ) ;
1043+ }
1044+
1045+ /** Populate the editor from a canvas returned by the server. */
1046+ function loadSelected ( ) {
1047+ const id = document . getElementById ( "loadSelect" ) . value ;
1048+ const c = ( window . _canvases || [ ] ) . find ( ( x ) => x . id === id ) ;
1049+ if ( ! c ) return ;
1050+ applyCanvasConfig ( c ) ;
1051+ editorToast (
1052+ `Loaded "${ c . id } "${ c . placement ? "" : " (placement defaults apply)" } ` ,
1053+ true ,
1054+ ) ;
1055+ }
1056+
1057+ /** Download the current editor state as a complete JSON config file. */
1058+ function exportConfig ( ) {
1059+ const payload = buildCanvasPayload ( ) ;
1060+ if ( ! payload . id ) return editorToast ( "Canvas id is required" , false ) ;
1061+ const blob = new Blob ( [ JSON . stringify ( payload , null , 2 ) ] , {
1062+ type : "application/json" ,
1063+ } ) ;
1064+ const url = URL . createObjectURL ( blob ) ;
1065+ const a = document . createElement ( "a" ) ;
1066+ a . href = url ;
1067+ a . download = `${ payload . id } .canvas.json` ;
1068+ document . body . appendChild ( a ) ;
1069+ a . click ( ) ;
1070+ a . remove ( ) ;
1071+ URL . revokeObjectURL ( url ) ;
1072+ editorToast ( `Exported "${ payload . id } "` , true ) ;
1073+ }
1074+
1075+ /**
1076+ * Load a complete config from a chosen JSON file into the editor. Does not
1077+ * save — the user reviews the populated fields and clicks Save canvas.
1078+ * @param {HTMLInputElement } input
1079+ */
1080+ function importConfig ( input ) {
1081+ const file = input . files && input . files [ 0 ] ;
1082+ if ( ! file ) return ;
1083+ const reader = new FileReader ( ) ;
1084+ reader . onload = ( ) => {
1085+ try {
1086+ const cfg = JSON . parse ( /** @type {string } */ ( reader . result ) ) ;
1087+ applyCanvasConfig ( cfg ) ;
1088+ editorToast (
1089+ `Imported "${ cfg . id || file . name } " — review, then Save canvas` ,
1090+ true ,
1091+ ) ;
1092+ } catch ( e ) {
1093+ editorToast ( "Import failed: " + e . message , false ) ;
1094+ }
1095+ } ;
1096+ reader . onerror = ( ) => editorToast ( "Could not read file" , false ) ;
1097+ reader . readAsText ( file ) ;
1098+ input . value = "" ; // let the same file be re-imported later
9841099 }
9851100
9861101 /** @returns {object } CanvasConfig payload for POST /api/robots/canvases */
0 commit comments