package main import ( "fmt" "image" "log" "os" "path/filepath" "strings" "time" ) // InstanceData holds the serializable state for a saved enclosure instance. type InstanceData struct { ID string `json:"id"` Name string `json:"name,omitempty"` CreatedAt time.Time `json:"createdAt"` // Source files (basenames relative to the project directory) GerberFiles map[string]string `json:"gerberFiles"` DrillPath string `json:"drillPath,omitempty"` NPTHPath string `json:"npthPath,omitempty"` // Discovered layer filenames (keys into GerberFiles) EdgeCutsFile string `json:"edgeCutsFile"` CourtyardFile string `json:"courtyardFile,omitempty"` SoldermaskFile string `json:"soldermaskFile,omitempty"` FabFile string `json:"fabFile,omitempty"` // Configuration Config EnclosureConfig `json:"config"` Exports []string `json:"exports"` // Board display info BoardW float64 `json:"boardW"` BoardH float64 `json:"boardH"` ProjectName string `json:"projectName,omitempty"` // Unified cutouts (new format) Cutouts []Cutout `json:"cutouts,omitempty"` // Legacy cutout fields — kept for backward compatibility when loading old projects SideCutouts []SideCutout `json:"sideCutouts,omitempty"` LidCutouts []LidCutout `json:"lidCutouts,omitempty"` } // MigrateCutouts returns the unified cutouts list, converting legacy fields if needed. func (inst *InstanceData) MigrateCutouts() []Cutout { if len(inst.Cutouts) > 0 { return inst.Cutouts } // Migrate legacy side cutouts var result []Cutout for _, sc := range inst.SideCutouts { result = append(result, Cutout{ ID: randomID(), Surface: "side", SideNum: sc.Side, X: sc.X, Y: sc.Y, Width: sc.Width, Height: sc.Height, CornerRadius: sc.CornerRadius, SourceLayer: sc.Layer, }) } // Migrate legacy lid cutouts for _, lc := range inst.LidCutouts { surface := "top" if lc.Plane == "tray" { surface = "bottom" } result = append(result, Cutout{ ID: randomID(), Surface: surface, X: lc.MinX, Y: lc.MinY, Width: lc.MaxX - lc.MinX, Height: lc.MaxY - lc.MinY, IsDado: lc.IsDado, Depth: lc.Depth, }) } return result } // restoreSessionFromDir rebuilds an EnclosureSession from an InstanceData, // resolving all file paths relative to baseDir. func restoreSessionFromDir(inst *InstanceData, baseDir string) (string, *EnclosureSession, error) { outlineBasename, ok := inst.GerberFiles[inst.EdgeCutsFile] if !ok { return "", nil, fmt.Errorf("edge cuts file not found: %s", inst.EdgeCutsFile) } outlinePath := filepath.Join(baseDir, outlineBasename) if _, err := os.Stat(outlinePath); err != nil { return "", nil, fmt.Errorf("source files no longer available: %v", err) } outlineGf, err := ParseGerber(outlinePath) if err != nil { return "", nil, fmt.Errorf("parse outline: %v", err) } outlineBounds := outlineGf.CalculateBounds() actualBoardW := outlineBounds.MaxX - outlineBounds.MinX actualBoardH := outlineBounds.MaxY - outlineBounds.MinY ecfg := inst.Config margin := ecfg.WallThickness + ecfg.Clearance + 5.0 outlineBounds.MinX -= margin outlineBounds.MinY -= margin outlineBounds.MaxX += margin outlineBounds.MaxY += margin ecfg.OutlineBounds = &outlineBounds outlineImg := outlineGf.Render(ecfg.DPI, &outlineBounds) minBX, maxBX := outlineImg.Bounds().Max.X, -1 var boardCenterY float64 var boardCount int _, boardMask := ComputeWallMask(outlineImg, ecfg.WallThickness+ecfg.Clearance, ecfg.DPI/25.4) imgW, imgH := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y for y := 0; y < imgH; y++ { for x := 0; x < imgW; x++ { if boardMask[y*imgW+x] { if x < minBX { minBX = x } if x > maxBX { maxBX = x } boardCenterY += float64(y) boardCount++ } } } if boardCount > 0 { boardCenterY /= float64(boardCount) } // Resolve gerber paths relative to baseDir and render ALL layers resolvedGerbers := make(map[string]string) allLayers := make(map[string]image.Image) allGerbers := make(map[string]*GerberFile) for origName, basename := range inst.GerberFiles { fullPath := filepath.Join(baseDir, basename) resolvedGerbers[origName] = fullPath if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") { continue } if origName == inst.EdgeCutsFile { allLayers[origName] = outlineImg allGerbers[origName] = outlineGf continue } if gf, err := ParseGerber(fullPath); err == nil { allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds) allGerbers[origName] = gf } } var courtyardImg, soldermaskImg image.Image if inst.CourtyardFile != "" { courtyardImg = allLayers[inst.CourtyardFile] } if inst.SoldermaskFile != "" { soldermaskImg = allLayers[inst.SoldermaskFile] } if courtyardImg == nil && inst.FabFile != "" { courtyardImg = allLayers[inst.FabFile] } var drillHoles []DrillHole if inst.DrillPath != "" { if holes, err := ParseDrill(filepath.Join(baseDir, inst.DrillPath)); err == nil { drillHoles = append(drillHoles, holes...) } } if inst.NPTHPath != "" { if holes, err := ParseDrill(filepath.Join(baseDir, inst.NPTHPath)); err == nil { drillHoles = append(drillHoles, holes...) } } var filteredHoles []DrillHole for _, h := range drillHoles { if h.Type != DrillTypeVia { filteredHoles = append(filteredHoles, h) } } pixelToMM := 25.4 / ecfg.DPI sessionID := randomID() session := &EnclosureSession{ Exports: inst.Exports, OutlineGf: outlineGf, OutlineImg: outlineImg, CourtyardImg: courtyardImg, SoldermaskImg: soldermaskImg, DrillHoles: filteredHoles, Config: ecfg, OutlineBounds: outlineBounds, BoardW: actualBoardW, BoardH: actualBoardH, TotalH: ecfg.WallHeight + ecfg.PCBThickness + 1.0, MinBX: float64(minBX)*pixelToMM + outlineBounds.MinX, MaxBX: float64(maxBX)*pixelToMM + outlineBounds.MinX, BoardCenterY: outlineBounds.MaxY - boardCenterY*pixelToMM, Sides: ExtractBoardSidesFromMask(boardMask, imgW, imgH, pixelToMM, &outlineBounds), GerberFiles: inst.GerberFiles, DrillPath: inst.DrillPath, NPTHPath: inst.NPTHPath, ProjectName: inst.ProjectName, EdgeCutsFile: inst.EdgeCutsFile, CourtyardFile: inst.CourtyardFile, SoldermaskFile: inst.SoldermaskFile, FabFile: inst.FabFile, EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg), AllLayerImages: allLayers, AllLayerGerbers: allGerbers, SourceDir: baseDir, } log.Printf("Restored session %s from %s (%s)", sessionID, baseDir, inst.ProjectName) return sessionID, session, nil }