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(`