Ill clean up the working space soon. It is now a desktop application in the loosest of terms. Its still just a webapp, but a nicely packed one
This commit is contained in:
parent
66ea1db755
commit
0f93fcdf86
|
|
@ -1,2 +1,8 @@
|
|||
./temp/*
|
||||
temp/
|
||||
build/bin
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
data/
|
||||
frontend/wailsjs/
|
||||
bin/
|
||||
22
README.md
22
README.md
|
|
@ -9,6 +9,10 @@ A Go tool to convert Gerber files (specifically solder paste layers) into 3D pri
|
|||
- Supports Aperture Macros (AM) with rotation (e.g., rounded rectangles).
|
||||
- Automatically crops the output to the PCB bounds.
|
||||
- Generates a 3D STL mesh optimized for 3D printing.
|
||||
- **Enclosure Generation**: Automatically generates a snap-fit enclosure and tray based on the PCB outline.
|
||||
- **Native OpenSCAD Support**: Exports native `.scad` scripts for parametric editing and customization.
|
||||
- **Smart Cutouts**: Interactive side-cutout placement with automatic USB port alignment.
|
||||
- **Tray Snap System**: Robust tray clips with vertical relief slots for secure enclosure latching.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -35,6 +39,15 @@ go run main.go gerber.go -height=0.16 -keep-png my_board_paste_top.gbr my_board_
|
|||
|
||||
This will generate `my_board_paste_top.stl` in the same directory.
|
||||
|
||||
### Enclosure Generation
|
||||
|
||||
To generate an enclosure, use the web interface. Upload a `.gbrjob` file and the associated Gerber layers. The tool will auto-discover the board thickness and outline.
|
||||
|
||||
1. **Upload**: Provide the Gerber job and layout files.
|
||||
2. **Configure**: Adjust wall thickness, clearance, and mounting hole parameters in the UI.
|
||||
3. **Preview**: Use the interactive preview to place and align side cutouts for connectors.
|
||||
4. **Export**: Generate STLs or OpenSCAD scripts for both the enclosure top and the tray.
|
||||
|
||||
### Web Interface
|
||||
|
||||
To start the web interface:
|
||||
|
|
@ -59,10 +72,11 @@ These settings assume you run the tool with `-height=0.16` (the default).
|
|||
|
||||
## How it Works
|
||||
|
||||
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws).
|
||||
2. **Rendering**: It renders the PCB layer into a high-resolution internal image.
|
||||
3. **Meshing**: It converts the image into a 3D mesh using a run-length encoding approach to optimize the triangle count.
|
||||
4. **Export**: The mesh is saved as a binary STL file.
|
||||
1. **Parsing**: The tool reads the Gerber file and interprets the drawing commands (flashes and draws). For enclosures, it parses the `.gbrjob` to identify board layers.
|
||||
2. **Rendering**: It renders the PCB outline and layers into a high-resolution internal image.
|
||||
3. **Path Extraction**: Board edges are traced and simplified to generate 2.5D geometry.
|
||||
4. **Meshing**: It converts the image into a 3D mesh (STL) or generates CSG primitives (SCAD) using the board topology.
|
||||
5. **Export**: The resulting files are saved for 3D printing or further CAD refinement.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,946 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ======== Image Server ========
|
||||
|
||||
// ImageServer serves dynamically-generated images at /api/* paths.
|
||||
// It implements http.Handler and is used as the Wails AssetServer fallback handler.
|
||||
type ImageServer struct {
|
||||
mu sync.RWMutex
|
||||
images map[string][]byte
|
||||
}
|
||||
|
||||
func NewImageServer() *ImageServer {
|
||||
return &ImageServer{images: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (s *ImageServer) Store(key string, data []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.images[key] = data
|
||||
}
|
||||
|
||||
func (s *ImageServer) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.images = make(map[string][]byte)
|
||||
}
|
||||
|
||||
func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
s.mu.RLock()
|
||||
data, ok := s.images[path]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, ".png") {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
} else if strings.HasSuffix(path, ".svg") {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// ======== Frontend-facing Types ========
|
||||
|
||||
type ProjectInfoJS struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
BoardW float64 `json:"boardW"`
|
||||
BoardH float64 `json:"boardH"`
|
||||
}
|
||||
|
||||
type SessionInfoJS struct {
|
||||
ProjectName string `json:"projectName"`
|
||||
BoardW float64 `json:"boardW"`
|
||||
BoardH float64 `json:"boardH"`
|
||||
Sides []BoardSide `json:"sides"`
|
||||
TotalH float64 `json:"totalH"`
|
||||
Cutouts []Cutout `json:"cutouts"`
|
||||
HasSession bool `json:"hasSession"`
|
||||
MinX float64 `json:"minX"`
|
||||
MaxY float64 `json:"maxY"`
|
||||
DPI float64 `json:"dpi"`
|
||||
}
|
||||
|
||||
type LayerInfoJS struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
ColorHex string `json:"colorHex"`
|
||||
Visible bool `json:"visible"`
|
||||
Highlight bool `json:"highlight"`
|
||||
BaseAlpha float64 `json:"baseAlpha"`
|
||||
}
|
||||
|
||||
type GenerateResultJS struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type StencilResultJS struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// ======== App ========
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
imageServer *ImageServer
|
||||
|
||||
mu sync.RWMutex
|
||||
enclosureSession *EnclosureSession
|
||||
cutouts []Cutout
|
||||
formerLayers []*FormerLayer
|
||||
stencilFiles []string
|
||||
}
|
||||
|
||||
func NewApp(imageServer *ImageServer) *App {
|
||||
return &App{
|
||||
imageServer: imageServer,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
// Render and cache the logo (PNG for favicon, SVG for background art)
|
||||
logoImg := renderSVGNative(formerLogoSVG, 512, 512)
|
||||
if logoImg != nil {
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, logoImg)
|
||||
a.imageServer.Store("/api/logo.png", buf.Bytes())
|
||||
}
|
||||
a.imageServer.Store("/api/logo.svg", formerLogoSVG)
|
||||
}
|
||||
|
||||
// ======== Landing Page ========
|
||||
|
||||
func (a *App) GetRecentProjects() []ProjectInfoJS {
|
||||
entries, err := ListProjects(20)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var result []ProjectInfoJS
|
||||
for _, e := range entries {
|
||||
name := e.Data.ProjectName
|
||||
if e.Data.Name != "" {
|
||||
name = e.Data.Name
|
||||
}
|
||||
if name == "" {
|
||||
name = "Untitled"
|
||||
}
|
||||
result = append(result, ProjectInfoJS{
|
||||
ID: e.Data.ID,
|
||||
Name: name,
|
||||
Path: e.Path,
|
||||
Type: e.Type,
|
||||
CreatedAt: e.ModTime.Format(time.RFC3339),
|
||||
BoardW: e.Data.BoardW,
|
||||
BoardH: e.Data.BoardH,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) GetLogoDataURL() string {
|
||||
logoImg := renderSVGNative(formerLogoSVG, 256, 256)
|
||||
if logoImg == nil {
|
||||
return ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, logoImg)
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
// ======== File Dialogs ========
|
||||
|
||||
func (a *App) SelectFile(title string, patterns string) (string, error) {
|
||||
var filters []wailsRuntime.FileFilter
|
||||
if patterns != "" {
|
||||
filters = append(filters, wailsRuntime.FileFilter{
|
||||
DisplayName: "Files",
|
||||
Pattern: patterns,
|
||||
})
|
||||
}
|
||||
return wailsRuntime.OpenFileDialog(a.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: title,
|
||||
Filters: filters,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) SelectFolder(title string) (string, error) {
|
||||
return wailsRuntime.OpenDirectoryDialog(a.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) SelectMultipleFiles(title string, patterns string) ([]string, error) {
|
||||
var filters []wailsRuntime.FileFilter
|
||||
if patterns != "" {
|
||||
filters = append(filters, wailsRuntime.FileFilter{
|
||||
DisplayName: "Files",
|
||||
Pattern: patterns,
|
||||
})
|
||||
}
|
||||
return wailsRuntime.OpenMultipleFilesDialog(a.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: title,
|
||||
Filters: filters,
|
||||
})
|
||||
}
|
||||
|
||||
// ======== Stencil Workflow ========
|
||||
|
||||
func (a *App) GenerateStencil(gerberPath, outlinePath string, height, wallHeight, wallThickness, dpi float64, exports []string) (*StencilResultJS, error) {
|
||||
if gerberPath == "" {
|
||||
return nil, fmt.Errorf("no solder paste gerber file selected")
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
StencilHeight: height,
|
||||
WallHeight: wallHeight,
|
||||
WallThickness: wallThickness,
|
||||
DPI: dpi,
|
||||
}
|
||||
if cfg.StencilHeight == 0 {
|
||||
cfg.StencilHeight = DefaultStencilHeight
|
||||
}
|
||||
if cfg.WallHeight == 0 {
|
||||
cfg.WallHeight = DefaultWallHeight
|
||||
}
|
||||
if cfg.WallThickness == 0 {
|
||||
cfg.WallThickness = DefaultWallThickness
|
||||
}
|
||||
if cfg.DPI == 0 {
|
||||
cfg.DPI = DefaultDPI
|
||||
}
|
||||
if len(exports) == 0 {
|
||||
exports = []string{"stl"}
|
||||
}
|
||||
|
||||
files, pasteImg, outlineImg, err := processPCB(gerberPath, outlinePath, cfg, exports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store layers for The Former
|
||||
a.mu.Lock()
|
||||
a.formerLayers = buildStencilLayers(pasteImg, outlineImg)
|
||||
a.stencilFiles = files
|
||||
a.mu.Unlock()
|
||||
a.prepareFormerImages()
|
||||
|
||||
return &StencilResultJS{Files: files}, nil
|
||||
}
|
||||
|
||||
// ======== Enclosure Workflow ========
|
||||
|
||||
func (a *App) DiscoverGerberFiles(folderPath string) ([]string, error) {
|
||||
entries, err := os.ReadDir(folderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read folder: %v", err)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(entry.Name())
|
||||
if strings.HasSuffix(name, ".gbr") || strings.HasSuffix(name, ".gbrjob") ||
|
||||
strings.HasSuffix(name, ".gtp") || strings.HasSuffix(name, ".gbp") ||
|
||||
strings.HasSuffix(name, ".gko") || strings.HasSuffix(name, ".gm1") ||
|
||||
strings.HasSuffix(name, ".gtl") || strings.HasSuffix(name, ".gbl") ||
|
||||
strings.HasSuffix(name, ".gts") || strings.HasSuffix(name, ".gbs") {
|
||||
paths = append(paths, filepath.Join(folderPath, entry.Name()))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func (a *App) BuildEnclosureSession(gbrjobPath string, gerberPaths []string, drillPath, npthPath string, wallThickness, wallHeight, clearance, dpi float64, exports []string) error {
|
||||
if gbrjobPath == "" {
|
||||
return fmt.Errorf("no gerber job file selected")
|
||||
}
|
||||
if len(gerberPaths) == 0 {
|
||||
return fmt.Errorf("no gerber files selected")
|
||||
}
|
||||
|
||||
ecfg := EnclosureConfig{
|
||||
WallThickness: wallThickness,
|
||||
WallHeight: wallHeight,
|
||||
Clearance: clearance,
|
||||
DPI: dpi,
|
||||
}
|
||||
if ecfg.WallThickness == 0 {
|
||||
ecfg.WallThickness = DefaultEncWallThick
|
||||
}
|
||||
if ecfg.WallHeight == 0 {
|
||||
ecfg.WallHeight = DefaultEncWallHeight
|
||||
}
|
||||
if ecfg.Clearance == 0 {
|
||||
ecfg.Clearance = DefaultClearance
|
||||
}
|
||||
if ecfg.DPI == 0 {
|
||||
ecfg.DPI = 600
|
||||
}
|
||||
if len(exports) == 0 {
|
||||
exports = []string{"stl", "scad"}
|
||||
}
|
||||
|
||||
os.MkdirAll("temp", 0755)
|
||||
uuid := randomID()
|
||||
|
||||
// Copy gbrjob
|
||||
gbrjobDst := filepath.Join("temp", uuid+"_"+filepath.Base(gbrjobPath))
|
||||
if err := CopyFile(gbrjobPath, gbrjobDst); err != nil {
|
||||
return fmt.Errorf("failed to copy gbrjob: %v", err)
|
||||
}
|
||||
|
||||
// Copy gerbers
|
||||
savedGerbers := make(map[string]string)
|
||||
var sourceDir string
|
||||
for _, src := range gerberPaths {
|
||||
baseName := filepath.Base(src)
|
||||
dst := filepath.Join("temp", uuid+"_"+baseName)
|
||||
if err := CopyFile(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to copy %s: %v", baseName, err)
|
||||
}
|
||||
savedGerbers[baseName] = dst
|
||||
if sourceDir == "" {
|
||||
sourceDir = filepath.Dir(src)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy drill files
|
||||
var drillDst, npthDst string
|
||||
if drillPath != "" {
|
||||
drillDst = filepath.Join("temp", uuid+"_drill"+filepath.Ext(drillPath))
|
||||
if err := CopyFile(drillPath, drillDst); err != nil {
|
||||
return fmt.Errorf("failed to copy PTH drill: %v", err)
|
||||
}
|
||||
}
|
||||
if npthPath != "" {
|
||||
npthDst = filepath.Join("temp", uuid+"_npth"+filepath.Ext(npthPath))
|
||||
if err := CopyFile(npthPath, npthDst); err != nil {
|
||||
return fmt.Errorf("failed to copy NPTH drill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, session, err := BuildEnclosureSession(gbrjobDst, savedGerbers, drillDst, npthDst, ecfg, exports)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session build failed: %v", err)
|
||||
}
|
||||
session.SourceDir = sourceDir
|
||||
|
||||
a.mu.Lock()
|
||||
a.enclosureSession = session
|
||||
a.cutouts = nil
|
||||
a.mu.Unlock()
|
||||
|
||||
// Render board preview
|
||||
if session.OutlineImg != nil {
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, session.OutlineImg)
|
||||
a.imageServer.Store("/api/board-preview.png", buf.Bytes())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetSessionInfo() *SessionInfoJS {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
if a.enclosureSession == nil {
|
||||
return &SessionInfoJS{HasSession: false}
|
||||
}
|
||||
s := a.enclosureSession
|
||||
result := make([]Cutout, len(a.cutouts))
|
||||
copy(result, a.cutouts)
|
||||
return &SessionInfoJS{
|
||||
ProjectName: s.ProjectName,
|
||||
BoardW: s.BoardW,
|
||||
BoardH: s.BoardH,
|
||||
Sides: s.Sides,
|
||||
TotalH: s.TotalH,
|
||||
Cutouts: result,
|
||||
HasSession: true,
|
||||
MinX: s.OutlineBounds.MinX,
|
||||
MaxY: s.OutlineBounds.MaxY,
|
||||
DPI: s.Config.DPI,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) AddSideCutout(side int, x, y, w, h, radius float64, layer string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cutouts = append(a.cutouts, Cutout{
|
||||
ID: randomID(),
|
||||
Surface: "side",
|
||||
SideNum: side,
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: w,
|
||||
Height: h,
|
||||
CornerRadius: radius,
|
||||
SourceLayer: layer,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) RemoveSideCutout(index int) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
// Find the Nth side cutout
|
||||
count := 0
|
||||
for i, c := range a.cutouts {
|
||||
if c.Surface == "side" {
|
||||
if count == index {
|
||||
a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...)
|
||||
return
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetSideCutouts() []SideCutout {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var result []SideCutout
|
||||
for _, c := range a.cutouts {
|
||||
if c.Surface == "side" {
|
||||
result = append(result, CutoutToSideCutout(c))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ======== Unified Cutout CRUD ========
|
||||
|
||||
func (a *App) AddCutout(c Cutout) string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if c.ID == "" {
|
||||
c.ID = randomID()
|
||||
}
|
||||
a.cutouts = append(a.cutouts, c)
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (a *App) UpdateCutout(c Cutout) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for i, existing := range a.cutouts {
|
||||
if existing.ID == c.ID {
|
||||
a.cutouts[i] = c
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RemoveCutout(id string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for i, c := range a.cutouts {
|
||||
if c.ID == id {
|
||||
a.cutouts = append(a.cutouts[:i], a.cutouts[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetCutouts() []Cutout {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
result := make([]Cutout, len(a.cutouts))
|
||||
copy(result, a.cutouts)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) DuplicateCutout(id string) string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, c := range a.cutouts {
|
||||
if c.ID == id {
|
||||
dup := c
|
||||
dup.ID = randomID()
|
||||
// Offset slightly so it's visible
|
||||
if dup.Surface == "side" {
|
||||
dup.X += 1.0
|
||||
} else {
|
||||
dup.X += 1.0
|
||||
dup.Y += 1.0
|
||||
}
|
||||
a.cutouts = append(a.cutouts, dup)
|
||||
return dup.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) GetSideLength(sideNum int) float64 {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.enclosureSession == nil {
|
||||
return 0
|
||||
}
|
||||
for _, s := range a.enclosureSession.Sides {
|
||||
if s.Num == sideNum {
|
||||
return s.Length
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AddLidCutouts converts element pixel bboxes to mm coordinates and stores them as unified cutouts.
|
||||
func (a *App) AddLidCutouts(elements []ElementBBox, plane string, isDado bool, depth float64) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.enclosureSession == nil {
|
||||
return
|
||||
}
|
||||
|
||||
bounds := a.enclosureSession.OutlineBounds
|
||||
dpi := a.enclosureSession.Config.DPI
|
||||
|
||||
surface := "top"
|
||||
if plane == "tray" {
|
||||
surface = "bottom"
|
||||
}
|
||||
|
||||
for _, el := range elements {
|
||||
// Convert pixel coordinates to gerber mm coordinates
|
||||
mmMinX := float64(el.MinX)*(25.4/dpi) + bounds.MinX
|
||||
mmMaxX := float64(el.MaxX)*(25.4/dpi) + bounds.MinX
|
||||
mmMinY := bounds.MaxY - float64(el.MaxY)*(25.4/dpi)
|
||||
mmMaxY := bounds.MaxY - float64(el.MinY)*(25.4/dpi)
|
||||
|
||||
a.cutouts = append(a.cutouts, Cutout{
|
||||
ID: randomID(),
|
||||
Surface: surface,
|
||||
X: mmMinX,
|
||||
Y: mmMinY,
|
||||
Width: mmMaxX - mmMinX,
|
||||
Height: mmMaxY - mmMinY,
|
||||
IsDado: isDado,
|
||||
Depth: depth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetLidCutouts() []LidCutout {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var result []LidCutout
|
||||
for _, c := range a.cutouts {
|
||||
if c.Surface == "top" || c.Surface == "bottom" {
|
||||
result = append(result, CutoutToLidCutout(c))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) ClearLidCutouts() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
// Remove only top/bottom cutouts, keep side cutouts
|
||||
var kept []Cutout
|
||||
for _, c := range a.cutouts {
|
||||
if c.Surface == "side" {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
a.cutouts = kept
|
||||
}
|
||||
|
||||
func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) {
|
||||
a.mu.RLock()
|
||||
session := a.enclosureSession
|
||||
allCutouts := make([]Cutout, len(a.cutouts))
|
||||
copy(allCutouts, a.cutouts)
|
||||
a.mu.RUnlock()
|
||||
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("no enclosure session active")
|
||||
}
|
||||
|
||||
outputDir := session.SourceDir
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Join(".", "temp")
|
||||
}
|
||||
|
||||
files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Auto-save session
|
||||
inst := InstanceData{
|
||||
ID: randomID(),
|
||||
CreatedAt: time.Now(),
|
||||
GerberFiles: session.GerberFiles,
|
||||
DrillPath: session.DrillPath,
|
||||
NPTHPath: session.NPTHPath,
|
||||
EdgeCutsFile: session.EdgeCutsFile,
|
||||
CourtyardFile: session.CourtyardFile,
|
||||
SoldermaskFile: session.SoldermaskFile,
|
||||
FabFile: session.FabFile,
|
||||
Config: session.Config,
|
||||
Exports: session.Exports,
|
||||
BoardW: session.BoardW,
|
||||
BoardH: session.BoardH,
|
||||
ProjectName: session.ProjectName,
|
||||
Cutouts: allCutouts,
|
||||
}
|
||||
if _, saveErr := SaveSession(inst, filepath.Join(".", "temp"), session.OutlineImg); saveErr != nil {
|
||||
log.Printf("Warning: could not save session: %v", saveErr)
|
||||
}
|
||||
|
||||
// Prepare Former layers
|
||||
a.mu.Lock()
|
||||
a.formerLayers = buildEnclosureLayers(session)
|
||||
a.mu.Unlock()
|
||||
a.prepareFormerImages()
|
||||
|
||||
return &GenerateResultJS{Files: files}, nil
|
||||
}
|
||||
|
||||
func (a *App) SaveEnclosureProfile(name string) error {
|
||||
a.mu.RLock()
|
||||
session := a.enclosureSession
|
||||
allCutouts := make([]Cutout, len(a.cutouts))
|
||||
copy(allCutouts, a.cutouts)
|
||||
a.mu.RUnlock()
|
||||
|
||||
if session == nil {
|
||||
return fmt.Errorf("no enclosure session active")
|
||||
}
|
||||
if name == "" {
|
||||
name = session.ProjectName
|
||||
}
|
||||
if name == "" {
|
||||
name = "Untitled"
|
||||
}
|
||||
|
||||
inst := InstanceData{
|
||||
ID: randomID(),
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
GerberFiles: session.GerberFiles,
|
||||
DrillPath: session.DrillPath,
|
||||
NPTHPath: session.NPTHPath,
|
||||
EdgeCutsFile: session.EdgeCutsFile,
|
||||
CourtyardFile: session.CourtyardFile,
|
||||
SoldermaskFile: session.SoldermaskFile,
|
||||
FabFile: session.FabFile,
|
||||
Config: session.Config,
|
||||
Exports: session.Exports,
|
||||
BoardW: session.BoardW,
|
||||
BoardH: session.BoardH,
|
||||
ProjectName: session.ProjectName,
|
||||
Cutouts: allCutouts,
|
||||
}
|
||||
sourceDir := session.SourceDir
|
||||
if sourceDir == "" {
|
||||
sourceDir = filepath.Join(".", "temp")
|
||||
}
|
||||
_, err := SaveProfile(inst, name, sourceDir, session.OutlineImg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) OpenProject(projectPath string) error {
|
||||
_, session, inst, err := RestoreProject(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.enclosureSession = session
|
||||
a.cutouts = inst.MigrateCutouts()
|
||||
a.mu.Unlock()
|
||||
|
||||
// Render board preview
|
||||
if session.OutlineImg != nil {
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, session.OutlineImg)
|
||||
a.imageServer.Store("/api/board-preview.png", buf.Bytes())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteProject(projectPath string) error {
|
||||
return DeleteProject(projectPath)
|
||||
}
|
||||
|
||||
// ======== Auto-Detect USB Port ========
|
||||
|
||||
type AutoDetectResultJS struct {
|
||||
Footprints []Footprint `json:"footprints"`
|
||||
FabImageURL string `json:"fabImageURL"`
|
||||
}
|
||||
|
||||
func (a *App) UploadAndDetectFootprints(fabPaths []string) (*AutoDetectResultJS, error) {
|
||||
a.mu.RLock()
|
||||
session := a.enclosureSession
|
||||
a.mu.RUnlock()
|
||||
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("no enclosure session active")
|
||||
}
|
||||
if len(fabPaths) == 0 {
|
||||
return nil, fmt.Errorf("no fab gerber files selected")
|
||||
}
|
||||
|
||||
footprints, fabImg := UploadFabAndExtractFootprints(session, fabPaths)
|
||||
|
||||
if fabImg != nil {
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, fabImg)
|
||||
a.imageServer.Store("/api/fab-overlay.png", buf.Bytes())
|
||||
}
|
||||
|
||||
return &AutoDetectResultJS{
|
||||
Footprints: footprints,
|
||||
FabImageURL: "/api/fab-overlay.png?t=" + fmt.Sprint(time.Now().UnixMilli()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ======== Former ========
|
||||
|
||||
type MountingHoleJS struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Diameter float64 `json:"diameter"`
|
||||
}
|
||||
|
||||
type Enclosure3DDataJS struct {
|
||||
OutlinePoints [][2]float64 `json:"outlinePoints"`
|
||||
WallThickness float64 `json:"wallThickness"`
|
||||
Clearance float64 `json:"clearance"`
|
||||
WallHeight float64 `json:"wallHeight"`
|
||||
PCBThickness float64 `json:"pcbThickness"`
|
||||
BoardW float64 `json:"boardW"`
|
||||
BoardH float64 `json:"boardH"`
|
||||
TrayFloor float64 `json:"trayFloor"`
|
||||
SnapHeight float64 `json:"snapHeight"`
|
||||
LidThick float64 `json:"lidThick"`
|
||||
TotalH float64 `json:"totalH"`
|
||||
MountingHoles []MountingHoleJS `json:"mountingHoles"`
|
||||
Sides []BoardSide `json:"sides"`
|
||||
Cutouts []Cutout `json:"cutouts"`
|
||||
MinBX float64 `json:"minBX"`
|
||||
MaxBX float64 `json:"maxBX"`
|
||||
BoardCenterY float64 `json:"boardCenterY"`
|
||||
}
|
||||
|
||||
func (a *App) GetEnclosure3DData() *Enclosure3DDataJS {
|
||||
a.mu.RLock()
|
||||
s := a.enclosureSession
|
||||
allCutouts := make([]Cutout, len(a.cutouts))
|
||||
copy(allCutouts, a.cutouts)
|
||||
a.mu.RUnlock()
|
||||
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
poly := ExtractPolygonFromGerber(s.OutlineGf)
|
||||
if poly == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
wt := s.Config.WallThickness
|
||||
trayFloor := 1.5
|
||||
pcbT := s.Config.PCBThickness
|
||||
totalH := s.Config.WallHeight + pcbT + trayFloor
|
||||
|
||||
var mountingHoles []MountingHoleJS
|
||||
for _, h := range s.DrillHoles {
|
||||
if h.Type == DrillTypeMounting {
|
||||
mountingHoles = append(mountingHoles, MountingHoleJS{X: h.X, Y: h.Y, Diameter: h.Diameter})
|
||||
}
|
||||
}
|
||||
|
||||
return &Enclosure3DDataJS{
|
||||
OutlinePoints: poly,
|
||||
WallThickness: wt,
|
||||
Clearance: s.Config.Clearance,
|
||||
WallHeight: s.Config.WallHeight,
|
||||
PCBThickness: pcbT,
|
||||
BoardW: s.BoardW,
|
||||
BoardH: s.BoardH,
|
||||
TrayFloor: trayFloor,
|
||||
SnapHeight: 2.5,
|
||||
LidThick: wt,
|
||||
TotalH: totalH,
|
||||
MountingHoles: mountingHoles,
|
||||
Sides: s.Sides,
|
||||
Cutouts: allCutouts,
|
||||
MinBX: s.MinBX,
|
||||
MaxBX: s.MaxBX,
|
||||
BoardCenterY: s.BoardCenterY,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetFormerLayers() []LayerInfoJS {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
var result []LayerInfoJS
|
||||
for i, l := range a.formerLayers {
|
||||
result = append(result, LayerInfoJS{
|
||||
Index: i,
|
||||
Name: l.Name,
|
||||
ColorHex: fmt.Sprintf("#%02x%02x%02x", l.Color.R, l.Color.G, l.Color.B),
|
||||
Visible: l.Visible,
|
||||
Highlight: l.Highlight,
|
||||
BaseAlpha: l.BaseAlpha,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) SetLayerVisibility(index int, visible bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if index >= 0 && index < len(a.formerLayers) {
|
||||
a.formerLayers[index].Visible = visible
|
||||
if !visible {
|
||||
a.formerLayers[index].Highlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ToggleHighlight(index int) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if index < 0 || index >= len(a.formerLayers) {
|
||||
return
|
||||
}
|
||||
if a.formerLayers[index].Highlight {
|
||||
a.formerLayers[index].Highlight = false
|
||||
} else {
|
||||
for i := range a.formerLayers {
|
||||
a.formerLayers[i].Highlight = false
|
||||
}
|
||||
a.formerLayers[index].Highlight = true
|
||||
a.formerLayers[index].Visible = true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetLayerElements(layerIndex int) ([]ElementBBox, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
if layerIndex < 0 || layerIndex >= len(a.formerLayers) {
|
||||
return nil, fmt.Errorf("layer index %d out of range", layerIndex)
|
||||
}
|
||||
|
||||
layer := a.formerLayers[layerIndex]
|
||||
if layer.SourceFile == "" {
|
||||
return nil, fmt.Errorf("no source file for layer %q", layer.Name)
|
||||
}
|
||||
|
||||
if a.enclosureSession == nil {
|
||||
return nil, fmt.Errorf("no enclosure session active")
|
||||
}
|
||||
|
||||
gf, ok := a.enclosureSession.AllLayerGerbers[layer.SourceFile]
|
||||
if !ok || gf == nil {
|
||||
return nil, fmt.Errorf("parsed gerber not available for %q", layer.SourceFile)
|
||||
}
|
||||
|
||||
bounds := a.enclosureSession.OutlineBounds
|
||||
dpi := a.enclosureSession.Config.DPI
|
||||
elements := ExtractElementBBoxes(gf, dpi, &bounds)
|
||||
return elements, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenFormerEnclosure() {
|
||||
a.mu.Lock()
|
||||
if a.enclosureSession != nil {
|
||||
a.formerLayers = buildEnclosureLayers(a.enclosureSession)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
a.prepareFormerImages()
|
||||
}
|
||||
|
||||
func (a *App) prepareFormerImages() {
|
||||
a.mu.RLock()
|
||||
layers := a.formerLayers
|
||||
a.mu.RUnlock()
|
||||
|
||||
for i, layer := range layers {
|
||||
if layer.Source == nil {
|
||||
continue
|
||||
}
|
||||
// Colorize at full alpha — frontend controls opacity via canvas globalAlpha
|
||||
colored := colorizeLayer(layer.Source, layer.Color, 1.0)
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, colored)
|
||||
a.imageServer.Store(fmt.Sprintf("/api/layers/%d.png", i), buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// RenderFromFormer generates output files (SCAD, SVG, etc.) from the current session.
|
||||
// Called from the Former 3D editor's "Render & View" button.
|
||||
func (a *App) RenderFromFormer() (*GenerateResultJS, error) {
|
||||
return a.GenerateEnclosureOutputs()
|
||||
}
|
||||
|
||||
// GetOutputDir returns the output directory path for the current session.
|
||||
func (a *App) GetOutputDir() (string, error) {
|
||||
a.mu.RLock()
|
||||
session := a.enclosureSession
|
||||
a.mu.RUnlock()
|
||||
|
||||
if session == nil {
|
||||
return "", fmt.Errorf("no enclosure session active")
|
||||
}
|
||||
|
||||
outputDir := session.SourceDir
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Join(".", "temp")
|
||||
}
|
||||
|
||||
absDir, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return outputDir, nil
|
||||
}
|
||||
return absDir, nil
|
||||
}
|
||||
|
||||
// OpenOutputFolder opens the output directory in the OS file manager.
|
||||
func (a *App) OpenOutputFolder() error {
|
||||
dir, err := a.GetOutputDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("open", dir).Start()
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
pkill -f Former || true
|
||||
export SDKROOT=$(xcrun --show-sdk-path)
|
||||
export CC=/usr/bin/clang
|
||||
export CGO_ENABLED=1
|
||||
|
||||
# Generate app icon from Former.svg
|
||||
echo "Generating app icon..."
|
||||
go run ./cmd/genicon 2>/dev/null && echo "Icon generated." || echo "Icon generation skipped."
|
||||
|
||||
~/go/bin/wails build -skipbindings
|
||||
open build/bin/Former.app
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package main
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <stdlib.h>
|
||||
|
||||
unsigned char* renderSVGToPixels(const void* svgBytes, int svgLen, int targetW, int targetH) {
|
||||
@autoreleasepool {
|
||||
NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO];
|
||||
NSImage *svgImage = [[NSImage alloc] initWithData:data];
|
||||
if (!svgImage) return NULL;
|
||||
|
||||
int w = targetW;
|
||||
int h = targetH;
|
||||
int rowBytes = w * 4;
|
||||
int totalBytes = rowBytes * h;
|
||||
|
||||
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
|
||||
initWithBitmapDataPlanes:NULL
|
||||
pixelsWide:w
|
||||
pixelsHigh:h
|
||||
bitsPerSample:8
|
||||
samplesPerPixel:4
|
||||
hasAlpha:YES
|
||||
isPlanar:NO
|
||||
colorSpaceName:NSDeviceRGBColorSpace
|
||||
bytesPerRow:rowBytes
|
||||
bitsPerPixel:32];
|
||||
|
||||
[NSGraphicsContext saveGraphicsState];
|
||||
NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep];
|
||||
[NSGraphicsContext setCurrentContext:ctx];
|
||||
|
||||
[[NSColor clearColor] set];
|
||||
NSRectFill(NSMakeRect(0, 0, w, h));
|
||||
|
||||
[svgImage drawInRect:NSMakeRect(0, 0, w, h)
|
||||
fromRect:NSZeroRect
|
||||
operation:NSCompositingOperationSourceOver
|
||||
fraction:1.0];
|
||||
|
||||
[NSGraphicsContext restoreGraphicsState];
|
||||
|
||||
unsigned char* result = (unsigned char*)malloc(totalBytes);
|
||||
if (result) {
|
||||
memcpy(result, [rep bitmapData], totalBytes);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func main() {
|
||||
svgPath := "static/Former.svg"
|
||||
outPath := "build/appicon.png"
|
||||
size := 1024
|
||||
|
||||
svgData, err := os.ReadFile(svgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to read SVG: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
pixels := C.renderSVGToPixels(
|
||||
unsafe.Pointer(&svgData[0]),
|
||||
C.int(len(svgData)),
|
||||
C.int(size),
|
||||
C.int(size),
|
||||
)
|
||||
if pixels == nil {
|
||||
fmt.Fprintln(os.Stderr, "SVG rendering failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer C.free(unsafe.Pointer(pixels))
|
||||
|
||||
rawLen := size * size * 4
|
||||
raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen)
|
||||
|
||||
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
i := (y*size + x) * 4
|
||||
r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3]
|
||||
if a > 0 && a < 255 {
|
||||
scale := 255.0 / float64(a)
|
||||
r = clamp(float64(r) * scale)
|
||||
g = clamp(float64(g) * scale)
|
||||
b = clamp(float64(b) * scale)
|
||||
}
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create output: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "PNG encode failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %s (%dx%d) from %s\n", outPath, size, size, svgPath)
|
||||
}
|
||||
|
||||
func clamp(v float64) uint8 {
|
||||
if v > 255 {
|
||||
return 255
|
||||
}
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint8(v)
|
||||
}
|
||||
104
enclosure.go
104
enclosure.go
|
|
@ -9,12 +9,12 @@ import (
|
|||
|
||||
// EnclosureConfig holds parameters for enclosure generation
|
||||
type EnclosureConfig struct {
|
||||
PCBThickness float64 // mm
|
||||
WallThickness float64 // mm
|
||||
WallHeight float64 // mm (height of walls above PCB)
|
||||
Clearance float64 // mm (gap between PCB and enclosure wall)
|
||||
DPI float64
|
||||
OutlineBounds *Bounds // gerber coordinate bounds for drill mapping
|
||||
PCBThickness float64 `json:"pcbThickness"`
|
||||
WallThickness float64 `json:"wallThickness"`
|
||||
WallHeight float64 `json:"wallHeight"`
|
||||
Clearance float64 `json:"clearance"`
|
||||
DPI float64 `json:"dpi"`
|
||||
OutlineBounds *Bounds `json:"-"`
|
||||
}
|
||||
|
||||
// Default enclosure values
|
||||
|
|
@ -33,11 +33,85 @@ type EnclosureResult struct {
|
|||
|
||||
// SideCutout defines a cutout on a side wall face
|
||||
type SideCutout struct {
|
||||
Side int // 1-indexed side number (matches BoardSide.Num)
|
||||
X, Y float64 // Position on the face in mm (from StartX/StartY, from bottom)
|
||||
Width float64 // Width in mm
|
||||
Height float64 // Height in mm
|
||||
CornerRadius float64 // Corner radius in mm (0 for square)
|
||||
Side int `json:"side"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"w"`
|
||||
Height float64 `json:"h"`
|
||||
CornerRadius float64 `json:"r"`
|
||||
Layer string `json:"l"`
|
||||
}
|
||||
|
||||
// LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces)
|
||||
type LidCutout struct {
|
||||
ID int `json:"id"`
|
||||
Plane string `json:"plane"` // "lid" or "tray"
|
||||
MinX float64 `json:"minX"` // gerber mm coordinates
|
||||
MinY float64 `json:"minY"`
|
||||
MaxX float64 `json:"maxX"`
|
||||
MaxY float64 `json:"maxY"`
|
||||
IsDado bool `json:"isDado"`
|
||||
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
|
||||
}
|
||||
|
||||
// Cutout is the unified cutout type — replaces separate SideCutout/LidCutout.
|
||||
type Cutout struct {
|
||||
ID string `json:"id"`
|
||||
Surface string `json:"surface"` // "top", "bottom", "side"
|
||||
SideNum int `json:"sideNum"` // only when Surface="side"
|
||||
X float64 `json:"x"` // side: mm along side; top/bottom: gerber mm minX
|
||||
Y float64 `json:"y"` // side: mm height from PCB; top/bottom: gerber mm minY
|
||||
Width float64 `json:"w"`
|
||||
Height float64 `json:"h"`
|
||||
CornerRadius float64 `json:"r"`
|
||||
IsDado bool `json:"isDado"`
|
||||
Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut
|
||||
SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts
|
||||
}
|
||||
|
||||
// CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout
|
||||
func CutoutToSideCutout(c Cutout) SideCutout {
|
||||
return SideCutout{
|
||||
Side: c.SideNum,
|
||||
X: c.X,
|
||||
Y: c.Y,
|
||||
Width: c.Width,
|
||||
Height: c.Height,
|
||||
CornerRadius: c.CornerRadius,
|
||||
Layer: c.SourceLayer,
|
||||
}
|
||||
}
|
||||
|
||||
// CutoutToLidCutout converts a unified Cutout (surface="top"/"bottom") to legacy LidCutout
|
||||
func CutoutToLidCutout(c Cutout) LidCutout {
|
||||
plane := "lid"
|
||||
if c.Surface == "bottom" {
|
||||
plane = "tray"
|
||||
}
|
||||
return LidCutout{
|
||||
Plane: plane,
|
||||
MinX: c.X,
|
||||
MinY: c.Y,
|
||||
MaxX: c.X + c.Width,
|
||||
MaxY: c.Y + c.Height,
|
||||
IsDado: c.IsDado,
|
||||
Depth: c.Depth,
|
||||
}
|
||||
}
|
||||
|
||||
// SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation.
|
||||
func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) {
|
||||
var sides []SideCutout
|
||||
var lids []LidCutout
|
||||
for _, c := range cutouts {
|
||||
switch c.Surface {
|
||||
case "side":
|
||||
sides = append(sides, CutoutToSideCutout(c))
|
||||
case "top", "bottom":
|
||||
lids = append(lids, CutoutToLidCutout(c))
|
||||
}
|
||||
}
|
||||
return sides, lids
|
||||
}
|
||||
|
||||
// BoardSide represents a physical straight edge of the board outline
|
||||
|
|
@ -657,9 +731,9 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
|||
minZ += trayFloor + pcbT
|
||||
maxZ += trayFloor + pcbT
|
||||
|
||||
// Wall below cutout: from 0 to minZ
|
||||
if minZ > 0.05 {
|
||||
addBoxAtZ(&cutoutEncTris, bx2, by2, 0, bw, bh, minZ)
|
||||
// Wall below cutout: from trayFloor to minZ (preserve enclosure floor)
|
||||
if minZ > trayFloor+0.3 {
|
||||
addBoxAtZ(&cutoutEncTris, bx2, by2, trayFloor, bw, bh, minZ-trayFloor)
|
||||
}
|
||||
// Wall above cutout: from maxZ to totalH
|
||||
if maxZ < totalH-0.05 {
|
||||
|
|
@ -697,7 +771,7 @@ func GenerateEnclosure(outlineImg image.Image, drillHoles []DrillHole, cfg Enclo
|
|||
by2 := float64(y) * pixelToMM
|
||||
bw := float64(x-runStart) * pixelToMM
|
||||
bh := pixelToMM
|
||||
AddBox(&newEncTris, bx, by2, bw, bh, totalH)
|
||||
addBoxAtZ(&newEncTris, bx, by2, trayFloor, bw, bh, totalH-trayFloor)
|
||||
runStart = -1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KiCad-standard layer colors (NRGBA, no alpha — alpha is controlled by BaseAlpha)
|
||||
var (
|
||||
ColorFCu = color.NRGBA{R: 200, G: 52, B: 52, A: 255}
|
||||
ColorBCu = color.NRGBA{R: 77, G: 127, B: 196, A: 255}
|
||||
ColorFPaste = color.NRGBA{R: 200, G: 52, B: 52, A: 255}
|
||||
ColorBPaste = color.NRGBA{R: 0, G: 194, B: 194, A: 255}
|
||||
ColorFMask = color.NRGBA{R: 132, G: 0, B: 132, A: 255}
|
||||
ColorBMask = color.NRGBA{R: 0, G: 132, B: 132, A: 255}
|
||||
ColorFSilkS = color.NRGBA{R: 232, G: 232, B: 232, A: 255}
|
||||
ColorBSilkS = color.NRGBA{R: 200, G: 200, B: 200, A: 255}
|
||||
ColorFab = color.NRGBA{R: 132, G: 132, B: 132, A: 255}
|
||||
ColorEdgeCuts = color.NRGBA{R: 200, G: 200, B: 0, A: 255}
|
||||
ColorCrtYd = color.NRGBA{R: 194, G: 194, B: 194, A: 255}
|
||||
ColorEnclosure = color.NRGBA{R: 255, G: 253, B: 230, A: 255}
|
||||
ColorStencil = color.NRGBA{R: 180, G: 180, B: 180, A: 255}
|
||||
)
|
||||
|
||||
// FormerLayer represents a single displayable layer in The Former.
|
||||
type FormerLayer struct {
|
||||
Name string
|
||||
Color color.NRGBA
|
||||
Source image.Image // raw white-on-black gerber render
|
||||
Visible bool
|
||||
Highlight bool
|
||||
BaseAlpha float64 // default opacity 0.0–1.0 (all layers slightly transparent)
|
||||
SourceFile string // original gerber filename (key into AllLayerGerbers)
|
||||
}
|
||||
|
||||
// ElementBBox describes a selectable graphic element's bounding box on a layer.
|
||||
type ElementBBox struct {
|
||||
ID int `json:"id"`
|
||||
MinX float64 `json:"minX"`
|
||||
MinY float64 `json:"minY"`
|
||||
MaxX float64 `json:"maxX"`
|
||||
MaxY float64 `json:"maxY"`
|
||||
Type string `json:"type"`
|
||||
Footprint string `json:"footprint"`
|
||||
}
|
||||
|
||||
// ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates.
|
||||
func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox {
|
||||
if gf == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mmToPx := func(mmX, mmY float64) (float64, float64) {
|
||||
px := (mmX - bounds.MinX) * dpi / 25.4
|
||||
py := (bounds.MaxY - mmY) * dpi / 25.4
|
||||
return px, py
|
||||
}
|
||||
|
||||
apertureRadius := func(dcode int) float64 {
|
||||
if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 {
|
||||
return ap.Modifiers[0] / 2.0
|
||||
}
|
||||
return 0.25
|
||||
}
|
||||
|
||||
var elements []ElementBBox
|
||||
id := 0
|
||||
curX, curY := 0.0, 0.0
|
||||
curDCode := 0
|
||||
|
||||
for _, cmd := range gf.Commands {
|
||||
switch cmd.Type {
|
||||
case "APERTURE":
|
||||
if cmd.D != nil {
|
||||
curDCode = *cmd.D
|
||||
}
|
||||
continue
|
||||
case "G01", "G02", "G03", "G36", "G37":
|
||||
continue
|
||||
}
|
||||
|
||||
prevX, prevY := curX, curY
|
||||
if cmd.X != nil {
|
||||
curX = *cmd.X
|
||||
}
|
||||
if cmd.Y != nil {
|
||||
curY = *cmd.Y
|
||||
}
|
||||
|
||||
switch cmd.Type {
|
||||
case "FLASH": // D03
|
||||
r := apertureRadius(curDCode)
|
||||
px, py := mmToPx(curX, curY)
|
||||
rpx := r * dpi / 25.4
|
||||
elements = append(elements, ElementBBox{
|
||||
ID: id,
|
||||
MinX: px - rpx,
|
||||
MinY: py - rpx,
|
||||
MaxX: px + rpx,
|
||||
MaxY: py + rpx,
|
||||
Type: "pad",
|
||||
Footprint: cmd.Footprint,
|
||||
})
|
||||
id++
|
||||
|
||||
case "DRAW": // D01
|
||||
r := apertureRadius(curDCode)
|
||||
px1, py1 := mmToPx(prevX, prevY)
|
||||
px2, py2 := mmToPx(curX, curY)
|
||||
rpx := r * dpi / 25.4
|
||||
minPx := px1
|
||||
if px2 < minPx {
|
||||
minPx = px2
|
||||
}
|
||||
maxPx := px1
|
||||
if px2 > maxPx {
|
||||
maxPx = px2
|
||||
}
|
||||
minPy := py1
|
||||
if py2 < minPy {
|
||||
minPy = py2
|
||||
}
|
||||
maxPy := py1
|
||||
if py2 > maxPy {
|
||||
maxPy = py2
|
||||
}
|
||||
elements = append(elements, ElementBBox{
|
||||
ID: id,
|
||||
MinX: minPx - rpx,
|
||||
MinY: minPy - rpx,
|
||||
MaxX: maxPx + rpx,
|
||||
MaxY: maxPy + rpx,
|
||||
Type: "trace",
|
||||
Footprint: cmd.Footprint,
|
||||
})
|
||||
id++
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
// colorizeLayer converts a white-on-black source image into a colored NRGBA image.
|
||||
// Bright pixels become the layer color at the given alpha; dark pixels become transparent.
|
||||
func colorizeLayer(src image.Image, col color.NRGBA, alpha float64) *image.NRGBA {
|
||||
bounds := src.Bounds()
|
||||
w := bounds.Dx()
|
||||
h := bounds.Dy()
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
a := uint8(alpha * 255)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, _, _, _ := src.At(x+bounds.Min.X, y+bounds.Min.Y).RGBA()
|
||||
// r is 0–65535; treat anything above ~10% as "active"
|
||||
if r > 6500 {
|
||||
dst.SetNRGBA(x, y, color.NRGBA{R: col.R, G: col.G, B: col.B, A: a})
|
||||
}
|
||||
// else: stays transparent (zero value)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// composeLayers blends all visible layers into a single image.
|
||||
// Background matches the app theme. If any layer has Highlight=true,
|
||||
// that layer renders at full BaseAlpha while others are dimmed.
|
||||
func composeLayers(layers []*FormerLayer, width, height int) *image.NRGBA {
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
// Fill with theme background
|
||||
bg := color.NRGBA{R: 52, G: 53, B: 60, A: 255}
|
||||
for i := 0; i < width*height*4; i += 4 {
|
||||
dst.Pix[i+0] = bg.R
|
||||
dst.Pix[i+1] = bg.G
|
||||
dst.Pix[i+2] = bg.B
|
||||
dst.Pix[i+3] = bg.A
|
||||
}
|
||||
|
||||
// Check if any layer is highlighted
|
||||
hasHighlight := false
|
||||
for _, l := range layers {
|
||||
if l.Highlight && l.Visible {
|
||||
hasHighlight = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, l := range layers {
|
||||
if !l.Visible || l.Source == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
alpha := l.BaseAlpha
|
||||
if hasHighlight && !l.Highlight {
|
||||
alpha *= 0.3 // dim non-highlighted layers
|
||||
}
|
||||
|
||||
colored := colorizeLayer(l.Source, l.Color, alpha)
|
||||
|
||||
// Alpha-blend colored layer onto dst
|
||||
srcBounds := colored.Bounds()
|
||||
for y := 0; y < height && y < srcBounds.Dy(); y++ {
|
||||
for x := 0; x < width && x < srcBounds.Dx(); x++ {
|
||||
si := (y*srcBounds.Dx() + x) * 4
|
||||
di := (y*width + x) * 4
|
||||
|
||||
sa := float64(colored.Pix[si+3]) / 255.0
|
||||
if sa == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sr := float64(colored.Pix[si+0])
|
||||
sg := float64(colored.Pix[si+1])
|
||||
sb := float64(colored.Pix[si+2])
|
||||
|
||||
dr := float64(dst.Pix[di+0])
|
||||
dg := float64(dst.Pix[di+1])
|
||||
db := float64(dst.Pix[di+2])
|
||||
|
||||
inv := 1.0 - sa
|
||||
dst.Pix[di+0] = uint8(sr*sa + dr*inv)
|
||||
dst.Pix[di+1] = uint8(sg*sa + dg*inv)
|
||||
dst.Pix[di+2] = uint8(sb*sa + db*inv)
|
||||
dst.Pix[di+3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// layerInfo holds the display name and color for a KiCad layer.
|
||||
type layerInfo struct {
|
||||
Name string
|
||||
Color color.NRGBA
|
||||
DefaultOn bool // visible by default
|
||||
Alpha float64 // base alpha
|
||||
SortOrder int // lower = drawn first (bottom)
|
||||
}
|
||||
|
||||
// inferLayer maps a gerber filename to its KiCad layer name, color, and defaults.
|
||||
func inferLayer(filename string) layerInfo {
|
||||
lf := strings.ToLower(filename)
|
||||
switch {
|
||||
case strings.Contains(lf, "edge_cuts") || strings.Contains(lf, "edge.cuts"):
|
||||
return layerInfo{"Edge Cuts", ColorEdgeCuts, true, 0.7, 10}
|
||||
case strings.Contains(lf, "f_cu") || strings.Contains(lf, "f.cu") || strings.Contains(lf, "-gtl"):
|
||||
return layerInfo{"F.Cu", ColorFCu, false, 0.7, 20}
|
||||
case strings.Contains(lf, "b_cu") || strings.Contains(lf, "b.cu") || strings.Contains(lf, "-gbl"):
|
||||
return layerInfo{"B.Cu", ColorBCu, false, 0.7, 21}
|
||||
case (strings.Contains(lf, "in1") || strings.Contains(lf, "in_1")) && strings.Contains(lf, "cu"):
|
||||
return layerInfo{"In1.Cu", color.NRGBA{R: 200, G: 160, B: 52, A: 255}, false, 0.7, 22}
|
||||
case (strings.Contains(lf, "in2") || strings.Contains(lf, "in_2")) && strings.Contains(lf, "cu"):
|
||||
return layerInfo{"In2.Cu", color.NRGBA{R: 200, G: 52, B: 200, A: 255}, false, 0.7, 23}
|
||||
case strings.Contains(lf, "f_paste") || strings.Contains(lf, "f.paste") || strings.Contains(lf, "-gtp"):
|
||||
return layerInfo{"F.Paste", ColorFPaste, false, 0.7, 30}
|
||||
case strings.Contains(lf, "b_paste") || strings.Contains(lf, "b.paste") || strings.Contains(lf, "-gbp"):
|
||||
return layerInfo{"B.Paste", ColorBPaste, false, 0.7, 31}
|
||||
case strings.Contains(lf, "f_silks") || strings.Contains(lf, "f.silks") || strings.Contains(lf, "f_silk"):
|
||||
return layerInfo{"F.SilkS", ColorFSilkS, false, 0.7, 40}
|
||||
case strings.Contains(lf, "b_silks") || strings.Contains(lf, "b.silks") || strings.Contains(lf, "b_silk"):
|
||||
return layerInfo{"B.SilkS", ColorBSilkS, false, 0.7, 41}
|
||||
case strings.Contains(lf, "f_mask") || strings.Contains(lf, "f.mask") || strings.Contains(lf, "-gts"):
|
||||
return layerInfo{"F.Mask", ColorFMask, false, 0.6, 50}
|
||||
case strings.Contains(lf, "b_mask") || strings.Contains(lf, "b.mask") || strings.Contains(lf, "-gbs"):
|
||||
return layerInfo{"B.Mask", ColorBMask, false, 0.6, 51}
|
||||
case strings.Contains(lf, "f_courtyard") || strings.Contains(lf, "f_crtyd") || strings.Contains(lf, "f.crtyd"):
|
||||
return layerInfo{"F.CrtYd", ColorCrtYd, false, 0.6, 60}
|
||||
case strings.Contains(lf, "b_courtyard") || strings.Contains(lf, "b_crtyd") || strings.Contains(lf, "b.crtyd"):
|
||||
return layerInfo{"B.CrtYd", ColorCrtYd, false, 0.6, 61}
|
||||
case strings.Contains(lf, "f_fab") || strings.Contains(lf, "f.fab"):
|
||||
return layerInfo{"F.Fab", ColorFab, false, 0.6, 70}
|
||||
case strings.Contains(lf, "b_fab") || strings.Contains(lf, "b.fab"):
|
||||
return layerInfo{"B.Fab", ColorFab, false, 0.6, 71}
|
||||
case strings.Contains(lf, ".gbrjob"):
|
||||
return layerInfo{} // skip job files
|
||||
default:
|
||||
return layerInfo{filename, color.NRGBA{R: 160, G: 160, B: 160, A: 255}, false, 0.6, 100}
|
||||
}
|
||||
}
|
||||
|
||||
// renderEnclosureWallImage generates a 2D top-down image of the enclosure walls
|
||||
// from the outline image and config. White pixels = wall area.
|
||||
func renderEnclosureWallImage(outlineImg image.Image, cfg EnclosureConfig) image.Image {
|
||||
bounds := outlineImg.Bounds()
|
||||
w := bounds.Dx()
|
||||
h := bounds.Dy()
|
||||
|
||||
pixelToMM := 25.4 / cfg.DPI
|
||||
wallDist, boardMask := ComputeWallMask(outlineImg, cfg.WallThickness+cfg.Clearance, pixelToMM)
|
||||
|
||||
wallThickPx := int(cfg.WallThickness / pixelToMM)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
idx := y*w + x
|
||||
// Wall area: outside the board, within wall thickness distance
|
||||
if !boardMask[idx] && wallDist[idx] > 0 && wallDist[idx] <= wallThickPx {
|
||||
dst.Pix[idx*4+0] = 255
|
||||
dst.Pix[idx*4+1] = 255
|
||||
dst.Pix[idx*4+2] = 255
|
||||
dst.Pix[idx*4+3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// buildStencilLayers creates FormerLayer slice for the stencil workflow.
|
||||
func buildStencilLayers(pasteImg, outlineImg image.Image) []*FormerLayer {
|
||||
var layers []*FormerLayer
|
||||
|
||||
if outlineImg != nil {
|
||||
layers = append(layers, &FormerLayer{
|
||||
Name: "Edge Cuts",
|
||||
Color: ColorEdgeCuts,
|
||||
Source: outlineImg,
|
||||
Visible: true,
|
||||
BaseAlpha: 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
if pasteImg != nil {
|
||||
layers = append(layers, &FormerLayer{
|
||||
Name: "Solder Paste",
|
||||
Color: ColorFPaste,
|
||||
Source: pasteImg,
|
||||
Visible: true,
|
||||
BaseAlpha: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
return layers
|
||||
}
|
||||
|
||||
// buildEnclosureLayers creates FormerLayer slice for the enclosure workflow
|
||||
// using ALL uploaded gerber layers, not just the ones with special roles.
|
||||
func buildEnclosureLayers(session *EnclosureSession) []*FormerLayer {
|
||||
type sortedLayer struct {
|
||||
layer *FormerLayer
|
||||
order int
|
||||
}
|
||||
var sorted []sortedLayer
|
||||
|
||||
// Tray — hidden by default, rendered as 3D geometry in the Former
|
||||
if session.EnclosureWallImg != nil {
|
||||
sorted = append(sorted, sortedLayer{
|
||||
layer: &FormerLayer{
|
||||
Name: "Tray",
|
||||
Color: color.NRGBA{R: 180, G: 200, B: 160, A: 255},
|
||||
Source: session.EnclosureWallImg, // placeholder image
|
||||
Visible: false,
|
||||
BaseAlpha: 0.4,
|
||||
},
|
||||
order: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// Enclosure walls — bottom layer, very transparent
|
||||
if session.EnclosureWallImg != nil {
|
||||
sorted = append(sorted, sortedLayer{
|
||||
layer: &FormerLayer{
|
||||
Name: "Enclosure",
|
||||
Color: ColorEnclosure,
|
||||
Source: session.EnclosureWallImg,
|
||||
Visible: true,
|
||||
BaseAlpha: 0.35,
|
||||
},
|
||||
order: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// All gerber layers from uploaded files
|
||||
for origName, img := range session.AllLayerImages {
|
||||
if img == nil {
|
||||
continue
|
||||
}
|
||||
info := inferLayer(origName)
|
||||
if info.Name == "" {
|
||||
continue
|
||||
}
|
||||
sorted = append(sorted, sortedLayer{
|
||||
layer: &FormerLayer{
|
||||
Name: info.Name,
|
||||
Color: info.Color,
|
||||
Source: img,
|
||||
Visible: info.DefaultOn,
|
||||
BaseAlpha: info.Alpha,
|
||||
SourceFile: origName,
|
||||
},
|
||||
order: info.SortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by order (lower = bottom)
|
||||
for i := 0; i < len(sorted); i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[j].order < sorted[i].order {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layers := make([]*FormerLayer, len(sorted))
|
||||
for i, s := range sorted {
|
||||
layers[i] = s.layer
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Former</title>
|
||||
<link rel="stylesheet" href="/src/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Navigation (includes macOS draggable titlebar region) -->
|
||||
<nav id="nav" style="--wails-draggable:drag">
|
||||
<span class="nav-brand" onclick="navigate('landing')">Former</span>
|
||||
<div class="nav-spacer"></div>
|
||||
<button class="nav-btn" onclick="navigate('stencil')">Stencil</button>
|
||||
<button class="nav-btn" onclick="navigate('enclosure')">Enclosure</button>
|
||||
<button class="nav-btn" onclick="openOutputFolder()" id="nav-open-output" style="display:none">Open Output</button>
|
||||
</nav>
|
||||
|
||||
<!-- Pages -->
|
||||
<main id="main">
|
||||
<section id="page-landing" class="page active"></section>
|
||||
<section id="page-stencil" class="page"></section>
|
||||
<section id="page-enclosure" class="page"></section>
|
||||
<section id="page-preview" class="page"></section>
|
||||
<section id="page-stencil-result" class="page"></section>
|
||||
<section id="page-enclosure-result" class="page"></section>
|
||||
<section id="page-former" class="page page-former"></section>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "former-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.183.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
98e9f2b9e6d5bad224e73bd97622e3b9
|
||||
|
|
@ -0,0 +1,873 @@
|
|||
// Former 3D — Three.js layer viewer with orbit controls, layer selection, cutout tools, and grid
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
const Z_SPACING = 3;
|
||||
|
||||
export class Former3D {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.layers = [];
|
||||
this.layerMeshes = [];
|
||||
this.selectedLayerIndex = -1;
|
||||
this.cutoutMode = false;
|
||||
this.elements = [];
|
||||
this.elementMeshes = [];
|
||||
this.hoveredElement = -1;
|
||||
this.cutouts = [];
|
||||
this.enclosureMesh = null;
|
||||
this.trayMesh = null;
|
||||
this.enclosureLayerIndex = -1;
|
||||
this.trayLayerIndex = -1;
|
||||
|
||||
this._onLayerSelect = null;
|
||||
this._onCutoutSelect = null;
|
||||
this._onCutoutHover = null;
|
||||
|
||||
this._initScene();
|
||||
this._initControls();
|
||||
this._initGrid();
|
||||
this._initRaycasting();
|
||||
this._animate();
|
||||
}
|
||||
|
||||
_initScene() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x000000);
|
||||
|
||||
const w = this.container.clientWidth;
|
||||
const h = this.container.clientHeight;
|
||||
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000);
|
||||
this.camera.position.set(0, -60, 80);
|
||||
this.camera.up.set(0, 0, 1);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.9));
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
dirLight.position.set(50, -50, 100);
|
||||
this.scene.add(dirLight);
|
||||
|
||||
this.layerGroup = new THREE.Group();
|
||||
this.scene.add(this.layerGroup);
|
||||
|
||||
this.arrowGroup = new THREE.Group();
|
||||
this.arrowGroup.visible = false;
|
||||
this.scene.add(this.arrowGroup);
|
||||
|
||||
this.elementGroup = new THREE.Group();
|
||||
this.elementGroup.visible = false;
|
||||
this.scene.add(this.elementGroup);
|
||||
|
||||
this.selectionOutline = null;
|
||||
|
||||
this._resizeObserver = new ResizeObserver(() => this._onResize());
|
||||
this._resizeObserver.observe(this.container);
|
||||
}
|
||||
|
||||
_initControls() {
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.1;
|
||||
this.controls.mouseButtons = {
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.PAN,
|
||||
RIGHT: THREE.MOUSE.DOLLY
|
||||
};
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
_initGrid() {
|
||||
this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222);
|
||||
this.gridHelper.rotation.x = Math.PI / 2;
|
||||
this.gridHelper.position.z = -0.5;
|
||||
this.scene.add(this.gridHelper);
|
||||
this.gridVisible = true;
|
||||
}
|
||||
|
||||
toggleGrid() {
|
||||
this.gridVisible = !this.gridVisible;
|
||||
this.gridHelper.visible = this.gridVisible;
|
||||
return this.gridVisible;
|
||||
}
|
||||
|
||||
_initRaycasting() {
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouse = new THREE.Vector2();
|
||||
this._isDragging = false;
|
||||
this._mouseDownPos = { x: 0, y: 0 };
|
||||
|
||||
const canvas = this.renderer.domElement;
|
||||
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
this._isDragging = false;
|
||||
this._mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||
|
||||
// Start rectangle selection in dado mode (left button only)
|
||||
if (this.cutoutMode && this.isDadoMode && e.button === 0) {
|
||||
this._rectSelecting = true;
|
||||
this._rectStart = { x: e.clientX, y: e.clientY };
|
||||
// Create overlay div
|
||||
if (!this._rectOverlay) {
|
||||
this._rectOverlay = document.createElement('div');
|
||||
this._rectOverlay.style.cssText = 'position:fixed;border:2px dashed #f9e2af;background:rgba(249,226,175,0.1);pointer-events:none;z-index:9999;display:none;';
|
||||
document.body.appendChild(this._rectOverlay);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
const dx = e.clientX - this._mouseDownPos.x;
|
||||
const dy = e.clientY - this._mouseDownPos.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
this._isDragging = true;
|
||||
}
|
||||
|
||||
// Update rectangle overlay during dado drag
|
||||
if (this._rectSelecting && this._rectStart && this._rectOverlay) {
|
||||
const x1 = Math.min(this._rectStart.x, e.clientX);
|
||||
const y1 = Math.min(this._rectStart.y, e.clientY);
|
||||
const w = Math.abs(e.clientX - this._rectStart.x);
|
||||
const h = Math.abs(e.clientY - this._rectStart.y);
|
||||
this._rectOverlay.style.left = x1 + 'px';
|
||||
this._rectOverlay.style.top = y1 + 'px';
|
||||
this._rectOverlay.style.width = w + 'px';
|
||||
this._rectOverlay.style.height = h + 'px';
|
||||
this._rectOverlay.style.display = (w > 5 || h > 5) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (this.cutoutMode && this.elementMeshes.length > 0) {
|
||||
this._updateMouse(e);
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const hits = this.raycaster.intersectObjects(this.elementMeshes);
|
||||
const newHover = hits.length > 0 ? this.elementMeshes.indexOf(hits[0].object) : -1;
|
||||
if (newHover !== this.hoveredElement) {
|
||||
if (this.hoveredElement >= 0 && this.hoveredElement < this.elementMeshes.length) {
|
||||
const m = this.elementMeshes[this.hoveredElement];
|
||||
if (!m.userData.selected) {
|
||||
m.material.opacity = 0.2;
|
||||
m.material.color.setHex(0x89b4fa);
|
||||
}
|
||||
}
|
||||
if (newHover >= 0) {
|
||||
const m = this.elementMeshes[newHover];
|
||||
if (!m.userData.selected) {
|
||||
m.material.opacity = 0.6;
|
||||
m.material.color.setHex(0xfab387);
|
||||
}
|
||||
}
|
||||
this.hoveredElement = newHover;
|
||||
if (this._onCutoutHover) this._onCutoutHover(newHover);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', e => {
|
||||
if (this._rectSelecting && this._rectStart && this._isDragging) {
|
||||
const x1 = Math.min(this._rectStart.x, e.clientX);
|
||||
const y1 = Math.min(this._rectStart.y, e.clientY);
|
||||
const x2 = Math.max(this._rectStart.x, e.clientX);
|
||||
const y2 = Math.max(this._rectStart.y, e.clientY);
|
||||
|
||||
if (x2 - x1 > 10 && y2 - y1 > 10) {
|
||||
// Select all elements whose projected center falls within the rectangle
|
||||
for (let i = 0; i < this.elementMeshes.length; i++) {
|
||||
const m = this.elementMeshes[i];
|
||||
if (m.userData.selected) continue;
|
||||
// Project mesh center to screen
|
||||
const pos = m.position.clone();
|
||||
pos.project(this.camera);
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
const sx = (pos.x * 0.5 + 0.5) * rect.width + rect.left;
|
||||
const sy = (-pos.y * 0.5 + 0.5) * rect.height + rect.top;
|
||||
|
||||
if (sx >= x1 && sx <= x2 && sy >= y1 && sy <= y2) {
|
||||
m.userData.selected = true;
|
||||
m.material.color.setHex(0xf9e2af);
|
||||
m.material.opacity = 0.7;
|
||||
this.cutouts.push(this.elements[i]);
|
||||
if (this._onCutoutSelect) this._onCutoutSelect(this.elements[i], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._rectSelecting = false;
|
||||
this._rectStart = null;
|
||||
if (this._rectOverlay) this._rectOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', e => {
|
||||
if (this._isDragging) return;
|
||||
|
||||
this._updateMouse(e);
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
|
||||
if (this.cutoutMode) {
|
||||
if (this.hoveredElement >= 0 && this.hoveredElement < this.elements.length) {
|
||||
const el = this.elements[this.hoveredElement];
|
||||
const m = this.elementMeshes[this.hoveredElement];
|
||||
if (m.userData.selected) {
|
||||
m.userData.selected = false;
|
||||
m.material.color.setHex(0xfab387);
|
||||
m.material.opacity = 0.6;
|
||||
this.cutouts = this.cutouts.filter(c => c.id !== el.id);
|
||||
} else {
|
||||
m.userData.selected = true;
|
||||
const selColor = this.isDadoMode ? 0xf9e2af : 0xa6e3a1;
|
||||
m.material.color.setHex(selColor);
|
||||
m.material.opacity = 0.7;
|
||||
this.cutouts.push(el);
|
||||
}
|
||||
if (this._onCutoutSelect) this._onCutoutSelect(el, m.userData.selected);
|
||||
}
|
||||
} else {
|
||||
const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible);
|
||||
const hits = this.raycaster.intersectObjects(clickables, true);
|
||||
if (hits.length > 0) {
|
||||
let hitObj = hits[0].object;
|
||||
let idx = this.layerMeshes.indexOf(hitObj);
|
||||
if (idx < 0) {
|
||||
// For enclosure mesh, check ancestors
|
||||
hitObj.traverseAncestors(p => {
|
||||
const ei = this.layerMeshes.indexOf(p);
|
||||
if (ei >= 0) idx = ei;
|
||||
});
|
||||
}
|
||||
if (idx >= 0) this.selectLayer(idx);
|
||||
} else {
|
||||
this.selectLayer(-1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_updateMouse(e) {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const w = this.container.clientWidth;
|
||||
const h = this.container.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
}
|
||||
|
||||
_animate() {
|
||||
this._animId = requestAnimationFrame(() => this._animate());
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
// ===== Layer loading =====
|
||||
async loadLayers(layers, imageUrls) {
|
||||
this.layers = layers;
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
while (this.layerGroup.children.length > 0) {
|
||||
const child = this.layerGroup.children[0];
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (child.material.map) child.material.map.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
this.layerGroup.remove(child);
|
||||
}
|
||||
this.layerMeshes = [];
|
||||
this.enclosureMesh = null;
|
||||
this.trayMesh = null;
|
||||
this.enclosureLayerIndex = -1;
|
||||
this.trayLayerIndex = -1;
|
||||
|
||||
let maxW = 0, maxH = 0;
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const layer = layers[i];
|
||||
const url = imageUrls[i];
|
||||
|
||||
if (layer.name === 'Enclosure') {
|
||||
this.enclosureLayerIndex = i;
|
||||
this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry
|
||||
continue;
|
||||
}
|
||||
if (layer.name === 'Tray') {
|
||||
this.trayLayerIndex = i;
|
||||
this.layerMeshes.push(null); // placeholder, replaced by loadEnclosureGeometry
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!url) { this.layerMeshes.push(null); continue; }
|
||||
|
||||
try {
|
||||
const tex = await new Promise((resolve, reject) => {
|
||||
loader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
|
||||
const imgW = tex.image.width;
|
||||
const imgH = tex.image.height;
|
||||
if (imgW > maxW) maxW = imgW;
|
||||
if (imgH > maxH) maxH = imgH;
|
||||
|
||||
const geo = new THREE.PlaneGeometry(imgW, imgH);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
opacity: layer.visible ? layer.baseAlpha : 0,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING);
|
||||
mesh.visible = layer.visible;
|
||||
mesh.userData = { layerIndex: i };
|
||||
|
||||
this.layerGroup.add(mesh);
|
||||
this.layerMeshes.push(mesh);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load layer ${i}:`, e);
|
||||
this.layerMeshes.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
this._maxW = maxW;
|
||||
this._maxH = maxH;
|
||||
|
||||
if (maxW > 0 && maxH > 0) {
|
||||
const cx = maxW / 2;
|
||||
const cy = -maxH / 2;
|
||||
const cz = (layers.length * Z_SPACING) / 2;
|
||||
this.controls.target.set(cx, cy, cz);
|
||||
const dist = Math.max(maxW, maxH) * 0.7;
|
||||
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
|
||||
this.camera.lookAt(cx, cy, cz);
|
||||
this.controls.update();
|
||||
|
||||
this.gridHelper.position.set(cx, cy, -0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 3D Enclosure geometry =====
|
||||
// Creates full enclosure + tray geometry from the same parameters as the SCAD output.
|
||||
loadEnclosureGeometry(encData, dpi, minX, maxY) {
|
||||
if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return;
|
||||
|
||||
// Remove previous meshes
|
||||
this._disposeEnclosureMeshes();
|
||||
|
||||
const s = dpi / 25.4; // mm to pixels
|
||||
|
||||
// Convert mm to 3D pixel-space coordinates (Y inverted for image space)
|
||||
const toPixel = (mmX, mmY) => [
|
||||
(mmX - minX) * s,
|
||||
-(maxY - mmY) * s
|
||||
];
|
||||
|
||||
const pts = encData.outlinePoints.map(p => toPixel(p[0], p[1]));
|
||||
|
||||
// Compute winding and offset function
|
||||
let area = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const j = (i + 1) % pts.length;
|
||||
area += pts[i][0] * pts[j][1] - pts[j][0] * pts[i][1];
|
||||
}
|
||||
const sign = area < 0 ? 1 : -1; // outward offset sign
|
||||
|
||||
const offsetPoly = (points, dist) => {
|
||||
const n = points.length;
|
||||
const result = [];
|
||||
const maxMiter = Math.abs(dist) * 2; // clamp miter to 2x offset (bevel-style at sharp corners)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = points[(i - 1 + n) % n];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % n];
|
||||
const e1x = curr[0] - prev[0], e1y = curr[1] - prev[1];
|
||||
const e2x = next[0] - curr[0], e2y = next[1] - curr[1];
|
||||
const len1 = Math.sqrt(e1x * e1x + e1y * e1y) || 1;
|
||||
const len2 = Math.sqrt(e2x * e2x + e2y * e2y) || 1;
|
||||
const n1x = -e1y / len1, n1y = e1x / len1;
|
||||
const n2x = -e2y / len2, n2y = e2x / len2;
|
||||
let nx = n1x + n2x, ny = n1y + n2y;
|
||||
const nlen = Math.sqrt(nx * nx + ny * ny) || 1;
|
||||
nx /= nlen; ny /= nlen;
|
||||
const dot = n1x * nx + n1y * ny;
|
||||
const rawMiter = dot > 0.01 ? dist / dot : dist;
|
||||
// Clamp: if corner is too sharp, insert bevel (two points)
|
||||
if (Math.abs(rawMiter) > maxMiter) {
|
||||
const d = dist;
|
||||
result.push([curr[0] + n1x * d, curr[1] + n1y * d]);
|
||||
result.push([curr[0] + n2x * d, curr[1] + n2y * d]);
|
||||
} else {
|
||||
result.push([curr[0] + nx * rawMiter, curr[1] + ny * rawMiter]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeShape = (poly) => {
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(poly[0][0], poly[0][1]);
|
||||
for (let i = 1; i < poly.length; i++) shape.lineTo(poly[i][0], poly[i][1]);
|
||||
shape.closePath();
|
||||
return shape;
|
||||
};
|
||||
|
||||
const makeHole = (poly) => {
|
||||
const path = new THREE.Path();
|
||||
path.moveTo(poly[0][0], poly[0][1]);
|
||||
for (let i = 1; i < poly.length; i++) path.lineTo(poly[i][0], poly[i][1]);
|
||||
path.closePath();
|
||||
return path;
|
||||
};
|
||||
|
||||
const makeRing = (outerPoly, innerPoly, depth, zPos) => {
|
||||
const shape = makeShape(outerPoly);
|
||||
shape.holes.push(makeHole(innerPoly));
|
||||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||||
return { geo, zPos };
|
||||
};
|
||||
|
||||
const makeSolid = (poly, depth, zPos) => {
|
||||
const shape = makeShape(poly);
|
||||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||||
return { geo, zPos };
|
||||
};
|
||||
|
||||
// Key dimensions from SCAD (converted to pixels for XY, raw mm for Z which we scale)
|
||||
const cl = encData.clearance;
|
||||
const wt = encData.wallThickness;
|
||||
const trayFloor = encData.trayFloor;
|
||||
const snapH = encData.snapHeight;
|
||||
const lidThick = encData.lidThick;
|
||||
const totalH = encData.totalH;
|
||||
|
||||
// Pre-compute offset polygons in pixel space
|
||||
const polyInner = offsetPoly(pts, sign * cl * s); // offset(clearance)
|
||||
const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s); // offset(clearance + wt)
|
||||
const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s); // offset(clearance + 2*wt)
|
||||
|
||||
const enclosureParts = [];
|
||||
const trayParts = [];
|
||||
|
||||
// ===== ENCLOSURE (lid-on-top piece) =====
|
||||
// Small epsilon to prevent Z-fighting at shared boundaries
|
||||
const eps = 0.05 * s;
|
||||
|
||||
// 1. Lid plate: solid from totalH-lidThick to totalH
|
||||
enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps));
|
||||
|
||||
// 2. Upper wall ring: outer to inner, from trayFloor+snapH to totalH-lidThick
|
||||
const upperWallH = totalH - lidThick - (trayFloor + snapH);
|
||||
if (upperWallH > 0.1) {
|
||||
enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps));
|
||||
}
|
||||
|
||||
// 3. Lower wall ring: outer to trayWall, from trayFloor to trayFloor+snapH
|
||||
// (wider inner cavity for tray snap-fit recess)
|
||||
if (snapH > 0.1) {
|
||||
enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s));
|
||||
}
|
||||
|
||||
// 4. Mounting pegs (cylinders)
|
||||
if (encData.mountingHoles) {
|
||||
for (const h of encData.mountingHoles) {
|
||||
const [px, py] = toPixel(h.x, h.y);
|
||||
const r = ((h.diameter / 2) - 0.15) * s;
|
||||
const pegH = (totalH - lidThick) * s;
|
||||
const cylGeo = new THREE.CylinderGeometry(r, r, pegH, 16);
|
||||
cylGeo.rotateX(Math.PI / 2); // align cylinder with Z axis
|
||||
enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRAY =====
|
||||
// 1. Tray floor: solid, from 0 to trayFloor
|
||||
trayParts.push(makeSolid(polyOuter, trayFloor * s, 0));
|
||||
|
||||
// 2. Tray inner wall: ring from trayWall to inner, from trayFloor to trayFloor+snapH
|
||||
if (snapH > 0.1) {
|
||||
trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps));
|
||||
}
|
||||
|
||||
// Build enclosure group mesh
|
||||
const encMat = new THREE.MeshPhongMaterial({
|
||||
color: 0xfffdcc, transparent: true, opacity: 0.55,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1,
|
||||
});
|
||||
|
||||
const encGroup = new THREE.Group();
|
||||
for (const part of enclosureParts) {
|
||||
const mesh = new THREE.Mesh(part.geo, encMat.clone());
|
||||
if (part.isCyl) {
|
||||
mesh.position.set(part.cx, part.cy, part.zPos);
|
||||
} else {
|
||||
mesh.position.z = part.zPos;
|
||||
}
|
||||
encGroup.add(mesh);
|
||||
}
|
||||
|
||||
if (this.enclosureLayerIndex >= 0) {
|
||||
encGroup.position.z = this.enclosureLayerIndex * Z_SPACING;
|
||||
encGroup.userData = { layerIndex: this.enclosureLayerIndex, isEnclosure: true };
|
||||
const layer = this.layers[this.enclosureLayerIndex];
|
||||
encGroup.visible = layer ? layer.visible : true;
|
||||
this.enclosureMesh = encGroup;
|
||||
this.layerMeshes[this.enclosureLayerIndex] = encGroup;
|
||||
this.layerGroup.add(encGroup);
|
||||
}
|
||||
|
||||
// Build tray group mesh
|
||||
const trayMat = new THREE.MeshPhongMaterial({
|
||||
color: 0xb8c8a0, transparent: true, opacity: 0.5,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1,
|
||||
});
|
||||
|
||||
const trayGroup = new THREE.Group();
|
||||
for (const part of trayParts) {
|
||||
const mesh = new THREE.Mesh(part.geo, trayMat.clone());
|
||||
mesh.position.z = part.zPos;
|
||||
trayGroup.add(mesh);
|
||||
}
|
||||
|
||||
if (this.trayLayerIndex >= 0) {
|
||||
trayGroup.position.z = this.trayLayerIndex * Z_SPACING;
|
||||
trayGroup.userData = { layerIndex: this.trayLayerIndex, isEnclosure: true };
|
||||
const layer = this.layers[this.trayLayerIndex];
|
||||
trayGroup.visible = layer ? layer.visible : false;
|
||||
this.trayMesh = trayGroup;
|
||||
this.layerMeshes[this.trayLayerIndex] = trayGroup;
|
||||
this.layerGroup.add(trayGroup);
|
||||
}
|
||||
}
|
||||
|
||||
_disposeEnclosureMeshes() {
|
||||
for (const mesh of [this.enclosureMesh, this.trayMesh]) {
|
||||
if (mesh) {
|
||||
this.layerGroup.remove(mesh);
|
||||
mesh.traverse(c => {
|
||||
if (c.geometry) c.geometry.dispose();
|
||||
if (c.material) c.material.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
this.enclosureMesh = null;
|
||||
this.trayMesh = null;
|
||||
}
|
||||
|
||||
// ===== Layer visibility =====
|
||||
setLayerVisibility(index, visible) {
|
||||
if (index < 0 || index >= this.layerMeshes.length) return;
|
||||
const mesh = this.layerMeshes[index];
|
||||
if (!mesh) return;
|
||||
this.layers[index].visible = visible;
|
||||
mesh.visible = visible;
|
||||
if (mesh.material && !mesh.userData?.isEnclosure) {
|
||||
if (!visible) mesh.material.opacity = 0;
|
||||
else mesh.material.opacity = this.layers[index].baseAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
_setGroupOpacity(group, opacity) {
|
||||
group.traverse(c => {
|
||||
if (c.material) c.material.opacity = opacity;
|
||||
});
|
||||
}
|
||||
|
||||
setLayerHighlight(index, highlight) {
|
||||
const hasHL = highlight && index >= 0;
|
||||
this.layers.forEach((l, i) => {
|
||||
l.highlight = (i === index && highlight);
|
||||
const mesh = this.layerMeshes[i];
|
||||
if (!mesh || !l.visible) return;
|
||||
if (mesh.userData?.isEnclosure) {
|
||||
this._setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55);
|
||||
} else if (mesh.material) {
|
||||
mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Selection =====
|
||||
selectLayer(index) {
|
||||
this.selectedLayerIndex = index;
|
||||
|
||||
if (this.selectionOutline) {
|
||||
this.scene.remove(this.selectionOutline);
|
||||
this.selectionOutline.geometry?.dispose();
|
||||
this.selectionOutline.material?.dispose();
|
||||
this.selectionOutline = null;
|
||||
}
|
||||
|
||||
this.arrowGroup.visible = false;
|
||||
while (this.arrowGroup.children.length) {
|
||||
const c = this.arrowGroup.children[0];
|
||||
this.arrowGroup.remove(c);
|
||||
}
|
||||
|
||||
if (index < 0 || index >= this.layerMeshes.length) {
|
||||
if (this._onLayerSelect) this._onLayerSelect(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const mesh = this.layerMeshes[index];
|
||||
if (!mesh) return;
|
||||
|
||||
// Selection outline (skip for enclosure — too complex)
|
||||
if (mesh.geometry && !mesh.userData?.isEnclosure) {
|
||||
const edges = new THREE.EdgesGeometry(mesh.geometry);
|
||||
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
|
||||
color: 0x89b4fa, linewidth: 2,
|
||||
}));
|
||||
line.position.copy(mesh.position);
|
||||
line.position.z += 0.1;
|
||||
this.selectionOutline = line;
|
||||
this.scene.add(line);
|
||||
}
|
||||
|
||||
// Z-axis arrows
|
||||
const pos = mesh.position.clone();
|
||||
if (mesh.userData?.isEnclosure) {
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
box.getCenter(pos);
|
||||
}
|
||||
const arrowLen = 8;
|
||||
const upArrow = new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
new THREE.Vector3(pos.x, pos.y, pos.z + 2),
|
||||
arrowLen, 0x89b4fa, 3, 2
|
||||
);
|
||||
const downArrow = new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 0, -1),
|
||||
new THREE.Vector3(pos.x, pos.y, pos.z - 2),
|
||||
arrowLen, 0x89b4fa, 3, 2
|
||||
);
|
||||
this.arrowGroup.add(upArrow);
|
||||
this.arrowGroup.add(downArrow);
|
||||
this.arrowGroup.visible = true;
|
||||
|
||||
if (this._onLayerSelect) this._onLayerSelect(index);
|
||||
}
|
||||
|
||||
moveSelectedZ(delta) {
|
||||
if (this.selectedLayerIndex < 0) return;
|
||||
const mesh = this.layerMeshes[this.selectedLayerIndex];
|
||||
if (!mesh) return;
|
||||
mesh.position.z += delta;
|
||||
if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1;
|
||||
if (this.arrowGroup.children.length >= 2) {
|
||||
const pos = mesh.position;
|
||||
this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2);
|
||||
this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Cutout mode =====
|
||||
enterCutoutMode(elements, layerIndex, isDado = false) {
|
||||
this.cutoutMode = true;
|
||||
this.isDadoMode = isDado;
|
||||
this.elements = elements;
|
||||
this.hoveredElement = -1;
|
||||
this.cutouts = [];
|
||||
this._rectSelecting = false;
|
||||
this._rectStart = null;
|
||||
this._rectOverlay = null;
|
||||
|
||||
while (this.elementGroup.children.length) {
|
||||
const c = this.elementGroup.children[0];
|
||||
c.geometry?.dispose();
|
||||
c.material?.dispose();
|
||||
this.elementGroup.remove(c);
|
||||
}
|
||||
this.elementMeshes = [];
|
||||
|
||||
const layerMesh = this.layerMeshes[layerIndex];
|
||||
const layerZ = layerMesh ? layerMesh.position.z : 0;
|
||||
|
||||
for (const el of elements) {
|
||||
const w = el.maxX - el.minX;
|
||||
const h = el.maxY - el.minY;
|
||||
if (w < 0.5 || h < 0.5) continue;
|
||||
|
||||
const geo = new THREE.PlaneGeometry(w, h);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0x89b4fa,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
const elCx = el.minX + w / 2;
|
||||
const elCy = el.minY + h / 2;
|
||||
mesh.position.set(elCx, -elCy, layerZ + 0.2);
|
||||
mesh.userData = { elementId: el.id, selected: false };
|
||||
|
||||
this.elementGroup.add(mesh);
|
||||
this.elementMeshes.push(mesh);
|
||||
}
|
||||
|
||||
this.elementGroup.visible = true;
|
||||
this._homeTopDown(layerIndex);
|
||||
}
|
||||
|
||||
exitCutoutMode() {
|
||||
this.cutoutMode = false;
|
||||
this.isDadoMode = false;
|
||||
this.elements = [];
|
||||
this.hoveredElement = -1;
|
||||
this._rectSelecting = false;
|
||||
this._rectStart = null;
|
||||
if (this._rectOverlay) {
|
||||
this._rectOverlay.remove();
|
||||
this._rectOverlay = null;
|
||||
}
|
||||
this.elementGroup.visible = false;
|
||||
while (this.elementGroup.children.length) {
|
||||
const c = this.elementGroup.children[0];
|
||||
c.geometry?.dispose();
|
||||
c.material?.dispose();
|
||||
this.elementGroup.remove(c);
|
||||
}
|
||||
this.elementMeshes = [];
|
||||
}
|
||||
|
||||
// ===== Camera =====
|
||||
_homeTopDown(layerIndex) {
|
||||
const mesh = this.layerMeshes[layerIndex];
|
||||
if (!mesh) return;
|
||||
const pos = mesh.position.clone();
|
||||
if (mesh.userData?.isEnclosure) {
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
box.getCenter(pos);
|
||||
}
|
||||
let imgW, imgH;
|
||||
if (mesh.geometry?.parameters) {
|
||||
imgW = mesh.geometry.parameters.width;
|
||||
imgH = mesh.geometry.parameters.height;
|
||||
} else {
|
||||
imgW = this._maxW || 500;
|
||||
imgH = this._maxH || 500;
|
||||
}
|
||||
const dist = Math.max(imgW, imgH) * 1.1;
|
||||
this.camera.position.set(pos.x, pos.y, pos.z + dist);
|
||||
this.camera.up.set(0, 1, 0);
|
||||
this.controls.target.set(pos.x, pos.y, pos.z);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
homeTopDown(layerIndex) {
|
||||
if (layerIndex !== undefined && layerIndex >= 0) {
|
||||
this._homeTopDown(layerIndex);
|
||||
} else if (this.selectedLayerIndex >= 0) {
|
||||
this._homeTopDown(this.selectedLayerIndex);
|
||||
} else {
|
||||
const cx = (this._maxW || 500) / 2;
|
||||
const cy = -(this._maxH || 500) / 2;
|
||||
const cz = (this.layers.length * Z_SPACING) / 2;
|
||||
const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1;
|
||||
this.camera.position.set(cx, cy, cz + dist);
|
||||
this.camera.up.set(0, 1, 0);
|
||||
this.controls.target.set(cx, cy, cz);
|
||||
this.controls.update();
|
||||
}
|
||||
}
|
||||
|
||||
resetView() {
|
||||
if (this.layers.length === 0) return;
|
||||
const maxW = this._maxW || 500;
|
||||
const maxH = this._maxH || 500;
|
||||
const cx = maxW / 2;
|
||||
const cy = -maxH / 2;
|
||||
const cz = (this.layers.length * Z_SPACING) / 2;
|
||||
const dist = Math.max(maxW, maxH) * 0.7;
|
||||
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
|
||||
this.camera.up.set(0, 0, 1);
|
||||
this.controls.target.set(cx, cy, cz);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
// Switch to solid render preview: show only enclosure+tray opaque, hide all other layers
|
||||
enterSolidView() {
|
||||
this._savedVisibility = this.layers.map(l => l.visible);
|
||||
for (let i = 0; i < this.layers.length; i++) {
|
||||
const mesh = this.layerMeshes[i];
|
||||
if (!mesh) continue;
|
||||
if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) {
|
||||
mesh.visible = true;
|
||||
mesh.traverse(c => {
|
||||
if (c.material) {
|
||||
c.material.opacity = 1.0;
|
||||
c.material.transparent = false;
|
||||
c.material.depthWrite = true;
|
||||
c.material.side = THREE.FrontSide;
|
||||
c.material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mesh.visible = false;
|
||||
}
|
||||
}
|
||||
this.selectLayer(-1);
|
||||
this.gridHelper.visible = false;
|
||||
this.scene.background = new THREE.Color(0x1e1e2e);
|
||||
this.resetView();
|
||||
}
|
||||
|
||||
// Return from solid view to normal editor
|
||||
exitSolidView() {
|
||||
this.scene.background = new THREE.Color(0x000000);
|
||||
this.gridHelper.visible = this.gridVisible;
|
||||
for (let i = 0; i < this.layers.length; i++) {
|
||||
const mesh = this.layerMeshes[i];
|
||||
if (!mesh) continue;
|
||||
const wasVisible = this._savedVisibility ? this._savedVisibility[i] : this.layers[i].visible;
|
||||
this.layers[i].visible = wasVisible;
|
||||
mesh.visible = wasVisible;
|
||||
if (i === this.enclosureLayerIndex || i === this.trayLayerIndex) {
|
||||
const baseOpacity = i === this.enclosureLayerIndex ? 0.55 : 0.5;
|
||||
mesh.traverse(c => {
|
||||
if (c.material) {
|
||||
c.material.opacity = baseOpacity;
|
||||
c.material.transparent = true;
|
||||
c.material.depthWrite = false;
|
||||
c.material.side = THREE.DoubleSide;
|
||||
c.material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
this._savedVisibility = null;
|
||||
this.resetView();
|
||||
}
|
||||
|
||||
// Callbacks
|
||||
onLayerSelect(cb) { this._onLayerSelect = cb; }
|
||||
onCutoutSelect(cb) { this._onCutoutSelect = cb; }
|
||||
onCutoutHover(cb) { this._onCutoutHover = cb; }
|
||||
|
||||
dispose() {
|
||||
if (this._animId) cancelAnimationFrame(this._animId);
|
||||
if (this._resizeObserver) this._resizeObserver.disconnect();
|
||||
if (this._rectOverlay) {
|
||||
this._rectOverlay.remove();
|
||||
this._rectOverlay = null;
|
||||
}
|
||||
this.controls.dispose();
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.domElement.parentNode) {
|
||||
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,700 @@
|
|||
/* Former — Dark Theme */
|
||||
:root {
|
||||
--bg-base: #1e1e2e;
|
||||
--bg-surface: #313244;
|
||||
--bg-overlay: #45475a;
|
||||
--bg-input: #1e1e2e;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-subtle: #6c7086;
|
||||
--accent: #89b4fa;
|
||||
--accent-hover: #74c7ec;
|
||||
--accent-dim: rgba(137, 180, 250, 0.15);
|
||||
--success: #a6e3a1;
|
||||
--error: #f38ba8;
|
||||
--warning: #fab387;
|
||||
--border: #585b70;
|
||||
--border-light: #45475a;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--transition: 150ms ease;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Navigation (includes macOS titlebar drag region) */
|
||||
#nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px 0 76px; /* left padding for macOS traffic lights */
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
gap: 4px;
|
||||
-webkit-app-region: drag;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.nav-spacer { flex: 1; }
|
||||
|
||||
/* Main content */
|
||||
#main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: none;
|
||||
padding: 24px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 200ms ease;
|
||||
}
|
||||
|
||||
.page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-former.active {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Landing page */
|
||||
#page-landing {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-bg-art {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -8px;
|
||||
margin-bottom: -60px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.1) 100%);
|
||||
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 20%, rgba(0,0,0,0.1) 100%);
|
||||
}
|
||||
|
||||
.landing-bg-logo {
|
||||
width: 50%;
|
||||
height: auto;
|
||||
opacity: 0.8;
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
|
||||
.landing-hero {
|
||||
text-align: center;
|
||||
padding: 0 0 28px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.landing-hero h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.landing-hero p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.landing-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.action-card h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Recent projects */
|
||||
.project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
select.form-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-overlay);
|
||||
border-color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1e1e2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
}
|
||||
|
||||
/* File picker row */
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-name.has-file {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Checkbox row */
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.action-bar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Board preview canvas */
|
||||
.board-canvas-wrap {
|
||||
background: #34353c;
|
||||
border-radius: var(--radius);
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-canvas-wrap canvas {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Side buttons */
|
||||
.side-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.side-tab {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.side-tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
/* Cutout list */
|
||||
.cutout-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cutout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-base);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.cutout-item .cutout-text {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Result page */
|
||||
.result-files {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-base);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(30, 30, 46, 0.8);
|
||||
z-index: 100;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-overlay .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== THE FORMER ===== */
|
||||
.former-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.former-canvas-wrap {
|
||||
flex: 1;
|
||||
background: #000000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.former-canvas-wrap canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.former-sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.former-sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.former-sidebar-header h3 {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.former-layers {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.former-layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.former-layer-row:hover {
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.former-layer-row.highlighted {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.former-layer-row.selected {
|
||||
background: rgba(137, 180, 250, 0.25);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.layer-vis-btn,
|
||||
.layer-hl-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-subtle);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.layer-vis-btn:hover,
|
||||
.layer-hl-btn:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layer-vis-btn.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layer-hl-btn.active {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.layer-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layer-name.dimmed {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.former-actions {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-overlay);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-subtle);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Preset buttons */
|
||||
.preset-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
33
go.mod
33
go.mod
|
|
@ -1,3 +1,36 @@
|
|||
module pcb-to-stencil
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require github.com/wailsapp/wails/v2 v2.11.0
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/bash
|
||||
pkill -f ./bin/pcb-to-stencil\ -server
|
||||
go clean && go build -o bin/pcb-to-stencil .
|
||||
./bin/pcb-to-stencil -server
|
||||
694
scad.go
694
scad.go
|
|
@ -6,6 +6,16 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
// snapToLine rounds a dimension to the nearest quarter-multiple of lineWidth.
|
||||
// If lineWidth is 0, the value is returned unchanged.
|
||||
func snapToLine(v, lineWidth float64) float64 {
|
||||
if lineWidth <= 0 {
|
||||
return v
|
||||
}
|
||||
unit := lineWidth / 4.0
|
||||
return math.Round(v/unit) * unit
|
||||
}
|
||||
|
||||
func WriteSCAD(filename string, triangles [][3]Point) error {
|
||||
// Fallback/legacy mesh WriteSCAD
|
||||
f, err := os.Create(filename)
|
||||
|
|
@ -37,6 +47,440 @@ func WriteSCAD(filename string, triangles [][3]Point) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// approximateArc returns intermediate arc points from (x1,y1) to (x2,y2),
|
||||
// excluding the start point, including the end point.
|
||||
func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float64 {
|
||||
centerX := x1 + iVal
|
||||
centerY := y1 + jVal
|
||||
radius := math.Sqrt(iVal*iVal + jVal*jVal)
|
||||
startAngle := math.Atan2(y1-centerY, x1-centerX)
|
||||
endAngle := math.Atan2(y2-centerY, x2-centerX)
|
||||
if mode == "G03" {
|
||||
if endAngle <= startAngle {
|
||||
endAngle += 2 * math.Pi
|
||||
}
|
||||
} else {
|
||||
if startAngle <= endAngle {
|
||||
startAngle += 2 * math.Pi
|
||||
}
|
||||
}
|
||||
arcLen := math.Abs(endAngle-startAngle) * radius
|
||||
steps := int(arcLen * 8)
|
||||
if steps < 4 {
|
||||
steps = 4
|
||||
}
|
||||
if steps > 128 {
|
||||
steps = 128
|
||||
}
|
||||
pts := make([][2]float64, steps)
|
||||
for s := 0; s < steps; s++ {
|
||||
t := float64(s+1) / float64(steps)
|
||||
a := startAngle + t*(endAngle-startAngle)
|
||||
pts[s] = [2]float64{centerX + radius*math.Cos(a), centerY + radius*math.Sin(a)}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// writeApertureFlash2D writes a 2D aperture shape centered at (x, y) into a SCAD file.
|
||||
// gf is needed to resolve macro apertures. lw is the nozzle line width for snapping.
|
||||
func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) {
|
||||
switch ap.Type {
|
||||
case "C":
|
||||
if len(ap.Modifiers) > 0 {
|
||||
r := snapToLine(ap.Modifiers[0]/2, lw)
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, x, y, r)
|
||||
}
|
||||
case "R":
|
||||
if len(ap.Modifiers) >= 2 {
|
||||
w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw)
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n", indent, x, y, w, h)
|
||||
}
|
||||
case "O":
|
||||
if len(ap.Modifiers) >= 2 {
|
||||
w, h := snapToLine(ap.Modifiers[0], lw), snapToLine(ap.Modifiers[1], lw)
|
||||
r := math.Min(w, h) / 2
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) hull() {\n", indent, x, y)
|
||||
if w >= h {
|
||||
d := (w - h) / 2
|
||||
fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, d, r)
|
||||
fmt.Fprintf(f, "%s translate([%f, 0]) circle(r=%f);\n", indent, -d, r)
|
||||
} else {
|
||||
d := (h - w) / 2
|
||||
fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, d, r)
|
||||
fmt.Fprintf(f, "%s translate([0, %f]) circle(r=%f);\n", indent, -d, r)
|
||||
}
|
||||
fmt.Fprintf(f, "%s}\n", indent)
|
||||
}
|
||||
case "P":
|
||||
if len(ap.Modifiers) >= 2 {
|
||||
dia, numV := ap.Modifiers[0], int(ap.Modifiers[1])
|
||||
r := snapToLine(dia/2, lw)
|
||||
rot := 0.0
|
||||
if len(ap.Modifiers) >= 3 {
|
||||
rot = ap.Modifiers[2]
|
||||
}
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n",
|
||||
indent, x, y, rot, r, numV)
|
||||
}
|
||||
default:
|
||||
// Macro aperture – compute bounding box from primitives and emit a simple square.
|
||||
if gf == nil {
|
||||
return
|
||||
}
|
||||
macro, ok := gf.State.Macros[ap.Type]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
minX, minY := math.Inf(1), math.Inf(1)
|
||||
maxX, maxY := math.Inf(-1), math.Inf(-1)
|
||||
trackPt := func(px, py, radius float64) {
|
||||
if px-radius < minX { minX = px - radius }
|
||||
if px+radius > maxX { maxX = px + radius }
|
||||
if py-radius < minY { minY = py - radius }
|
||||
if py+radius > maxY { maxY = py + radius }
|
||||
}
|
||||
for _, prim := range macro.Primitives {
|
||||
switch prim.Code {
|
||||
case 1: // Circle
|
||||
if len(prim.Modifiers) >= 4 {
|
||||
dia := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
|
||||
cx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
|
||||
cy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
|
||||
trackPt(cx, cy, dia/2)
|
||||
}
|
||||
case 4: // Outline polygon
|
||||
if len(prim.Modifiers) >= 3 {
|
||||
numV := int(evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers))
|
||||
for i := 0; i < numV && 2+i*2+1 < len(prim.Modifiers); i++ {
|
||||
vx := evaluateMacroExpression(prim.Modifiers[2+i*2], ap.Modifiers)
|
||||
vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], ap.Modifiers)
|
||||
trackPt(vx, vy, 0)
|
||||
}
|
||||
}
|
||||
case 20: // Vector line
|
||||
if len(prim.Modifiers) >= 7 {
|
||||
w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
|
||||
sx := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
|
||||
sy := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
|
||||
ex := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers)
|
||||
ey := evaluateMacroExpression(prim.Modifiers[5], ap.Modifiers)
|
||||
trackPt(sx, sy, w/2)
|
||||
trackPt(ex, ey, w/2)
|
||||
}
|
||||
case 21: // Center line rect
|
||||
if len(prim.Modifiers) >= 6 {
|
||||
w := evaluateMacroExpression(prim.Modifiers[1], ap.Modifiers)
|
||||
h := evaluateMacroExpression(prim.Modifiers[2], ap.Modifiers)
|
||||
cx := evaluateMacroExpression(prim.Modifiers[3], ap.Modifiers)
|
||||
cy := evaluateMacroExpression(prim.Modifiers[4], ap.Modifiers)
|
||||
trackPt(cx, cy, math.Max(w, h)/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !math.IsInf(minX, 1) {
|
||||
w := snapToLine(maxX-minX, lw)
|
||||
h := snapToLine(maxY-minY, lw)
|
||||
cx := (minX + maxX) / 2
|
||||
cy := (minY + maxY) / 2
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) square([%f, %f], center=true);\n",
|
||||
indent, x+cx, y+cy, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry.
|
||||
func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) {
|
||||
switch prim.Code {
|
||||
case 1: // Circle: Exposure, Diameter, CenterX, CenterY
|
||||
if len(prim.Modifiers) >= 4 {
|
||||
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
|
||||
if exposure == 0 {
|
||||
return
|
||||
}
|
||||
dia := evaluateMacroExpression(prim.Modifiers[1], params)
|
||||
cx := evaluateMacroExpression(prim.Modifiers[2], params)
|
||||
cy := evaluateMacroExpression(prim.Modifiers[3], params)
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) circle(r=%f);\n", indent, cx, cy, dia/2)
|
||||
}
|
||||
case 4: // Outline (Polygon): Exposure, NumVertices, X1,Y1,...,Xn,Yn, Rotation
|
||||
if len(prim.Modifiers) >= 3 {
|
||||
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
|
||||
if exposure == 0 {
|
||||
return
|
||||
}
|
||||
numV := int(evaluateMacroExpression(prim.Modifiers[1], params))
|
||||
if len(prim.Modifiers) < 2+numV*2+1 {
|
||||
return
|
||||
}
|
||||
rot := evaluateMacroExpression(prim.Modifiers[2+numV*2], params)
|
||||
fmt.Fprintf(f, "%srotate([0, 0, %f]) polygon(points=[\n", indent, rot)
|
||||
for i := 0; i < numV; i++ {
|
||||
vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params)
|
||||
vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params)
|
||||
comma := ","
|
||||
if i == numV-1 {
|
||||
comma = ""
|
||||
}
|
||||
fmt.Fprintf(f, "%s [%f, %f]%s\n", indent, vx, vy, comma)
|
||||
}
|
||||
fmt.Fprintf(f, "%s]);\n", indent)
|
||||
}
|
||||
case 5: // Regular Polygon: Exposure, NumVertices, CenterX, CenterY, Diameter, Rotation
|
||||
if len(prim.Modifiers) >= 6 {
|
||||
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
|
||||
if exposure == 0 {
|
||||
return
|
||||
}
|
||||
numV := int(evaluateMacroExpression(prim.Modifiers[1], params))
|
||||
cx := evaluateMacroExpression(prim.Modifiers[2], params)
|
||||
cy := evaluateMacroExpression(prim.Modifiers[3], params)
|
||||
dia := evaluateMacroExpression(prim.Modifiers[4], params)
|
||||
rot := evaluateMacroExpression(prim.Modifiers[5], params)
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) circle(r=%f, $fn=%d);\n",
|
||||
indent, cx, cy, rot, dia/2, numV)
|
||||
}
|
||||
case 20: // Vector Line: Exposure, Width, StartX, StartY, EndX, EndY, Rotation
|
||||
if len(prim.Modifiers) >= 7 {
|
||||
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
|
||||
if exposure == 0 {
|
||||
return
|
||||
}
|
||||
width := evaluateMacroExpression(prim.Modifiers[1], params)
|
||||
sx := evaluateMacroExpression(prim.Modifiers[2], params)
|
||||
sy := evaluateMacroExpression(prim.Modifiers[3], params)
|
||||
ex := evaluateMacroExpression(prim.Modifiers[4], params)
|
||||
ey := evaluateMacroExpression(prim.Modifiers[5], params)
|
||||
rot := evaluateMacroExpression(prim.Modifiers[6], params)
|
||||
// hull() of two squares at start/end for a rectangle with the given width
|
||||
fmt.Fprintf(f, "%srotate([0, 0, %f]) hull() {\n", indent, rot)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, sx, sy, width)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) square([0.001, %f], center=true);\n", indent, ex, ey, width)
|
||||
fmt.Fprintf(f, "%s}\n", indent)
|
||||
}
|
||||
case 21: // Center Line (Rect): Exposure, Width, Height, CenterX, CenterY, Rotation
|
||||
if len(prim.Modifiers) >= 6 {
|
||||
exposure := evaluateMacroExpression(prim.Modifiers[0], params)
|
||||
if exposure == 0 {
|
||||
return
|
||||
}
|
||||
w := evaluateMacroExpression(prim.Modifiers[1], params)
|
||||
h := evaluateMacroExpression(prim.Modifiers[2], params)
|
||||
cx := evaluateMacroExpression(prim.Modifiers[3], params)
|
||||
cy := evaluateMacroExpression(prim.Modifiers[4], params)
|
||||
rot := evaluateMacroExpression(prim.Modifiers[5], params)
|
||||
fmt.Fprintf(f, "%stranslate([%f, %f]) rotate([0, 0, %f]) square([%f, %f], center=true);\n",
|
||||
indent, cx, cy, rot, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeApertureLinearDraw2D writes a 2D stroke between two points using hull() of the aperture.
|
||||
func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64, indent string) {
|
||||
switch ap.Type {
|
||||
case "C":
|
||||
if len(ap.Modifiers) > 0 {
|
||||
r := ap.Modifiers[0] / 2
|
||||
fmt.Fprintf(f, "%shull() {\n", indent)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r)
|
||||
fmt.Fprintf(f, "%s}\n", indent)
|
||||
}
|
||||
case "R":
|
||||
if len(ap.Modifiers) >= 2 {
|
||||
w, h := ap.Modifiers[0], ap.Modifiers[1]
|
||||
fmt.Fprintf(f, "%shull() {\n", indent)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x1, y1, w, h)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) square([%f, %f], center=true);\n", indent, x2, y2, w, h)
|
||||
fmt.Fprintf(f, "%s}\n", indent)
|
||||
}
|
||||
default:
|
||||
if len(ap.Modifiers) > 0 {
|
||||
r := ap.Modifiers[0] / 2
|
||||
fmt.Fprintf(f, "%shull() {\n", indent)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x1, y1, r)
|
||||
fmt.Fprintf(f, "%s translate([%f, %f]) circle(r=%f);\n", indent, x2, y2, r)
|
||||
fmt.Fprintf(f, "%s}\n", indent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeGerberShapes2D writes a 2D SCAD union body representing all drawn shapes
|
||||
// from the Gerber file. Call this inside a union() block.
|
||||
func writeGerberShapes2D(f *os.File, gf *GerberFile, lw float64, indent string) {
|
||||
curX, curY := 0.0, 0.0
|
||||
curDCode := 0
|
||||
interpolationMode := "G01"
|
||||
inRegion := false
|
||||
var regionPts [][2]float64
|
||||
|
||||
for _, cmd := range gf.Commands {
|
||||
if cmd.Type == "APERTURE" {
|
||||
if cmd.D != nil {
|
||||
curDCode = *cmd.D
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cmd.Type == "G01" || cmd.Type == "G02" || cmd.Type == "G03" {
|
||||
interpolationMode = cmd.Type
|
||||
continue
|
||||
}
|
||||
if cmd.Type == "G36" {
|
||||
inRegion = true
|
||||
regionPts = nil
|
||||
continue
|
||||
}
|
||||
if cmd.Type == "G37" {
|
||||
if len(regionPts) >= 3 {
|
||||
fmt.Fprintf(f, "%spolygon(points=[\n", indent)
|
||||
for i, pt := range regionPts {
|
||||
fmt.Fprintf(f, "%s [%f, %f]", indent, pt[0], pt[1])
|
||||
if i < len(regionPts)-1 {
|
||||
fmt.Fprintf(f, ",")
|
||||
}
|
||||
fmt.Fprintf(f, "\n")
|
||||
}
|
||||
fmt.Fprintf(f, "%s]);\n", indent)
|
||||
}
|
||||
inRegion = false
|
||||
regionPts = nil
|
||||
continue
|
||||
}
|
||||
|
||||
prevX, prevY := curX, curY
|
||||
if cmd.X != nil {
|
||||
curX = *cmd.X
|
||||
}
|
||||
if cmd.Y != nil {
|
||||
curY = *cmd.Y
|
||||
}
|
||||
|
||||
if inRegion {
|
||||
switch cmd.Type {
|
||||
case "MOVE":
|
||||
regionPts = append(regionPts, [2]float64{curX, curY})
|
||||
case "DRAW":
|
||||
if interpolationMode == "G01" {
|
||||
regionPts = append(regionPts, [2]float64{curX, curY})
|
||||
} else {
|
||||
iVal, jVal := 0.0, 0.0
|
||||
if cmd.I != nil {
|
||||
iVal = *cmd.I
|
||||
}
|
||||
if cmd.J != nil {
|
||||
jVal = *cmd.J
|
||||
}
|
||||
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
|
||||
regionPts = append(regionPts, arcPts...)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ap, ok := gf.State.Apertures[curDCode]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch cmd.Type {
|
||||
case "FLASH":
|
||||
writeApertureFlash2D(f, gf, ap, curX, curY, lw, indent)
|
||||
case "DRAW":
|
||||
if interpolationMode == "G01" {
|
||||
writeApertureLinearDraw2D(f, ap, prevX, prevY, curX, curY, indent)
|
||||
} else {
|
||||
iVal, jVal := 0.0, 0.0
|
||||
if cmd.I != nil {
|
||||
iVal = *cmd.I
|
||||
}
|
||||
if cmd.J != nil {
|
||||
jVal = *cmd.J
|
||||
}
|
||||
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
|
||||
all := append([][2]float64{{prevX, prevY}}, arcPts...)
|
||||
for i := 0; i < len(all)-1; i++ {
|
||||
writeApertureLinearDraw2D(f, ap, all[i][0], all[i][1], all[i+1][0], all[i+1][1], indent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WriteStencilSCAD generates native parametric OpenSCAD for a solder paste stencil.
|
||||
// Instead of a rasterised mesh, it uses CSG primitives (circles, squares, hulls,
|
||||
// polygons) so the result prints cleanly at any nozzle size.
|
||||
func WriteStencilSCAD(filename string, gf *GerberFile, outlineGf *GerberFile, cfg Config, bounds *Bounds) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fmt.Fprintf(f, "// Generated by pcb-to-stencil (Native SCAD)\n")
|
||||
fmt.Fprintf(f, "$fn = 60;\n\n")
|
||||
lw := cfg.LineWidth
|
||||
fmt.Fprintf(f, "stencil_height = %f; // mm – solder paste layer thickness\n", snapToLine(cfg.StencilHeight, lw))
|
||||
fmt.Fprintf(f, "wall_height = %f; // mm – alignment frame height\n", snapToLine(cfg.WallHeight, lw))
|
||||
fmt.Fprintf(f, "wall_thickness = %f; // mm – alignment frame wall thickness\n", snapToLine(cfg.WallThickness, lw))
|
||||
if lw > 0 {
|
||||
fmt.Fprintf(f, "// line_width = %f; // mm – all dimensions snapped to multiples/fractions of this\n", lw)
|
||||
}
|
||||
fmt.Fprintf(f, "\n")
|
||||
|
||||
var outlineVerts [][2]float64
|
||||
if outlineGf != nil {
|
||||
outlineVerts = ExtractPolygonFromGerber(outlineGf)
|
||||
}
|
||||
|
||||
centerX := (bounds.MinX + bounds.MaxX) / 2.0
|
||||
centerY := (bounds.MinY + bounds.MaxY) / 2.0
|
||||
|
||||
// Board outline module (2D)
|
||||
if len(outlineVerts) > 0 {
|
||||
fmt.Fprintf(f, "module board_outline() {\n polygon(points=[\n")
|
||||
for i, v := range outlineVerts {
|
||||
fmt.Fprintf(f, " [%f, %f]", v[0], v[1])
|
||||
if i < len(outlineVerts)-1 {
|
||||
fmt.Fprintf(f, ",")
|
||||
}
|
||||
fmt.Fprintf(f, "\n")
|
||||
}
|
||||
fmt.Fprintf(f, " ]);\n}\n\n")
|
||||
} else {
|
||||
// Fallback: bounding rectangle
|
||||
fmt.Fprintf(f, "module board_outline() {\n")
|
||||
fmt.Fprintf(f, " translate([%f, %f]) square([%f, %f]);\n",
|
||||
bounds.MinX, bounds.MinY, bounds.MaxX-bounds.MinX, bounds.MaxY-bounds.MinY)
|
||||
fmt.Fprintf(f, "}\n\n")
|
||||
}
|
||||
|
||||
// Paste pad openings module (2D union of all aperture shapes)
|
||||
fmt.Fprintf(f, "module paste_pads() {\n union() {\n")
|
||||
writeGerberShapes2D(f, gf, cfg.LineWidth, " ")
|
||||
fmt.Fprintf(f, " }\n}\n\n")
|
||||
|
||||
// Main body – centred at origin for easy placement on the print bed
|
||||
fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY)
|
||||
fmt.Fprintf(f, " difference() {\n")
|
||||
fmt.Fprintf(f, " union() {\n")
|
||||
fmt.Fprintf(f, " // Thin stencil plate\n")
|
||||
fmt.Fprintf(f, " linear_extrude(height=stencil_height)\n")
|
||||
fmt.Fprintf(f, " board_outline();\n")
|
||||
fmt.Fprintf(f, " // Alignment wall – keeps stencil registered to the PCB edge\n")
|
||||
fmt.Fprintf(f, " linear_extrude(height=wall_height)\n")
|
||||
fmt.Fprintf(f, " difference() {\n")
|
||||
fmt.Fprintf(f, " offset(r=wall_thickness) board_outline();\n")
|
||||
fmt.Fprintf(f, " board_outline();\n")
|
||||
fmt.Fprintf(f, " }\n")
|
||||
fmt.Fprintf(f, " }\n")
|
||||
fmt.Fprintf(f, " // Paste pad cutouts (punched through the stencil plate)\n")
|
||||
fmt.Fprintf(f, " translate([0, 0, -0.1])\n")
|
||||
fmt.Fprintf(f, " linear_extrude(height=stencil_height + 0.2)\n")
|
||||
fmt.Fprintf(f, " paste_pads();\n")
|
||||
fmt.Fprintf(f, " }\n")
|
||||
fmt.Fprintf(f, "}\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractPolygonFromGerber traces the Edge.Cuts gerber to form a continuous 2D polygon
|
||||
func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
||||
var strokes [][][2]float64
|
||||
|
|
@ -210,7 +654,7 @@ func ExtractPolygonFromGerber(gf *GerberFile) [][2]float64 {
|
|||
}
|
||||
|
||||
// WriteNativeSCAD generates native parametrically defined CSG OpenSCAD code
|
||||
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error {
|
||||
func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64, cfg EnclosureConfig, holes []DrillHole, cutouts []SideCutout, lidCutouts []LidCutout, sides []BoardSide, minBX, maxBX, boardCenterY float64) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -272,7 +716,8 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
|
|||
|
||||
// Cutouts are relative to board. UI specifies c.Y from bottom, so c.Y adds to Z.
|
||||
z := c.Height/2 + trayFloor + pcbT + c.Y
|
||||
w, d, h := c.Width, 20.0, c.Height // d is deep enough to cut through walls
|
||||
wallDepth := 2*(clearance+2*wt) + 2.0 // just enough to cut through walls
|
||||
w, d, h := c.Width, wallDepth, c.Height
|
||||
|
||||
dx := bs.EndX - bs.StartX
|
||||
dy := bs.EndY - bs.StartY
|
||||
|
|
@ -319,6 +764,67 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
|
|||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", maxBX+clearance+wt+0.5, boardCenterY, clipZ, 1.0, pryW, clipH)
|
||||
fmt.Fprintf(f, "}\n\n")
|
||||
|
||||
// Lid/Tray Cutouts Module
|
||||
fmt.Fprintf(f, "module lid_cutouts() {\n")
|
||||
for _, lc := range lidCutouts {
|
||||
cx := (lc.MinX + lc.MaxX) / 2.0
|
||||
cy := (lc.MinY + lc.MaxY) / 2.0
|
||||
w := lc.MaxX - lc.MinX
|
||||
h := lc.MaxY - lc.MinY
|
||||
if w < 0.01 || h < 0.01 {
|
||||
continue
|
||||
}
|
||||
if lc.Plane == "lid" {
|
||||
if lc.IsDado && lc.Depth > 0 {
|
||||
// Dado on lid: cut from top surface downward
|
||||
fmt.Fprintf(f, " // Lid dado (depth=%.2f)\n", lc.Depth)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
cx, cy, totalH-lc.Depth/2.0, w, h, lc.Depth+0.1)
|
||||
} else {
|
||||
// Through-cut on lid
|
||||
fmt.Fprintf(f, " // Lid through-cut\n")
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
cx, cy, totalH-lidThick/2.0, w, h, lidThick+0.2)
|
||||
}
|
||||
} else if lc.Plane == "tray" {
|
||||
if lc.IsDado && lc.Depth > 0 {
|
||||
// Dado on tray: cut from bottom surface upward
|
||||
fmt.Fprintf(f, " // Tray dado (depth=%.2f)\n", lc.Depth)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
cx, cy, lc.Depth/2.0-0.05, w, h, lc.Depth+0.1)
|
||||
} else {
|
||||
// Through-cut on tray floor
|
||||
fmt.Fprintf(f, " // Tray through-cut\n")
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
cx, cy, trayFloor/2.0, w, h, trayFloor+0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(f, "}\n\n")
|
||||
|
||||
// cutoutMid returns the midpoint XY and rotation angle for a side cutout,
|
||||
// matching the geometry used in side_cutouts().
|
||||
cutoutMid := func(c SideCutout) (midX, midY, rotDeg float64, ok bool) {
|
||||
for i := range sides {
|
||||
if sides[i].Num != c.Side {
|
||||
continue
|
||||
}
|
||||
bs := &sides[i]
|
||||
dx := bs.EndX - bs.StartX
|
||||
dy := bs.EndY - bs.StartY
|
||||
if l := math.Sqrt(dx*dx + dy*dy); l > 0 {
|
||||
dx /= l
|
||||
dy /= l
|
||||
}
|
||||
midX = bs.StartX + dx*(c.X+c.Width/2)
|
||||
midY = bs.StartY + dy*(c.X+c.Width/2)
|
||||
rotDeg = (bs.Angle*180.0/math.Pi) - 90.0
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
centerX := cfg.OutlineBounds.MinX + (cfg.OutlineBounds.MaxX-cfg.OutlineBounds.MinX)/2.0
|
||||
centerY := cfg.OutlineBounds.MinY + (cfg.OutlineBounds.MaxY-cfg.OutlineBounds.MinY)/2.0
|
||||
fmt.Fprintf(f, "translate([%f, %f, 0]) {\n", -centerX, -centerY)
|
||||
|
|
@ -352,6 +858,69 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
|
|||
fmt.Fprintf(f, " translate([%f,%f,-1]) cylinder(h=%f, r=%f, $fn=32);\n", hole.X, hole.Y, trayFloor+2, socketRadius)
|
||||
}
|
||||
fmt.Fprintf(f, " side_cutouts();\n")
|
||||
fmt.Fprintf(f, " lid_cutouts();\n")
|
||||
// Board dado on tray: layer-aware groove on each side with port cutouts.
|
||||
{
|
||||
trayWallDepth := 2*(clearance+wt) + 2.0
|
||||
type trayDadoInfo struct {
|
||||
hasF bool
|
||||
hasB bool
|
||||
fPortTop float64
|
||||
bPortBot float64
|
||||
}
|
||||
trayDadoSides := make(map[int]*trayDadoInfo)
|
||||
for _, c := range cutouts {
|
||||
di, ok := trayDadoSides[c.Side]
|
||||
if !ok {
|
||||
di = &trayDadoInfo{fPortTop: 0, bPortBot: 1e9}
|
||||
trayDadoSides[c.Side] = di
|
||||
}
|
||||
portBot := trayFloor + pcbT + c.Y
|
||||
portTop := portBot + c.Height
|
||||
if c.Layer == "F" {
|
||||
di.hasF = true
|
||||
if portTop > di.fPortTop {
|
||||
di.fPortTop = portTop
|
||||
}
|
||||
} else {
|
||||
di.hasB = true
|
||||
if portBot < di.bPortBot {
|
||||
di.bPortBot = portBot
|
||||
}
|
||||
}
|
||||
}
|
||||
trayH := trayFloor + snapHeight + wt + pcbT + 2.0
|
||||
for _, bs := range sides {
|
||||
di, ok := trayDadoSides[bs.Num]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
midX := (bs.StartX + bs.EndX) / 2.0
|
||||
midY := (bs.StartY + bs.EndY) / 2.0
|
||||
rotDeg := (bs.Angle*180.0/math.Pi) - 90.0
|
||||
dadoLen := bs.Length + 1.0
|
||||
if di.hasF {
|
||||
// F-layer: dado above ports (toward lid), same direction as enclosure
|
||||
dadoBot := di.fPortTop
|
||||
dadoH := trayH - dadoBot
|
||||
if dadoH > 0.1 {
|
||||
fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH)
|
||||
}
|
||||
}
|
||||
if di.hasB {
|
||||
// B-layer: dado below ports (toward floor)
|
||||
dadoBot := trayFloor + 0.3
|
||||
dadoH := di.bPortBot - dadoBot
|
||||
if dadoH > 0.1 {
|
||||
fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, trayWallDepth, dadoH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(f, "}\n")
|
||||
fmt.Fprintf(f, "pry_clips();\n\n")
|
||||
|
||||
|
|
@ -369,12 +938,127 @@ func WriteNativeSCAD(filename string, isTray bool, outlineVertices [][2]float64,
|
|||
fmt.Fprintf(f, " translate([0,0,-1]) linear_extrude(height=%f) offset(r=%f) board_polygon();\n", trayFloor+snapHeight+0.2, clearance+wt+0.15)
|
||||
|
||||
fmt.Fprintf(f, " // Vertical relief slots for the tray clips to slide into\n")
|
||||
fmt.Fprintf(f, " clipZ = %f;\n", trayFloor+snapHeight)
|
||||
fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, pryW+1.0)
|
||||
fmt.Fprintf(f, " translate([%f, %f, trayFloor - 1]) cube([1.5, %f, clipZ+1], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, pryW+1.0)
|
||||
reliefClipZ := trayFloor + snapHeight
|
||||
reliefH := reliefClipZ + 1.0
|
||||
reliefZ := trayFloor - 1.0
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", minBX-clearance-wt-0.25, boardCenterY, reliefZ, pryW+1.0, reliefH)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) cube([1.5, %f, %f], center=true);\n", maxBX+clearance+wt+0.25, boardCenterY, reliefZ, pryW+1.0, reliefH)
|
||||
|
||||
fmt.Fprintf(f, " pry_slots();\n")
|
||||
|
||||
// Port cutouts – only these go through the full wall to the outside
|
||||
fmt.Fprintf(f, " side_cutouts();\n")
|
||||
fmt.Fprintf(f, " lid_cutouts();\n")
|
||||
|
||||
wallDepth := 2*(clearance+2*wt) + 2.0
|
||||
lidBottom := totalH - lidThick
|
||||
|
||||
// Inner wall ring helper – used to limit slots and dado to the
|
||||
// inner rim only (outer wall stays solid, only ports break through).
|
||||
// Inner wall spans from offset(clearance) to offset(clearance+wt).
|
||||
fmt.Fprintf(f, " // --- Entry slots & board dado (inner wall only) ---\n")
|
||||
fmt.Fprintf(f, " intersection() {\n")
|
||||
fmt.Fprintf(f, " // Clamp to inner wall ring\n")
|
||||
fmt.Fprintf(f, " translate([0,0,%f]) linear_extrude(height=%f) difference() {\n", trayFloor-1, totalH+2)
|
||||
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance+wt+0.5)
|
||||
fmt.Fprintf(f, " offset(r=%f) board_polygon();\n", clearance-0.5)
|
||||
fmt.Fprintf(f, " }\n")
|
||||
fmt.Fprintf(f, " union() {\n")
|
||||
|
||||
// Port entry slots – vertical channel from port to lid/floor,
|
||||
// only in the inner wall so the outer wall stays solid.
|
||||
for _, c := range cutouts {
|
||||
mX, mY, mRot, ok := cutoutMid(c)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
zTopCut := trayFloor + pcbT + c.Y + c.Height
|
||||
|
||||
if c.Layer == "F" {
|
||||
// F-layer: ports on top of board, slot from port top toward lid (plate)
|
||||
slotH := lidBottom - zTopCut
|
||||
if slotH > 0.1 {
|
||||
fmt.Fprintf(f, " // Port entry slot (F-layer, open toward plate)\n")
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
mX, mY, zTopCut+slotH/2.0, mRot, c.Width, wallDepth, slotH)
|
||||
}
|
||||
} else {
|
||||
// B-layer: ports under board, slot from floor up to port bottom
|
||||
zBotCut := trayFloor + pcbT + c.Y
|
||||
slotH := zBotCut - (trayFloor + 0.3)
|
||||
if slotH > 0.1 {
|
||||
slotBot := trayFloor + 0.3
|
||||
fmt.Fprintf(f, " // Port entry slot (B-layer, open toward rim)\n")
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
mX, mY, slotBot+slotH/2.0, mRot, c.Width, wallDepth, slotH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Board dado – full-length groove at PCB height, inner wall only.
|
||||
// For F-layer: dado sits below ports (board under ports), from tray floor to port bottom.
|
||||
// For B-layer: dado sits above ports (board over ports), from port top to lid.
|
||||
// Collect per-side: lowest port bottom (F) or highest port top (B).
|
||||
type dadoInfo struct {
|
||||
hasF bool
|
||||
hasB bool
|
||||
fPortTop float64 // highest port-top on this side (F-layer)
|
||||
bPortBot float64 // lowest port-bottom on this side (B-layer)
|
||||
}
|
||||
dadoSides := make(map[int]*dadoInfo)
|
||||
for _, c := range cutouts {
|
||||
di, ok := dadoSides[c.Side]
|
||||
if !ok {
|
||||
di = &dadoInfo{fPortTop: 0, bPortBot: 1e9}
|
||||
dadoSides[c.Side] = di
|
||||
}
|
||||
portBot := trayFloor + pcbT + c.Y
|
||||
portTop := portBot + c.Height
|
||||
if c.Layer == "F" {
|
||||
di.hasF = true
|
||||
if portTop > di.fPortTop {
|
||||
di.fPortTop = portTop
|
||||
}
|
||||
} else {
|
||||
di.hasB = true
|
||||
if portBot < di.bPortBot {
|
||||
di.bPortBot = portBot
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bs := range sides {
|
||||
di, ok := dadoSides[bs.Num]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
midX := (bs.StartX + bs.EndX) / 2.0
|
||||
midY := (bs.StartY + bs.EndY) / 2.0
|
||||
rotDeg := (bs.Angle*180.0/math.Pi) - 90.0
|
||||
dadoLen := bs.Length + 1.0
|
||||
if di.hasF {
|
||||
// F-layer: ports on top of board, dado above ports (toward lid/plate)
|
||||
dadoBot := di.fPortTop
|
||||
dadoH := lidBottom - dadoBot
|
||||
if dadoH > 0.1 {
|
||||
fmt.Fprintf(f, " // Board dado (F-layer) on side %d\n", bs.Num)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH)
|
||||
}
|
||||
}
|
||||
if di.hasB {
|
||||
// B-layer: ports under board, dado below ports (toward open rim)
|
||||
dadoBot := trayFloor + 0.3
|
||||
dadoH := di.bPortBot - dadoBot
|
||||
if dadoH > 0.1 {
|
||||
fmt.Fprintf(f, " // Board dado (B-layer) on side %d\n", bs.Num)
|
||||
fmt.Fprintf(f, " translate([%f, %f, %f]) rotate([0, 0, %f]) cube([%f, %f, %f], center=true);\n",
|
||||
midX, midY, dadoBot+dadoH/2.0, rotDeg, dadoLen, wallDepth, dadoH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(f, " } // end union\n")
|
||||
fmt.Fprintf(f, " } // end intersection\n")
|
||||
fmt.Fprintf(f, "}\n")
|
||||
fmt.Fprintf(f, "mounting_pegs(false);\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnclosureSession holds all state for an active enclosure editing session
|
||||
type EnclosureSession struct {
|
||||
Exports []string
|
||||
OutlineGf *GerberFile
|
||||
OutlineImg image.Image
|
||||
CourtyardImg image.Image
|
||||
SoldermaskImg image.Image
|
||||
DrillHoles []DrillHole
|
||||
Config EnclosureConfig
|
||||
OutlineBounds Bounds
|
||||
BoardW float64
|
||||
BoardH float64
|
||||
TotalH float64
|
||||
MinBX float64
|
||||
MaxBX float64
|
||||
BoardCenterY float64
|
||||
Sides []BoardSide
|
||||
FabImg image.Image
|
||||
EnclosureWallImg image.Image // 2D top-down view of enclosure walls
|
||||
AllLayerImages map[string]image.Image // all rendered gerber layers keyed by original filename
|
||||
AllLayerGerbers map[string]*GerberFile // parsed gerber files keyed by original filename
|
||||
SourceDir string // original directory of the gerber files
|
||||
|
||||
// Persistence metadata
|
||||
GerberFiles map[string]string
|
||||
DrillPath string
|
||||
NPTHPath string
|
||||
ProjectName string
|
||||
EdgeCutsFile string
|
||||
CourtyardFile string
|
||||
SoldermaskFile string
|
||||
FabFile string
|
||||
}
|
||||
|
||||
|
||||
// BuildEnclosureSession creates a session from uploaded files and configuration.
|
||||
// This is used by both the initial upload and by instance restore.
|
||||
func BuildEnclosureSession(
|
||||
gbrjobPath string,
|
||||
gerberPaths map[string]string, // original filename -> saved path
|
||||
drillPath, npthPath string,
|
||||
ecfg EnclosureConfig,
|
||||
exports []string,
|
||||
) (string, *EnclosureSession, error) {
|
||||
|
||||
// Parse gbrjob
|
||||
jobResult, err := ParseGerberJob(gbrjobPath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse gbrjob: %v", err)
|
||||
}
|
||||
|
||||
pcbThickness := jobResult.BoardThickness
|
||||
if pcbThickness == 0 {
|
||||
pcbThickness = DefaultPCBThickness
|
||||
}
|
||||
ecfg.PCBThickness = pcbThickness
|
||||
|
||||
// Find outline
|
||||
outlinePath, ok := gerberPaths[jobResult.EdgeCutsFile]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("Edge.Cuts file '%s' not found in uploaded gerbers", jobResult.EdgeCutsFile)
|
||||
}
|
||||
|
||||
// Parse outline
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
// Compute board mask
|
||||
minBX, _, maxBX, _ := outlineImg.Bounds().Max.X, outlineImg.Bounds().Max.Y, -1, -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)
|
||||
}
|
||||
|
||||
// Parse drill files
|
||||
var drillHoles []DrillHole
|
||||
if drillPath != "" {
|
||||
if holes, err := ParseDrill(drillPath); err == nil {
|
||||
drillHoles = append(drillHoles, holes...)
|
||||
}
|
||||
}
|
||||
if npthPath != "" {
|
||||
if holes, err := ParseDrill(npthPath); err == nil {
|
||||
drillHoles = append(drillHoles, holes...)
|
||||
}
|
||||
}
|
||||
|
||||
var filteredHoles []DrillHole
|
||||
for _, h := range drillHoles {
|
||||
if h.Type != DrillTypeVia {
|
||||
filteredHoles = append(filteredHoles, h)
|
||||
}
|
||||
}
|
||||
|
||||
// Render layer images
|
||||
var courtyardImg image.Image
|
||||
if courtPath, ok := gerberPaths[jobResult.CourtyardFile]; ok && jobResult.CourtyardFile != "" {
|
||||
if courtGf, err := ParseGerber(courtPath); err == nil {
|
||||
courtyardImg = courtGf.Render(ecfg.DPI, &outlineBounds)
|
||||
}
|
||||
}
|
||||
|
||||
var soldermaskImg image.Image
|
||||
if maskPath, ok := gerberPaths[jobResult.SoldermaskFile]; ok && jobResult.SoldermaskFile != "" {
|
||||
if maskGf, err := ParseGerber(maskPath); err == nil {
|
||||
soldermaskImg = maskGf.Render(ecfg.DPI, &outlineBounds)
|
||||
}
|
||||
}
|
||||
|
||||
// Fab fallback for courtyard
|
||||
if courtyardImg == nil && jobResult.FabFile != "" {
|
||||
if fabPath, ok := gerberPaths[jobResult.FabFile]; ok {
|
||||
if fabGf, err := ParseGerber(fabPath); err == nil {
|
||||
courtyardImg = fabGf.Render(ecfg.DPI, &outlineBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixelToMM := 25.4 / ecfg.DPI
|
||||
|
||||
// Render ALL uploaded gerber layers
|
||||
allLayers := make(map[string]image.Image)
|
||||
allGerbers := make(map[string]*GerberFile)
|
||||
for origName, fullPath := range gerberPaths {
|
||||
if strings.HasSuffix(strings.ToLower(origName), ".gbrjob") {
|
||||
continue
|
||||
}
|
||||
// Skip edge cuts — already rendered as outlineImg
|
||||
if origName == jobResult.EdgeCutsFile {
|
||||
allLayers[origName] = outlineImg
|
||||
allGerbers[origName] = outlineGf
|
||||
continue
|
||||
}
|
||||
gf, err := ParseGerber(fullPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not parse %s: %v", origName, err)
|
||||
continue
|
||||
}
|
||||
allLayers[origName] = gf.Render(ecfg.DPI, &outlineBounds)
|
||||
allGerbers[origName] = gf
|
||||
}
|
||||
|
||||
// Build basenames map for persistence
|
||||
gerberBasenames := make(map[string]string)
|
||||
for origName, fullPath := range gerberPaths {
|
||||
gerberBasenames[origName] = filepath.Base(fullPath)
|
||||
}
|
||||
|
||||
sessionID := randomID()
|
||||
session := &EnclosureSession{
|
||||
Exports: 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: gerberBasenames,
|
||||
DrillPath: filepath.Base(drillPath),
|
||||
NPTHPath: filepath.Base(npthPath),
|
||||
ProjectName: jobResult.ProjectName,
|
||||
EdgeCutsFile: jobResult.EdgeCutsFile,
|
||||
CourtyardFile: jobResult.CourtyardFile,
|
||||
SoldermaskFile: jobResult.SoldermaskFile,
|
||||
FabFile: jobResult.FabFile,
|
||||
EnclosureWallImg: renderEnclosureWallImage(outlineImg, ecfg),
|
||||
AllLayerImages: allLayers,
|
||||
AllLayerGerbers: allGerbers,
|
||||
}
|
||||
|
||||
log.Printf("Created session %s for project %s (%.1f x %.1f mm)", sessionID, jobResult.ProjectName, actualBoardW, actualBoardH)
|
||||
return sessionID, session, nil
|
||||
}
|
||||
|
||||
// UploadFabAndExtractFootprints processes fab gerber files and returns footprint data
|
||||
func UploadFabAndExtractFootprints(session *EnclosureSession, fabPaths []string) ([]Footprint, image.Image) {
|
||||
var allFootprints []Footprint
|
||||
var fabGfList []*GerberFile
|
||||
|
||||
for _, path := range fabPaths {
|
||||
gf, err := ParseGerber(path)
|
||||
if err == nil {
|
||||
allFootprints = append(allFootprints, ExtractFootprints(gf)...)
|
||||
fabGfList = append(fabGfList, gf)
|
||||
}
|
||||
}
|
||||
|
||||
// Composite fab images
|
||||
var fabImg image.Image
|
||||
if len(fabGfList) > 0 {
|
||||
bounds := session.OutlineBounds
|
||||
imgW := int((bounds.MaxX - bounds.MinX) * session.Config.DPI / 25.4)
|
||||
imgH := int((bounds.MaxY - bounds.MinY) * session.Config.DPI / 25.4)
|
||||
if imgW > 0 && imgH > 0 {
|
||||
composite := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
|
||||
draw.Draw(composite, composite.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
|
||||
|
||||
for _, gf := range fabGfList {
|
||||
layerImg := gf.Render(session.Config.DPI, &bounds)
|
||||
if rgba, ok := layerImg.(*image.RGBA); ok {
|
||||
for y := 0; y < imgH; y++ {
|
||||
for x := 0; x < imgW; x++ {
|
||||
if r, _, _, _ := rgba.At(x, y).RGBA(); r > 0x7FFF {
|
||||
composite.Set(x, y, color.RGBA{0, 255, 255, 180})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fabImg = composite
|
||||
}
|
||||
}
|
||||
|
||||
return allFootprints, fabImg
|
||||
}
|
||||
|
||||
// GenerateEnclosureOutputs produces all requested output files for the enclosure
|
||||
func GenerateEnclosureOutputs(session *EnclosureSession, cutouts []Cutout, outputDir string) ([]string, error) {
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
|
||||
// Split unified cutouts into legacy types for STL/SCAD generation
|
||||
sideCutouts, lidCutouts := SplitCutouts(cutouts)
|
||||
|
||||
id := randomID()
|
||||
var generatedFiles []string
|
||||
|
||||
wantsType := func(t string) bool {
|
||||
for _, e := range session.Exports {
|
||||
if e == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if len(session.Exports) == 0 {
|
||||
session.Exports = []string{"stl"}
|
||||
}
|
||||
|
||||
// STL
|
||||
if wantsType("stl") {
|
||||
result := GenerateEnclosure(session.OutlineImg, session.DrillHoles, session.Config, session.CourtyardImg, session.SoldermaskImg, sideCutouts, session.Sides)
|
||||
encPath := filepath.Join(outputDir, id+"_enclosure.stl")
|
||||
trayPath := filepath.Join(outputDir, id+"_tray.stl")
|
||||
WriteSTL(encPath, result.EnclosureTriangles)
|
||||
WriteSTL(trayPath, result.TrayTriangles)
|
||||
generatedFiles = append(generatedFiles, encPath, trayPath)
|
||||
}
|
||||
|
||||
// SCAD
|
||||
if wantsType("scad") {
|
||||
scadPathEnc := filepath.Join(outputDir, id+"_enclosure.scad")
|
||||
scadPathTray := filepath.Join(outputDir, id+"_tray.scad")
|
||||
outlinePoly := ExtractPolygonFromGerber(session.OutlineGf)
|
||||
WriteNativeSCAD(scadPathEnc, false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
||||
WriteNativeSCAD(scadPathTray, true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY)
|
||||
generatedFiles = append(generatedFiles, scadPathEnc, scadPathTray)
|
||||
}
|
||||
|
||||
// SVG
|
||||
if wantsType("svg") && session.OutlineGf != nil {
|
||||
svgPath := filepath.Join(outputDir, id+"_outline.svg")
|
||||
WriteSVG(svgPath, session.OutlineGf, &session.OutlineBounds)
|
||||
generatedFiles = append(generatedFiles, svgPath)
|
||||
}
|
||||
|
||||
// PNG
|
||||
if wantsType("png") && session.OutlineImg != nil {
|
||||
pngPath := filepath.Join(outputDir, id+"_outline.png")
|
||||
if f, err := os.Create(pngPath); err == nil {
|
||||
png.Encode(f, session.OutlineImg)
|
||||
f.Close()
|
||||
generatedFiles = append(generatedFiles, pngPath)
|
||||
}
|
||||
}
|
||||
|
||||
return generatedFiles, nil
|
||||
}
|
||||
|
||||
// SaveOutlineImage saves the outline image as PNG to a file
|
||||
func SaveOutlineImage(session *EnclosureSession, path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return png.Encode(f, session.OutlineImg)
|
||||
}
|
||||
|
||||
// CopyFile copies a file from src to dst
|
||||
func CopyFile(src, dst string) error {
|
||||
s, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
d, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
_, err = io.Copy(d, s)
|
||||
return err
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 996 KiB |
|
|
@ -0,0 +1,217 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="100.34781mm"
|
||||
height="100.34781mm"
|
||||
viewBox="0 0 100.34781 100.34781"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
sodipodi:docname="Former.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.96756968"
|
||||
inkscape:cx="159.67842"
|
||||
inkscape:cy="203.60291"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1147"
|
||||
inkscape:window-x="1800"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg1"
|
||||
showgrid="false" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="-0.27345145 : -77.411507 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="209.72656 : -77.411507 : 1"
|
||||
inkscape:persp3d-origin="104.72656 : -126.9115 : 1"
|
||||
id="perspective43" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="501.67766 : 36.518513 : 1"
|
||||
inkscape:vp_y="0 : -181.14384 : 0"
|
||||
inkscape:vp_z="-620.91289 : 36.518513 : 1"
|
||||
inkscape:persp3d-origin="-59.61763 : 45.485133 : 1"
|
||||
id="perspective37" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="-301.47692 : 93.432223 : 1"
|
||||
inkscape:vp_y="0 : -124.00936 : 0"
|
||||
inkscape:vp_z="-331.61284 : 48.296543 : 1"
|
||||
inkscape:persp3d-origin="313.00181 : 54.434983 : 1"
|
||||
id="perspective1" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="-30.15488 : 45.957953 : 1"
|
||||
inkscape:vp_y="0 : -32.81081 : 0"
|
||||
inkscape:vp_z="-38.12834 : 34.015803 : 1"
|
||||
inkscape:persp3d-origin="132.42596 : 35.639933 : 1"
|
||||
id="perspective1-0" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="-28.701185 : 45.524883 : 1"
|
||||
inkscape:vp_y="0 : -32.81081 : 0"
|
||||
inkscape:vp_z="-36.674645 : 33.582733 : 1"
|
||||
inkscape:persp3d-origin="133.87965 : 35.206863 : 1"
|
||||
id="perspective1-2" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="212.76167 : -7.7584967 : 1"
|
||||
inkscape:vp_y="0 : -47.927641 : 0"
|
||||
inkscape:vp_z="-112.56885 : -7.7584967 : 1"
|
||||
inkscape:persp3d-origin="50.0964 : -5.3860767 : 1"
|
||||
id="perspective37-4" />
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="210.52305 : -19.135577 : 1"
|
||||
inkscape:vp_y="0 : -47.927641 : 0"
|
||||
inkscape:vp_z="-86.495696 : -19.135577 : 1"
|
||||
inkscape:persp3d-origin="62.013671 : -16.763157 : 1"
|
||||
id="perspective37-5" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer7"
|
||||
inkscape:label="Layer 1"
|
||||
transform="translate(-44.713747,-64.128802)" />
|
||||
<g
|
||||
id="g1"
|
||||
transform="translate(9.844254,-9.8413531)">
|
||||
<g
|
||||
id="layer5"
|
||||
inkscape:label="Layer 4"
|
||||
style="stroke:#24db72;stroke-opacity:1"
|
||||
transform="matrix(1.3883972,0,0,1.289212,-91.924665,-108.45675)">
|
||||
<path
|
||||
d="m 89.791895,138.26313 -23.31906,3.62375 v -8.13995 l 23.31906,-2.64885 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1-1" />
|
||||
<path
|
||||
d="M 124.04082,141.44239 V 133.422 l -34.248925,-2.32392 v 7.16505 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2-5" />
|
||||
<path
|
||||
d="m 124.04082,141.44239 -21.23125,4.61552 -36.336735,-4.17103 23.31906,-3.62375 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path3-80" />
|
||||
<path
|
||||
d="m 124.04082,133.422 -21.23125,3.3738 -36.336735,-3.04887 23.31906,-2.64885 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path4-9" />
|
||||
<path
|
||||
d="m 102.80957,146.05791 v -9.26211 l -36.336735,-3.04887 v 8.13995 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path5-2" />
|
||||
<path
|
||||
d="m 124.04082,141.44239 -21.23125,4.61552 v -9.26211 l 21.23125,-3.3738 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#24db72;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path6-4" />
|
||||
</g>
|
||||
<g
|
||||
id="layer4"
|
||||
inkscape:label="Layer 3"
|
||||
style="stroke:#e51b8f;stroke-opacity:1"
|
||||
transform="matrix(1.3883972,0,0,1.289212,-91.923901,-67.614303)">
|
||||
<path
|
||||
d="m 89.791351,122.21329 -23.319059,3.62374 v -8.13996 l 23.319059,-2.64884 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1-0" />
|
||||
<path
|
||||
d="m 124.04027,125.39253 v -8.02036 l -34.248919,-2.32394 v 7.16506 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2-3" />
|
||||
<path
|
||||
d="m 124.04027,125.39253 -21.23125,4.61554 -36.336728,-4.17104 23.319059,-3.62374 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path3-9" />
|
||||
<path
|
||||
d="m 124.04027,117.37217 -21.23125,3.37378 -36.336728,-3.04888 23.319059,-2.64884 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path4-4" />
|
||||
<path
|
||||
d="m 102.80902,130.00807 v -9.26212 l -36.336728,-3.04888 v 8.13996 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path5-9" />
|
||||
<path
|
||||
d="m 124.04027,125.39253 -21.23125,4.61554 v -9.26212 l 21.23125,-3.37378 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#e51b8f;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path6-65" />
|
||||
</g>
|
||||
<g
|
||||
id="layer3"
|
||||
inkscape:label="Layer 2"
|
||||
transform="matrix(1.3883972,0,0,1.289212,-109.94614,-6.279169)"
|
||||
style="stroke:#ecea47;stroke-opacity:1">
|
||||
<path
|
||||
d="M 102.77195,43.376986 79.452902,47.000733 V 38.860775 L 102.77195,36.21194 Z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1-7" />
|
||||
<path
|
||||
d="M 137.02088,46.55624 V 38.535865 L 102.77195,36.21194 v 7.165046 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2-6" />
|
||||
<path
|
||||
d="M 137.02088,46.55624 115.78964,51.171765 79.452902,47.000733 102.77195,43.376986 Z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path3-8" />
|
||||
<path
|
||||
d="m 137.02088,38.535865 -21.23124,3.37379 -36.336738,-3.04888 23.319048,-2.648835 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path4-3" />
|
||||
<path
|
||||
d="m 115.78964,51.171765 v -9.26211 l -36.336738,-3.04888 v 8.139958 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path5-8" />
|
||||
<path
|
||||
d="m 137.02088,46.55624 -21.23124,4.615525 v -9.26211 l 21.23124,-3.37379 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#ecea47;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path6-6" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
id="layer1"
|
||||
style="fill:#424242;fill-opacity:1;stroke:#220eff;stroke-opacity:1"
|
||||
transform="matrix(1.3883972,0,0,1.289212,-91.923146,-87.705827)">
|
||||
<path
|
||||
d="M 89.790811,90.737556 66.471752,94.3613 v -8.139957 l 23.319059,-2.648834 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1" />
|
||||
<path
|
||||
d="M 124.03973,93.916808 V 85.896434 L 89.790811,83.572509 v 7.165047 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M 124.03973,93.916808 102.80849,98.532333 66.471752,94.3613 89.790811,90.737556 Z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path3" />
|
||||
<path
|
||||
d="m 124.03973,85.896434 -21.23124,3.37379 -36.336738,-3.048881 23.319059,-2.648834 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path4" />
|
||||
<path
|
||||
d="M 102.80849,98.532333 V 89.270224 L 66.471752,86.221343 V 94.3613 Z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path5" />
|
||||
<path
|
||||
d="m 124.03973,93.916808 -21.23124,4.615525 v -9.262109 l 21.23124,-3.37379 z"
|
||||
style="fill:#1c1c21;fill-opacity:1;fill-rule:evenodd;stroke:#3151ff;stroke-width:0.538028;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path6" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -64,10 +64,18 @@
|
|||
<label for="height">Stencil Height (mm)</label>
|
||||
<input type="number" id="height" name="height" value="0.16" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lineWidth">Nozzle Line Width (mm)</label>
|
||||
<input type="number" id="lineWidth" name="lineWidth" value="0.42" step="0.01">
|
||||
<div class="hint">Pad sizes snap to multiples of this.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="dpi">DPI</label>
|
||||
<input type="number" id="dpi" name="dpi" value="1000" step="100">
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
|
|
@ -150,6 +158,18 @@
|
|||
<div class="spinner"></div>
|
||||
<div>Processing... This may take 10-20 seconds.</div>
|
||||
</div>
|
||||
|
||||
<!-- Instance Management Panel -->
|
||||
<div id="instancePanel" style="display:none; margin-top: 1.5rem;">
|
||||
<div id="profilesSection" style="display:none;">
|
||||
<h3 style="font-size: 0.9rem; font-weight: 600; color: #374151; margin: 0 0 0.5rem 0;">Saved Profiles</h3>
|
||||
<div id="profilesList" class="instance-list"></div>
|
||||
</div>
|
||||
<div id="recentsSection" style="display:none;">
|
||||
<h3 style="font-size: 0.9rem; font-weight: 600; color: #6b7280; margin: 1rem 0 0.5rem 0;">Recent Enclosures</h3>
|
||||
<div id="recentsList2" class="instance-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
@ -195,6 +215,7 @@
|
|||
var config = {
|
||||
stencilHeight: document.getElementById('height').value,
|
||||
stencilDpi: document.getElementById('dpi').value,
|
||||
stencilLineWidth: document.getElementById('lineWidth').value,
|
||||
stencilWallHeight: document.getElementById('wallHeight').value,
|
||||
stencilWallThickness: document.getElementById('wallThickness').value,
|
||||
|
||||
|
|
@ -219,6 +240,7 @@
|
|||
if (config) {
|
||||
if (config.stencilHeight) document.getElementById('height').value = config.stencilHeight;
|
||||
if (config.stencilDpi) document.getElementById('dpi').value = config.stencilDpi;
|
||||
if (config.stencilLineWidth) document.getElementById('lineWidth').value = config.stencilLineWidth;
|
||||
if (config.stencilWallHeight) document.getElementById('wallHeight').value = config.stencilWallHeight;
|
||||
if (config.stencilWallThickness) document.getElementById('wallThickness').value = config.stencilWallThickness;
|
||||
|
||||
|
|
@ -253,6 +275,108 @@
|
|||
document.addEventListener('DOMContentLoaded', updateRecents);
|
||||
|
||||
// Removed old triggerExport since we now use multi-select checkboxes
|
||||
|
||||
// --- Instance Management ---
|
||||
function loadServerInstances() {
|
||||
fetch('/api/instances').then(function(r) { return r.json(); }).then(function(data) {
|
||||
var panel = document.getElementById('instancePanel');
|
||||
var hasContent = false;
|
||||
|
||||
// Profiles
|
||||
if (data.profiles && data.profiles.length > 0) {
|
||||
document.getElementById('profilesSection').style.display = 'block';
|
||||
var list = document.getElementById('profilesList');
|
||||
list.innerHTML = '';
|
||||
data.profiles.forEach(function(inst) {
|
||||
list.appendChild(createInstanceCard(inst, 'profile'));
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
|
||||
// Recents
|
||||
if (data.recents && data.recents.length > 0) {
|
||||
document.getElementById('recentsSection').style.display = 'block';
|
||||
var list2 = document.getElementById('recentsList2');
|
||||
list2.innerHTML = '';
|
||||
data.recents.forEach(function(inst) {
|
||||
list2.appendChild(createInstanceCard(inst, 'recent'));
|
||||
});
|
||||
hasContent = true;
|
||||
}
|
||||
|
||||
if (hasContent) panel.style.display = 'block';
|
||||
}).catch(function(e) { console.log('No instances:', e); });
|
||||
}
|
||||
|
||||
function createInstanceCard(inst, type) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'instance-card';
|
||||
|
||||
var title = inst.name || inst.projectName || 'Unnamed';
|
||||
var dims = (inst.boardW || 0).toFixed(1) + ' x ' + (inst.boardH || 0).toFixed(1) + ' mm';
|
||||
var cutouts = (inst.sideCutouts || []).length;
|
||||
var date = inst.createdAt ? new Date(inst.createdAt).toLocaleDateString() : '';
|
||||
|
||||
card.innerHTML =
|
||||
'<div class="instance-card-info" onclick="restoreInstance(\'' + inst.id + '\')">' +
|
||||
'<div class="instance-card-title">' + escapeHtml(title) + '</div>' +
|
||||
'<div class="instance-card-meta">' + dims + ' · ' + cutouts + ' cutout' + (cutouts !== 1 ? 's' : '') + ' · ' + date + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="instance-card-actions">' +
|
||||
(type === 'recent' ? '<button class="inst-btn inst-save" onclick="saveAsProfile(\'' + inst.id + '\')">Save</button>' : '') +
|
||||
'<button class="inst-btn inst-del" onclick="deleteInstance(\'' + inst.id + '\',\'' + type + '\')">Del</button>' +
|
||||
'</div>';
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function restoreInstance(id) {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
fetch('/api/instances/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: id })
|
||||
}).then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
if (data.sideCutouts && data.sideCutouts.length > 0) {
|
||||
sessionStorage.setItem('restoredCutouts', JSON.stringify(data.sideCutouts));
|
||||
}
|
||||
window.location.href = '/preview?id=' + data.sessionId;
|
||||
}).catch(function(e) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
alert('Restore failed: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
function saveAsProfile(id) {
|
||||
var name = prompt('Save profile as:');
|
||||
if (!name) return;
|
||||
fetch('/api/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: id, name: name })
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('Save failed');
|
||||
loadServerInstances();
|
||||
}).catch(function(e) { alert(e.message); });
|
||||
}
|
||||
|
||||
function deleteInstance(id, type) {
|
||||
if (!confirm('Delete this ' + type + '?')) return;
|
||||
var url = type === 'profile' ? '/api/profiles/' + id : '/api/recents/' + id;
|
||||
fetch(url, { method: 'DELETE' }).then(function() {
|
||||
loadServerInstances();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadServerInstances);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
|
|
@ -359,7 +359,13 @@
|
|||
<label for="cutR">Corner Radius (mm)</label>
|
||||
<input type="number" id="cutR" value="1.3" step="0.01">
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group">
|
||||
<label for="cutLayer">Board Layer</label>
|
||||
<select id="cutLayer" style="font-size:0.85rem; padding:0.3rem;">
|
||||
<option value="F">F (top)</option>
|
||||
<option value="B">B (bottom)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group">
|
||||
<div class="unit-note">All values in mm (0.01mm precision)</div>
|
||||
|
|
@ -388,6 +394,8 @@
|
|||
<a href="/" class="btn secondary"
|
||||
style="flex: 1; text-align: center; text-decoration: none; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); color: var(--text-color);">Go
|
||||
Back</a>
|
||||
<button type="button" id="btnSaveProfile" class="btn secondary"
|
||||
style="flex: 0; padding: 10px 16px; border-radius: 4px; font-size: 0.85rem; cursor: pointer; white-space: nowrap;">Save</button>
|
||||
<button type="submit" class="submit-btn" style="flex: 1; margin-top: 0;">Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -824,7 +832,8 @@
|
|||
y: parseFloat(document.getElementById('cutY').value) || 0,
|
||||
w: parseFloat(document.getElementById('cutW').value) || 9,
|
||||
h: parseFloat(document.getElementById('cutH').value) || 3.5,
|
||||
r: parseFloat(document.getElementById('cutR').value) || 1.3
|
||||
r: parseFloat(document.getElementById('cutR').value) || 1.3,
|
||||
l: document.getElementById('cutLayer').value || 'F'
|
||||
};
|
||||
sideCutouts.push(c);
|
||||
updateCutoutList();
|
||||
|
|
@ -838,7 +847,9 @@
|
|||
var div = document.createElement('div');
|
||||
div.className = 'cutout-item';
|
||||
var color = sideColors[(c.side - 1) % sideColors.length];
|
||||
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
|
||||
var layerLabel = (c.l === 'B') ? 'B' : 'F';
|
||||
div.innerHTML = '<span style="color:' + color + ';font-weight:600;">Side ' + c.side + '</span> ' +
|
||||
'<span style="background:#555;color:#fff;padding:0 3px;border-radius:2px;font-size:0.75rem;">' + layerLabel + '</span> ' +
|
||||
c.w.toFixed(1) + '×' + c.h.toFixed(1) + 'mm at (' +
|
||||
c.x.toFixed(1) + ',' + c.y.toFixed(1) + ') r=' + c.r.toFixed(1) +
|
||||
'<button onclick="removeCutout(' + i + ')">✕</button>';
|
||||
|
|
@ -973,6 +984,47 @@
|
|||
}
|
||||
});
|
||||
|
||||
// --- Restore cutouts from sessionStorage (instance restore) ---
|
||||
(function() {
|
||||
var stored = sessionStorage.getItem('restoredCutouts');
|
||||
if (stored) {
|
||||
sessionStorage.removeItem('restoredCutouts');
|
||||
try {
|
||||
var restored = JSON.parse(stored);
|
||||
if (restored && restored.length > 0) {
|
||||
sideCutouts = restored;
|
||||
// Enable side editor
|
||||
document.getElementById('optSideCutout').checked = true;
|
||||
document.getElementById('sideEditor').classList.add('active');
|
||||
updateCutoutList();
|
||||
drawSideFace();
|
||||
}
|
||||
} catch(e) { console.log('Could not restore cutouts:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// --- Save Profile Button ---
|
||||
document.getElementById('btnSaveProfile').addEventListener('click', function() {
|
||||
var name = prompt('Save profile as:');
|
||||
if (!name) return;
|
||||
fetch('/api/profiles/from-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
sideCutouts: sideCutouts
|
||||
})
|
||||
}).then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
||||
return r.json();
|
||||
}).then(function() {
|
||||
alert('Profile saved!');
|
||||
}).catch(function(e) {
|
||||
alert('Save failed: ' + e.message);
|
||||
});
|
||||
});
|
||||
|
||||
function applyAlignment(fp, lip) {
|
||||
var bx = fp.centerX;
|
||||
var by = fp.centerY;
|
||||
|
|
@ -1017,6 +1069,7 @@
|
|||
var cutX = bestPosX - (9.0 / 2);
|
||||
document.getElementById('cutX').value = cutX.toFixed(2);
|
||||
document.getElementById('cutY').value = '0.00';
|
||||
// Default to F layer for auto-aligned cutouts (user can change before adding)
|
||||
|
||||
currentSide = closestSide.num;
|
||||
document.getElementById('btnAddCutout').click();
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
|
|
@ -391,3 +391,85 @@ input[type="file"] {
|
|||
.help-popup-close:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Instance cards */
|
||||
.instance-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.instance-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.instance-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.instance-card-info {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-card-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.instance-card-meta {
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.instance-card-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.inst-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inst-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.inst-btn.inst-save {
|
||||
color: #059669;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
.inst-btn.inst-save:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.inst-btn.inst-del {
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.inst-btn.inst-del:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds stencil generation parameters
|
||||
type Config struct {
|
||||
StencilHeight float64
|
||||
WallHeight float64
|
||||
WallThickness float64
|
||||
LineWidth float64
|
||||
DPI float64
|
||||
KeepPNG bool
|
||||
}
|
||||
|
||||
// Default values
|
||||
const (
|
||||
DefaultStencilHeight = 0.16
|
||||
DefaultWallHeight = 2.0
|
||||
DefaultWallThickness = 1.0
|
||||
DefaultDPI = 1000.0
|
||||
)
|
||||
|
||||
// ComputeWallMask generates a mask for the wall based on the outline image.
|
||||
func ComputeWallMask(img image.Image, thicknessMM float64, pixelToMM float64) ([]int, []bool) {
|
||||
bounds := img.Bounds()
|
||||
w := bounds.Max.X
|
||||
h := bounds.Max.Y
|
||||
size := w * h
|
||||
|
||||
dx := []int{0, 0, 1, -1}
|
||||
dy := []int{1, -1, 0, 0}
|
||||
|
||||
isOutline := make([]bool, size)
|
||||
outlineQueue := []int{}
|
||||
for i := 0; i < size; i++ {
|
||||
cx := i % w
|
||||
cy := i / w
|
||||
c := img.At(cx, cy)
|
||||
r, _, _, _ := c.RGBA()
|
||||
if r > 10000 {
|
||||
isOutline[i] = true
|
||||
outlineQueue = append(outlineQueue, i)
|
||||
}
|
||||
}
|
||||
|
||||
gapClosingMM := 0.5
|
||||
gapClosingPixels := int(gapClosingMM / pixelToMM)
|
||||
if gapClosingPixels < 1 {
|
||||
gapClosingPixels = 1
|
||||
}
|
||||
|
||||
dist := make([]int, size)
|
||||
for i := 0; i < size; i++ {
|
||||
if isOutline[i] {
|
||||
dist[i] = 0
|
||||
} else {
|
||||
dist[i] = -1
|
||||
}
|
||||
}
|
||||
|
||||
dilatedOutline := make([]bool, size)
|
||||
copy(dilatedOutline, isOutline)
|
||||
|
||||
dQueue := make([]int, len(outlineQueue))
|
||||
copy(dQueue, outlineQueue)
|
||||
|
||||
for len(dQueue) > 0 {
|
||||
idx := dQueue[0]
|
||||
dQueue = dQueue[1:]
|
||||
d := dist[idx]
|
||||
if d >= gapClosingPixels {
|
||||
continue
|
||||
}
|
||||
cx := idx % w
|
||||
cy := idx / w
|
||||
for i := 0; i < 4; i++ {
|
||||
nx, ny := cx+dx[i], cy+dy[i]
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
nIdx := ny*w + nx
|
||||
if dist[nIdx] == -1 {
|
||||
dist[nIdx] = d + 1
|
||||
dilatedOutline[nIdx] = true
|
||||
dQueue = append(dQueue, nIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isOutside := make([]bool, size)
|
||||
if !dilatedOutline[0] {
|
||||
isOutside[0] = true
|
||||
fQueue := []int{0}
|
||||
for len(fQueue) > 0 {
|
||||
idx := fQueue[0]
|
||||
fQueue = fQueue[1:]
|
||||
cx := idx % w
|
||||
cy := idx / w
|
||||
for i := 0; i < 4; i++ {
|
||||
nx, ny := cx+dx[i], cy+dy[i]
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
nIdx := ny*w + nx
|
||||
if !isOutside[nIdx] && !dilatedOutline[nIdx] {
|
||||
isOutside[nIdx] = true
|
||||
fQueue = append(fQueue, nIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
if isOutside[i] {
|
||||
dist[i] = 0
|
||||
} else {
|
||||
dist[i] = -1
|
||||
}
|
||||
}
|
||||
|
||||
oQueue := []int{}
|
||||
for i := 0; i < size; i++ {
|
||||
if isOutside[i] {
|
||||
oQueue = append(oQueue, i)
|
||||
}
|
||||
}
|
||||
|
||||
isOutsideExpanded := make([]bool, size)
|
||||
copy(isOutsideExpanded, isOutside)
|
||||
|
||||
for len(oQueue) > 0 {
|
||||
idx := oQueue[0]
|
||||
oQueue = oQueue[1:]
|
||||
d := dist[idx]
|
||||
if d >= gapClosingPixels {
|
||||
continue
|
||||
}
|
||||
cx := idx % w
|
||||
cy := idx / w
|
||||
for i := 0; i < 4; i++ {
|
||||
nx, ny := cx+dx[i], cy+dy[i]
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
nIdx := ny*w + nx
|
||||
if dist[nIdx] == -1 {
|
||||
dist[nIdx] = d + 1
|
||||
isOutsideExpanded[nIdx] = true
|
||||
oQueue = append(oQueue, nIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isBoard := make([]bool, size)
|
||||
for i := 0; i < size; i++ {
|
||||
isBoard[i] = !isOutsideExpanded[i]
|
||||
}
|
||||
|
||||
thicknessPixels := int(thicknessMM / pixelToMM)
|
||||
if thicknessPixels < 1 {
|
||||
thicknessPixels = 1
|
||||
}
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
if isBoard[i] {
|
||||
dist[i] = 0
|
||||
} else {
|
||||
dist[i] = -1
|
||||
}
|
||||
}
|
||||
|
||||
wQueue := []int{}
|
||||
for i := 0; i < size; i++ {
|
||||
if isBoard[i] {
|
||||
wQueue = append(wQueue, i)
|
||||
}
|
||||
}
|
||||
|
||||
wallDist := make([]int, size)
|
||||
for i := range wallDist {
|
||||
wallDist[i] = -1
|
||||
}
|
||||
|
||||
for len(wQueue) > 0 {
|
||||
idx := wQueue[0]
|
||||
wQueue = wQueue[1:]
|
||||
d := dist[idx]
|
||||
if d >= thicknessPixels {
|
||||
continue
|
||||
}
|
||||
cx := idx % w
|
||||
cy := idx / w
|
||||
for i := 0; i < 4; i++ {
|
||||
nx, ny := cx+dx[i], cy+dy[i]
|
||||
if nx >= 0 && nx < w && ny >= 0 && ny < h {
|
||||
nIdx := ny*w + nx
|
||||
if dist[nIdx] == -1 {
|
||||
dist[nIdx] = d + 1
|
||||
wallDist[nIdx] = d + 1
|
||||
wQueue = append(wQueue, nIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wallDist, isBoard
|
||||
}
|
||||
|
||||
func GenerateMeshFromImages(stencilImg, outlineImg image.Image, cfg Config) [][3]Point {
|
||||
pixelToMM := 25.4 / cfg.DPI
|
||||
bounds := stencilImg.Bounds()
|
||||
width := bounds.Max.X
|
||||
height := bounds.Max.Y
|
||||
var triangles [][3]Point
|
||||
|
||||
var wallDist []int
|
||||
var boardMask []bool
|
||||
if outlineImg != nil {
|
||||
wallDist, boardMask = ComputeWallMask(outlineImg, cfg.WallThickness, pixelToMM)
|
||||
}
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
var startX = -1
|
||||
var currentHeight = 0.0
|
||||
|
||||
for x := 0; x < width; x++ {
|
||||
sc := stencilImg.At(x, y)
|
||||
sr, sg, sb, _ := sc.RGBA()
|
||||
isStencilSolid := sr < 10000 && sg < 10000 && sb < 10000
|
||||
|
||||
isWall := false
|
||||
isInsideBoard := true
|
||||
if wallDist != nil {
|
||||
idx := y*width + x
|
||||
isWall = wallDist[idx] >= 0
|
||||
if boardMask != nil {
|
||||
isInsideBoard = boardMask[idx]
|
||||
}
|
||||
}
|
||||
|
||||
h := 0.0
|
||||
if isWall {
|
||||
h = cfg.WallHeight
|
||||
} else if isStencilSolid {
|
||||
if isInsideBoard {
|
||||
h = cfg.WallHeight
|
||||
}
|
||||
}
|
||||
|
||||
if h > 0 {
|
||||
if startX == -1 {
|
||||
startX = x
|
||||
currentHeight = h
|
||||
} else if h != currentHeight {
|
||||
stripLen := x - startX
|
||||
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
|
||||
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
|
||||
startX = x
|
||||
currentHeight = h
|
||||
}
|
||||
} else {
|
||||
if startX != -1 {
|
||||
stripLen := x - startX
|
||||
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
|
||||
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
|
||||
startX = -1
|
||||
currentHeight = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
if startX != -1 {
|
||||
stripLen := width - startX
|
||||
AddBox(&triangles, float64(startX)*pixelToMM, float64(y)*pixelToMM,
|
||||
float64(stripLen)*pixelToMM, pixelToMM, currentHeight)
|
||||
}
|
||||
}
|
||||
return triangles
|
||||
}
|
||||
|
||||
// processPCB handles stencil generation from gerber files
|
||||
func processPCB(gerberPath, outlinePath string, cfg Config, exports []string) ([]string, image.Image, image.Image, error) {
|
||||
baseName := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath))
|
||||
var generatedFiles []string
|
||||
|
||||
wantsType := func(t string) bool {
|
||||
for _, e := range exports {
|
||||
if e == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if len(exports) == 0 {
|
||||
exports = []string{"stl"}
|
||||
}
|
||||
|
||||
fmt.Printf("Parsing %s...\n", gerberPath)
|
||||
gf, err := ParseGerber(gerberPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error parsing gerber: %v", err)
|
||||
}
|
||||
|
||||
var outlineGf *GerberFile
|
||||
if outlinePath != "" {
|
||||
fmt.Printf("Parsing outline %s...\n", outlinePath)
|
||||
outlineGf, err = ParseGerber(outlinePath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error parsing outline gerber: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
bounds := gf.CalculateBounds()
|
||||
if outlineGf != nil {
|
||||
outlineBounds := outlineGf.CalculateBounds()
|
||||
if outlineBounds.MinX < bounds.MinX {
|
||||
bounds.MinX = outlineBounds.MinX
|
||||
}
|
||||
if outlineBounds.MinY < bounds.MinY {
|
||||
bounds.MinY = outlineBounds.MinY
|
||||
}
|
||||
if outlineBounds.MaxX > bounds.MaxX {
|
||||
bounds.MaxX = outlineBounds.MaxX
|
||||
}
|
||||
if outlineBounds.MaxY > bounds.MaxY {
|
||||
bounds.MaxY = outlineBounds.MaxY
|
||||
}
|
||||
}
|
||||
|
||||
margin := cfg.WallThickness + 5.0
|
||||
bounds.MinX -= margin
|
||||
bounds.MinY -= margin
|
||||
bounds.MaxX += margin
|
||||
bounds.MaxY += margin
|
||||
|
||||
fmt.Println("Rendering to internal image...")
|
||||
img := gf.Render(cfg.DPI, &bounds)
|
||||
|
||||
var outlineImg image.Image
|
||||
if outlineGf != nil {
|
||||
outlineImg = outlineGf.Render(cfg.DPI, &bounds)
|
||||
}
|
||||
|
||||
if cfg.KeepPNG {
|
||||
pngPath := strings.TrimSuffix(gerberPath, filepath.Ext(gerberPath)) + ".png"
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not create PNG file: %v", err)
|
||||
} else {
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
log.Printf("Warning: Could not encode PNG: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var triangles [][3]Point
|
||||
if wantsType("stl") {
|
||||
fmt.Println("Generating mesh...")
|
||||
triangles = GenerateMeshFromImages(img, outlineImg, cfg)
|
||||
}
|
||||
|
||||
if wantsType("stl") {
|
||||
outputFilename := baseName + ".stl"
|
||||
fmt.Printf("Saving to %s (%d triangles)...\n", outputFilename, len(triangles))
|
||||
if err := WriteSTL(outputFilename, triangles); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error writing stl: %v", err)
|
||||
}
|
||||
generatedFiles = append(generatedFiles, outputFilename)
|
||||
}
|
||||
|
||||
if wantsType("svg") {
|
||||
outputFilename := baseName + ".svg"
|
||||
if err := WriteSVG(outputFilename, gf, &bounds); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error writing svg: %v", err)
|
||||
}
|
||||
generatedFiles = append(generatedFiles, outputFilename)
|
||||
}
|
||||
|
||||
if wantsType("png") {
|
||||
outputFilename := baseName + ".png"
|
||||
if f, err := os.Create(outputFilename); err == nil {
|
||||
png.Encode(f, img)
|
||||
f.Close()
|
||||
generatedFiles = append(generatedFiles, outputFilename)
|
||||
}
|
||||
}
|
||||
|
||||
if wantsType("scad") {
|
||||
outputFilename := baseName + ".scad"
|
||||
if err := WriteStencilSCAD(outputFilename, gf, outlineGf, cfg, &bounds); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("error writing scad: %v", err)
|
||||
}
|
||||
generatedFiles = append(generatedFiles, outputFilename)
|
||||
}
|
||||
|
||||
return generatedFiles, img, outlineImg, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// --- STL Types and Helpers ---
|
||||
|
||||
type Point struct {
|
||||
X, Y, Z float64
|
||||
}
|
||||
|
||||
func WriteSTL(filename string, triangles [][3]Point) error {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header := make([]byte, 80)
|
||||
copy(header, "Generated by pcb-to-stencil")
|
||||
if _, err := f.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count := uint32(len(triangles))
|
||||
if err := binary.Write(f, binary.LittleEndian, count); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 50)
|
||||
for _, t := range triangles {
|
||||
binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(0))
|
||||
binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(0))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], math.Float32bits(0))
|
||||
|
||||
binary.LittleEndian.PutUint32(buf[12:16], math.Float32bits(float32(t[0].X)))
|
||||
binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(float32(t[0].Y)))
|
||||
binary.LittleEndian.PutUint32(buf[20:24], math.Float32bits(float32(t[0].Z)))
|
||||
|
||||
binary.LittleEndian.PutUint32(buf[24:28], math.Float32bits(float32(t[1].X)))
|
||||
binary.LittleEndian.PutUint32(buf[28:32], math.Float32bits(float32(t[1].Y)))
|
||||
binary.LittleEndian.PutUint32(buf[32:36], math.Float32bits(float32(t[1].Z)))
|
||||
|
||||
binary.LittleEndian.PutUint32(buf[36:40], math.Float32bits(float32(t[2].X)))
|
||||
binary.LittleEndian.PutUint32(buf[40:44], math.Float32bits(float32(t[2].Y)))
|
||||
binary.LittleEndian.PutUint32(buf[44:48], math.Float32bits(float32(t[2].Z)))
|
||||
|
||||
binary.LittleEndian.PutUint16(buf[48:50], 0)
|
||||
|
||||
if _, err := f.Write(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddBox(triangles *[][3]Point, x, y, w, h, zHeight float64) {
|
||||
x0, y0 := x, y
|
||||
x1, y1 := x+w, y+h
|
||||
z0, z1 := 0.0, zHeight
|
||||
|
||||
p000 := Point{x0, y0, z0}
|
||||
p100 := Point{x1, y0, z0}
|
||||
p110 := Point{x1, y1, z0}
|
||||
p010 := Point{x0, y1, z0}
|
||||
p001 := Point{x0, y0, z1}
|
||||
p101 := Point{x1, y0, z1}
|
||||
p111 := Point{x1, y1, z1}
|
||||
p011 := Point{x0, y1, z1}
|
||||
|
||||
addQuad := func(a, b, c, d Point) {
|
||||
*triangles = append(*triangles, [3]Point{a, b, c})
|
||||
*triangles = append(*triangles, [3]Point{c, d, a})
|
||||
}
|
||||
|
||||
addQuad(p000, p010, p110, p100) // Bottom
|
||||
addQuad(p101, p111, p011, p001) // Top
|
||||
addQuad(p000, p100, p101, p001) // Front
|
||||
addQuad(p100, p110, p111, p101) // Right
|
||||
addQuad(p110, p010, p011, p111) // Back
|
||||
addQuad(p010, p000, p001, p011) // Left
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formerBaseDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "former")
|
||||
}
|
||||
|
||||
func formerSessionsDir() string {
|
||||
return filepath.Join(formerBaseDir(), "sessions")
|
||||
}
|
||||
|
||||
func formerProfilesDir() string {
|
||||
return filepath.Join(formerBaseDir(), "profiles")
|
||||
}
|
||||
|
||||
func ensureFormerDirs() {
|
||||
os.MkdirAll(formerSessionsDir(), 0755)
|
||||
os.MkdirAll(formerProfilesDir(), 0755)
|
||||
}
|
||||
|
||||
// ProjectEntry represents a saved project on disk
|
||||
type ProjectEntry struct {
|
||||
Path string
|
||||
Type string // "session" or "profile"
|
||||
Data InstanceData
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// SaveSession persists an enclosure session to ~/former/sessions/
|
||||
func SaveSession(inst InstanceData, sourceDir string, thumbnail image.Image) (string, error) {
|
||||
ensureFormerDirs()
|
||||
name := sanitizeDirName(inst.ProjectName)
|
||||
if name == "" {
|
||||
name = "untitled"
|
||||
}
|
||||
id := inst.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
projectDir := filepath.Join(formerSessionsDir(), fmt.Sprintf("%s-%s", name, id))
|
||||
if err := saveProject(projectDir, inst, sourceDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if thumbnail != nil {
|
||||
SaveThumbnail(projectDir, thumbnail)
|
||||
}
|
||||
return projectDir, nil
|
||||
}
|
||||
|
||||
// SaveProfile persists an enclosure session as a named profile to ~/former/profiles/
|
||||
func SaveProfile(inst InstanceData, name string, sourceDir string, thumbnail image.Image) (string, error) {
|
||||
ensureFormerDirs()
|
||||
dirLabel := sanitizeDirName(name)
|
||||
if dirLabel == "" {
|
||||
dirLabel = "untitled"
|
||||
}
|
||||
id := inst.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
projectDir := filepath.Join(formerProfilesDir(), fmt.Sprintf("%s-%s", dirLabel, id))
|
||||
inst.Name = name
|
||||
if err := saveProject(projectDir, inst, sourceDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if thumbnail != nil {
|
||||
SaveThumbnail(projectDir, thumbnail)
|
||||
}
|
||||
return projectDir, nil
|
||||
}
|
||||
|
||||
func saveProject(projectDir string, inst InstanceData, sourceDir string) error {
|
||||
os.MkdirAll(projectDir, 0755)
|
||||
|
||||
// Copy gerber files using original filenames
|
||||
newGerberFiles := make(map[string]string)
|
||||
for origName, savedBasename := range inst.GerberFiles {
|
||||
srcPath := filepath.Join(sourceDir, savedBasename)
|
||||
dstPath := filepath.Join(projectDir, origName)
|
||||
if err := CopyFile(srcPath, dstPath); err != nil {
|
||||
// Fallback: try using origName directly
|
||||
srcPath = filepath.Join(sourceDir, origName)
|
||||
if err2 := CopyFile(srcPath, dstPath); err2 != nil {
|
||||
return fmt.Errorf("copy %s: %v", origName, err)
|
||||
}
|
||||
}
|
||||
newGerberFiles[origName] = origName
|
||||
}
|
||||
inst.GerberFiles = newGerberFiles
|
||||
|
||||
// Copy drill files
|
||||
if inst.DrillPath != "" {
|
||||
srcPath := filepath.Join(sourceDir, inst.DrillPath)
|
||||
ext := filepath.Ext(inst.DrillPath)
|
||||
if ext == "" {
|
||||
ext = ".drl"
|
||||
}
|
||||
dstName := "drill" + ext
|
||||
dstPath := filepath.Join(projectDir, dstName)
|
||||
if CopyFile(srcPath, dstPath) == nil {
|
||||
inst.DrillPath = dstName
|
||||
}
|
||||
}
|
||||
if inst.NPTHPath != "" {
|
||||
srcPath := filepath.Join(sourceDir, inst.NPTHPath)
|
||||
ext := filepath.Ext(inst.NPTHPath)
|
||||
if ext == "" {
|
||||
ext = ".drl"
|
||||
}
|
||||
dstName := "npth" + ext
|
||||
dstPath := filepath.Join(projectDir, dstName)
|
||||
if CopyFile(srcPath, dstPath) == nil {
|
||||
inst.NPTHPath = dstName
|
||||
}
|
||||
}
|
||||
|
||||
// Write former.json
|
||||
data, err := json.MarshalIndent(inst, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644)
|
||||
}
|
||||
|
||||
// ListProjects returns all saved projects sorted by modification time (newest first).
|
||||
// Pass limit=0 for no limit.
|
||||
func ListProjects(limit int) ([]ProjectEntry, error) {
|
||||
ensureFormerDirs()
|
||||
var entries []ProjectEntry
|
||||
|
||||
sessEntries, _ := listProjectsInDir(formerSessionsDir(), "session")
|
||||
entries = append(entries, sessEntries...)
|
||||
|
||||
profEntries, _ := listProjectsInDir(formerProfilesDir(), "profile")
|
||||
entries = append(entries, profEntries...)
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ModTime.After(entries[j].ModTime)
|
||||
})
|
||||
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func listProjectsInDir(dir, projType string) ([]ProjectEntry, error) {
|
||||
dirEntries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []ProjectEntry
|
||||
for _, de := range dirEntries {
|
||||
if !de.IsDir() {
|
||||
continue
|
||||
}
|
||||
jsonPath := filepath.Join(dir, de.Name(), "former.json")
|
||||
info, err := os.Stat(jsonPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(jsonPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var inst InstanceData
|
||||
if err := json.Unmarshal(raw, &inst); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ProjectEntry{
|
||||
Path: filepath.Join(dir, de.Name()),
|
||||
Type: projType,
|
||||
Data: inst,
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// LoadProject reads former.json from a project directory
|
||||
func LoadProject(projectDir string) (*InstanceData, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(projectDir, "former.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inst InstanceData
|
||||
if err := json.Unmarshal(raw, &inst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// TouchProject updates the mtime of a project's former.json
|
||||
func TouchProject(projectDir string) {
|
||||
jsonPath := filepath.Join(projectDir, "former.json")
|
||||
now := time.Now()
|
||||
os.Chtimes(jsonPath, now, now)
|
||||
}
|
||||
|
||||
// DeleteProject removes a project directory entirely
|
||||
func DeleteProject(projectDir string) error {
|
||||
return os.RemoveAll(projectDir)
|
||||
}
|
||||
|
||||
// RestoreProject loads and rebuilds a session from a project directory
|
||||
func RestoreProject(projectDir string) (string, *EnclosureSession, *InstanceData, error) {
|
||||
inst, err := LoadProject(projectDir)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
sid, session, err := restoreSessionFromDir(inst, projectDir)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
TouchProject(projectDir)
|
||||
return sid, session, inst, nil
|
||||
}
|
||||
|
||||
// SaveThumbnail saves a preview image to the project directory
|
||||
func SaveThumbnail(projectDir string, img image.Image) error {
|
||||
f, err := os.Create(filepath.Join(projectDir, "thumbnail.png"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return png.Encode(f, img)
|
||||
}
|
||||
|
||||
func sanitizeDirName(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.TrimSpace(name)
|
||||
if len(name) > 50 {
|
||||
name = name[:50]
|
||||
}
|
||||
return name
|
||||
}
|
||||
56
svg.go
56
svg.go
|
|
@ -69,12 +69,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
|
|||
}
|
||||
|
||||
if inRegion {
|
||||
if cmd.Type == "MOVE" || cmd.Type == "DRAW" && interpolationMode == "G01" {
|
||||
if cmd.Type == "MOVE" || (cmd.Type == "DRAW" && interpolationMode == "G01") {
|
||||
regionVertices = append(regionVertices, [2]float64{curX, curY})
|
||||
} else if cmd.Type == "DRAW" && (interpolationMode == "G02" || interpolationMode == "G03") {
|
||||
// We don't have perfect analytic translation to SVG path for region arcs yet.
|
||||
// We can just output the line for now, or approximate it as before.
|
||||
// For SVG, we can just output line segments just like we did for image processing.
|
||||
iVal, jVal := 0.0, 0.0
|
||||
if cmd.I != nil {
|
||||
iVal = *cmd.I
|
||||
|
|
@ -82,29 +79,9 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
|
|||
if cmd.J != nil {
|
||||
jVal = *cmd.J
|
||||
}
|
||||
centerX, centerY := prevX+iVal, prevY+jVal
|
||||
radius := math.Sqrt(iVal*iVal + jVal*jVal)
|
||||
startAngle := math.Atan2(prevY-centerY, prevX-centerX)
|
||||
endAngle := math.Atan2(curY-centerY, curX-centerX)
|
||||
if interpolationMode == "G03" {
|
||||
if endAngle <= startAngle {
|
||||
endAngle += 2 * math.Pi
|
||||
}
|
||||
} else {
|
||||
if startAngle <= endAngle {
|
||||
startAngle += 2 * math.Pi
|
||||
}
|
||||
}
|
||||
arcLen := math.Abs(endAngle-startAngle) * radius
|
||||
steps := int(arcLen * 10) // 10 segments per mm
|
||||
if steps < 10 {
|
||||
steps = 10
|
||||
}
|
||||
for s := 1; s <= steps; s++ {
|
||||
t := float64(s) / float64(steps)
|
||||
a := startAngle + t*(endAngle-startAngle)
|
||||
ax, ay := centerX+radius*math.Cos(a), centerY+radius*math.Sin(a)
|
||||
regionVertices = append(regionVertices, [2]float64{ax, ay})
|
||||
arcPts := approximateArc(prevX, prevY, curX, curY, iVal, jVal, interpolationMode)
|
||||
for _, pt := range arcPts {
|
||||
regionVertices = append(regionVertices, pt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
|
@ -136,15 +113,26 @@ func WriteSVG(filename string, gf *GerberFile, bounds *Bounds) error {
|
|||
jVal = *cmd.J
|
||||
}
|
||||
|
||||
// SVG path Arc
|
||||
rx, ry := math.Sqrt(iVal*iVal+jVal*jVal), math.Sqrt(iVal*iVal+jVal*jVal)
|
||||
sweep := 1 // G03 CCW -> SVG path sweep up due to inverted Y
|
||||
if interpolationMode == "G02" {
|
||||
sweep = 0
|
||||
// SVG path arc (Y-axis inverted: G02 CW -> CCW in SVG, G03 CCW -> CW in SVG)
|
||||
r := math.Sqrt(iVal*iVal + jVal*jVal)
|
||||
acx, acy := prevX+iVal, prevY+jVal
|
||||
sa := math.Atan2(prevY-acy, prevX-acx)
|
||||
ea := math.Atan2(curY-acy, curX-acx)
|
||||
var arcSpan float64
|
||||
if interpolationMode == "G03" {
|
||||
if ea <= sa { ea += 2 * math.Pi }
|
||||
arcSpan = ea - sa
|
||||
} else {
|
||||
if sa <= ea { sa += 2 * math.Pi }
|
||||
arcSpan = sa - ea
|
||||
}
|
||||
largeArc := 0
|
||||
if arcSpan > math.Pi { largeArc = 1 }
|
||||
sweep := 1 // G03 CCW Gerber -> CW SVG
|
||||
if interpolationMode == "G02" { sweep = 0 }
|
||||
|
||||
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 0 %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>`+"\n",
|
||||
toSVGX(prevX), toSVGY(prevY), rx, ry, sweep, toSVGX(curX), toSVGY(curY), w)
|
||||
fmt.Fprintf(f, `<path d="M %f %f A %f %f 0 %d %d %f %f" stroke-width="%f" fill="none" stroke-linecap="round"/>` + "\n",
|
||||
toSVGX(prevX), toSVGY(prevY), r, r, largeArc, sweep, toSVGX(curX), toSVGY(curY), w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package main
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <stdlib.h>
|
||||
|
||||
// Renders SVG data to raw RGBA pixels using macOS native NSImage.
|
||||
// Returns NULL on failure. Caller must free() the returned pixels.
|
||||
unsigned char* nativeRenderSVG(const void* svgBytes, int svgLen, int targetW, int targetH) {
|
||||
@autoreleasepool {
|
||||
NSData *data = [NSData dataWithBytesNoCopy:(void*)svgBytes length:svgLen freeWhenDone:NO];
|
||||
NSImage *svgImage = [[NSImage alloc] initWithData:data];
|
||||
if (!svgImage) return NULL;
|
||||
|
||||
int w = targetW;
|
||||
int h = targetH;
|
||||
int rowBytes = w * 4;
|
||||
int totalBytes = rowBytes * h;
|
||||
|
||||
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
|
||||
initWithBitmapDataPlanes:NULL
|
||||
pixelsWide:w
|
||||
pixelsHigh:h
|
||||
bitsPerSample:8
|
||||
samplesPerPixel:4
|
||||
hasAlpha:YES
|
||||
isPlanar:NO
|
||||
colorSpaceName:NSDeviceRGBColorSpace
|
||||
bytesPerRow:rowBytes
|
||||
bitsPerPixel:32];
|
||||
|
||||
[NSGraphicsContext saveGraphicsState];
|
||||
NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:rep];
|
||||
[NSGraphicsContext setCurrentContext:ctx];
|
||||
|
||||
// Start with fully transparent background
|
||||
[[NSColor clearColor] set];
|
||||
NSRectFill(NSMakeRect(0, 0, w, h));
|
||||
|
||||
// Draw SVG, preserving alpha
|
||||
[svgImage drawInRect:NSMakeRect(0, 0, w, h)
|
||||
fromRect:NSZeroRect
|
||||
operation:NSCompositingOperationSourceOver
|
||||
fraction:1.0];
|
||||
|
||||
[NSGraphicsContext restoreGraphicsState];
|
||||
|
||||
unsigned char* result = (unsigned char*)malloc(totalBytes);
|
||||
if (result) {
|
||||
memcpy(result, [rep bitmapData], totalBytes);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// renderSVGNative uses macOS NSImage to render SVG data to an image.Image
|
||||
// with full transparency support.
|
||||
func renderSVGNative(svgData []byte, width, height int) image.Image {
|
||||
if len(svgData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pixels := C.nativeRenderSVG(
|
||||
unsafe.Pointer(&svgData[0]),
|
||||
C.int(len(svgData)),
|
||||
C.int(width),
|
||||
C.int(height),
|
||||
)
|
||||
if pixels == nil {
|
||||
return nil
|
||||
}
|
||||
defer C.free(unsafe.Pointer(pixels))
|
||||
|
||||
rawLen := width * height * 4
|
||||
raw := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), rawLen)
|
||||
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
i := (y*width + x) * 4
|
||||
r, g, b, a := raw[i], raw[i+1], raw[i+2], raw[i+3]
|
||||
|
||||
// NSImage gives premultiplied alpha — convert to straight
|
||||
if a > 0 && a < 255 {
|
||||
scale := 255.0 / float64(a)
|
||||
r = clampByte(float64(r) * scale)
|
||||
g = clampByte(float64(g) * scale)
|
||||
b = clampByte(float64(b) * scale)
|
||||
}
|
||||
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
func clampByte(v float64) uint8 {
|
||||
if v > 255 {
|
||||
return 255
|
||||
}
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint8(v)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "image"
|
||||
|
||||
// renderSVGNative is a no-op on non-macOS platforms.
|
||||
// Returns nil, causing callers to fall back to Fyne's built-in renderer.
|
||||
func renderSVGNative(svgData []byte, width, height int) image.Image {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Former",
|
||||
"outputfilename": "Former",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue