package main import ( "fmt" "os" "path/filepath" "strings" ) type FaceTemplateConfig struct { NumFaces int LongestSide float64 // mm — longest edge of any face on the object PageWidth float64 PageHeight float64 GridSpacing float64 SinglePage bool // ignored — pages are auto-calculated from face count and size } // TracedFace represents a polygon outline drawn into a face's tracing area. // Coordinates in mm relative to the tracing area center (0,0). type TracedFace struct { FaceNum int Outline [][2]float64 } func GenerateFaceTemplateSVGs(cfg FaceTemplateConfig, outputDir string) ([]string, error) { return generateFaceTemplateSVGsWithShapes(cfg, outputDir, nil) } func generateFaceTemplateSVGsWithShapes(cfg FaceTemplateConfig, outputDir string, shapes []TracedFace) ([]string, error) { os.MkdirAll(outputDir, 0755) if cfg.PageWidth <= 0 { cfg.PageWidth = 215.9 } if cfg.PageHeight <= 0 { cfg.PageHeight = 279.4 } if cfg.NumFaces < 1 { cfg.NumFaces = 6 } if cfg.LongestSide <= 0 { cfg.LongestSide = 50 } if cfg.GridSpacing <= 0 { cfg.GridSpacing = pickGridSpacing(cfg.LongestSide) } shapeMap := map[int]TracedFace{} for _, s := range shapes { shapeMap[s.FaceNum] = s } layout := computePageLayout(cfg) var files []string for pageIdx, page := range layout { filename := fmt.Sprintf("faces_page_%d.svg", pageIdx+1) if len(layout) == 1 { filename = "faces.svg" } path := filepath.Join(outputDir, filename) svg := renderTemplatePage(cfg, page, shapeMap) if err := os.WriteFile(path, []byte(svg), 0644); err != nil { return files, fmt.Errorf("write %s: %v", filename, err) } files = append(files, path) } // Always generate PDF (renders SVGs via Chrome for pixel-perfect output) pdfPath, err := GenerateFaceTemplatePDF(cfg, outputDir, shapes) if err != nil { debugLog("GenerateFaceTemplateSVGs: PDF generation failed: %v", err) } else { files = append(files, pdfPath) } debugLog("GenerateFaceTemplateSVGs: %d faces, longestSide=%.1fmm, %d output files to %s", cfg.NumFaces, cfg.LongestSide, len(files), outputDir) return files, nil } // faceCell describes where a face tracing area is positioned on a page. type faceCell struct { FaceNum int X, Y float64 // top-left of the tracing area W, H float64 // tracing area dimensions } type pageLayout struct { Cells []faceCell PageNum int TotalPgs int } // computePageLayout determines how many faces fit per page and assigns positions. func computePageLayout(cfg FaceTemplateConfig) []pageLayout { margin := 15.0 headerH := 16.0 footerH := 14.0 usableW := cfg.PageWidth - 2*margin usableH := cfg.PageHeight - 2*margin - headerH - footerH // Each face cell needs the longest side + buffer for tracing overshoot. // Add generous separation between cells so borders don't get confused // with traced outlines. cellPad := 8.0 // padding around each cell's tracing area sepW := 12.0 // separation between cells (thick visual gap) minCellDim := cfg.LongestSide + 2*cellPad if minCellDim < 30 { minCellDim = 30 } // How many cells fit in each direction? // cells * minCellDim + (cells-1) * sep <= usable cols := int((usableW + sepW) / (minCellDim + sepW)) rows := int((usableH + sepW) / (minCellDim + sepW)) if cols < 1 { cols = 1 } if rows < 1 { rows = 1 } perPage := cols * rows // Actual cell dimensions (divide remaining space evenly) cellW := (usableW - float64(cols-1)*sepW) / float64(cols) cellH := (usableH - float64(rows-1)*sepW) / float64(rows) totalPages := (cfg.NumFaces + perPage - 1) / perPage var pages []pageLayout faceIdx := 1 for pg := 0; pg < totalPages; pg++ { var cells []faceCell for r := 0; r < rows && faceIdx <= cfg.NumFaces; r++ { for c := 0; c < cols && faceIdx <= cfg.NumFaces; c++ { cx := margin + float64(c)*(cellW+sepW) + cellPad cy := margin + headerH + float64(r)*(cellH+sepW) + cellPad cw := cellW - 2*cellPad ch := cellH - 2*cellPad cells = append(cells, faceCell{ FaceNum: faceIdx, X: cx, Y: cy, W: cw, H: ch, }) faceIdx++ } } pages = append(pages, pageLayout{ Cells: cells, PageNum: pg + 1, TotalPgs: totalPages, }) } return pages } // renderTemplatePage generates one SVG page with face cells. func renderTemplatePage(cfg FaceTemplateConfig, page pageLayout, shapes map[int]TracedFace) string { var b strings.Builder pageW := cfg.PageWidth pageH := cfg.PageHeight grid := cfg.GridSpacing writeSVGHeader(&b, pageW, pageH) // Bullseye fiducial markers at 4 corners mPos := markerPositionsMM(pageW, pageH) cornerIDs := [4]int{CornerTL, CornerTR, CornerBL, CornerBR} for i, pos := range mPos { renderMarkerSVG(&b, pos[0], pos[1], MarkerData{ PageNum: page.PageNum, CornerID: cornerIDs[i], NumFaces: cfg.NumFaces, LongestMM: int(cfg.LongestSide), }) } // Encoded calibration barcodes along bottom barY := pageH - 15.0 renderCalibBarsSVG(&b, mPos[0][0]+float64(markerN)*markerCellMM/2+3, barY, calibBarSpecs()) // Header headerX := mPos[0][0] + float64(markerN)*markerCellMM/2 + 3 rightX := mPos[1][0] - float64(markerN)*markerCellMM/2 - 3 if page.TotalPgs > 1 { b.WriteString(fmt.Sprintf(`Face Templates — Page %d / %d`, headerX, mPos[0][1]+1.5, page.PageNum, page.TotalPgs)) } else { b.WriteString(fmt.Sprintf(`Face Templates — %d faces`, headerX, mPos[0][1]+1.5, cfg.NumFaces)) } b.WriteString("\n") b.WriteString(fmt.Sprintf(`Trace outline, label shared edges with adjoining face #`, rightX, mPos[0][1]-0.5)) b.WriteString("\n") if page.PageNum == 1 { b.WriteString(fmt.Sprintf(`Leave unused cells blank. Only unique faces needed.`, rightX, mPos[0][1]+2.5)) b.WriteString("\n") } // Render each face cell for _, cell := range page.Cells { renderFaceCell(&b, cell, grid, cfg.NumFaces) if s, ok := shapes[cell.FaceNum]; ok && len(s.Outline) > 0 { drawShapeInArea(&b, s.Outline, cell.X, cell.Y, cell.W, cell.H) } } b.WriteString("\n") return b.String() } // renderFaceCell draws a single face tracing area. // Thin dashed border (dies to any morph operation), grid, and face label. // NO bracket corners — those are gone, replaced by page-level bullseye markers. func renderFaceCell(b *strings.Builder, cell faceCell, grid float64, totalFaces int) { x, y, w, h := cell.X, cell.Y, cell.W, cell.H // Thin dashed border — visual guide only, will not survive scan processing b.WriteString(fmt.Sprintf(``, x, y, w, h)) b.WriteString("\n") writeGridLines(b, x, y, w, h, grid) cx := x + w/2 cy := y + h/2 writeCenterCross(b, cx, cy, 3) b.WriteString(fmt.Sprintf(`%d`, x+2, y+8, cell.FaceNum)) b.WriteString("\n") b.WriteString(fmt.Sprintf(`/%d`, x+12, y+8, totalFaces)) b.WriteString("\n") } // writeBracketCorner draws an L-shaped bracket at a corner. func writeBracketCorner(b *strings.Builder, x, y, length, dx, dy float64) { b.WriteString(fmt.Sprintf(``, x, y, x+length*dx, y)) b.WriteString("\n") b.WriteString(fmt.Sprintf(``, x, y, x, y+length*dy)) b.WriteString("\n") } // pickGridSpacing chooses a clean grid interval based on the object's longest side. func pickGridSpacing(longestSide float64) float64 { if longestSide <= 30 { return 5 } if longestSide <= 100 { return 10 } if longestSide <= 250 { return 20 } return 25 } // drawShapeInArea renders a polygon outline centered in the tracing area. // Coordinates in shape are in mm relative to shape center (0,0). func drawShapeInArea(b *strings.Builder, outline [][2]float64, areaX, areaY, areaW, areaH float64) { if len(outline) < 2 { return } cx := areaX + areaW/2 cy := areaY + areaH/2 b.WriteString(``) b.WriteString("\n") } // --- shared SVG building blocks --- func writeSVGHeader(b *strings.Builder, w, h float64) { b.WriteString(fmt.Sprintf(` `, w, h, w, h)) b.WriteString(fmt.Sprintf(` `, w, h)) } func writeGridLines(b *strings.Builder, x, y, w, h, grid float64) { // Fine sub-grid: 1mm lines (very faint) fine := 1.0 if grid <= 2 { fine = grid / 2 } b.WriteString(`` + "\n") for gx := x + fine; gx < x+w; gx += fine { b.WriteString(fmt.Sprintf(` `, gx, y, gx, y+h)) b.WriteString("\n") } for gy := y + fine; gy < y+h; gy += fine { b.WriteString(fmt.Sprintf(` `, x, gy, x+w, gy)) b.WriteString("\n") } b.WriteString("\n") // Minor grid at the configured spacing b.WriteString(`` + "\n") for gx := x + grid; gx < x+w; gx += grid { b.WriteString(fmt.Sprintf(` `, gx, y, gx, y+h)) b.WriteString("\n") } for gy := y + grid; gy < y+h; gy += grid { b.WriteString(fmt.Sprintf(` `, x, gy, x+w, gy)) b.WriteString("\n") } b.WriteString("\n") // Major grid at 5x spacing major := grid * 5 b.WriteString(`` + "\n") for gx := x + major; gx < x+w; gx += major { b.WriteString(fmt.Sprintf(` `, gx, y, gx, y+h)) b.WriteString("\n") } for gy := y + major; gy < y+h; gy += major { b.WriteString(fmt.Sprintf(` `, x, gy, x+w, gy)) b.WriteString("\n") } b.WriteString("\n") } func writeRegistrationMarks(b *strings.Builder, x, y, w, h, size float64) { writeCornerMark(b, x, y, size, 1, 1) writeCornerMark(b, x+w, y, size, -1, 1) writeCornerMark(b, x, y+h, size, 1, -1) writeCornerMark(b, x+w, y+h, size, -1, -1) } func writeCenterCross(b *strings.Builder, cx, cy, size float64) { b.WriteString(fmt.Sprintf(``, cx-size*2, cy, cx+size*2, cy)) b.WriteString("\n") b.WriteString(fmt.Sprintf(``, cx, cy-size*2, cx, cy+size*2)) b.WriteString("\n") } func writeScaleBar(b *strings.Builder, x, y, length float64) { b.WriteString(fmt.Sprintf(``, x, y, x+length, y)) b.WriteString("\n") b.WriteString(fmt.Sprintf(``, x, y-2, x, y+2)) b.WriteString("\n") b.WriteString(fmt.Sprintf(``, x+length, y-2, x+length, y+2)) b.WriteString("\n") b.WriteString(fmt.Sprintf(``, x+length/2, y-1.5, x+length/2, y+1.5)) b.WriteString("\n") b.WriteString(fmt.Sprintf(`%.0fmm`, x+length/2, y+5, length)) b.WriteString("\n") } // cubeFaces returns 6 TracedFace entries for a cube of the given side length (mm). func cubeFaces(side float64) []TracedFace { half := side / 2 square := [][2]float64{ {-half, -half}, {half, -half}, {half, half}, {-half, half}, } faces := make([]TracedFace, 6) for i := 0; i < 6; i++ { cp := make([][2]float64, len(square)) copy(cp, square) faces[i] = TracedFace{FaceNum: i + 1, Outline: cp} } return faces }