400 lines
12 KiB
Go
400 lines
12 KiB
Go
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(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="4" font-weight="bold" fill="black">Face Templates — Page %d / %d</text>`,
|
|
headerX, mPos[0][1]+1.5, page.PageNum, page.TotalPgs))
|
|
} else {
|
|
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="4" font-weight="bold" fill="black">Face Templates — %d faces</text>`,
|
|
headerX, mPos[0][1]+1.5, cfg.NumFaces))
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2.2" fill="#888" text-anchor="end">Trace outline, label shared edges with adjoining face #</text>`,
|
|
rightX, mPos[0][1]-0.5))
|
|
b.WriteString("\n")
|
|
|
|
if page.PageNum == 1 {
|
|
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2" fill="#aaa" text-anchor="end">Leave unused cells blank. Only unique faces needed.</text>`,
|
|
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("</svg>\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(`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="none" stroke="#aaa" stroke-width="0.2" stroke-dasharray="4,2"/>`,
|
|
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(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="8" font-weight="bold" fill="#ccc">%d</text>`,
|
|
x+2, y+8, cell.FaceNum))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="3" fill="#ddd">/%d</text>`,
|
|
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(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="1.0"/>`,
|
|
x, y, x+length*dx, y))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="1.0"/>`,
|
|
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(`<polygon fill="none" stroke="#2288cc" stroke-width="0.4" points="`)
|
|
for i, pt := range outline {
|
|
if i > 0 {
|
|
b.WriteString(" ")
|
|
}
|
|
b.WriteString(fmt.Sprintf("%.2f,%.2f", cx+pt[0], cy+pt[1]))
|
|
}
|
|
b.WriteString(`"/>`)
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// --- shared SVG building blocks ---
|
|
|
|
func writeSVGHeader(b *strings.Builder, w, h float64) {
|
|
b.WriteString(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
|
|
width="%.1fmm" height="%.1fmm"
|
|
viewBox="0 0 %.1f %.1f">
|
|
`, w, h, w, h))
|
|
b.WriteString(fmt.Sprintf(`<rect x="0" y="0" width="%.1f" height="%.1f" fill="white"/>
|
|
`, 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(`<g stroke="#f0f0f0" stroke-width="0.06">` + "\n")
|
|
for gx := x + fine; gx < x+w; gx += fine {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, gx, y, gx, y+h))
|
|
b.WriteString("\n")
|
|
}
|
|
for gy := y + fine; gy < y+h; gy += fine {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, gy, x+w, gy))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("</g>\n")
|
|
|
|
// Minor grid at the configured spacing
|
|
b.WriteString(`<g stroke="#e0e0e0" stroke-width="0.1">` + "\n")
|
|
for gx := x + grid; gx < x+w; gx += grid {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, gx, y, gx, y+h))
|
|
b.WriteString("\n")
|
|
}
|
|
for gy := y + grid; gy < y+h; gy += grid {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, gy, x+w, gy))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("</g>\n")
|
|
|
|
// Major grid at 5x spacing
|
|
major := grid * 5
|
|
b.WriteString(`<g stroke="#ccc" stroke-width="0.2">` + "\n")
|
|
for gx := x + major; gx < x+w; gx += major {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, gx, y, gx, y+h))
|
|
b.WriteString("\n")
|
|
}
|
|
for gy := y + major; gy < y+h; gy += major {
|
|
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, gy, x+w, gy))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("</g>\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(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#ddd" stroke-width="0.15"/>`,
|
|
cx-size*2, cy, cx+size*2, cy))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#ddd" stroke-width="0.15"/>`,
|
|
cx, cy-size*2, cx, cy+size*2))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
func writeScaleBar(b *strings.Builder, x, y, length float64) {
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
|
x, y, x+length, y))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
|
x, y-2, x, y+2))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
|
x+length, y-2, x+length, y+2))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.3"/>`,
|
|
x+length/2, y-1.5, x+length/2, y+1.5))
|
|
b.WriteString("\n")
|
|
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2.5" fill="black" text-anchor="middle">%.0fmm</text>`,
|
|
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
|
|
}
|