From 6bf857e58c08f7a8c9c0dac63519ed2821e67bb1 Mon Sep 17 00:00:00 2001 From: pszsh Date: Sun, 1 Mar 2026 15:59:16 -0800 Subject: [PATCH] let's call this the half-way point for the implementation of lots of the new features. framework is in place, bugs are like oxygen and we are at normal atmospheric pressure: there's lots --- Dockerfile.linux | 21 + app.go | 1069 ++++++++-- build-linux64.sh | 14 + enclosure.go | 17 +- former.go | 301 ++- frontend/index.html | 18 +- frontend/src/engine/former-engine.js | 652 +++++++ frontend/src/engine/modes/cutout-mode.js | 420 ++++ frontend/src/engine/modes/enclosure-mode.js | 798 ++++++++ frontend/src/engine/modes/structural-mode.js | 12 + frontend/src/engine/modes/vectorwrap-mode.js | 798 ++++++++ frontend/src/former3d.js | 1840 +----------------- frontend/src/main.js | 1099 ++++++++++- frontend/src/style.css | 73 + frontend/src/ui/enclosure-ui.js | 48 + frontend/src/ui/structural-ui.js | 45 + frontend/src/ui/unwrap-ui.js | 21 + frontend/src/ui/vectorwrap-ui.js | 75 + instance.go | 1 + pattern.go | 333 ++++ project.go | 134 ++ scad.go | 76 +- scangrid.go | 183 ++ session.go | 2 +- storage.go | 489 +++-- svg_parse.go | 878 +++++++++ test_shapes_test.go | 44 + unwrap.go | 387 ++++ vectorwrap.go | 38 + 29 files changed, 7614 insertions(+), 2272 deletions(-) create mode 100644 Dockerfile.linux create mode 100755 build-linux64.sh create mode 100644 frontend/src/engine/former-engine.js create mode 100644 frontend/src/engine/modes/cutout-mode.js create mode 100644 frontend/src/engine/modes/enclosure-mode.js create mode 100644 frontend/src/engine/modes/structural-mode.js create mode 100644 frontend/src/engine/modes/vectorwrap-mode.js create mode 100644 frontend/src/ui/enclosure-ui.js create mode 100644 frontend/src/ui/structural-ui.js create mode 100644 frontend/src/ui/unwrap-ui.js create mode 100644 frontend/src/ui/vectorwrap-ui.js create mode 100644 pattern.go create mode 100644 project.go create mode 100644 scangrid.go create mode 100644 svg_parse.go create mode 100644 test_shapes_test.go create mode 100644 unwrap.go create mode 100644 vectorwrap.go diff --git a/Dockerfile.linux b/Dockerfile.linux new file mode 100644 index 0000000..7255ae2 --- /dev/null +++ b/Dockerfile.linux @@ -0,0 +1,21 @@ +FROM --platform=linux/amd64 golang:1.23-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nodejs npm \ + libgtk-3-dev libwebkit2gtk-4.0-dev \ + pkg-config build-essential \ + && rm -rf /var/lib/apt/lists/* + +RUN go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +RUN cd frontend && npm install && cd .. +RUN wails build -skipbindings -platform linux/amd64 + +FROM --platform=linux/amd64 debian:bookworm-slim +COPY --from=builder /src/build/bin/Former /Former-linux-amd64 +CMD ["cp", "/Former-linux-amd64", "/out/Former-linux-amd64"] diff --git a/app.go b/app.go index c302f97..d416002 100644 --- a/app.go +++ b/app.go @@ -67,13 +67,19 @@ func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ======== 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"` + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + CreatedAt string `json:"createdAt"` + HasStencil bool `json:"hasStencil"` + HasEnclosure bool `json:"hasEnclosure"` + HasVectorWrap bool `json:"hasVectorWrap"` + HasStructural bool `json:"hasStructural"` + HasScanHelper bool `json:"hasScanHelper"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + ShowGrid bool `json:"showGrid"` + TraditionalControls bool `json:"traditionalControls"` } type SessionInfoJS struct { @@ -90,12 +96,13 @@ type SessionInfoJS struct { } 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"` + Index int `json:"index"` + Name string `json:"name"` + ColorHex string `json:"colorHex"` + Visible bool `json:"visible"` + Highlight bool `json:"highlight"` + BaseAlpha float64 `json:"baseAlpha"` + SourceFile string `json:"sourceFile"` } type GenerateResultJS struct { @@ -112,12 +119,17 @@ type App struct { ctx context.Context imageServer *ImageServer - mu sync.RWMutex - enclosureSession *EnclosureSession - cutouts []Cutout - projectDir string // path to the current project directory (for auto-saving) - formerLayers []*FormerLayer - stencilFiles []string + mu sync.RWMutex + project *ProjectData + projectPath string + + enclosureSession *EnclosureSession + vectorWrapSession *VectorWrapSession + structuralSession *StructuralSession + scanHelperConfig *ScanHelperConfig + cutouts []Cutout + formerLayers []*FormerLayer + stencilFiles []string } func NewApp(imageServer *ImageServer) *App { @@ -126,19 +138,23 @@ func NewApp(imageServer *ImageServer) *App { } } -// autosaveCutouts persists the current cutouts to the project directory's former.json -func (a *App) autosaveCutouts() { +// autosaveProject persists the current project state to project.json +func (a *App) autosaveProject() { a.mu.RLock() - dir := a.projectDir + proj := a.project + projPath := a.projectPath cutouts := make([]Cutout, len(a.cutouts)) copy(cutouts, a.cutouts) a.mu.RUnlock() - if dir == "" { + if proj == nil || projPath == "" { return } - if err := UpdateProjectCutouts(dir, cutouts); err != nil { - log.Printf("autosave cutouts failed: %v", err) + if proj.Enclosure != nil { + proj.Enclosure.Cutouts = cutouts + } + if err := SaveProject(projPath, proj); err != nil { + log.Printf("autosave project failed: %v", err) } } @@ -146,7 +162,8 @@ func (a *App) startup(ctx context.Context) { debugLog("app.startup() called") a.ctx = ctx - // Render and cache the logo (PNG for favicon, SVG for background art) + MigrateOldProjects() + logoImg := renderSVGNative(formerLogoSVG, 512, 512) if logoImg != nil { var buf bytes.Buffer @@ -160,31 +177,39 @@ func (a *App) startup(ctx context.Context) { // ======== Landing Page ======== func (a *App) GetRecentProjects() []ProjectInfoJS { - debugLog("GetRecentProjects() called") - entries, err := ListProjects(20) - if err != nil { - debugLog("GetRecentProjects() error: %v", err) - return nil - } - debugLog("GetRecentProjects() found %d projects", len(entries)) + entries := ListRecentProjects() var result []ProjectInfoJS for _, e := range entries { - name := e.Data.ProjectName - if e.Data.Name != "" { - name = e.Data.Name + proj, err := LoadProjectData(e.Path) + if err != nil { + continue } - if name == "" { - name = "Untitled" - } - result = append(result, ProjectInfoJS{ - ID: e.Data.ID, - Name: name, + info := ProjectInfoJS{ + ID: proj.ID, + Name: proj.Name, Path: e.Path, - Type: e.Type, - CreatedAt: e.ModTime.Format(time.RFC3339), - BoardW: e.Data.BoardW, - BoardH: e.Data.BoardH, - }) + CreatedAt: e.LastOpened.Format(time.RFC3339), + ShowGrid: proj.Settings.ShowGrid, + TraditionalControls: proj.Settings.TraditionalControls, + } + if proj.Stencil != nil { + info.HasStencil = true + } + if proj.Enclosure != nil { + info.HasEnclosure = true + info.BoardW = proj.Enclosure.BoardW + info.BoardH = proj.Enclosure.BoardH + } + if proj.VectorWrap != nil { + info.HasVectorWrap = true + } + if proj.Structural != nil { + info.HasStructural = true + } + if proj.ScanHelper != nil { + info.HasScanHelper = true + } + result = append(result, info) } return result } @@ -412,18 +437,54 @@ func (a *App) BuildEnclosureSession(gbrjobPath string, gerberPaths []string, dri a.mu.Lock() a.enclosureSession = session a.cutouts = nil + // Populate project enclosure data if a project is open + if a.project != nil { + a.project.Enclosure = &EnclosureData{ + 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, + } + // Copy gerber files into project's enclosure subdir + if a.projectPath != "" { + encDir := filepath.Join(a.projectPath, "enclosure") + os.MkdirAll(encDir, 0755) + newGerbers := make(map[string]string) + for origName, fullPath := range savedGerbers { + dst := filepath.Join(encDir, origName) + CopyFile(fullPath, dst) + newGerbers[origName] = origName + } + a.project.Enclosure.GerberFiles = newGerbers + if drillDst != "" { + dstName := "drill" + filepath.Ext(drillPath) + CopyFile(drillDst, filepath.Join(encDir, dstName)) + a.project.Enclosure.DrillPath = dstName + } + if npthDst != "" { + dstName := "npth" + filepath.Ext(npthPath) + CopyFile(npthDst, filepath.Join(encDir, dstName)) + a.project.Enclosure.NPTHPath = dstName + } + } + } a.mu.Unlock() - debugLog(" session stored in app state") - // 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()) - debugLog(" board preview rendered (%d bytes)", buf.Len()) } - debugLog("BuildEnclosureSession() returning nil (success)") + a.autosaveProject() return nil } @@ -465,7 +526,7 @@ func (a *App) AddSideCutout(side int, x, y, w, h, radius float64, layer string) SourceLayer: layer, }) a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) RemoveSideCutout(index int) { @@ -482,7 +543,7 @@ func (a *App) RemoveSideCutout(index int) { } } a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) GetSideCutouts() []SideCutout { @@ -506,7 +567,7 @@ func (a *App) AddCutout(c Cutout) string { } a.cutouts = append(a.cutouts, c) a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() return c.ID } @@ -519,7 +580,7 @@ func (a *App) UpdateCutout(c Cutout) { } } a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) RemoveCutout(id string) { @@ -531,7 +592,7 @@ func (a *App) RemoveCutout(id string) { } } a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) GetCutouts() []Cutout { @@ -561,7 +622,7 @@ func (a *App) DuplicateCutout(id string) string { } } a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() return dupID } @@ -580,7 +641,7 @@ func (a *App) GetSideLength(sideNum int) float64 { } // 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) { +func (a *App) AddLidCutouts(elements []ElementBBox, plane string, isDado bool, depth float64, gerberSource string) { a.mu.Lock() if a.enclosureSession == nil { @@ -602,19 +663,25 @@ func (a *App) AddLidCutouts(elements []ElementBBox, plane string, isDado bool, d mmMinY := bounds.MaxY - float64(el.MaxY)*(25.4/dpi) mmMaxY := bounds.MaxY - float64(el.MinY)*(25.4/dpi) + shape := el.Shape + if shape == "" { + shape = "rect" + } a.cutouts = append(a.cutouts, Cutout{ - ID: randomID(), - Surface: surface, - X: mmMinX, - Y: mmMinY, - Width: mmMaxX - mmMinX, - Height: mmMaxY - mmMinY, - IsDado: isDado, - Depth: depth, + ID: randomID(), + Surface: surface, + X: mmMinX, + Y: mmMinY, + Width: mmMaxX - mmMinX, + Height: mmMaxY - mmMinY, + IsDado: isDado, + Depth: depth, + Shape: shape, + GerberSource: gerberSource, }) } a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) GetLidCutouts() []LidCutout { @@ -639,7 +706,7 @@ func (a *App) ClearLidCutouts() { } a.cutouts = kept a.mu.Unlock() - a.autosaveCutouts() + a.autosaveProject() } func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) { @@ -648,53 +715,28 @@ func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) { session := a.enclosureSession allCutouts := make([]Cutout, len(a.cutouts)) copy(allCutouts, a.cutouts) + projPath := a.projectPath a.mu.RUnlock() if session == nil { - debugLog(" ERROR: no enclosure session active") return nil, fmt.Errorf("no enclosure session active") } outputDir := session.SourceDir - if outputDir == "" { + if projPath != "" { + outputDir = filepath.Join(projPath, "enclosure") + os.MkdirAll(outputDir, 0755) + } else if outputDir == "" { outputDir = formerTempDir() } - debugLog(" outputDir=%s cutouts=%d", outputDir, len(allCutouts)) files, err := GenerateEnclosureOutputs(session, allCutouts, outputDir) if err != nil { - debugLog(" ERROR generate: %v", err) return nil, err } - debugLog(" generated %d files", len(files)) - // 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 savedDir, saveErr := SaveSession(inst, formerTempDir(), session.OutlineImg); saveErr != nil { - log.Printf("Warning: could not save session: %v", saveErr) - } else { - a.mu.Lock() - a.projectDir = savedDir - a.mu.Unlock() - } + a.autosaveProject() - // Prepare Former layers a.mu.Lock() a.formerLayers = buildEnclosureLayers(session) a.mu.Unlock() @@ -703,76 +745,192 @@ func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) { 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() +// ======== Project Lifecycle ======== - if session == nil { - return fmt.Errorf("no enclosure session active") - } - if name == "" { - name = session.ProjectName - } +func (a *App) CreateNewProject(name string) (string, error) { if name == "" { name = "Untitled" } + safeName := sanitizeDirName(name) + if safeName == "" { + safeName = "untitled" + } + path := filepath.Join(formerProjectsDir(), safeName+".former") - 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, + // Avoid overwriting existing project + if _, err := os.Stat(path); err == nil { + path = filepath.Join(formerProjectsDir(), safeName+"-"+randomID()[:8]+".former") } - sourceDir := session.SourceDir - if sourceDir == "" { - sourceDir = formerTempDir() + + proj, err := CreateProject(path) + if err != nil { + return "", err } - savedDir, err := SaveProfile(inst, name, sourceDir, session.OutlineImg) - if err == nil && savedDir != "" { - a.mu.Lock() - a.projectDir = savedDir - a.mu.Unlock() - } - return err + proj.Name = name + SaveProject(path, proj) + + a.mu.Lock() + a.project = proj + a.projectPath = path + a.enclosureSession = nil + a.vectorWrapSession = nil + a.structuralSession = nil + a.scanHelperConfig = nil + a.cutouts = nil + a.formerLayers = nil + a.stencilFiles = nil + a.mu.Unlock() + + return path, nil } -func (a *App) OpenProject(projectPath string) error { - _, session, inst, err := RestoreProject(projectPath) +func (a *App) OpenProjectDialog() (string, error) { + path, err := wailsRuntime.OpenDirectoryDialog(a.ctx, wailsRuntime.OpenDialogOptions{ + Title: "Open Former Project", + DefaultDirectory: formerProjectsDir(), + }) + if err != nil || path == "" { + return "", err + } + if err := a.OpenProjectByPath(path); err != nil { + return "", err + } + return path, nil +} + +func (a *App) OpenProjectByPath(path string) error { + proj, err := LoadProjectData(path) if err != nil { - return err + return fmt.Errorf("load project: %v", err) } a.mu.Lock() - a.enclosureSession = session - a.cutouts = inst.MigrateCutouts() - a.projectDir = projectPath + a.project = proj + a.projectPath = path + a.enclosureSession = nil + a.vectorWrapSession = nil + a.structuralSession = nil + a.scanHelperConfig = nil + a.cutouts = nil + a.formerLayers = nil + a.stencilFiles = 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()) + // Restore enclosure session if data exists + if proj.Enclosure != nil && proj.Enclosure.EdgeCutsFile != "" { + sid, session, err := RestoreEnclosureFromProject(path, proj.Enclosure) + if err == nil { + a.mu.Lock() + a.enclosureSession = session + a.cutouts = proj.Enclosure.MigrateCutouts() + a.mu.Unlock() + debugLog("Restored enclosure session %s from project", sid) + + if session.OutlineImg != nil { + var buf bytes.Buffer + png.Encode(&buf, session.OutlineImg) + a.imageServer.Store("/api/board-preview.png", buf.Bytes()) + } + } else { + debugLog("Could not restore enclosure from project: %v", err) + } } + // Restore scan helper config + if proj.ScanHelper != nil { + a.mu.Lock() + a.scanHelperConfig = &ScanHelperConfig{ + PageWidth: proj.ScanHelper.PageWidth, + PageHeight: proj.ScanHelper.PageHeight, + GridSpacing: proj.ScanHelper.GridSpacing, + PagesWide: proj.ScanHelper.PagesWide, + PagesTall: proj.ScanHelper.PagesTall, + DPI: proj.ScanHelper.DPI, + } + a.mu.Unlock() + } + + AddRecentProject(path, proj.Name) return nil } +func (a *App) CloseProject() { + a.autosaveProject() + a.mu.Lock() + a.project = nil + a.projectPath = "" + a.enclosureSession = nil + a.vectorWrapSession = nil + a.structuralSession = nil + a.scanHelperConfig = nil + a.cutouts = nil + a.formerLayers = nil + a.stencilFiles = nil + a.mu.Unlock() + a.imageServer.Clear() +} + +func (a *App) GetProjectInfo() *ProjectInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.project == nil { + return nil + } + p := a.project + info := &ProjectInfoJS{ + ID: p.ID, + Name: p.Name, + Path: a.projectPath, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + ShowGrid: p.Settings.ShowGrid, + TraditionalControls: p.Settings.TraditionalControls, + } + if p.Stencil != nil { + info.HasStencil = true + } + if p.Enclosure != nil { + info.HasEnclosure = true + info.BoardW = p.Enclosure.BoardW + info.BoardH = p.Enclosure.BoardH + } + if p.VectorWrap != nil { + info.HasVectorWrap = true + } + if p.Structural != nil { + info.HasStructural = true + } + if p.ScanHelper != nil { + info.HasScanHelper = true + } + return info +} + +func (a *App) SaveProjectSettings(showGrid, traditionalControls bool) { + a.mu.Lock() + if a.project != nil { + a.project.Settings.ShowGrid = showGrid + a.project.Settings.TraditionalControls = traditionalControls + } + a.mu.Unlock() + a.autosaveProject() +} + +func (a *App) GetProjectOutputFiles(mode string) []string { + a.mu.RLock() + projPath := a.projectPath + a.mu.RUnlock() + if projPath == "" { + return nil + } + return ListProjectOutputFiles(projPath, mode) +} + +// OpenProject opens an old-style project or new .former project (backward compat for frontend) +func (a *App) OpenProject(projectPath string) error { + return a.OpenProjectByPath(projectPath) +} + func (a *App) DeleteProject(projectPath string) error { return DeleteProject(projectPath) } @@ -893,12 +1051,13 @@ func (a *App) GetFormerLayers() []LayerInfoJS { 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, + 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, + SourceFile: l.SourceFile, }) } return result @@ -1020,7 +1179,7 @@ func (a *App) GetEnclosureSCAD() (*SCADResult, error) { } debugLog(" outline polygon: %d vertices", len(outlinePoly)) - sideCutouts, lidCutouts := SplitCutouts(allCutouts) + sideCutouts, lidCutouts := SplitCutouts(allCutouts, session.AllLayerGerbers) debugLog(" cutouts: %d side, %d lid", len(sideCutouts), len(lidCutouts)) debugLog(" generating enclosure SCAD string...") @@ -1046,26 +1205,20 @@ func (a *App) GetEnclosureSCAD() (*SCADResult, error) { }, nil } -// GetOutputDir returns the output directory path for the current session. +// GetOutputDir returns the output directory path for the current project/session. func (a *App) GetOutputDir() (string, error) { a.mu.RLock() + projPath := a.projectPath session := a.enclosureSession a.mu.RUnlock() - if session == nil { - return "", fmt.Errorf("no enclosure session active") + if projPath != "" { + return projPath, nil } - - outputDir := session.SourceDir - if outputDir == "" { - outputDir = formerTempDir() + if session != nil && session.SourceDir != "" { + return filepath.Abs(session.SourceDir) } - - absDir, err := filepath.Abs(outputDir) - if err != nil { - return outputDir, nil - } - return absDir, nil + return formerTempDir(), nil } // OpenOutputFolder opens the output directory in the OS file manager. @@ -1076,3 +1229,581 @@ func (a *App) OpenOutputFolder() error { } return exec.Command("open", dir).Start() } + +// ======== Active Mode Detection ======== + +func (a *App) GetActiveMode() string { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.vectorWrapSession != nil && (a.vectorWrapSession.SVGPath != "" || a.vectorWrapSession.ModelPath != "") { + return "vectorwrap" + } + if a.structuralSession != nil && a.structuralSession.SVGPath != "" { + return "structural" + } + if a.enclosureSession != nil { + return "enclosure" + } + if len(a.formerLayers) > 0 { + return "stencil" + } + return "" +} + +// ======== Vector Wrap ======== + +type SVGValidationResultJS struct { + Width float64 `json:"width"` + Height float64 `json:"height"` + Elements int `json:"elements"` + Layers int `json:"layers"` + Warnings []string `json:"warnings"` + LayerNames []string `json:"layerNames"` +} + +type SVGLayerInfoJS struct { + ID string `json:"id"` + Label string `json:"label"` + Visible bool `json:"visible"` +} + +type VectorWrapInfoJS struct { + HasSession bool `json:"hasSession"` + SVGPath string `json:"svgPath"` + SVGWidth float64 `json:"svgWidth"` + SVGHeight float64 `json:"svgHeight"` + ModelPath string `json:"modelPath"` + ModelType string `json:"modelType"` + HasProjectEnclosure bool `json:"hasProjectEnclosure"` +} + +func (a *App) ImportSVGForVectorWrap(svgPath string) (*SVGValidationResultJS, error) { + doc, err := ParseSVG(svgPath) + if err != nil { + return nil, fmt.Errorf("parse SVG: %v", err) + } + + // Render SVG to image via native renderer + renderW, renderH := 1024, 1024 + if doc.Width > 0 && doc.Height > 0 { + aspect := doc.Width / doc.Height + if aspect > 1 { + renderH = int(float64(renderW) / aspect) + } else { + renderW = int(float64(renderH) * aspect) + } + } + + svgImg := renderSVGNative(doc.RawSVG, renderW, renderH) + if svgImg != nil { + var buf bytes.Buffer + png.Encode(&buf, svgImg) + a.imageServer.Store("/api/vectorwrap-svg.png", buf.Bytes()) + } + + a.mu.Lock() + if a.vectorWrapSession == nil { + a.vectorWrapSession = &VectorWrapSession{} + } + a.vectorWrapSession.SVGDoc = doc + a.vectorWrapSession.SVGPath = svgPath + a.vectorWrapSession.SVGImage = svgImg + a.mu.Unlock() + + return &SVGValidationResultJS{ + Width: doc.Width, + Height: doc.Height, + Elements: len(doc.Elements), + Layers: doc.LayerCount(), + Warnings: doc.Warnings, + LayerNames: doc.VisibleLayerNames(), + }, nil +} + +func (a *App) ImportModelForVectorWrap(modelPath string) error { + ext := strings.ToLower(filepath.Ext(modelPath)) + var modelType string + var stlData []byte + + switch ext { + case ".stl": + modelType = "stl" + data, err := os.ReadFile(modelPath) + if err != nil { + return fmt.Errorf("read STL: %v", err) + } + stlData = data + case ".scad": + modelType = "scad" + default: + return fmt.Errorf("unsupported model format: %s (use .stl or .scad)", ext) + } + + a.mu.Lock() + if a.vectorWrapSession == nil { + a.vectorWrapSession = &VectorWrapSession{} + } + a.vectorWrapSession.ModelPath = modelPath + a.vectorWrapSession.ModelType = modelType + a.vectorWrapSession.ModelSTL = stlData + a.mu.Unlock() + + return nil +} + +func (a *App) GetVectorWrapInfo() *VectorWrapInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + hasEnc := a.enclosureSession != nil + if a.vectorWrapSession == nil { + return &VectorWrapInfoJS{HasSession: false, HasProjectEnclosure: hasEnc} + } + s := a.vectorWrapSession + info := &VectorWrapInfoJS{ + HasSession: true, + SVGPath: s.SVGPath, + ModelPath: s.ModelPath, + ModelType: s.ModelType, + HasProjectEnclosure: hasEnc, + } + if s.SVGDoc != nil { + info.SVGWidth = s.SVGDoc.Width + info.SVGHeight = s.SVGDoc.Height + } + return info +} + +func (a *App) GetVectorWrapModelSTL() ([]byte, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.vectorWrapSession == nil || len(a.vectorWrapSession.ModelSTL) == 0 { + return nil, fmt.Errorf("no STL model loaded") + } + return a.vectorWrapSession.ModelSTL, nil +} + +func (a *App) GetVectorWrapSCADSource() (string, error) { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.vectorWrapSession == nil || a.vectorWrapSession.ModelType != "scad" { + return "", fmt.Errorf("no SCAD model loaded") + } + data, err := os.ReadFile(a.vectorWrapSession.ModelPath) + if err != nil { + return "", fmt.Errorf("read SCAD: %v", err) + } + return string(data), nil +} + +func (a *App) UseProjectEnclosureForVectorWrap() error { + debugLog("UseProjectEnclosureForVectorWrap() called") + a.mu.RLock() + session := a.enclosureSession + allCutouts := make([]Cutout, len(a.cutouts)) + copy(allCutouts, a.cutouts) + a.mu.RUnlock() + + if session == nil { + debugLog(" ERROR: no enclosure session active") + return fmt.Errorf("no enclosure session active") + } + + debugLog(" extracting outline polygon...") + outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) + if outlinePoly == nil { + debugLog(" ERROR: could not extract board outline polygon") + return fmt.Errorf("could not extract board outline polygon") + } + debugLog(" outline polygon: %d vertices", len(outlinePoly)) + + sideCutouts, lidCutouts := SplitCutouts(allCutouts, session.AllLayerGerbers) + debugLog(" cutouts: %d side, %d lid", len(sideCutouts), len(lidCutouts)) + + debugLog(" generating enclosure SCAD string...") + encSCAD, err := GenerateNativeSCADString(false, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + if err != nil { + debugLog(" ERROR enclosure SCAD: %v", err) + return fmt.Errorf("generate enclosure SCAD: %v", err) + } + debugLog(" enclosure SCAD: %d chars", len(encSCAD)) + + debugLog(" generating tray SCAD string...") + traySCAD, err := GenerateNativeSCADString(true, outlinePoly, session.Config, session.DrillHoles, sideCutouts, lidCutouts, session.Sides, session.MinBX, session.MaxBX, session.BoardCenterY) + if err != nil { + debugLog(" ERROR tray SCAD: %v", err) + return fmt.Errorf("generate tray SCAD: %v", err) + } + debugLog(" tray SCAD: %d chars", len(traySCAD)) + + a.mu.Lock() + if a.vectorWrapSession == nil { + a.vectorWrapSession = &VectorWrapSession{} + } + a.vectorWrapSession.ModelType = "project-enclosure" + a.vectorWrapSession.ModelPath = "" + a.vectorWrapSession.ModelSTL = nil + a.vectorWrapSession.EnclosureSCAD = encSCAD + a.vectorWrapSession.TraySCAD = traySCAD + a.mu.Unlock() + + debugLog("UseProjectEnclosureForVectorWrap() returning OK") + return nil +} + +type VectorWrapProjectSCADJS struct { + EnclosureSCAD string `json:"enclosureSCAD"` + TraySCAD string `json:"traySCAD"` +} + +func (a *App) GetVectorWrapProjectSCAD() (*VectorWrapProjectSCADJS, error) { + debugLog("GetVectorWrapProjectSCAD() called") + a.mu.RLock() + vws := a.vectorWrapSession + projPath := a.projectPath + a.mu.RUnlock() + + if vws == nil || vws.ModelType != "project-enclosure" { + debugLog(" ERROR: no project enclosure loaded (session=%v, type=%q)", vws != nil, func() string { + if vws != nil { + return vws.ModelType + } + return "" + }()) + return nil, fmt.Errorf("no project enclosure loaded for vector wrap") + } + + // Return in-memory SCAD if available + if vws.EnclosureSCAD != "" { + debugLog(" returning in-memory enc=%d chars, tray=%d chars", len(vws.EnclosureSCAD), len(vws.TraySCAD)) + return &VectorWrapProjectSCADJS{ + EnclosureSCAD: vws.EnclosureSCAD, + TraySCAD: vws.TraySCAD, + }, nil + } + + // Fall back to reading SCAD files from disk + if projPath == "" { + return nil, fmt.Errorf("no in-memory SCAD and no project path") + } + encDir := filepath.Join(projPath, "enclosure") + encSCAD, traySCAD, err := findLatestSCADFiles(encDir) + if err != nil { + debugLog(" disk fallback failed: %v", err) + return nil, fmt.Errorf("no SCAD available: %v", err) + } + debugLog(" returning disk enc=%d chars, tray=%d chars", len(encSCAD), len(traySCAD)) + + // Cache in session for subsequent calls + a.mu.Lock() + if a.vectorWrapSession != nil { + a.vectorWrapSession.EnclosureSCAD = encSCAD + a.vectorWrapSession.TraySCAD = traySCAD + } + a.mu.Unlock() + + return &VectorWrapProjectSCADJS{ + EnclosureSCAD: encSCAD, + TraySCAD: traySCAD, + }, nil +} + +// findLatestSCADFiles scans dir for *_enclosure.scad and *_tray.scad, +// returning the contents of the most recently modified pair. +func findLatestSCADFiles(dir string) (encSCAD, traySCAD string, err error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", "", err + } + + var bestEncFile, bestTrayFile string + var bestEncTime, bestTrayTime time.Time + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".scad") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + name := e.Name() + if strings.HasSuffix(name, "_enclosure.scad") && info.ModTime().After(bestEncTime) { + bestEncFile = filepath.Join(dir, name) + bestEncTime = info.ModTime() + } else if strings.HasSuffix(name, "_tray.scad") && info.ModTime().After(bestTrayTime) { + bestTrayFile = filepath.Join(dir, name) + bestTrayTime = info.ModTime() + } + } + + if bestEncFile == "" { + return "", "", fmt.Errorf("no *_enclosure.scad found in %s", dir) + } + + encData, err := os.ReadFile(bestEncFile) + if err != nil { + return "", "", fmt.Errorf("read enclosure SCAD: %v", err) + } + encSCAD = string(encData) + debugLog(" found enclosure SCAD: %s (%d chars)", filepath.Base(bestEncFile), len(encSCAD)) + + if bestTrayFile != "" { + trayData, err := os.ReadFile(bestTrayFile) + if err == nil { + traySCAD = string(trayData) + debugLog(" found tray SCAD: %s (%d chars)", filepath.Base(bestTrayFile), len(traySCAD)) + } + } + return encSCAD, traySCAD, nil +} + +func (a *App) GetVectorWrapSVGLayers() []SVGLayerInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.vectorWrapSession == nil || a.vectorWrapSession.SVGDoc == nil { + return nil + } + var layers []SVGLayerInfoJS + for _, g := range a.vectorWrapSession.SVGDoc.Groups { + if g.Label != "" { + layers = append(layers, SVGLayerInfoJS{ + ID: g.ID, + Label: g.Label, + Visible: g.Visible, + }) + } + } + return layers +} + +// ======== Enclosure Unwrap ======== + +func (a *App) GetUnwrapSVG() (string, error) { + a.mu.RLock() + session := a.enclosureSession + cutouts := a.cutouts + a.mu.RUnlock() + + if session == nil { + return "", fmt.Errorf("no enclosure session active") + } + if len(session.Sides) == 0 { + return "", fmt.Errorf("no board sides detected") + } + + layout := ComputeUnwrapLayout(session, cutouts) + if layout == nil { + return "", fmt.Errorf("failed to compute unwrap layout") + } + return GenerateUnwrapSVG(layout), nil +} + +func (a *App) GetUnwrapLayout() (*UnwrapLayout, error) { + a.mu.RLock() + session := a.enclosureSession + cutouts := a.cutouts + a.mu.RUnlock() + + if session == nil { + return nil, fmt.Errorf("no enclosure session active") + } + if len(session.Sides) == 0 { + return nil, fmt.Errorf("no board sides detected") + } + + layout := ComputeUnwrapLayout(session, cutouts) + if layout == nil { + return nil, fmt.Errorf("failed to compute unwrap layout") + } + return layout, nil +} + +func (a *App) ImportUnwrapArtwork(svgContent string) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.vectorWrapSession == nil { + a.vectorWrapSession = &VectorWrapSession{} + } + // Store the artwork SVG for use with surface wrapping + a.vectorWrapSession.EnclosureSCAD = svgContent + return nil +} + +// ======== Structural Procedures ======== + +type StructuralInfoJS struct { + HasSession bool `json:"hasSession"` + SVGPath string `json:"svgPath"` + SVGWidth float64 `json:"svgWidth"` + SVGHeight float64 `json:"svgHeight"` + Pattern string `json:"pattern"` + CellSize float64 `json:"cellSize"` + WallThick float64 `json:"wallThick"` + Height float64 `json:"height"` +} + +func (a *App) ImportSVGForStructural(svgPath string) (*SVGValidationResultJS, error) { + doc, err := ParseSVG(svgPath) + if err != nil { + return nil, fmt.Errorf("parse SVG: %v", err) + } + + a.mu.Lock() + a.structuralSession = &StructuralSession{ + SVGDoc: doc, + SVGPath: svgPath, + Pattern: "hexagon", + CellSize: 10.0, + WallThick: 1.2, + Height: 20.0, + ShellThick: 1.6, + } + a.mu.Unlock() + + return &SVGValidationResultJS{ + Width: doc.Width, + Height: doc.Height, + Elements: len(doc.Elements), + Layers: doc.LayerCount(), + Warnings: doc.Warnings, + LayerNames: doc.VisibleLayerNames(), + }, nil +} + +func (a *App) UpdateStructuralParams(pattern string, cellSize, wallThick, height, shellThick float64) error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.structuralSession == nil { + return fmt.Errorf("no structural session active") + } + if pattern != "" { + a.structuralSession.Pattern = pattern + } + if cellSize > 0 { + a.structuralSession.CellSize = cellSize + } + if wallThick > 0 { + a.structuralSession.WallThick = wallThick + } + if height > 0 { + a.structuralSession.Height = height + } + if shellThick > 0 { + a.structuralSession.ShellThick = shellThick + } + return nil +} + +func (a *App) GenerateStructuralSCAD() (string, error) { + a.mu.RLock() + session := a.structuralSession + a.mu.RUnlock() + + if session == nil { + return "", fmt.Errorf("no structural session active") + } + return GenerateStructuralSCADString(session) +} + +func (a *App) GetStructuralInfo() *StructuralInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.structuralSession == nil { + return &StructuralInfoJS{HasSession: false} + } + s := a.structuralSession + info := &StructuralInfoJS{ + HasSession: true, + SVGPath: s.SVGPath, + Pattern: s.Pattern, + CellSize: s.CellSize, + WallThick: s.WallThick, + Height: s.Height, + } + if s.SVGDoc != nil { + info.SVGWidth = s.SVGDoc.Width + info.SVGHeight = s.SVGDoc.Height + } + return info +} + +// ======== Scan Helper ======== + +type ScanHelperInfoJS struct { + PageWidth float64 `json:"pageWidth"` + PageHeight float64 `json:"pageHeight"` + GridSpacing float64 `json:"gridSpacing"` + PagesWide int `json:"pagesWide"` + PagesTall int `json:"pagesTall"` + DPI float64 `json:"dpi"` +} + +func (a *App) UpdateScanHelperConfig(pageW, pageH, gridSpacing, dpi float64, pagesWide, pagesTall int) { + a.mu.Lock() + a.scanHelperConfig = &ScanHelperConfig{ + PageWidth: pageW, + PageHeight: pageH, + GridSpacing: gridSpacing, + PagesWide: pagesWide, + PagesTall: pagesTall, + DPI: dpi, + } + if a.project != nil { + a.project.ScanHelper = &ScanHelperData{ + PageWidth: pageW, + PageHeight: pageH, + GridSpacing: gridSpacing, + PagesWide: pagesWide, + PagesTall: pagesTall, + DPI: dpi, + } + } + a.mu.Unlock() + a.autosaveProject() +} + +func (a *App) GenerateScanGrid() ([]string, error) { + a.mu.RLock() + cfg := a.scanHelperConfig + projPath := a.projectPath + a.mu.RUnlock() + + if cfg == nil { + cfg = &ScanHelperConfig{ + PageWidth: 210, PageHeight: 297, + GridSpacing: 10, PagesWide: 1, PagesTall: 1, DPI: 300, + } + } + outDir := formerTempDir() + if projPath != "" { + outDir = filepath.Join(projPath, "scanhelper") + os.MkdirAll(outDir, 0755) + } + return GenerateScanGridSVG(cfg, outDir) +} + +func (a *App) GetScanHelperInfo() *ScanHelperInfoJS { + a.mu.RLock() + defer a.mu.RUnlock() + + if a.scanHelperConfig == nil { + return &ScanHelperInfoJS{ + PageWidth: 210, PageHeight: 297, + GridSpacing: 10, PagesWide: 1, PagesTall: 1, DPI: 300, + } + } + c := a.scanHelperConfig + return &ScanHelperInfoJS{ + PageWidth: c.PageWidth, PageHeight: c.PageHeight, + GridSpacing: c.GridSpacing, PagesWide: c.PagesWide, + PagesTall: c.PagesTall, DPI: c.DPI, + } +} diff --git a/build-linux64.sh b/build-linux64.sh new file mode 100755 index 0000000..faf81fd --- /dev/null +++ b/build-linux64.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Build Former for Linux amd64 via Docker +set -e + +if ! command -v docker &>/dev/null || ! docker info &>/dev/null 2>&1; then + echo "ERROR: Docker is required for cross-compiling the Linux build." + echo " Install: https://docs.docker.com/get-docker/" + exit 1 +fi + +echo "Building Linux amd64 via Docker..." +docker build --platform linux/amd64 -t former-linux-build -f Dockerfile.linux . 2>&1 +docker run --rm --platform linux/amd64 -v "$(pwd)/build/bin:/out" former-linux-build 2>&1 +echo "Linux binary: build/bin/Former-linux-amd64" diff --git a/enclosure.go b/enclosure.go index 7acda29..2b118a9 100644 --- a/enclosure.go +++ b/enclosure.go @@ -40,6 +40,7 @@ type SideCutout struct { Height float64 `json:"h"` CornerRadius float64 `json:"r"` Layer string `json:"l"` + Shape string `json:"shape"` } // LidCutout defines a cutout on the lid or tray plane (top/bottom flat surfaces) @@ -52,6 +53,9 @@ type LidCutout struct { MaxY float64 `json:"maxY"` IsDado bool `json:"isDado"` Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut + Shape string `json:"shape"` // "circle" or "rect" + // Text engrave: gerber source for clipped 2D shape rendering + GerberFile *GerberFile `json:"-"` } // Cutout is the unified cutout type — replaces separate SideCutout/LidCutout. @@ -67,6 +71,8 @@ type Cutout struct { IsDado bool `json:"isDado"` Depth float64 `json:"depth"` // dado depth mm; 0 = through-cut SourceLayer string `json:"sourceLayer"` // "F" or "B" for side cutouts + Shape string `json:"shape"` // "circle" or "rect" (default "rect") + GerberSource string `json:"gerberSource"` // gerber filename for text engrave dados } // CutoutToSideCutout converts a unified Cutout (surface="side") to legacy SideCutout @@ -79,6 +85,7 @@ func CutoutToSideCutout(c Cutout) SideCutout { Height: c.Height, CornerRadius: c.CornerRadius, Layer: c.SourceLayer, + Shape: c.Shape, } } @@ -96,11 +103,13 @@ func CutoutToLidCutout(c Cutout) LidCutout { MaxY: c.Y + c.Height, IsDado: c.IsDado, Depth: c.Depth, + Shape: c.Shape, } } // SplitCutouts partitions unified cutouts into side and lid slices for SCAD/STL generation. -func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) { +// gerberMap is optional; when provided, text engrave dados resolve their GerberFile reference. +func SplitCutouts(cutouts []Cutout, gerberMap map[string]*GerberFile) ([]SideCutout, []LidCutout) { var sides []SideCutout var lids []LidCutout for _, c := range cutouts { @@ -108,7 +117,11 @@ func SplitCutouts(cutouts []Cutout) ([]SideCutout, []LidCutout) { case "side": sides = append(sides, CutoutToSideCutout(c)) case "top", "bottom": - lids = append(lids, CutoutToLidCutout(c)) + lc := CutoutToLidCutout(c) + if c.GerberSource != "" && gerberMap != nil { + lc.GerberFile = gerberMap[c.GerberSource] + } + lids = append(lids, lc) } } return sides, lids diff --git a/former.go b/former.go index 9a1dc6a..1d3f9c9 100644 --- a/former.go +++ b/former.go @@ -3,6 +3,7 @@ package main import ( "image" "image/color" + "math" "strings" ) @@ -42,9 +43,114 @@ type ElementBBox struct { MaxX float64 `json:"maxX"` MaxY float64 `json:"maxY"` Type string `json:"type"` + Shape string `json:"shape"` // "circle" or "rect" Footprint string `json:"footprint"` } +// macroApertureSize examines a macro's primitives to determine shape and half-extents +// by computing the full bounding box from all primitive positions and sizes. +func macroApertureSize(macro Macro, params []float64) (float64, float64, string) { + onlyCircles := true // track if macro has ONLY circle primitives centered at origin + minX, minY := math.MaxFloat64, math.MaxFloat64 + maxX, maxY := -math.MaxFloat64, -math.MaxFloat64 + hasPrimitives := false + + expand := func(x1, y1, x2, y2 float64) { + if x1 < minX { minX = x1 } + if y1 < minY { minY = y1 } + if x2 > maxX { maxX = x2 } + if y2 > maxY { maxY = y2 } + hasPrimitives = true + } + + for _, prim := range macro.Primitives { + switch prim.Code { + case 1: // Circle: exposure, diameter, centerX, centerY + if len(prim.Modifiers) >= 4 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + dia := evaluateMacroExpression(prim.Modifiers[1], params) + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + r := dia / 2.0 + expand(cx-r, cy-r, cx+r, cy+r) + if cx != 0 || cy != 0 { onlyCircles = false } + } else if len(prim.Modifiers) >= 2 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + dia := evaluateMacroExpression(prim.Modifiers[1], params) + r := dia / 2.0 + expand(-r, -r, r, r) + } + case 5: // Regular polygon: exposure, numVerts, centerX, centerY, diameter, rotation + if len(prim.Modifiers) >= 5 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + cx := evaluateMacroExpression(prim.Modifiers[2], params) + cy := evaluateMacroExpression(prim.Modifiers[3], params) + dia := evaluateMacroExpression(prim.Modifiers[4], params) + r := dia / 2.0 + expand(cx-r, cy-r, cx+r, cy+r) + if cx != 0 || cy != 0 { onlyCircles = false } + } + case 4: // Outline polygon: exposure, numVerts, x1, y1, ..., xn, yn, rotation + onlyCircles = false + if len(prim.Modifiers) >= 3 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + numVerts := int(evaluateMacroExpression(prim.Modifiers[1], params)) + for i := 0; i < numVerts && 2+i*2+1 < len(prim.Modifiers); i++ { + vx := evaluateMacroExpression(prim.Modifiers[2+i*2], params) + vy := evaluateMacroExpression(prim.Modifiers[2+i*2+1], params) + expand(vx, vy, vx, vy) + } + } + case 20: // Vector line: exposure, width, startX, startY, endX, endY, rotation + onlyCircles = false + if len(prim.Modifiers) >= 6 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + w := 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) + hw := w / 2 + expand(math.Min(sx, ex)-hw, math.Min(sy, ey)-hw, + math.Max(sx, ex)+hw, math.Max(sy, ey)+hw) + } + case 21: // Center line: exposure, width, height, centerX, centerY, rotation + onlyCircles = false + if len(prim.Modifiers) >= 5 { + exposure := evaluateMacroExpression(prim.Modifiers[0], params) + if exposure == 0 { continue } + w := evaluateMacroExpression(prim.Modifiers[1], params) + h := evaluateMacroExpression(prim.Modifiers[2], params) + cx := evaluateMacroExpression(prim.Modifiers[3], params) + cy := evaluateMacroExpression(prim.Modifiers[4], params) + expand(cx-w/2, cy-h/2, cx+w/2, cy+h/2) + } + } + } + + if !hasPrimitives { + if len(params) > 0 { + r := params[0] / 2.0 + return r, r, "rect" + } + return 0.25, 0.25, "rect" + } + + hw := (maxX - minX) / 2.0 + hh := (maxY - minY) / 2.0 + + // If the macro only has circle primitives centered at origin, it's a circle + if onlyCircles { + return hw, hh, "circle" + } + return hw, hh, "rect" +} + // ExtractElementBBoxes walks gerber commands and returns bounding boxes in image pixel coordinates. func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []ElementBBox { if gf == nil { @@ -57,6 +163,42 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element return px, py } + apertureSize := func(dcode int) (float64, float64, string) { + ap, ok := gf.State.Apertures[dcode] + if !ok || len(ap.Modifiers) == 0 { + return 0.25, 0.25, "rect" + } + switch ap.Type { + case ApertureCircle: + r := ap.Modifiers[0] / 2.0 + return r, r, "circle" + case ApertureRect: + hw := ap.Modifiers[0] / 2.0 + hh := hw + if len(ap.Modifiers) >= 2 { + hh = ap.Modifiers[1] / 2.0 + } + return hw, hh, "rect" + case ApertureObround: + hw := ap.Modifiers[0] / 2.0 + hh := hw + if len(ap.Modifiers) >= 2 { + hh = ap.Modifiers[1] / 2.0 + } + return hw, hh, "obround" + case AperturePolygon: + r := ap.Modifiers[0] / 2.0 + return r, r, "circle" + default: + // Check if this is a macro aperture and determine shape from primitives + if macro, mok := gf.State.Macros[ap.Type]; mok { + return macroApertureSize(macro, ap.Modifiers) + } + r := ap.Modifiers[0] / 2.0 + return r, r, "rect" + } + } + apertureRadius := func(dcode int) float64 { if ap, ok := gf.State.Apertures[dcode]; ok && len(ap.Modifiers) > 0 { return ap.Modifiers[0] / 2.0 @@ -68,6 +210,7 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element id := 0 curX, curY := 0.0, 0.0 curDCode := 0 + interpMode := "G01" // linear by default for _, cmd := range gf.Commands { switch cmd.Type { @@ -76,7 +219,10 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element curDCode = *cmd.D } continue - case "G01", "G02", "G03", "G36", "G37": + case "G01", "G02", "G03": + interpMode = cmd.Type + continue + case "G36", "G37": continue } @@ -90,52 +236,149 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element switch cmd.Type { case "FLASH": // D03 - r := apertureRadius(curDCode) + hw, hh, shape := apertureSize(curDCode) px, py := mmToPx(curX, curY) - rpx := r * dpi / 25.4 + hwPx := hw * dpi / 25.4 + hhPx := hh * dpi / 25.4 elements = append(elements, ElementBBox{ ID: id, - MinX: px - rpx, - MinY: py - rpx, - MaxX: px + rpx, - MaxY: py + rpx, + MinX: px - hwPx, + MinY: py - hhPx, + MaxX: px + hwPx, + MaxY: py + hhPx, Type: "pad", + Shape: shape, 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 + + if (interpMode == "G02" || interpMode == "G03") && cmd.I != nil && cmd.J != nil { + // Arc draw: compute actual arc bounding box from center + radius + ci := *cmd.I + cj := *cmd.J + cx := prevX + ci + cy := prevY + cj + arcR := math.Sqrt(ci*ci + cj*cj) + // Arc bounding box: conservatively use the full circle extent + // (precise arc bbox requires start/end angle clipping, but this is good enough) + arcRPx := arcR * dpi / 25.4 + cpx, cpy := mmToPx(cx, cy) + elements = append(elements, ElementBBox{ + ID: id, + MinX: cpx - arcRPx - rpx, + MinY: cpy - arcRPx - rpx, + MaxX: cpx + arcRPx + rpx, + MaxY: cpy + arcRPx + rpx, + Type: "arc", + Shape: "circle", + Footprint: cmd.Footprint, + }) + } else { + // Linear draw + px1, py1 := mmToPx(prevX, prevY) + px2, py2 := mmToPx(curX, curY) + minPx := math.Min(px1, px2) + maxPx := math.Max(px1, px2) + minPy := math.Min(py1, py2) + maxPy := math.Max(py1, py2) + elements = append(elements, ElementBBox{ + ID: id, + MinX: minPx - rpx, + MinY: minPy - rpx, + MaxX: maxPx + rpx, + MaxY: maxPy + rpx, + Type: "trace", + Shape: "rect", + Footprint: cmd.Footprint, + }) } - maxPx := px1 - if px2 > maxPx { - maxPx = px2 + id++ + } + } + + // Post-process: merge overlapping arc elements into single circles. + // Half-circle arcs sharing the same center produce overlapping bboxes. + if len(elements) > 1 { + var merged []ElementBBox + used := make([]bool, len(elements)) + for i := 0; i < len(elements); i++ { + if used[i] { + continue } - minPy := py1 - if py2 < minPy { - minPy = py2 + el := elements[i] + if el.Type == "arc" { + // Try to merge with nearby arcs that overlap substantially + for j := i + 1; j < len(elements); j++ { + if used[j] || elements[j].Type != "arc" { + continue + } + ej := elements[j] + // Check if centers are close (overlapping circles from same center) + cx1 := (el.MinX + el.MaxX) / 2 + cy1 := (el.MinY + el.MaxY) / 2 + cx2 := (ej.MinX + ej.MaxX) / 2 + cy2 := (ej.MinY + ej.MaxY) / 2 + r1 := (el.MaxX - el.MinX) / 2 + dist := math.Sqrt((cx2-cx1)*(cx2-cx1) + (cy2-cy1)*(cy2-cy1)) + if dist < r1*0.5 { + // Merge: expand bbox + if ej.MinX < el.MinX { el.MinX = ej.MinX } + if ej.MinY < el.MinY { el.MinY = ej.MinY } + if ej.MaxX > el.MaxX { el.MaxX = ej.MaxX } + if ej.MaxY > el.MaxY { el.MaxY = ej.MaxY } + used[j] = true + } + } } - maxPy := py1 - if py2 > maxPy { - maxPy = py2 + merged = append(merged, el) + } + elements = merged + + // Also merge trace elements by footprint + type fpGroup struct { + minX, minY, maxX, maxY float64 + } + groups := map[string]*fpGroup{} + var final []ElementBBox + for _, el := range elements { + if el.Footprint == "" || el.Type == "arc" || el.Type == "pad" { + final = append(final, el) + continue } - elements = append(elements, ElementBBox{ - ID: id, - MinX: minPx - rpx, - MinY: minPy - rpx, - MaxX: maxPx + rpx, - MaxY: maxPy + rpx, - Type: "trace", - Footprint: cmd.Footprint, + g, ok := groups[el.Footprint] + if !ok { + g = &fpGroup{minX: el.MinX, minY: el.MinY, maxX: el.MaxX, maxY: el.MaxY} + groups[el.Footprint] = g + } else { + if el.MinX < g.minX { g.minX = el.MinX } + if el.MinY < g.minY { g.minY = el.MinY } + if el.MaxX > g.maxX { g.maxX = el.MaxX } + if el.MaxY > g.maxY { g.maxY = el.MaxY } + } + } + for fp, g := range groups { + w := g.maxX - g.minX + h := g.maxY - g.minY + shape := "rect" + if w > 0 && h > 0 { + ratio := w / h + if ratio > 0.85 && ratio < 1.15 { + shape = "circle" + } + } + final = append(final, ElementBBox{ + ID: id, MinX: g.minX, MinY: g.minY, MaxX: g.maxX, MaxY: g.maxY, + Type: "component", Shape: shape, Footprint: fp, }) id++ } + if len(groups) > 0 { + elements = final + } } return elements diff --git a/frontend/index.html b/frontend/index.html index cde0206..9c538ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,12 +8,18 @@
- -
+
+
+
+
+
diff --git a/frontend/src/engine/former-engine.js b/frontend/src/engine/former-engine.js new file mode 100644 index 0000000..85a3207 --- /dev/null +++ b/frontend/src/engine/former-engine.js @@ -0,0 +1,652 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { STLLoader } from 'three/addons/loaders/STLLoader.js'; + +export const Z_SPACING = 3; + +export function dbg(...args) { + try { + const fn = window?.go?.main?.App?.JSDebugLog; + if (!fn) return; + const msg = '[engine] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + fn(msg); + } catch (_) {} +} + +export class FormerEngine { + constructor(container) { + this.container = container; + this.layers = []; + this.layerMeshes = []; + this.selectedLayerIndex = -1; + this.enclosureLayerIndex = -1; + this.trayLayerIndex = -1; + + this.layerGroup = null; + this.arrowGroup = null; + this.elementGroup = null; + this.selectionOutline = null; + + this._onLayerSelect = null; + this._onCutoutSelect = null; + this._onCutoutHover = null; + this._onPlacedCutoutSelect = null; + + this._modes = {}; + + this._clickHandlers = []; + this._mouseMoveHandlers = []; + this._mouseDownHandlers = []; + this._mouseUpHandlers = []; + + this._initScene(); + this._initControls(); + this._initGrid(); + this._initRaycasting(); + this._animate(); + } + + // ===== Plugin API ===== + + use(mode) { + this._modes[mode.name] = mode; + mode.install(this); + } + + getMode(name) { + return this._modes[name]; + } + + registerClickHandler(handler) { this._clickHandlers.push(handler); } + unregisterClickHandler(handler) { + const i = this._clickHandlers.indexOf(handler); + if (i >= 0) this._clickHandlers.splice(i, 1); + } + + registerMouseMoveHandler(handler) { this._mouseMoveHandlers.push(handler); } + unregisterMouseMoveHandler(handler) { + const i = this._mouseMoveHandlers.indexOf(handler); + if (i >= 0) this._mouseMoveHandlers.splice(i, 1); + } + + registerMouseDownHandler(handler) { this._mouseDownHandlers.push(handler); } + unregisterMouseDownHandler(handler) { + const i = this._mouseDownHandlers.indexOf(handler); + if (i >= 0) this._mouseDownHandlers.splice(i, 1); + } + + registerMouseUpHandler(handler) { this._mouseUpHandlers.push(handler); } + unregisterMouseUpHandler(handler) { + const i = this._mouseUpHandlers.indexOf(handler); + if (i >= 0) this._mouseUpHandlers.splice(i, 1); + } + + // ===== Callbacks ===== + + onLayerSelect(cb) { this._onLayerSelect = cb; } + onCutoutSelect(cb) { this._onCutoutSelect = cb; } + onCutoutHover(cb) { this._onCutoutHover = cb; } + onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; } + + // ===== Scene Setup ===== + + _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._ambientLight = new THREE.AmbientLight(0xffffff, 0.9); + this.scene.add(this._ambientLight); + this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3); + this._dirLight.position.set(50, -50, 100); + this.scene.add(this._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._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.enableZoom = true; + 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(); + + this.renderer.domElement.addEventListener('wheel', (e) => { + e.preventDefault(); + + if (this._traditionalControls) { + if (e.shiftKey && e.deltaX === 0) { + e.stopImmediatePropagation(); + const dist = this.camera.position.distanceTo(this.controls.target); + const panSpeed = dist * 0.001; + const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); + const offset = new THREE.Vector3(); + offset.addScaledVector(right, e.deltaY * panSpeed); + this.camera.position.add(offset); + this.controls.target.add(offset); + this.controls.update(); + } + return; + } + + e.stopImmediatePropagation(); + + if (e.ctrlKey || e.metaKey) { + const factor = 1 + e.deltaY * 0.01; + const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); + dir.multiplyScalar(factor); + this.camera.position.copy(this.controls.target).add(dir); + } else { + let dx = e.deltaX; + let dy = e.deltaY; + if (e.shiftKey && dx === 0) { + dx = dy; + dy = 0; + } + const dist = this.camera.position.distanceTo(this.controls.target); + const panSpeed = dist * 0.001; + const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); + const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1); + const offset = new THREE.Vector3(); + offset.addScaledVector(right, dx * panSpeed); + offset.addScaledVector(up, -dy * panSpeed); + this.camera.position.add(offset); + this.controls.target.add(offset); + } + this.controls.update(); + }, { passive: false, capture: true }); + + this._ctrlDragging = false; + this._ctrlDragLastY = 0; + + this.renderer.domElement.addEventListener('mousedown', (e) => { + if (e.button === 0 && (e.ctrlKey || e.metaKey)) { + this._ctrlDragging = true; + this._ctrlDragLastY = e.clientY; + e.preventDefault(); + e.stopImmediatePropagation(); + } + }, { capture: true }); + + const onCtrlDragMove = (e) => { + if (!this._ctrlDragging) return; + const dy = e.clientY - this._ctrlDragLastY; + this._ctrlDragLastY = e.clientY; + const factor = 1 + dy * 0.005; + const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); + dir.multiplyScalar(factor); + this.camera.position.copy(this.controls.target).add(dir); + this.controls.update(); + }; + + const onCtrlDragUp = () => { + this._ctrlDragging = false; + }; + + window.addEventListener('mousemove', onCtrlDragMove); + window.addEventListener('mouseup', onCtrlDragUp); + this._ctrlDragCleanup = () => { + window.removeEventListener('mousemove', onCtrlDragMove); + window.removeEventListener('mouseup', onCtrlDragUp); + }; + } + + _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 }; + for (const h of this._mouseDownHandlers) h(e); + }); + + 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; + for (const h of this._mouseMoveHandlers) h(e); + }); + + canvas.addEventListener('mouseup', e => { + for (const h of this._mouseUpHandlers) h(e); + }); + + canvas.addEventListener('click', e => { + if (this._isDragging) return; + this._updateMouse(e); + this.raycaster.setFromCamera(this.mouse, this.camera); + + for (const h of this._clickHandlers) { + if (h(e)) return; + } + + // Default: layer selection + 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) { + 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.enclosureLayerIndex = -1; + this.trayLayerIndex = -1; + + // Reset enclosure mode state if present + const enc = this.getMode('enclosure'); + if (enc) { + enc.enclosureMesh = null; + enc.trayMesh = null; + } + + 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); + continue; + } + if (layer.name === 'Tray') { + this.trayLayerIndex = i; + this.layerMeshes.push(null); + 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); + } + } + + // ===== 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) { + if (this._onLayerSelect) this._onLayerSelect(index); + return; + } + + 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); + } + + 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); + } + } + + // ===== 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 - dist * 0.01, pos.z + dist); + this.camera.up.set(0, 0, 1); + 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 - dist * 0.01, cz + dist); + this.camera.up.set(0, 0, 1); + 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(); + } + + setControlScheme(traditional) { + this._traditionalControls = traditional; + if (traditional) { + this.controls.mouseButtons = { + LEFT: THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.PAN, + RIGHT: THREE.MOUSE.PAN + }; + } else { + this.controls.mouseButtons = { + LEFT: THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.PAN, + RIGHT: THREE.MOUSE.DOLLY + }; + } + } + + // ===== Shared Utilities ===== + + disposeGroup(group) { + if (!group) return; + if (group.parent) group.parent.remove(group); + group.traverse(c => { + if (c.geometry) c.geometry.dispose(); + if (c.material) { + if (c.material.map) c.material.map.dispose(); + c.material.dispose(); + } + }); + } + + loadSTLCentered(arrayBuffer, materialOpts) { + const loader = new STLLoader(); + const geometry = loader.parse(arrayBuffer); + geometry.computeBoundingBox(); + if (materialOpts.computeNormals) { + geometry.computeVertexNormals(); + delete materialOpts.computeNormals; + } + const mat = new THREE.MeshPhongMaterial(materialOpts); + const mesh = new THREE.Mesh(geometry, mat); + const box = geometry.boundingBox; + const center = new THREE.Vector3( + (box.min.x + box.max.x) / 2, + (box.min.y + box.max.y) / 2, + (box.min.z + box.max.z) / 2 + ); + mesh.position.set(-center.x, -center.y, -center.z); + const size = box.getSize(new THREE.Vector3()); + return { mesh, center, size, box }; + } + + fitCamera(size, target) { + const maxDim = Math.max(size.x, size.y, size.z); + const dist = maxDim * 1.5; + const t = target || new THREE.Vector3(0, 0, 0); + this.controls.target.copy(t); + this.camera.position.set(t.x, t.y - dist * 0.5, t.z + dist * 0.6); + this.camera.up.set(0, 0, 1); + this.controls.update(); + } + + // ===== Dispose ===== + + dispose() { + if (this._ctrlDragCleanup) this._ctrlDragCleanup(); + if (this._animId) cancelAnimationFrame(this._animId); + if (this._resizeObserver) this._resizeObserver.disconnect(); + + for (const mode of Object.values(this._modes)) { + if (mode.dispose) mode.dispose(); + } + + this.controls.dispose(); + this.renderer.dispose(); + if (this.renderer.domElement.parentNode) { + this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); + } + } +} diff --git a/frontend/src/engine/modes/cutout-mode.js b/frontend/src/engine/modes/cutout-mode.js new file mode 100644 index 0000000..54e5d5c --- /dev/null +++ b/frontend/src/engine/modes/cutout-mode.js @@ -0,0 +1,420 @@ +import * as THREE from 'three'; +import { Z_SPACING } from '../former-engine.js'; + +export function createCutoutMode() { + const mode = { + name: 'cutout', + engine: null, + + cutoutMode: false, + isDadoMode: false, + elements: [], + elementMeshes: [], + hoveredElement: -1, + cutouts: [], + + _rectSelecting: false, + _rectStart: null, + _rectOverlay: null, + _cutoutLayerZ: 0, + + _clickHandler: null, + _moveHandler: null, + _downHandler: null, + _upHandler: null, + + install(engine) { + this.engine = engine; + }, + + enterCutoutMode(elements, layerIndex, isDado) { + const eng = this.engine; + this.cutoutMode = true; + this.isDadoMode = isDado || false; + this.elements = elements; + this.hoveredElement = -1; + this.cutouts = []; + this._rectSelecting = false; + this._rectStart = null; + this._rectOverlay = null; + + while (eng.elementGroup.children.length) { + const c = eng.elementGroup.children[0]; + c.geometry?.dispose(); + c.material?.dispose(); + eng.elementGroup.remove(c); + } + this.elementMeshes = []; + + const layerMesh = eng.layerMeshes[layerIndex]; + const layerZ = layerMesh ? layerMesh.position.z : 0; + this._cutoutLayerZ = layerZ; + + const encMode = eng.getMode('enclosure'); + + 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 isCircle = el.shape === 'circle'; + const isObround = el.shape === 'obround'; + let geo; + if (isCircle) { + geo = new THREE.CircleGeometry(Math.max(w, h) / 2, 32); + } else if (isObround && encMode) { + geo = encMode._makeCutoutGeo(w, h, 0, 'obround'); + } else { + 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 }; + + eng.elementGroup.add(mesh); + this.elementMeshes.push(mesh); + } + + eng.elementGroup.visible = true; + + // Register event handlers + this._clickHandler = (e) => this._handleClick(e); + this._moveHandler = (e) => this._handleMouseMove(e); + this._downHandler = (e) => this._handleMouseDown(e); + this._upHandler = (e) => this._handleMouseUp(e); + eng.registerClickHandler(this._clickHandler); + eng.registerMouseMoveHandler(this._moveHandler); + eng.registerMouseDownHandler(this._downHandler); + eng.registerMouseUpHandler(this._upHandler); + + if (isDado) { + eng.controls.enabled = false; + } else { + eng._homeTopDown(layerIndex); + } + }, + + exitCutoutMode() { + const eng = this.engine; + 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; + } + eng.controls.enabled = true; + eng.elementGroup.visible = false; + while (eng.elementGroup.children.length) { + const c = eng.elementGroup.children[0]; + c.geometry?.dispose(); + c.material?.dispose(); + eng.elementGroup.remove(c); + } + this.elementMeshes = []; + + // Unregister event handlers + if (this._clickHandler) eng.unregisterClickHandler(this._clickHandler); + if (this._moveHandler) eng.unregisterMouseMoveHandler(this._moveHandler); + if (this._downHandler) eng.unregisterMouseDownHandler(this._downHandler); + if (this._upHandler) eng.unregisterMouseUpHandler(this._upHandler); + this._clickHandler = null; + this._moveHandler = null; + this._downHandler = null; + this._upHandler = null; + }, + + _handleClick(e) { + if (!this.cutoutMode) return false; + const eng = this.engine; + + 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 (eng._onCutoutSelect) eng._onCutoutSelect(el, m.userData.selected); + } + return true; + }, + + _handleMouseMove(e) { + if (!this.cutoutMode || this.elementMeshes.length === 0) return; + const eng = this.engine; + + // 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'; + } + + // Element hover + eng._updateMouse(e); + eng.raycaster.setFromCamera(eng.mouse, eng.camera); + const hits = eng.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 (eng._onCutoutHover) eng._onCutoutHover(newHover); + } + }, + + _handleMouseDown(e) { + if (!this.cutoutMode || !this.isDadoMode || e.button !== 0) return; + + this._rectSelecting = true; + this._rectStart = { x: e.clientX, y: e.clientY }; + 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); + } + }, + + _handleMouseUp(e) { + if (!this._rectSelecting || !this._rectStart || !this.engine._isDragging) { + this._rectSelecting = false; + this._rectStart = null; + if (this._rectOverlay) this._rectOverlay.style.display = 'none'; + return; + } + + const eng = this.engine; + 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) { + const topLeft = this._screenToLayerPixel(x1, y1); + const bottomRight = this._screenToLayerPixel(x2, y2); + if (topLeft && bottomRight) { + const rMinX = Math.min(topLeft.x, bottomRight.x); + const rMinY = Math.min(topLeft.y, bottomRight.y); + const rMaxX = Math.max(topLeft.x, bottomRight.x); + const rMaxY = Math.max(topLeft.y, bottomRight.y); + const synth = { + id: Date.now(), + minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY, + type: 'custom', shape: 'rect', + }; + const w = rMaxX - rMinX; + const h = rMaxY - rMinY; + const geo = new THREE.PlaneGeometry(w, h); + const mat = new THREE.MeshBasicMaterial({ + color: 0xf9e2af, transparent: true, opacity: 0.7, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(rMinX + w / 2, -(rMinY + h / 2), (this._cutoutLayerZ || 0) + 0.3); + mesh.userData = { elementId: synth.id, selected: true }; + eng.elementGroup.add(mesh); + this.elementMeshes.push(mesh); + this.cutouts.push(synth); + if (eng._onCutoutSelect) eng._onCutoutSelect(synth, true); + } + } + this._rectSelecting = false; + this._rectStart = null; + if (this._rectOverlay) this._rectOverlay.style.display = 'none'; + }, + + _screenToLayerPixel(screenX, screenY) { + const eng = this.engine; + const rect = eng.renderer.domElement.getBoundingClientRect(); + const ndcX = ((screenX - rect.left) / rect.width) * 2 - 1; + const ndcY = -((screenY - rect.top) / rect.height) * 2 + 1; + const rc = new THREE.Raycaster(); + rc.setFromCamera(new THREE.Vector2(ndcX, ndcY), eng.camera); + const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -(this._cutoutLayerZ || 0)); + const target = new THREE.Vector3(); + if (rc.ray.intersectPlane(plane, target)) { + return { x: target.x, y: -target.y }; + } + return null; + }, + + enterSidePlacementMode(projectedCutouts, sideNum) { + return new Promise((resolve) => { + const eng = this.engine; + const encMode = eng.getMode('enclosure'); + if (!encMode?._encData) { resolve(null); return; } + const encData = encMode._encData; + const side = encData.sides.find(sd => sd.num === sideNum); + if (!side) { resolve(null); return; } + + const s = encMode._s; + const cl = encData.clearance; + const wt = encData.wallThickness; + const trayFloor = encData.trayFloor; + const pcbT = encData.pcbThickness; + const totalH = encData.totalH; + const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0; + + const [startPx, startPy] = encMode._toPixel(side.startX, side.startY); + const [endPx, endPy] = encMode._toPixel(side.endX, side.endY); + const wallDx = endPx - startPx; + const wallDy = endPy - startPy; + const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); + const wallAngle = Math.atan2(wallDy, wallDx); + + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const offset = (cl + wt) * s; + + encMode.lookAtSide(sideNum); + encMode.highlightSide(sideNum); + + const planeW = wallLen * 2; + const planeH = totalH * s * 2; + const planeGeo = new THREE.PlaneGeometry(planeW, planeH); + const planeMat = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide }); + const planeMesh = new THREE.Mesh(planeGeo, planeMat); + const midX = (startPx + endPx) / 2 + nx * offset; + const midY = (startPy + endPy) / 2 + ny * offset; + planeMesh.position.set(midX, midY, encZ + totalH * s / 2); + planeMesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + eng.scene.add(planeMesh); + + const ghostMeshes = []; + for (const pc of projectedCutouts) { + const geo = encMode._makeCutoutGeo(pc.width * s, pc.height * s, 0, pc.shape || 'rect'); + const mat = new THREE.MeshBasicMaterial({ + color: 0xa6e3a1, transparent: true, opacity: 0.5, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + + const posX = side.startX + (side.endX - side.startX) * ((pc.x + pc.width / 2) / side.length); + const posY = side.startY + (side.endY - side.startY) * ((pc.x + pc.width / 2) / side.length); + const [px, py] = encMode._toPixel(posX, posY); + + mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2); + mesh.userData = { projectedCutout: pc }; + eng.scene.add(mesh); + ghostMeshes.push(mesh); + } + + const wallHeight = totalH - trayFloor - pcbT; + let currentYMM = wallHeight / 2; + + const updateGhostZ = (zMM) => { + for (const gm of ghostMeshes) { + const fullZ = trayFloor + pcbT + zMM; + gm.position.z = encZ + (totalH - fullZ) * s; + } + }; + updateGhostZ(currentYMM); + + const moveHandler = (e) => { + eng._updateMouse(e); + eng.raycaster.setFromCamera(eng.mouse, eng.camera); + const hits = eng.raycaster.intersectObject(planeMesh); + if (hits.length > 0) { + const hitZ = hits[0].point.z; + const zMM = totalH - (hitZ - encZ) / s; + const yAbovePCB = zMM - trayFloor - pcbT; + const maxH = Math.max(...projectedCutouts.map(pc => pc.height)); + const clamped = Math.max(0, Math.min(wallHeight - maxH, yAbovePCB - maxH / 2)); + currentYMM = clamped; + updateGhostZ(currentYMM); + } + }; + + const canvas = eng.renderer.domElement; + + const clickHandler = () => { + cleanup(); + const results = projectedCutouts.map(pc => ({ + x: pc.x, + y: currentYMM, + width: pc.width, + height: pc.height, + shape: pc.shape || 'rect', + })); + resolve(results); + }; + + const escHandler = (e) => { + if (e.key === 'Escape') { + cleanup(); + resolve(null); + } + }; + + const cleanup = () => { + canvas.removeEventListener('mousemove', moveHandler); + canvas.removeEventListener('click', clickHandler); + document.removeEventListener('keydown', escHandler); + eng.scene.remove(planeMesh); + planeGeo.dispose(); + planeMat.dispose(); + for (const gm of ghostMeshes) { + eng.scene.remove(gm); + gm.geometry.dispose(); + gm.material.dispose(); + } + encMode.clearSideHighlight(); + }; + + canvas.addEventListener('mousemove', moveHandler); + canvas.addEventListener('click', clickHandler); + document.addEventListener('keydown', escHandler); + }); + }, + + dispose() { + if (this.cutoutMode) this.exitCutoutMode(); + if (this._rectOverlay) { + this._rectOverlay.remove(); + this._rectOverlay = null; + } + }, + }; + + return mode; +} diff --git a/frontend/src/engine/modes/enclosure-mode.js b/frontend/src/engine/modes/enclosure-mode.js new file mode 100644 index 0000000..450f24f --- /dev/null +++ b/frontend/src/engine/modes/enclosure-mode.js @@ -0,0 +1,798 @@ +import * as THREE from 'three'; +import { STLLoader } from 'three/addons/loaders/STLLoader.js'; +import { Z_SPACING, dbg } from '../former-engine.js'; + +export function createEnclosureMode() { + const mode = { + name: 'enclosure', + engine: null, + + enclosureMesh: null, + trayMesh: null, + + _encData: null, + _dpi: null, + _minX: null, + _maxY: null, + _s: null, + + _renderedSTLLoaded: false, + _savedVisibility: null, + _savedEnclosurePos: null, + _savedEnclosureRot: null, + _savedTrayPos: null, + _savedTrayRot: null, + _solidFillLight: null, + _sideHighlightMesh: null, + _sideHighlightLabel: null, + + _cutoutVizGroup: null, + _cutoutVizMeshes: [], + _cutoutOutlines: [], + selectedCutoutIds: new Set(), + + _vizClickHandler: null, + + install(engine) { + this.engine = engine; + this._vizClickHandler = (e) => this._handleVizClick(e); + engine.registerClickHandler(this._vizClickHandler); + }, + + _handleVizClick(e) { + if (!this._cutoutVizMeshes || this._cutoutVizMeshes.length === 0) return false; + + const eng = this.engine; + const cutoutHits = eng.raycaster.intersectObjects(this._cutoutVizMeshes); + if (cutoutHits.length > 0) { + const hitMesh = cutoutHits[0].object; + const cutoutId = hitMesh.userData.cutoutId; + if (e.shiftKey) { + this._toggleCutoutSelection(cutoutId); + } else { + this._selectCutout(cutoutId); + } + return true; + } + + // Clicked empty — deselect all cutouts (don't consume event) + this._deselectAllCutouts(); + return false; + }, + + _selectCutout(cutoutId) { + this.selectedCutoutIds = new Set([cutoutId]); + this._updateCutoutHighlights(); + if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([...this.selectedCutoutIds]); + }, + + _toggleCutoutSelection(cutoutId) { + if (!this.selectedCutoutIds) this.selectedCutoutIds = new Set(); + if (this.selectedCutoutIds.has(cutoutId)) { + this.selectedCutoutIds.delete(cutoutId); + } else { + this.selectedCutoutIds.add(cutoutId); + } + this._updateCutoutHighlights(); + if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([...this.selectedCutoutIds]); + }, + + _deselectAllCutouts() { + if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) { + this.selectedCutoutIds = new Set(); + this._updateCutoutHighlights(); + if (this.engine._onPlacedCutoutSelect) this.engine._onPlacedCutoutSelect([]); + } + }, + + _updateCutoutHighlights() { + const scene = this.engine.scene; + if (this._cutoutOutlines) { + for (const ol of this._cutoutOutlines) { + scene.remove(ol); + ol.geometry.dispose(); + ol.material.dispose(); + } + } + this._cutoutOutlines = []; + + if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return; + for (const mesh of this._cutoutVizMeshes) { + const isSelected = this.selectedCutoutIds.has(mesh.userData.cutoutId); + mesh.material.opacity = isSelected ? 0.9 : 0.6; + if (isSelected) { + 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.quaternion.copy(mesh.quaternion); + this._cutoutOutlines.push(line); + scene.add(line); + } + } + }, + + getSelectedCutouts() { + if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return []; + return this._cutoutVizMeshes + .filter(m => this.selectedCutoutIds.has(m.userData.cutoutId)) + .map(m => m.userData.cutout); + }, + + storeEnclosureContext(encData, dpi, minX, maxY) { + this._encData = encData; + this._dpi = dpi; + this._minX = minX; + this._maxY = maxY; + this._s = dpi / 25.4; + }, + + _toPixel(mmX, mmY) { + const s = this._s; + return [(mmX - this._minX) * s, -(this._maxY - mmY) * s]; + }, + + loadEnclosureGeometry(encData, dpi, minX, maxY) { + if (!encData || !encData.outlinePoints || encData.outlinePoints.length < 3) return; + const eng = this.engine; + this.storeEnclosureContext(encData, dpi, minX, maxY); + this._disposeEnclosureMeshes(); + + const s = dpi / 25.4; + const toPixel = (mmX, mmY) => [ + (mmX - minX) * s, + -(maxY - mmY) * s + ]; + + let pts = encData.outlinePoints.map(p => toPixel(p[0], p[1])); + if (pts.length > 2) { + const first = pts[0], last = pts[pts.length - 1]; + if (Math.abs(first[0] - last[0]) < 0.01 && Math.abs(first[1] - last[1]) < 0.01) { + pts = pts.slice(0, -1); + } + } + + 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; + + const offsetPoly = (points, dist) => { + const n = points.length; + const result = []; + const maxMiter = Math.abs(dist) * 2; + 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; + 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 }; + }; + + const cl = encData.clearance; + const wt = encData.wallThickness; + const trayFloor = encData.trayFloor; + const snapH = encData.snapHeight; + const lidThick = encData.lidThick; + const totalH = encData.totalH; + + const polyInner = offsetPoly(pts, sign * cl * s); + const polyTrayWall = offsetPoly(pts, sign * (cl + wt) * s); + const polyOuter = offsetPoly(pts, sign * (cl + 2 * wt) * s); + + const enclosureParts = []; + const trayParts = []; + const eps = 0.05 * s; + + enclosureParts.push(makeSolid(polyOuter, lidThick * s, (totalH - lidThick) * s + eps)); + const upperWallH = totalH - lidThick - (trayFloor + snapH); + if (upperWallH > 0.1) { + enclosureParts.push(makeRing(polyOuter, polyInner, upperWallH * s - eps, (trayFloor + snapH) * s + eps)); + } + if (snapH > 0.1) { + enclosureParts.push(makeRing(polyOuter, polyTrayWall, snapH * s - eps, trayFloor * s)); + } + + 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); + enclosureParts.push({ geo: cylGeo, zPos: pegH / 2, cx: px, cy: py, isCyl: true }); + } + } + + trayParts.push(makeSolid(polyOuter, trayFloor * s, 0)); + if (snapH > 0.1) { + trayParts.push(makeRing(polyTrayWall, polyInner, snapH * s - eps, trayFloor * s + eps)); + } + + 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 (eng.enclosureLayerIndex >= 0) { + encGroup.scale.z = -1; + encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s; + encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true }; + const layer = eng.layers[eng.enclosureLayerIndex]; + encGroup.visible = layer ? layer.visible : true; + this.enclosureMesh = encGroup; + eng.layerMeshes[eng.enclosureLayerIndex] = encGroup; + eng.layerGroup.add(encGroup); + } + + 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 (eng.trayLayerIndex >= 0) { + trayGroup.scale.z = -1; + trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s; + trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true }; + const layer = eng.layers[eng.trayLayerIndex]; + trayGroup.visible = layer ? layer.visible : false; + this.trayMesh = trayGroup; + eng.layerMeshes[eng.trayLayerIndex] = trayGroup; + eng.layerGroup.add(trayGroup); + } + }, + + _disposeEnclosureMeshes() { + const eng = this.engine; + for (const mesh of [this.enclosureMesh, this.trayMesh]) { + if (mesh) { + eng.layerGroup.remove(mesh); + mesh.traverse(c => { + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + }); + } + } + this.enclosureMesh = null; + this.trayMesh = null; + }, + + enterSolidView() { + const eng = this.engine; + this._savedVisibility = eng.layers.map(l => l.visible); + this._savedEnclosurePos = null; + this._savedEnclosureRot = null; + this._savedTrayPos = null; + + for (let i = 0; i < eng.layers.length; i++) { + const mesh = eng.layerMeshes[i]; + if (!mesh) continue; + if (i === eng.enclosureLayerIndex || i === eng.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; + } + } + + if (this.enclosureMesh && this.trayMesh) { + this._savedEnclosurePos = this.enclosureMesh.position.clone(); + this._savedEnclosureRot = this.enclosureMesh.quaternion.clone(); + this._savedTrayPos = this.trayMesh.position.clone(); + this._savedTrayRot = this.trayMesh.quaternion.clone(); + + this.enclosureMesh.rotateX(Math.PI); + this.trayMesh.rotateZ(Math.PI); + this.enclosureMesh.position.z = this.trayMesh.position.z; + + const encBox = new THREE.Box3().setFromObject(this.enclosureMesh); + const trayBox = new THREE.Box3().setFromObject(this.trayMesh); + const encCY = (encBox.min.y + encBox.max.y) / 2; + const trayCY = (trayBox.min.y + trayBox.max.y) / 2; + this.trayMesh.position.y += encCY - trayCY; + + const trayBox2 = new THREE.Box3().setFromObject(this.trayMesh); + const encWidth = encBox.max.x - encBox.min.x; + const gap = Math.max(encWidth * 0.05, 5); + this.trayMesh.position.x += encBox.min.x - trayBox2.max.x - gap; + } + + eng.selectLayer(-1); + if (this._cutoutVizGroup) this._cutoutVizGroup.visible = false; + if (this._cutoutOutlines) { + for (const ol of this._cutoutOutlines) ol.visible = false; + } + this.clearSideHighlight(); + eng.gridHelper.visible = false; + eng.scene.background = new THREE.Color(0x1e1e2e); + + eng._ambientLight.intensity = 0.45; + eng._dirLight.intensity = 0.7; + eng._dirLight.position.set(1, -1, 2).normalize(); + this._solidFillLight = new THREE.DirectionalLight(0xffffff, 0.25); + this._solidFillLight.position.set(-1, 1, 0.5).normalize(); + eng.scene.add(this._solidFillLight); + + if (this.enclosureMesh) { + const box = new THREE.Box3().setFromObject(this.enclosureMesh); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const dist = Math.max(size.x, size.y, size.z) * 1.2; + eng.controls.target.copy(center); + eng.camera.position.set(center.x, center.y - dist * 0.5, center.z + dist * 0.6); + eng.camera.up.set(0, 0, 1); + eng.controls.update(); + } else { + eng.resetView(); + } + }, + + exitSolidView() { + const eng = this.engine; + eng.scene.background = new THREE.Color(0x000000); + eng.gridHelper.visible = eng.gridVisible; + + eng._ambientLight.intensity = 0.9; + eng._dirLight.intensity = 0.3; + eng._dirLight.position.set(50, -50, 100); + if (this._solidFillLight) { + eng.scene.remove(this._solidFillLight); + this._solidFillLight = null; + } + + if (this._renderedSTLLoaded) { + this._disposeEnclosureMeshes(); + this._renderedSTLLoaded = false; + if (this._encData && this._dpi && this._minX !== undefined && this._maxY !== undefined) { + this.loadEnclosureGeometry(this._encData, this._dpi, this._minX, this._maxY); + } + } else { + if (this._savedEnclosurePos && this.enclosureMesh) { + this.enclosureMesh.position.copy(this._savedEnclosurePos); + this.enclosureMesh.quaternion.copy(this._savedEnclosureRot); + } + if (this._savedTrayPos && this.trayMesh) { + this.trayMesh.position.copy(this._savedTrayPos); + if (this._savedTrayRot) this.trayMesh.quaternion.copy(this._savedTrayRot); + } + } + this._savedEnclosurePos = null; + this._savedEnclosureRot = null; + this._savedTrayPos = null; + this._savedTrayRot = null; + + for (let i = 0; i < eng.layers.length; i++) { + const mesh = eng.layerMeshes[i]; + if (!mesh) continue; + const wasVisible = this._savedVisibility ? this._savedVisibility[i] : eng.layers[i].visible; + eng.layers[i].visible = wasVisible; + mesh.visible = wasVisible; + if (i === eng.enclosureLayerIndex || i === eng.trayLayerIndex) { + const baseOpacity = i === eng.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; + } + }); + } + } + if (this._cutoutVizGroup) this._cutoutVizGroup.visible = true; + if (this._cutoutOutlines) { + for (const ol of this._cutoutOutlines) ol.visible = true; + } + this._savedVisibility = null; + eng.resetView(); + }, + + loadRenderedSTL(enclosureArrayBuffer, trayArrayBuffer) { + const eng = this.engine; + dbg('loadRenderedSTL: called, encBuf=', enclosureArrayBuffer?.byteLength || 0, 'trayBuf=', trayArrayBuffer?.byteLength || 0); + this._disposeEnclosureMeshes(); + + const loader = new STLLoader(); + const s = (this._dpi || 600) / 25.4; + const minX = this._minX || 0; + const maxY = this._maxY || 0; + + const encData = this._encData; + const totalH = encData ? encData.totalH : 0; + let centerX = 0, centerY = 0; + if (encData) { + if (encData.outlinePoints && encData.outlinePoints.length > 0) { + centerX = (minX + (minX + (eng._maxW || 500) / s)) / 2; + centerY = (maxY + (maxY - (eng._maxH || 500) / s)) / 2; + } + } + + const originPx = (0 + centerX - minX) * s; + const originPy = -(maxY - (0 + centerY)) * s; + + const createMeshFromSTL = (arrayBuffer, color, label) => { + const geometry = loader.parse(arrayBuffer); + geometry.scale(s, s, s); + geometry.scale(1, -1, 1); + const pos = geometry.attributes.position.array; + for (let i = 0; i < pos.length; i += 9) { + for (let j = 0; j < 3; j++) { + const tmp = pos[i + 3 + j]; + pos[i + 3 + j] = pos[i + 6 + j]; + pos[i + 6 + j] = tmp; + } + } + geometry.attributes.position.needsUpdate = true; + geometry.computeBoundingBox(); + geometry.computeVertexNormals(); + const material = new THREE.MeshStandardMaterial({ + color, + roughness: 0.6, + metalness: 0.0, + side: THREE.FrontSide, + flatShading: true, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(originPx, originPy, 0); + return mesh; + }; + + if (enclosureArrayBuffer && enclosureArrayBuffer.byteLength > 84) { + const encGroup = new THREE.Group(); + const encMesh = createMeshFromSTL(enclosureArrayBuffer, 0xffe090, 'enclosure'); + encGroup.add(encMesh); + + if (eng.enclosureLayerIndex >= 0) { + encGroup.scale.z = -1; + encGroup.position.z = eng.enclosureLayerIndex * Z_SPACING + totalH * s; + encGroup.userData = { layerIndex: eng.enclosureLayerIndex, isEnclosure: true }; + const layer = eng.layers[eng.enclosureLayerIndex]; + encGroup.visible = layer ? layer.visible : true; + this.enclosureMesh = encGroup; + eng.layerMeshes[eng.enclosureLayerIndex] = encGroup; + eng.layerGroup.add(encGroup); + } + } + + if (trayArrayBuffer && trayArrayBuffer.byteLength > 84) { + const trayGroup = new THREE.Group(); + const trayMesh = createMeshFromSTL(trayArrayBuffer, 0xa0d880, 'tray'); + trayGroup.add(trayMesh); + + if (eng.trayLayerIndex >= 0) { + trayGroup.scale.z = -1; + trayGroup.position.z = eng.trayLayerIndex * Z_SPACING + totalH * s; + trayGroup.userData = { layerIndex: eng.trayLayerIndex, isEnclosure: true }; + const layer = eng.layers[eng.trayLayerIndex]; + trayGroup.visible = layer ? layer.visible : false; + this.trayMesh = trayGroup; + eng.layerMeshes[eng.trayLayerIndex] = trayGroup; + eng.layerGroup.add(trayGroup); + } + } + + this._renderedSTLLoaded = true; + }, + + highlightSide(sideNum) { + this.clearSideHighlight(); + if (!this._encData) return; + const eng = this.engine; + const side = this._encData.sides.find(s => s.num === sideNum); + if (!side) return; + + const s = this._s; + const [startPx, startPy] = this._toPixel(side.startX, side.startY); + const [endPx, endPy] = this._toPixel(side.endX, side.endY); + const dx = endPx - startPx; + const dy = endPy - startPy; + const len = Math.sqrt(dx * dx + dy * dy); + const totalH = this._encData.totalH * s; + const cl = this._encData.clearance; + const wt = this._encData.wallThickness; + + const geo = new THREE.PlaneGeometry(len, totalH); + const sideColors = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x14b8a6, 0xf97316]; + const color = sideColors[(sideNum - 1) % sideColors.length]; + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.4, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + + const midX = (startPx + endPx) / 2; + const midY = (startPy + endPy) / 2; + const offset = (cl + wt) * s; + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const wallAngle = Math.atan2(dy, dx); + + const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0; + mesh.position.set(midX + nx * offset, midY + ny * offset, encZ + totalH / 2); + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + + this._sideHighlightMesh = mesh; + eng.scene.add(mesh); + + const canvas2d = document.createElement('canvas'); + canvas2d.width = 64; + canvas2d.height = 64; + const ctx = canvas2d.getContext('2d'); + ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`; + ctx.beginPath(); + ctx.arc(32, 32, 28, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'white'; + ctx.font = 'bold 32px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(sideNum.toString(), 32, 33); + + const tex = new THREE.CanvasTexture(canvas2d); + const spriteMat = new THREE.SpriteMaterial({ map: tex, depthWrite: false }); + const sprite = new THREE.Sprite(spriteMat); + const labelScale = Math.max(len, totalH) * 0.15; + sprite.scale.set(labelScale, labelScale, 1); + sprite.position.set(midX + nx * offset * 1.5, midY + ny * offset * 1.5, encZ + totalH / 2); + this._sideHighlightLabel = sprite; + eng.scene.add(sprite); + }, + + clearSideHighlight() { + const eng = this.engine; + if (!eng) return; + if (this._sideHighlightMesh) { + eng.scene.remove(this._sideHighlightMesh); + this._sideHighlightMesh.geometry.dispose(); + this._sideHighlightMesh.material.dispose(); + this._sideHighlightMesh = null; + } + if (this._sideHighlightLabel) { + eng.scene.remove(this._sideHighlightLabel); + this._sideHighlightLabel.material.map.dispose(); + this._sideHighlightLabel.material.dispose(); + this._sideHighlightLabel = null; + } + }, + + lookAtSide(sideNum) { + if (!this._encData) return; + const eng = this.engine; + const side = this._encData.sides.find(s => s.num === sideNum); + if (!side) return; + + const s = this._s; + const [startPx, startPy] = this._toPixel(side.startX, side.startY); + const [endPx, endPy] = this._toPixel(side.endX, side.endY); + const midX = (startPx + endPx) / 2; + const midY = (startPy + endPy) / 2; + const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0; + const totalH = this._encData.totalH * s; + const midZ = encZ + totalH / 2; + + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + const dist = Math.max(eng._maxW || 500, eng._maxH || 500) * 0.5; + + eng.camera.position.set(midX + nx * dist, midY + ny * dist, midZ); + eng.camera.up.set(0, 0, 1); + eng.controls.target.set(midX, midY, midZ); + eng.controls.update(); + }, + + _makeCutoutGeo(w, h, r, geoShape) { + if (geoShape === 'circle') return new THREE.CircleGeometry(Math.max(w, h) / 2, 32); + if (geoShape === 'obround') { + const shape = new THREE.Shape(); + const hw = w / 2, hh = h / 2; + const cr = Math.min(hw, hh); + if (w >= h) { + shape.absarc(hw - cr, 0, cr, -Math.PI / 2, Math.PI / 2, false); + shape.absarc(-hw + cr, 0, cr, Math.PI / 2, 3 * Math.PI / 2, false); + } else { + shape.absarc(0, hh - cr, cr, 0, Math.PI, false); + shape.absarc(0, -hh + cr, cr, Math.PI, 2 * Math.PI, false); + } + shape.closePath(); + return new THREE.ShapeGeometry(shape); + } + if (!r || r <= 0) return new THREE.PlaneGeometry(w, h); + const cr = Math.min(r, w / 2, h / 2); + const hw = w / 2, hh = h / 2; + const shape = new THREE.Shape(); + shape.moveTo(-hw + cr, -hh); + shape.lineTo(hw - cr, -hh); + shape.quadraticCurveTo(hw, -hh, hw, -hh + cr); + shape.lineTo(hw, hh - cr); + shape.quadraticCurveTo(hw, hh, hw - cr, hh); + shape.lineTo(-hw + cr, hh); + shape.quadraticCurveTo(-hw, hh, -hw, hh - cr); + shape.lineTo(-hw, -hh + cr); + shape.quadraticCurveTo(-hw, -hh, -hw + cr, -hh); + return new THREE.ShapeGeometry(shape); + }, + + refreshCutouts(cutouts, encData, dpi, minX, maxY) { + this._disposeCutoutViz(); + if (!cutouts || cutouts.length === 0 || !encData) return; + const eng = this.engine; + + this.storeEnclosureContext(encData, dpi, minX, maxY); + const s = this._s; + + this._cutoutVizGroup = new THREE.Group(); + this._cutoutVizMeshes = []; + + const cl = encData.clearance; + const wt = encData.wallThickness; + const trayFloor = encData.trayFloor; + const pcbT = encData.pcbThickness; + const totalH = encData.totalH; + const encZ = eng.enclosureLayerIndex >= 0 ? eng.enclosureLayerIndex * Z_SPACING : 0; + + for (const c of cutouts) { + let mesh; + const color = c.isDado ? 0xf9e2af : 0xa6e3a1; + const cr = (c.r || 0) * s; + + if (c.surface === 'top') { + const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); + mesh.position.set(px, py, encZ - 0.5); + } else if (c.surface === 'bottom') { + const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); + mesh.position.set(px, py, encZ + totalH * s + 0.5); + } else if (c.surface === 'side') { + const side = encData.sides.find(sd => sd.num === c.sideNum); + if (!side) continue; + + const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr, c.shape); + const mat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.6, + side: THREE.DoubleSide, depthWrite: false, + }); + mesh = new THREE.Mesh(geo, mat); + + const sdx = side.endX - side.startX; + const sdy = side.endY - side.startY; + const sLen = Math.sqrt(sdx * sdx + sdy * sdy); + const ux = sdx / sLen, uy = sdy / sLen; + const midAlongSide = c.x + c.w / 2; + const mmX = side.startX + ux * midAlongSide; + const mmY = side.startY + uy * midAlongSide; + const [px, py] = this._toPixel(mmX, mmY); + + const zMM = trayFloor + pcbT + c.y + c.h / 2; + const offset = (cl + wt) * s; + const nx = Math.cos(side.angle); + const ny = -Math.sin(side.angle); + + mesh.position.set(px + nx * offset, py + ny * offset, encZ + (totalH - zMM) * s); + const wallAngle = Math.atan2( + this._toPixel(side.endX, side.endY)[1] - this._toPixel(side.startX, side.startY)[1], + this._toPixel(side.endX, side.endY)[0] - this._toPixel(side.startX, side.startY)[0] + ); + mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); + } + + if (mesh) { + mesh.userData.cutoutId = c.id; + mesh.userData.cutout = c; + this._cutoutVizGroup.add(mesh); + this._cutoutVizMeshes.push(mesh); + } + } + + eng.scene.add(this._cutoutVizGroup); + }, + + _disposeCutoutViz() { + if (this._cutoutVizGroup) { + this.engine.disposeGroup(this._cutoutVizGroup); + this._cutoutVizGroup = null; + } + this._cutoutVizMeshes = []; + }, + + dispose() { + if (this._vizClickHandler) { + this.engine.unregisterClickHandler(this._vizClickHandler); + } + this.clearSideHighlight(); + this._disposeCutoutViz(); + this._disposeEnclosureMeshes(); + }, + }; + + return mode; +} diff --git a/frontend/src/engine/modes/structural-mode.js b/frontend/src/engine/modes/structural-mode.js new file mode 100644 index 0000000..964b978 --- /dev/null +++ b/frontend/src/engine/modes/structural-mode.js @@ -0,0 +1,12 @@ +export function createStructuralMode() { + return { + name: 'structural', + engine: null, + + install(engine) { + this.engine = engine; + }, + + dispose() {}, + }; +} diff --git a/frontend/src/engine/modes/vectorwrap-mode.js b/frontend/src/engine/modes/vectorwrap-mode.js new file mode 100644 index 0000000..2c16e22 --- /dev/null +++ b/frontend/src/engine/modes/vectorwrap-mode.js @@ -0,0 +1,798 @@ +import * as THREE from 'three'; +import { dbg } from '../former-engine.js'; + +export function createVectorWrapMode() { + const mode = { + name: 'vectorwrap', + engine: null, + + _externalModelMesh: null, + _externalModelCenter: null, + _externalModelSize: null, + + _projEncGroup: null, + _projEncLidMesh: null, + _projEncTrayMesh: null, + _projEncAssembled: true, + + _vwGroup: null, + _vectorOverlayMesh: null, + _vwTexture: null, + _vwWidth: 0, + _vwHeight: 0, + _vwRestPositions: null, + + _ffdCols: 0, + _ffdRows: 0, + _ffdGroup: null, + _ffdControlPoints: null, + _ffdCurrentPoints: null, + _ffdSpheres: null, + _ffdLines: null, + _ffdGridVisible: true, + _ffdDragging: null, + _ffdDragCleanup: null, + + _cameraLocked: false, + _sleeveMesh: null, + _surfaceDragCleanup: null, + _dragStartUV: null, + + install(engine) { + this.engine = engine; + }, + + loadExternalModel(stlArrayBuffer) { + this._disposeExternalModel(); + const eng = this.engine; + + const result = eng.loadSTLCentered(stlArrayBuffer, { + color: 0x888888, + side: THREE.DoubleSide, + flatShading: true, + }); + + this._externalModelMesh = result.mesh; + this._externalModelCenter = result.center; + this._externalModelSize = result.size; + eng.scene.add(result.mesh); + + eng.fitCamera(result.size); + eng.gridHelper.position.set(0, 0, result.box.min.z - result.center.z - 0.5); + }, + + _disposeExternalModel() { + if (this._externalModelMesh) { + this.engine.scene.remove(this._externalModelMesh); + this._externalModelMesh.geometry.dispose(); + this._externalModelMesh.material.dispose(); + this._externalModelMesh = null; + } + }, + + loadProjectEnclosure(enclosureSTL, traySTL) { + dbg('vw-mode: loadProjectEnclosure called, encSTL=', enclosureSTL?.byteLength || 0, 'traySTL=', traySTL?.byteLength || 0); + this._disposeExternalModel(); + this._disposeProjectEnclosure(); + const eng = this.engine; + + this._projEncGroup = new THREE.Group(); + this._projEncAssembled = true; + + if (enclosureSTL && enclosureSTL.byteLength > 84) { + dbg('vw-mode: parsing enclosure STL...'); + try { + const loader = new (await_stl_loader())(); + const geometry = loader.parse(enclosureSTL); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + dbg('vw-mode: enclosure geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox)); + const mat = new THREE.MeshPhongMaterial({ + color: 0xffe090, side: THREE.DoubleSide, flatShading: true, + }); + this._projEncLidMesh = new THREE.Mesh(geometry, mat); + this._projEncGroup.add(this._projEncLidMesh); + dbg('vw-mode: enclosure lid mesh added'); + } catch (e) { + dbg('vw-mode: enclosure STL parse FAILED:', e?.message || e); + } + } else { + dbg('vw-mode: skipping enclosure STL (empty or too small)'); + } + + if (traySTL && traySTL.byteLength > 84) { + dbg('vw-mode: parsing tray STL...'); + try { + const loader = new (await_stl_loader())(); + const geometry = loader.parse(traySTL); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + dbg('vw-mode: tray geometry verts=', geometry.attributes.position.count, 'bbox=', JSON.stringify(geometry.boundingBox)); + const mat = new THREE.MeshPhongMaterial({ + color: 0xa0d880, side: THREE.DoubleSide, flatShading: true, + }); + this._projEncTrayMesh = new THREE.Mesh(geometry, mat); + this._projEncGroup.add(this._projEncTrayMesh); + dbg('vw-mode: tray mesh added'); + } catch (e) { + dbg('vw-mode: tray STL parse FAILED:', e?.message || e); + } + } else { + dbg('vw-mode: skipping tray STL (empty or too small)'); + } + + dbg('vw-mode: projEncGroup children=', this._projEncGroup.children.length); + if (this._projEncGroup.children.length === 0) { + dbg('vw-mode: NO meshes loaded, returning early'); + return; + } + + const box = new THREE.Box3().setFromObject(this._projEncGroup); + const cx = (box.min.x + box.max.x) / 2; + const cy = (box.min.y + box.max.y) / 2; + const cz = (box.min.z + box.max.z) / 2; + this._projEncGroup.position.set(-cx, -cy, -cz); + + this._externalModelCenter = new THREE.Vector3(cx, cy, cz); + this._externalModelSize = box.getSize(new THREE.Vector3()); + dbg('vw-mode: group centered, size=', this._externalModelSize.x.toFixed(1), 'x', this._externalModelSize.y.toFixed(1), 'x', this._externalModelSize.z.toFixed(1)); + eng.scene.add(this._projEncGroup); + + eng.fitCamera(this._externalModelSize); + eng.gridHelper.position.set(0, 0, box.min.z - cz - 0.5); + dbg('vw-mode: loadProjectEnclosure complete'); + }, + + toggleProjectEnclosurePart(part) { + const mesh = part === 'lid' ? this._projEncLidMesh : this._projEncTrayMesh; + if (!mesh) return true; + mesh.visible = !mesh.visible; + return mesh.visible; + }, + + toggleProjectEnclosureAssembly() { + if (!this._projEncLidMesh || !this._projEncGroup) return true; + this._projEncAssembled = !this._projEncAssembled; + + if (this._projEncAssembled) { + this._projEncLidMesh.position.set(0, 0, 0); + } else { + const box = new THREE.Box3().setFromObject(this._projEncGroup); + const width = box.max.x - box.min.x; + this._projEncLidMesh.position.x = width * 1.2; + } + return this._projEncAssembled; + }, + + _disposeProjectEnclosure() { + if (this._projEncGroup) { + this.engine.disposeGroup(this._projEncGroup); + this._projEncGroup = null; + } + this._projEncLidMesh = null; + this._projEncTrayMesh = null; + this._projEncAssembled = true; + }, + + loadVectorOverlay(imageUrl, svgWidthMM, svgHeightMM) { + this._disposeVectorOverlay(); + const eng = this.engine; + + const loader = new THREE.TextureLoader(); + loader.load(imageUrl, (tex) => { + tex.minFilter = THREE.LinearFilter; + tex.magFilter = THREE.LinearFilter; + + const w = svgWidthMM || tex.image.width * (25.4 / 96); + const h = svgHeightMM || tex.image.height * (25.4 / 96); + this._vwWidth = w; + this._vwHeight = h; + this._vwTexture = tex; + + const segsX = 32, segsY = 32; + const geo = new THREE.PlaneGeometry(w, h, segsX, segsY); + const mat = new THREE.MeshBasicMaterial({ + map: tex, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + + let zPos = 0; + const targetObj = this._externalModelMesh || this._projEncGroup; + if (targetObj) { + const box = new THREE.Box3().setFromObject(targetObj); + zPos = box.max.z + 2; + } + + this._vwGroup = new THREE.Group(); + this._vwGroup.position.set(0, 0, zPos); + this._vwGroup.add(mesh); + this._vectorOverlayMesh = mesh; + + const pos = geo.attributes.position; + this._vwRestPositions = new Float32Array(pos.count * 3); + for (let i = 0; i < pos.count; i++) { + this._vwRestPositions[i * 3] = pos.getX(i); + this._vwRestPositions[i * 3 + 1] = pos.getY(i); + this._vwRestPositions[i * 3 + 2] = pos.getZ(i); + } + + eng.scene.add(this._vwGroup); + this.initFFDGrid(4, 4); + }); + }, + + initFFDGrid(cols, rows) { + this._disposeFFDGrid(); + const eng = this.engine; + const w = this._vwWidth; + const h = this._vwHeight; + if (!w || !h) return; + + this._ffdCols = cols; + this._ffdRows = rows; + this._ffdGroup = new THREE.Group(); + this._ffdControlPoints = []; + this._ffdCurrentPoints = []; + this._ffdSpheres = []; + + const halfW = w / 2, halfH = h / 2; + + for (let iy = 0; iy <= rows; iy++) { + for (let ix = 0; ix <= cols; ix++) { + const px = -halfW + (ix / cols) * w; + const py = -halfH + (iy / rows) * h; + this._ffdControlPoints.push(new THREE.Vector2(px, py)); + this._ffdCurrentPoints.push(new THREE.Vector2(px, py)); + + const sphere = new THREE.Mesh( + new THREE.SphereGeometry(Math.max(w, h) * 0.012, 8, 8), + new THREE.MeshBasicMaterial({ color: 0x89b4fa, transparent: true, opacity: 0.8 }) + ); + sphere.position.set(px, py, 0.5); + sphere.userData.ffdIndex = iy * (cols + 1) + ix; + this._ffdSpheres.push(sphere); + this._ffdGroup.add(sphere); + } + } + + this._ffdLines = []; + this._rebuildFFDLines(); + + this._vwGroup.add(this._ffdGroup); + this._ffdGridVisible = true; + this._ffdDragging = null; + this._setupFFDDrag(); + }, + + _rebuildFFDLines() { + if (this._ffdLines) { + for (const line of this._ffdLines) { + this._ffdGroup.remove(line); + line.geometry.dispose(); + line.material.dispose(); + } + } + this._ffdLines = []; + + const cols = this._ffdCols, rows = this._ffdRows; + const cp = this._ffdCurrentPoints; + const lineMat = new THREE.LineBasicMaterial({ color: 0x585b70, opacity: 0.5, transparent: true }); + + for (let iy = 0; iy <= rows; iy++) { + const pts = []; + for (let ix = 0; ix <= cols; ix++) { + const p = cp[iy * (cols + 1) + ix]; + pts.push(new THREE.Vector3(p.x, p.y, 0.3)); + } + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const line = new THREE.Line(geo, lineMat.clone()); + this._ffdLines.push(line); + this._ffdGroup.add(line); + } + + for (let ix = 0; ix <= cols; ix++) { + const pts = []; + for (let iy = 0; iy <= rows; iy++) { + const p = cp[iy * (cols + 1) + ix]; + pts.push(new THREE.Vector3(p.x, p.y, 0.3)); + } + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const line = new THREE.Line(geo, lineMat.clone()); + this._ffdLines.push(line); + this._ffdGroup.add(line); + } + }, + + _applyFFD() { + if (!this._vectorOverlayMesh || !this._ffdCurrentPoints) return; + + const pos = this._vectorOverlayMesh.geometry.attributes.position; + const rest = this._vwRestPositions; + const cols = this._ffdCols, rows = this._ffdRows; + const cp = this._ffdCurrentPoints; + const restCp = this._ffdControlPoints; + const w = this._vwWidth, h = this._vwHeight; + const halfW = w / 2, halfH = h / 2; + + for (let i = 0; i < pos.count; i++) { + const rx = rest[i * 3]; + const ry = rest[i * 3 + 1]; + + let u = (rx + halfW) / w; + let v = (ry + halfH) / h; + u = Math.max(0, Math.min(1, u)); + v = Math.max(0, Math.min(1, v)); + + const cellX = Math.min(Math.floor(u * cols), cols - 1); + const cellY = Math.min(Math.floor(v * rows), rows - 1); + + const lu = (u * cols) - cellX; + const lv = (v * rows) - cellY; + + const i00 = cellY * (cols + 1) + cellX; + const i10 = i00 + 1; + const i01 = i00 + (cols + 1); + const i11 = i01 + 1; + + const r00 = restCp[i00], r10 = restCp[i10]; + const r01 = restCp[i01], r11 = restCp[i11]; + + const c00 = cp[i00], c10 = cp[i10]; + const c01 = cp[i01], c11 = cp[i11]; + + const d00x = c00.x - r00.x, d00y = c00.y - r00.y; + const d10x = c10.x - r10.x, d10y = c10.y - r10.y; + const d01x = c01.x - r01.x, d01y = c01.y - r01.y; + const d11x = c11.x - r11.x, d11y = c11.y - r11.y; + + const dx = (1 - lu) * (1 - lv) * d00x + lu * (1 - lv) * d10x + + (1 - lu) * lv * d01x + lu * lv * d11x; + const dy = (1 - lu) * (1 - lv) * d00y + lu * (1 - lv) * d10y + + (1 - lu) * lv * d01y + lu * lv * d11y; + + pos.setXY(i, rx + dx, ry + dy); + } + + pos.needsUpdate = true; + this._vectorOverlayMesh.geometry.computeBoundingBox(); + this._vectorOverlayMesh.geometry.computeBoundingSphere(); + }, + + _setupFFDDrag() { + const eng = this.engine; + const canvas = eng.renderer.domElement; + const raycaster = new THREE.Raycaster(); + const mouse = new THREE.Vector2(); + let dragPlane = null; + let dragOffset = new THREE.Vector3(); + + const getWorldPos = (e) => { + const rect = canvas.getBoundingClientRect(); + mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + return mouse; + }; + + const onDown = (e) => { + if (e.button !== 0 || !this._ffdGridVisible) return; + if (e.ctrlKey || e.metaKey || e.shiftKey) return; + + getWorldPos(e); + raycaster.setFromCamera(mouse, eng.camera); + const hits = raycaster.intersectObjects(this._ffdSpheres); + if (hits.length === 0) return; + + e.stopImmediatePropagation(); + eng.controls.enabled = false; + + const sphere = hits[0].object; + this._ffdDragging = sphere.userData.ffdIndex; + + const normal = eng.camera.getWorldDirection(new THREE.Vector3()); + const worldPos = new THREE.Vector3(); + sphere.getWorldPosition(worldPos); + dragPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, worldPos); + + const intersection = new THREE.Vector3(); + raycaster.ray.intersectPlane(dragPlane, intersection); + dragOffset.copy(worldPos).sub(intersection); + }; + + const onMove = (e) => { + if (this._ffdDragging === null) return; + + getWorldPos(e); + raycaster.setFromCamera(mouse, eng.camera); + + const intersection = new THREE.Vector3(); + if (!raycaster.ray.intersectPlane(dragPlane, intersection)) return; + intersection.add(dragOffset); + + const local = this._vwGroup.worldToLocal(intersection.clone()); + const idx = this._ffdDragging; + this._ffdCurrentPoints[idx].set(local.x, local.y); + this._ffdSpheres[idx].position.set(local.x, local.y, 0.5); + + this._applyFFD(); + this._rebuildFFDLines(); + }; + + const onUp = () => { + if (this._ffdDragging !== null) { + this._ffdDragging = null; + eng.controls.enabled = true; + } + }; + + canvas.addEventListener('pointerdown', onDown, { capture: true }); + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerup', onUp); + + this._ffdDragCleanup = () => { + canvas.removeEventListener('pointerdown', onDown, { capture: true }); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerup', onUp); + }; + }, + + setFFDGridVisible(visible) { + this._ffdGridVisible = visible; + if (this._ffdGroup) this._ffdGroup.visible = visible; + }, + + resetFFDGrid() { + if (!this._ffdControlPoints || !this._ffdCurrentPoints) return; + for (let i = 0; i < this._ffdControlPoints.length; i++) { + this._ffdCurrentPoints[i].copy(this._ffdControlPoints[i]); + if (this._ffdSpheres[i]) { + this._ffdSpheres[i].position.set( + this._ffdControlPoints[i].x, + this._ffdControlPoints[i].y, + 0.5 + ); + } + } + this._applyFFD(); + this._rebuildFFDLines(); + }, + + setFFDResolution(cols, rows) { + this.initFFDGrid(cols, rows); + }, + + getFFDState() { + if (!this._ffdCurrentPoints) return null; + return { + cols: this._ffdCols, + rows: this._ffdRows, + points: this._ffdCurrentPoints.map(p => [p.x, p.y]), + }; + }, + + setFFDState(state) { + if (!state || !state.points) return; + this.initFFDGrid(state.cols, state.rows); + for (let i = 0; i < state.points.length && i < this._ffdCurrentPoints.length; i++) { + this._ffdCurrentPoints[i].set(state.points[i][0], state.points[i][1]); + if (this._ffdSpheres[i]) { + this._ffdSpheres[i].position.set(state.points[i][0], state.points[i][1], 0.5); + } + } + this._applyFFD(); + this._rebuildFFDLines(); + }, + + setVWTranslation(x, y) { + if (this._vwGroup) { + this._vwGroup.position.x = x; + this._vwGroup.position.y = y; + } + }, + + setVWRotation(degrees) { + if (this._vwGroup) { + this._vwGroup.rotation.z = degrees * Math.PI / 180; + } + }, + + setVWScale(sx, sy) { + if (this._vwGroup) { + this._vwGroup.scale.set(sx, sy, 1); + } + }, + + _disposeFFDGrid() { + if (this._ffdDragCleanup) { + this._ffdDragCleanup(); + this._ffdDragCleanup = null; + } + if (this._ffdLines) { + for (const line of this._ffdLines) { + if (line.parent) line.parent.remove(line); + line.geometry.dispose(); + line.material.dispose(); + } + this._ffdLines = null; + } + if (this._ffdSpheres) { + for (const s of this._ffdSpheres) { + if (s.parent) s.parent.remove(s); + s.geometry.dispose(); + s.material.dispose(); + } + this._ffdSpheres = null; + } + if (this._ffdGroup && this._vwGroup) { + this._vwGroup.remove(this._ffdGroup); + } + this._ffdGroup = null; + this._ffdControlPoints = null; + this._ffdCurrentPoints = null; + this._ffdDragging = null; + }, + + _disposeVectorOverlay() { + this._disposeFFDGrid(); + if (this._vwGroup) { + this.engine.scene.remove(this._vwGroup); + } + if (this._vectorOverlayMesh) { + this._vectorOverlayMesh.geometry.dispose(); + if (this._vectorOverlayMesh.material.map) { + this._vectorOverlayMesh.material.map.dispose(); + } + this._vectorOverlayMesh.material.dispose(); + this._vectorOverlayMesh = null; + } + this._vwGroup = null; + this._vwTexture = null; + this._vwRestPositions = null; + }, + + setVWCameraLock(locked) { + this._cameraLocked = locked; + const eng = this.engine; + if (!eng) return; + + if (locked) { + eng.controls.enabled = false; + this._buildSleeveMesh(); + this._setupSurfaceDrag(); + } else { + eng.controls.enabled = true; + this._disposeSleeveMesh(); + if (this._surfaceDragCleanup) { + this._surfaceDragCleanup(); + this._surfaceDragCleanup = null; + } + } + }, + + _buildSleeveMesh() { + this._disposeSleeveMesh(); + if (!this._vwTexture || !this._vwGroup) return; + + const targetObj = this._externalModelMesh || this._projEncGroup; + if (!targetObj) return; + + const box = new THREE.Box3().setFromObject(targetObj); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + + // Build a box-shaped sleeve around the enclosure + const hw = size.x / 2, hh = size.y / 2, hz = size.z / 2; + const perimeter = 2 * (size.x + size.y); + + // Create a merged geometry wrapping around the box perimeter + // 4 side planes + top + bottom + const segments = []; + + // Front face (Y = -hh) + segments.push({ pos: [0, -hh, 0], rot: [Math.PI/2, 0, 0], w: size.x, h: size.z, uStart: 0, uEnd: size.x / perimeter }); + // Right face (X = hw) + segments.push({ pos: [hw, 0, 0], rot: [Math.PI/2, 0, -Math.PI/2], w: size.y, h: size.z, uStart: size.x / perimeter, uEnd: (size.x + size.y) / perimeter }); + // Back face (Y = hh) + segments.push({ pos: [0, hh, 0], rot: [Math.PI/2, 0, Math.PI], w: size.x, h: size.z, uStart: (size.x + size.y) / perimeter, uEnd: (2 * size.x + size.y) / perimeter }); + // Left face (X = -hw) + segments.push({ pos: [-hw, 0, 0], rot: [Math.PI/2, 0, Math.PI/2], w: size.y, h: size.z, uStart: (2 * size.x + size.y) / perimeter, uEnd: 1.0 }); + + const geometries = []; + + for (const seg of segments) { + const geo = new THREE.PlaneGeometry(seg.w, seg.h, 8, 8); + const pos = geo.attributes.position; + const uv = geo.attributes.uv; + + // Remap UVs for continuous wrapping + for (let i = 0; i < uv.count; i++) { + const u = uv.getX(i); + const v = uv.getY(i); + uv.setXY(i, seg.uStart + u * (seg.uEnd - seg.uStart), v); + } + uv.needsUpdate = true; + + // Apply rotation and position + const euler = new THREE.Euler(seg.rot[0], seg.rot[1], seg.rot[2]); + const quat = new THREE.Quaternion().setFromEuler(euler); + const offset = new THREE.Vector3(seg.pos[0], seg.pos[1], seg.pos[2]); + + for (let i = 0; i < pos.count; i++) { + const v3 = new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i)); + v3.applyQuaternion(quat); + v3.add(offset); + pos.setXYZ(i, v3.x, v3.y, v3.z); + } + pos.needsUpdate = true; + geometries.push(geo); + } + + // Merge geometries + const merged = this._mergeGeometries(geometries); + if (!merged) return; + + const tex = this._vwTexture.clone(); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.needsUpdate = true; + + const mat = new THREE.MeshBasicMaterial({ + map: tex, + transparent: true, + opacity: 0.7, + side: THREE.DoubleSide, + depthWrite: false, + }); + + this._sleeveMesh = new THREE.Mesh(merged, mat); + // Position relative to the target object's local space + const eng = this.engine; + eng.scene.add(this._sleeveMesh); + + // Hide the flat overlay while sleeve is active + if (this._vectorOverlayMesh) { + this._vectorOverlayMesh.visible = false; + } + if (this._ffdGroup) { + this._ffdGroup.visible = false; + } + }, + + _mergeGeometries(geometries) { + if (geometries.length === 0) return null; + + let totalVerts = 0; + let totalIdx = 0; + for (const g of geometries) { + totalVerts += g.attributes.position.count; + totalIdx += g.index ? g.index.count : 0; + } + + const pos = new Float32Array(totalVerts * 3); + const uv = new Float32Array(totalVerts * 2); + const idx = new Uint32Array(totalIdx); + + let vOff = 0, iOff = 0, vBase = 0; + for (const g of geometries) { + const gPos = g.attributes.position; + const gUV = g.attributes.uv; + for (let i = 0; i < gPos.count; i++) { + pos[(vOff + i) * 3] = gPos.getX(i); + pos[(vOff + i) * 3 + 1] = gPos.getY(i); + pos[(vOff + i) * 3 + 2] = gPos.getZ(i); + uv[(vOff + i) * 2] = gUV.getX(i); + uv[(vOff + i) * 2 + 1] = gUV.getY(i); + } + if (g.index) { + for (let i = 0; i < g.index.count; i++) { + idx[iOff + i] = g.index.getX(i) + vBase; + } + iOff += g.index.count; + } + vBase += gPos.count; + vOff += gPos.count; + g.dispose(); + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + merged.setIndex(new THREE.BufferAttribute(idx, 1)); + merged.computeVertexNormals(); + return merged; + }, + + _setupSurfaceDrag() { + if (this._surfaceDragCleanup) { + this._surfaceDragCleanup(); + } + if (!this._sleeveMesh) return; + + const canvas = this.engine.renderer.domElement; + const camera = this.engine.camera; + let dragging = false; + let lastX = 0, lastY = 0; + + const onDown = (e) => { + if (e.button !== 0) return; + dragging = true; + lastX = e.clientX; + lastY = e.clientY; + e.preventDefault(); + }; + + const onMove = (e) => { + if (!dragging || !this._sleeveMesh) return; + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + + // Convert pixel delta to UV offset based on camera FOV and distance + const rect = canvas.getBoundingClientRect(); + const fovRad = camera.fov * Math.PI / 180; + const dist = camera.position.length(); + const viewH = 2 * dist * Math.tan(fovRad / 2); + const pxToMM = viewH / rect.height; + + const tex = this._sleeveMesh.material.map; + if (tex) { + tex.offset.x -= (dx * pxToMM) / (tex.image?.width || 100); + tex.offset.y += (dy * pxToMM) / (tex.image?.height || 100); + } + }; + + const onUp = () => { + dragging = false; + }; + + canvas.addEventListener('pointerdown', onDown); + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerup', onUp); + + this._surfaceDragCleanup = () => { + canvas.removeEventListener('pointerdown', onDown); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerup', onUp); + }; + }, + + _disposeSleeveMesh() { + if (this._sleeveMesh) { + this.engine.scene.remove(this._sleeveMesh); + this._sleeveMesh.geometry.dispose(); + if (this._sleeveMesh.material.map) { + this._sleeveMesh.material.map.dispose(); + } + this._sleeveMesh.material.dispose(); + this._sleeveMesh = null; + } + // Restore flat overlay visibility + if (this._vectorOverlayMesh) { + this._vectorOverlayMesh.visible = true; + } + if (this._ffdGroup && this._ffdGridVisible) { + this._ffdGroup.visible = true; + } + }, + + dispose() { + this._disposeExternalModel(); + this._disposeProjectEnclosure(); + this._disposeVectorOverlay(); + this._disposeSleeveMesh(); + if (this._surfaceDragCleanup) { + this._surfaceDragCleanup(); + this._surfaceDragCleanup = null; + } + }, + }; + + return mode; +} + +import { STLLoader } from 'three/addons/loaders/STLLoader.js'; +function await_stl_loader() { return STLLoader; } diff --git a/frontend/src/former3d.js b/frontend/src/former3d.js index 21f2312..fbbc587 100644 --- a/frontend/src/former3d.js +++ b/frontend/src/former3d.js @@ -1,1750 +1,102 @@ -// 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'; -import { STLLoader } from 'three/addons/loaders/STLLoader.js'; - -const Z_SPACING = 3; - -// Debug bridge — pipes to Go's debugLog via Wails binding when built with -tags debug. -function dbg(...args) { - try { - const fn = window?.go?.main?.App?.JSDebugLog; - if (!fn) return; - const msg = '[former3d] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); - fn(msg); - } catch (_) {} -} +import { FormerEngine } from './engine/former-engine.js'; +import { createEnclosureMode } from './engine/modes/enclosure-mode.js'; +import { createCutoutMode } from './engine/modes/cutout-mode.js'; +import { createVectorWrapMode } from './engine/modes/vectorwrap-mode.js'; +import { createStructuralMode } from './engine/modes/structural-mode.js'; 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(); + this.engine = new FormerEngine(container); + this.engine.use(createEnclosureMode()); + this.engine.use(createCutoutMode()); + this.engine.use(createVectorWrapMode()); + this.engine.use(createStructuralMode()); } - _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._ambientLight = new THREE.AmbientLight(0xffffff, 0.9); - this.scene.add(this._ambientLight); - this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3); - this._dirLight.position.set(50, -50, 100); - this.scene.add(this._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.enableZoom = true; // Must stay true for right-drag dolly to work - 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(); - - // Custom scroll handler — capture phase so we fire before OrbitControls' bubble listener. - // In default mode we stopImmediatePropagation to prevent OrbitControls scroll-zoom. - this.renderer.domElement.addEventListener('wheel', (e) => { - e.preventDefault(); - - if (this._traditionalControls) { - // Traditional: shift+scroll = horizontal pan (we handle, block OrbitControls) - if (e.shiftKey && e.deltaX === 0) { - e.stopImmediatePropagation(); - const dist = this.camera.position.distanceTo(this.controls.target); - const panSpeed = dist * 0.001; - const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); - const offset = new THREE.Vector3(); - offset.addScaledVector(right, e.deltaY * panSpeed); - this.camera.position.add(offset); - this.controls.target.add(offset); - this.controls.update(); - } - // Regular scroll: let OrbitControls handle zoom - return; - } - - // Default (modern) mode: we handle everything, block OrbitControls scroll-zoom - e.stopImmediatePropagation(); - - if (e.ctrlKey || e.metaKey) { - // Ctrl+scroll / trackpad pinch: zoom - const factor = 1 + e.deltaY * 0.01; - const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); - dir.multiplyScalar(factor); - this.camera.position.copy(this.controls.target).add(dir); - } else { - // Scroll: pan - let dx = e.deltaX; - let dy = e.deltaY; - // Shift+vertical scroll → horizontal pan (for mouse users) - if (e.shiftKey && dx === 0) { - dx = dy; - dy = 0; - } - const dist = this.camera.position.distanceTo(this.controls.target); - const panSpeed = dist * 0.001; - const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0); - const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1); - const offset = new THREE.Vector3(); - offset.addScaledVector(right, dx * panSpeed); - offset.addScaledVector(up, -dy * panSpeed); - this.camera.position.add(offset); - this.controls.target.add(offset); - } - this.controls.update(); - }, { passive: false, capture: true }); - - // Ctrl/Cmd + left-drag: zoom fallback (Cmd on Mac, Ctrl on Windows/Linux) - this._ctrlDragging = false; - this._ctrlDragLastY = 0; - - this.renderer.domElement.addEventListener('mousedown', (e) => { - if (e.button === 0 && (e.ctrlKey || e.metaKey)) { - this._ctrlDragging = true; - this._ctrlDragLastY = e.clientY; - e.preventDefault(); - e.stopImmediatePropagation(); // prevent OrbitControls rotate - } - }, { capture: true }); - - const onCtrlDragMove = (e) => { - if (!this._ctrlDragging) return; - const dy = e.clientY - this._ctrlDragLastY; - this._ctrlDragLastY = e.clientY; - const factor = 1 + dy * 0.005; - const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target); - dir.multiplyScalar(factor); - this.camera.position.copy(this.controls.target).add(dir); - this.controls.update(); - }; - - const onCtrlDragUp = () => { - this._ctrlDragging = false; - }; - - window.addEventListener('mousemove', onCtrlDragMove); - window.addEventListener('mouseup', onCtrlDragUp); - this._ctrlDragCleanup = () => { - window.removeEventListener('mousemove', onCtrlDragMove); - window.removeEventListener('mouseup', onCtrlDragUp); - }; - } - - _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 { - // Check for cutout viz click first - if (this._cutoutVizMeshes && this._cutoutVizMeshes.length > 0) { - const cutoutHits = this.raycaster.intersectObjects(this._cutoutVizMeshes); - if (cutoutHits.length > 0) { - const hitMesh = cutoutHits[0].object; - const cutoutId = hitMesh.userData.cutoutId; - if (e.shiftKey) { - this._toggleCutoutSelection(cutoutId); - } else { - this._selectCutout(cutoutId); - } - return; - } - } - - 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._deselectAllCutouts(); - 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; - - // Store context for cutout viz and side highlighting - this.storeEnclosureContext(encData, dpi, minX, maxY); - - // 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 - ]; - - let pts = encData.outlinePoints.map(p => toPixel(p[0], p[1])); - - // Strip closing duplicate vertex (Go closes polygons by repeating first point) - if (pts.length > 2) { - const first = pts[0], last = pts[pts.length - 1]; - if (Math.abs(first[0] - last[0]) < 0.01 && Math.abs(first[1] - last[1]) < 0.01) { - pts = pts.slice(0, -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) { - // Still fire callback so sidebar tools appear - if (this._onLayerSelect) this._onLayerSelect(index); - 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; - // Slight Y offset avoids gimbal lock at the Z-axis pole; - // keep camera.up as Z-up so OrbitControls stays consistent. - this.camera.position.set(pos.x, pos.y - dist * 0.01, pos.z + dist); - this.camera.up.set(0, 0, 1); - 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 - dist * 0.01, cz + dist); - this.camera.up.set(0, 0, 1); - 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. - // Flips the enclosure (lid) upside-down and places it beside the tray so both are visible. - enterSolidView() { - this._savedVisibility = this.layers.map(l => l.visible); - this._savedEnclosurePos = null; - this._savedEnclosureRot = null; - this._savedTrayPos = null; - - 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; - } - } - - // Pop the lid off: flip enclosure 180° around X and lay it beside the tray - if (this.enclosureMesh && this.trayMesh) { - // Save original transforms for exitSolidView - this._savedEnclosurePos = this.enclosureMesh.position.clone(); - this._savedEnclosureRot = this.enclosureMesh.quaternion.clone(); - this._savedTrayPos = this.trayMesh.position.clone(); - this._savedTrayRot = this.trayMesh.quaternion.clone(); - - // Flip enclosure upside-down (rotate 180° around local X axis) - this.enclosureMesh.rotateX(Math.PI); - - // Rotate tray 180° in XY plane so both face the same direction - this.trayMesh.rotateZ(Math.PI); - - // Put both at same Z layer - this.enclosureMesh.position.z = this.trayMesh.position.z; - - // Compute world bounding boxes after rotation - const encBox = new THREE.Box3().setFromObject(this.enclosureMesh); - const trayBox = new THREE.Box3().setFromObject(this.trayMesh); - - // Align Y centers (rotations may shift Y) - const encCY = (encBox.min.y + encBox.max.y) / 2; - const trayCY = (trayBox.min.y + trayBox.max.y) / 2; - this.trayMesh.position.y += encCY - trayCY; - - // Place tray to the left of enclosure with small padding - const trayBox2 = new THREE.Box3().setFromObject(this.trayMesh); - const encWidth = encBox.max.x - encBox.min.x; - const gap = Math.max(encWidth * 0.05, 5); - this.trayMesh.position.x += encBox.min.x - trayBox2.max.x - gap; - } - - this.selectLayer(-1); - // Hide cutout visualization, outlines, and side highlights - if (this._cutoutVizGroup) this._cutoutVizGroup.visible = false; - if (this._cutoutOutlines) { - for (const ol of this._cutoutOutlines) ol.visible = false; - } - this.clearSideHighlight(); - this.gridHelper.visible = false; - this.scene.background = new THREE.Color(0x1e1e2e); - - // Solid view lighting: lower ambient + stronger directional for boundary clarity - this._ambientLight.intensity = 0.45; - this._dirLight.intensity = 0.7; - this._dirLight.position.set(1, -1, 2).normalize(); - this._solidFillLight = new THREE.DirectionalLight(0xffffff, 0.25); - this._solidFillLight.position.set(-1, 1, 0.5).normalize(); - this.scene.add(this._solidFillLight); - - // Center camera on enclosure - if (this.enclosureMesh) { - const box = new THREE.Box3().setFromObject(this.enclosureMesh); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - const dist = Math.max(size.x, size.y, size.z) * 1.2; - this.controls.target.copy(center); - this.camera.position.set(center.x, center.y - dist * 0.5, center.z + dist * 0.6); - this.camera.up.set(0, 0, 1); - this.controls.update(); - } else { - this.resetView(); - } - } - - // Return from solid view to normal editor - exitSolidView() { - this.scene.background = new THREE.Color(0x000000); - this.gridHelper.visible = this.gridVisible; - - // Restore base lighting - this._ambientLight.intensity = 0.9; - this._dirLight.intensity = 0.3; - this._dirLight.position.set(50, -50, 100); - if (this._solidFillLight) { - this.scene.remove(this._solidFillLight); - this._solidFillLight = null; - } - - // If WASM-rendered STL meshes are loaded, dispose them and reload approximate geometry - if (this._renderedSTLLoaded) { - this._disposeEnclosureMeshes(); - this._renderedSTLLoaded = false; - // Reload approximate geometry if we have enclosure context - if (this._encData && this._dpi && this._minX !== undefined && this._maxY !== undefined) { - this.loadEnclosureGeometry(this._encData, this._dpi, this._minX, this._maxY); - } - } else { - // Restore saved transforms (from lid separation) - if (this._savedEnclosurePos && this.enclosureMesh) { - this.enclosureMesh.position.copy(this._savedEnclosurePos); - this.enclosureMesh.quaternion.copy(this._savedEnclosureRot); - } - if (this._savedTrayPos && this.trayMesh) { - this.trayMesh.position.copy(this._savedTrayPos); - if (this._savedTrayRot) this.trayMesh.quaternion.copy(this._savedTrayRot); - } - } - this._savedEnclosurePos = null; - this._savedEnclosureRot = null; - this._savedTrayPos = null; - this._savedTrayRot = null; - - 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; - } - }); - } - } - // Restore cutout visualization - if (this._cutoutVizGroup) this._cutoutVizGroup.visible = true; - if (this._cutoutOutlines) { - for (const ol of this._cutoutOutlines) ol.visible = true; - } - this._savedVisibility = null; - this.resetView(); - } - - // ===== Placed cutout selection ===== - _selectCutout(cutoutId) { - this.selectedCutoutIds = new Set([cutoutId]); - this._updateCutoutHighlights(); - if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); - } - - _toggleCutoutSelection(cutoutId) { - if (!this.selectedCutoutIds) this.selectedCutoutIds = new Set(); - if (this.selectedCutoutIds.has(cutoutId)) { - this.selectedCutoutIds.delete(cutoutId); - } else { - this.selectedCutoutIds.add(cutoutId); - } - this._updateCutoutHighlights(); - if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([...this.selectedCutoutIds]); - } - - _deselectAllCutouts() { - if (this.selectedCutoutIds && this.selectedCutoutIds.size > 0) { - this.selectedCutoutIds = new Set(); - this._updateCutoutHighlights(); - if (this._onPlacedCutoutSelect) this._onPlacedCutoutSelect([]); - } - } - - _updateCutoutHighlights() { - // Remove old outlines - if (this._cutoutOutlines) { - for (const ol of this._cutoutOutlines) { - this.scene.remove(ol); - ol.geometry.dispose(); - ol.material.dispose(); - } - } - this._cutoutOutlines = []; - - if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return; - for (const mesh of this._cutoutVizMeshes) { - const isSelected = this.selectedCutoutIds.has(mesh.userData.cutoutId); - mesh.material.opacity = isSelected ? 0.9 : 0.6; - if (isSelected) { - 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.quaternion.copy(mesh.quaternion); - this._cutoutOutlines.push(line); - this.scene.add(line); - } - } - } - - onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; } - - getSelectedCutouts() { - if (!this._cutoutVizMeshes || !this.selectedCutoutIds) return []; - return this._cutoutVizMeshes - .filter(m => this.selectedCutoutIds.has(m.userData.cutoutId)) - .map(m => m.userData.cutout); - } - - // ===== Store enclosure context for cutout viz / side highlight ===== - storeEnclosureContext(encData, dpi, minX, maxY) { - this._encData = encData; - this._dpi = dpi; - this._minX = minX; - this._maxY = maxY; - this._s = dpi / 25.4; - } - - _toPixel(mmX, mmY) { - const s = this._s; - return [(mmX - this._minX) * s, -(this._maxY - mmY) * s]; - } - - // ===== Side Highlight ===== - highlightSide(sideNum) { - this.clearSideHighlight(); - if (!this._encData) return; - const side = this._encData.sides.find(s => s.num === sideNum); - if (!side) return; - - const s = this._s; - const [startPx, startPy] = this._toPixel(side.startX, side.startY); - const [endPx, endPy] = this._toPixel(side.endX, side.endY); - const dx = endPx - startPx; - const dy = endPy - startPy; - const len = Math.sqrt(dx * dx + dy * dy); - const totalH = this._encData.totalH * s; - const cl = this._encData.clearance; - const wt = this._encData.wallThickness; - - const geo = new THREE.PlaneGeometry(len, totalH); - const sideColors = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x14b8a6, 0xf97316]; - const color = sideColors[(sideNum - 1) % sideColors.length]; - const mat = new THREE.MeshBasicMaterial({ - color, transparent: true, opacity: 0.4, - side: THREE.DoubleSide, depthWrite: false, - }); - const mesh = new THREE.Mesh(geo, mat); - - // Position at wall midpoint - const midX = (startPx + endPx) / 2; - const midY = (startPy + endPy) / 2; - const wallAngle = Math.atan2(dy, dx); - - // Offset outward to wall exterior - const offset = (cl + wt) * s; - const nx = Math.cos(side.angle); - const ny = -Math.sin(side.angle); - - const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; - - mesh.position.set( - midX + nx * offset, - midY + ny * offset, - encZ + totalH / 2 - ); - - // Rotate: first face XY plane upright, then rotate around Z to match wall direction - mesh.rotation.set(Math.PI / 2, 0, wallAngle); - mesh.rotation.order = 'ZXY'; - mesh.rotation.set(0, 0, 0); - // Build rotation manually: wall runs along wallAngle in XY, and is vertical in Z - mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); - - this._sideHighlightMesh = mesh; - this.scene.add(mesh); - - // Add label sprite - const canvas2d = document.createElement('canvas'); - canvas2d.width = 64; - canvas2d.height = 64; - const ctx = canvas2d.getContext('2d'); - ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`; - ctx.beginPath(); - ctx.arc(32, 32, 28, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = 'white'; - ctx.font = 'bold 32px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(sideNum.toString(), 32, 33); - - const tex = new THREE.CanvasTexture(canvas2d); - const spriteMat = new THREE.SpriteMaterial({ map: tex, depthWrite: false }); - const sprite = new THREE.Sprite(spriteMat); - const labelScale = Math.max(len, totalH) * 0.15; - sprite.scale.set(labelScale, labelScale, 1); - sprite.position.set( - midX + nx * offset * 1.5, - midY + ny * offset * 1.5, - encZ + totalH / 2 - ); - this._sideHighlightLabel = sprite; - this.scene.add(sprite); - } - - clearSideHighlight() { - if (this._sideHighlightMesh) { - this.scene.remove(this._sideHighlightMesh); - this._sideHighlightMesh.geometry.dispose(); - this._sideHighlightMesh.material.dispose(); - this._sideHighlightMesh = null; - } - if (this._sideHighlightLabel) { - this.scene.remove(this._sideHighlightLabel); - this._sideHighlightLabel.material.map.dispose(); - this._sideHighlightLabel.material.dispose(); - this._sideHighlightLabel = null; - } - } - - lookAtSide(sideNum) { - if (!this._encData) return; - const side = this._encData.sides.find(s => s.num === sideNum); - if (!side) return; - - const s = this._s; - const [startPx, startPy] = this._toPixel(side.startX, side.startY); - const [endPx, endPy] = this._toPixel(side.endX, side.endY); - const midX = (startPx + endPx) / 2; - const midY = (startPy + endPy) / 2; - const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; - const totalH = this._encData.totalH * s; - const midZ = encZ + totalH / 2; - - const nx = Math.cos(side.angle); - const ny = -Math.sin(side.angle); - const dist = Math.max(this._maxW || 500, this._maxH || 500) * 0.5; - - this.camera.position.set(midX + nx * dist, midY + ny * dist, midZ); - this.camera.up.set(0, 0, 1); - this.controls.target.set(midX, midY, midZ); - this.controls.update(); - } - - // ===== Cutout Visualization ===== - - // Create a rounded rectangle geometry for cutout visualization - _makeCutoutGeo(w, h, r) { - if (!r || r <= 0) return new THREE.PlaneGeometry(w, h); - // Clamp radius to half the smallest dimension - const cr = Math.min(r, w / 2, h / 2); - const hw = w / 2, hh = h / 2; - const shape = new THREE.Shape(); - shape.moveTo(-hw + cr, -hh); - shape.lineTo(hw - cr, -hh); - shape.quadraticCurveTo(hw, -hh, hw, -hh + cr); - shape.lineTo(hw, hh - cr); - shape.quadraticCurveTo(hw, hh, hw - cr, hh); - shape.lineTo(-hw + cr, hh); - shape.quadraticCurveTo(-hw, hh, -hw, hh - cr); - shape.lineTo(-hw, -hh + cr); - shape.quadraticCurveTo(-hw, -hh, -hw + cr, -hh); - return new THREE.ShapeGeometry(shape); - } - - refreshCutouts(cutouts, encData, dpi, minX, maxY) { - this._disposeCutoutViz(); - if (!cutouts || cutouts.length === 0 || !encData) return; - - this.storeEnclosureContext(encData, dpi, minX, maxY); - const s = this._s; - - this._cutoutVizGroup = new THREE.Group(); - this._cutoutVizMeshes = []; - - const cl = encData.clearance; - const wt = encData.wallThickness; - const trayFloor = encData.trayFloor; - const pcbT = encData.pcbThickness; - const totalH = encData.totalH; - const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; - - for (const c of cutouts) { - let mesh; - const color = c.isDado ? 0xf9e2af : 0xa6e3a1; - const cr = (c.r || 0) * s; // corner radius in pixel-space - - if (c.surface === 'top') { - const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); - const mat = new THREE.MeshBasicMaterial({ - color, transparent: true, opacity: 0.6, - side: THREE.DoubleSide, depthWrite: false, - }); - mesh = new THREE.Mesh(geo, mat); - const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); - mesh.position.set(px, py, encZ + totalH * s + 0.5); - - } else if (c.surface === 'bottom') { - const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); - const mat = new THREE.MeshBasicMaterial({ - color, transparent: true, opacity: 0.6, - side: THREE.DoubleSide, depthWrite: false, - }); - mesh = new THREE.Mesh(geo, mat); - const [px, py] = this._toPixel(c.x + c.w / 2, c.y + c.h / 2); - mesh.position.set(px, py, encZ - 0.5); - - } else if (c.surface === 'side') { - const side = encData.sides.find(sd => sd.num === c.sideNum); - if (!side) continue; - - const geo = this._makeCutoutGeo(c.w * s, c.h * s, cr); - const mat = new THREE.MeshBasicMaterial({ - color, transparent: true, opacity: 0.6, - side: THREE.DoubleSide, depthWrite: false, - }); - mesh = new THREE.Mesh(geo, mat); - - // Position along the side edge - const sdx = side.endX - side.startX; - const sdy = side.endY - side.startY; - const sLen = Math.sqrt(sdx * sdx + sdy * sdy); - const ux = sdx / sLen, uy = sdy / sLen; - const midAlongSide = c.x + c.w / 2; - const mmX = side.startX + ux * midAlongSide; - const mmY = side.startY + uy * midAlongSide; - const [px, py] = this._toPixel(mmX, mmY); - - // Z = trayFloor + pcbT + y + h/2 - const zMM = trayFloor + pcbT + c.y + c.h / 2; - - // Offset outward to wall exterior - const offset = (cl + wt) * s; - const nx = Math.cos(side.angle); - const ny = -Math.sin(side.angle); - - mesh.position.set( - px + nx * offset, - py + ny * offset, - encZ + zMM * s - ); - - // Rotate to face outward along the wall - const wallAngle = Math.atan2( - this._toPixel(side.endX, side.endY)[1] - this._toPixel(side.startX, side.startY)[1], - this._toPixel(side.endX, side.endY)[0] - this._toPixel(side.startX, side.startY)[0] - ); - mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); - } - - if (mesh) { - mesh.userData.cutoutId = c.id; - mesh.userData.cutout = c; - this._cutoutVizGroup.add(mesh); - this._cutoutVizMeshes.push(mesh); - } - } - - this.scene.add(this._cutoutVizGroup); - } - - _disposeCutoutViz() { - if (this._cutoutVizGroup) { - this.scene.remove(this._cutoutVizGroup); - this._cutoutVizGroup.traverse(c => { - if (c.geometry) c.geometry.dispose(); - if (c.material) c.material.dispose(); - }); - this._cutoutVizGroup = null; - } - this._cutoutVizMeshes = []; - } - - // ===== Side Placement Mode ===== - // Enter mode where ghost cutouts follow mouse on a side wall. - // Returns a promise that resolves with array of {y} positions when user clicks to place. - enterSidePlacementMode(projectedCutouts, sideNum) { - return new Promise((resolve) => { - if (!this._encData) { resolve(null); return; } - const side = this._encData.sides.find(sd => sd.num === sideNum); - if (!side) { resolve(null); return; } - - const s = this._s; - const cl = this._encData.clearance; - const wt = this._encData.wallThickness; - const trayFloor = this._encData.trayFloor; - const pcbT = this._encData.pcbThickness; - const totalH = this._encData.totalH; - const encZ = this.enclosureLayerIndex >= 0 ? this.enclosureLayerIndex * Z_SPACING : 0; - - // Compute wall geometry - const [startPx, startPy] = this._toPixel(side.startX, side.startY); - const [endPx, endPy] = this._toPixel(side.endX, side.endY); - const wallDx = endPx - startPx; - const wallDy = endPy - startPy; - const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); - const wallAngle = Math.atan2(wallDy, wallDx); - - const nx = Math.cos(side.angle); - const ny = -Math.sin(side.angle); - const offset = (cl + wt) * s; - - // Move camera to face the side - this.lookAtSide(sideNum); - this.highlightSide(sideNum); - - // Create invisible raycast plane covering the side wall - const planeW = wallLen * 2; - const planeH = totalH * s * 2; - const planeGeo = new THREE.PlaneGeometry(planeW, planeH); - const planeMat = new THREE.MeshBasicMaterial({ visible: false, side: THREE.DoubleSide }); - const planeMesh = new THREE.Mesh(planeGeo, planeMat); - const midX = (startPx + endPx) / 2 + nx * offset; - const midY = (startPy + endPy) / 2 + ny * offset; - planeMesh.position.set(midX, midY, encZ + totalH * s / 2); - planeMesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); - this.scene.add(planeMesh); - - // Create ghost meshes - const ghostMeshes = []; - for (const pc of projectedCutouts) { - const geo = new THREE.PlaneGeometry(pc.width * s, pc.height * s); - const mat = new THREE.MeshBasicMaterial({ - color: 0xa6e3a1, transparent: true, opacity: 0.5, - side: THREE.DoubleSide, depthWrite: false, - }); - const mesh = new THREE.Mesh(geo, mat); - mesh.quaternion.setFromEuler(new THREE.Euler(Math.PI / 2, wallAngle, 0, 'ZYX')); - - // Position along wall at projected X - const ux = wallDx / wallLen, uy = wallDy / wallLen; - const t = pc.x + pc.width / 2; // center along side in mm - const mmX = side.startX + (ux / s) * t * s; // hmm, just use side coords - const posX = side.startX + (side.endX - side.startX) * (t / side.length); - const posY = side.startY + (side.endY - side.startY) * (t / side.length); - const [px, py] = this._toPixel(posX, posY); - - mesh.position.set(px + nx * offset, py + ny * offset, encZ + totalH * s / 2); - mesh.userData = { projectedCutout: pc }; - this.scene.add(mesh); - ghostMeshes.push(mesh); - } - - // Default Y (mm above PCB) = wall center - const wallHeight = totalH - trayFloor - pcbT; - let currentYMM = wallHeight / 2; - - const updateGhostZ = (zMM) => { - for (const gm of ghostMeshes) { - const fullZ = trayFloor + pcbT + zMM; - gm.position.z = encZ + fullZ * s; - } - }; - updateGhostZ(currentYMM); - - // Mouse move handler: update Z from raycast - const moveHandler = (e) => { - this._updateMouse(e); - this.raycaster.setFromCamera(this.mouse, this.camera); - const hits = this.raycaster.intersectObject(planeMesh); - if (hits.length > 0) { - const hitZ = hits[0].point.z; - // Convert back to mm - const zMM = (hitZ - encZ) / s; - // Y above PCB - const yAbovePCB = zMM - trayFloor - pcbT; - const maxH = Math.max(...projectedCutouts.map(pc => pc.height)); - const clamped = Math.max(0, Math.min(wallHeight - maxH, yAbovePCB - maxH / 2)); - currentYMM = clamped; - updateGhostZ(currentYMM); - } - }; - - const canvas = this.renderer.domElement; - - // Click handler: place and resolve - const clickHandler = (e) => { - cleanup(); - const results = projectedCutouts.map(pc => ({ - x: pc.x, - y: currentYMM, - width: pc.width, - height: pc.height, - })); - resolve(results); - }; - - // Escape handler: cancel - const escHandler = (e) => { - if (e.key === 'Escape') { - cleanup(); - resolve(null); - } - }; - - const cleanup = () => { - canvas.removeEventListener('mousemove', moveHandler); - canvas.removeEventListener('click', clickHandler); - document.removeEventListener('keydown', escHandler); - this.scene.remove(planeMesh); - planeGeo.dispose(); - planeMat.dispose(); - for (const gm of ghostMeshes) { - this.scene.remove(gm); - gm.geometry.dispose(); - gm.material.dispose(); - } - this.clearSideHighlight(); - }; - - canvas.addEventListener('mousemove', moveHandler); - canvas.addEventListener('click', clickHandler); - document.addEventListener('keydown', escHandler); - }); - } - - // Callbacks - onLayerSelect(cb) { this._onLayerSelect = cb; } - onCutoutSelect(cb) { this._onCutoutSelect = cb; } - onCutoutHover(cb) { this._onCutoutHover = cb; } - - // Load real SCAD-rendered binary STL into the viewer, replacing the approximate geometry. - // enclosureArrayBuffer and trayArrayBuffer are ArrayBuffer of binary STL data. - // The STL is in mm, centered at origin by the SCAD translate([-centerX, -centerY, 0]). - // We need to convert from mm to pixel-space to match the existing viewer coordinate system. - loadRenderedSTL(enclosureArrayBuffer, trayArrayBuffer) { - dbg('loadRenderedSTL: called, encBuf=', enclosureArrayBuffer?.byteLength || 0, 'trayBuf=', trayArrayBuffer?.byteLength || 0); - this._disposeEnclosureMeshes(); - - const loader = new STLLoader(); - const s = (this._dpi || 600) / 25.4; // mm to pixel-space scale - const minX = this._minX || 0; - const maxY = this._maxY || 0; - dbg('loadRenderedSTL: scale=', s, 'minX=', minX, 'maxY=', maxY); - dbg('loadRenderedSTL: _maxW=', this._maxW, '_maxH=', this._maxH); - - // The SCAD output is centered at origin via translate([-centerX, -centerY, 0]). - // centerX/Y are the midpoint of the outline bounds in mm. - // In our viewer, pixel coords are: px = (mmX - minX) * s, py = -(maxY - mmY) * s - // At origin (0,0) in SCAD space, the mm coords were (centerX, centerY). - // So we offset the STL group to place it at the correct pixel position. - const encData = this._encData; - let centerX = 0, centerY = 0; - if (encData) { - const bounds = { - minX: minX, - maxX: minX + (this._maxW || 500) / s, - minY: maxY - (this._maxH || 500) / s, - maxY: maxY, - }; - dbg('loadRenderedSTL: computed bounds=', JSON.stringify(bounds)); - // The SCAD code uses: translate([-centerX, -centerY, 0]) - // where centerX/Y come from cfg.OutlineBounds - // These are the same bounds we have stored. Let's compute them. - if (encData.outlinePoints && encData.outlinePoints.length > 0) { - let bminX = Infinity, bmaxX = -Infinity, bminY = Infinity, bmaxY = -Infinity; - for (const p of encData.outlinePoints) { - if (p[0] < bminX) bminX = p[0]; - if (p[0] > bmaxX) bmaxX = p[0]; - if (p[1] < bminY) bminY = p[1]; - if (p[1] > bmaxY) bmaxY = p[1]; - } - dbg('loadRenderedSTL: outline bbox (mm):', bminX, bminY, bmaxX, bmaxY); - // The SCAD OutlineBounds include margin. But the centering in SCAD uses - // cfg.OutlineBounds which IS the session OutlineBounds (with margin). - // The viewer's minX and maxY ARE from those same bounds. - centerX = (minX + (minX + (this._maxW || 500) / s)) / 2; - centerY = (maxY + (maxY - (this._maxH || 500) / s)) / 2; - dbg('loadRenderedSTL: centerX=', centerX, 'centerY=', centerY); - } - } else { - dbg('loadRenderedSTL: WARNING no encData stored'); - } - - // Position in pixel-space where the SCAD origin maps to - const originPx = (0 + centerX - minX) * s; - const originPy = -(maxY - (0 + centerY)) * s; - dbg('loadRenderedSTL: originPx=', originPx, 'originPy=', originPy); - - const createMeshFromSTL = (arrayBuffer, color, label) => { - dbg(`loadRenderedSTL: parsing ${label} STL (${arrayBuffer.byteLength} bytes)...`); - const geometry = loader.parse(arrayBuffer); - dbg(`loadRenderedSTL: ${label} parsed, vertices=${geometry.attributes.position?.count || 0}`); - // Scale from mm to pixel-space and flip Y - geometry.scale(s, s, s); - // Flip the Y axis: SCAD Y+ is up, viewer Y is inverted (image space) - geometry.scale(1, -1, 1); - // The Y-mirror reverses triangle winding, making normals point inward. - // Flip winding order so FrontSide renders the outside correctly. - const pos = geometry.attributes.position.array; - for (let i = 0; i < pos.length; i += 9) { - // Swap vertices 1 and 2 of each triangle - for (let j = 0; j < 3; j++) { - const tmp = pos[i + 3 + j]; - pos[i + 3 + j] = pos[i + 6 + j]; - pos[i + 6 + j] = tmp; - } - } - geometry.attributes.position.needsUpdate = true; - geometry.computeBoundingBox(); - dbg(`loadRenderedSTL: ${label} bbox after scale:`, geometry.boundingBox?.min?.toArray(), geometry.boundingBox?.max?.toArray()); - // Use MeshStandardMaterial for proper solid rendering — no polygon offset - // needed since this is real CSG geometry (no coplanar faces to fight). - geometry.computeVertexNormals(); - const material = new THREE.MeshStandardMaterial({ - color, - roughness: 0.6, - metalness: 0.0, - side: THREE.FrontSide, - flatShading: true, - }); - const mesh = new THREE.Mesh(geometry, material); - // Position at the pixel-space location of the SCAD origin - mesh.position.set(originPx, originPy, 0); - dbg(`loadRenderedSTL: ${label} mesh positioned at (${originPx}, ${originPy}, 0)`); - return mesh; - }; - - // Enclosure - if (enclosureArrayBuffer && enclosureArrayBuffer.byteLength > 84) { - dbg('loadRenderedSTL: creating enclosure mesh, layerIndex=', this.enclosureLayerIndex); - const encGroup = new THREE.Group(); - const encMesh = createMeshFromSTL(enclosureArrayBuffer, 0xffe090, 'enclosure'); - encGroup.add(encMesh); - - 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); - dbg('loadRenderedSTL: enclosure added to scene at z=', encGroup.position.z); - } else { - dbg('loadRenderedSTL: WARNING enclosureLayerIndex < 0, enclosure not added'); - } - } else { - dbg('loadRenderedSTL: skipping enclosure (no data or too small)'); - } - - // Tray - if (trayArrayBuffer && trayArrayBuffer.byteLength > 84) { - dbg('loadRenderedSTL: creating tray mesh, layerIndex=', this.trayLayerIndex); - const trayGroup = new THREE.Group(); - const trayMesh = createMeshFromSTL(trayArrayBuffer, 0xa0d880, 'tray'); - trayGroup.add(trayMesh); - - 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); - dbg('loadRenderedSTL: tray added to scene at z=', trayGroup.position.z); - } else { - dbg('loadRenderedSTL: WARNING trayLayerIndex < 0, tray not added'); - } - } else { - dbg('loadRenderedSTL: skipping tray (no data or too small)'); - } - - this._renderedSTLLoaded = true; - dbg('loadRenderedSTL: complete'); - } - - setControlScheme(traditional) { - this._traditionalControls = traditional; - if (traditional) { - this.controls.mouseButtons = { - LEFT: THREE.MOUSE.ROTATE, - MIDDLE: THREE.MOUSE.PAN, - RIGHT: THREE.MOUSE.PAN - }; - } else { - this.controls.mouseButtons = { - LEFT: THREE.MOUSE.ROTATE, - MIDDLE: THREE.MOUSE.PAN, - RIGHT: THREE.MOUSE.DOLLY - }; - } - } - - dispose() { - if (this._ctrlDragCleanup) this._ctrlDragCleanup(); - if (this._animId) cancelAnimationFrame(this._animId); - if (this._resizeObserver) this._resizeObserver.disconnect(); - if (this._rectOverlay) { - this._rectOverlay.remove(); - this._rectOverlay = null; - } - this.clearSideHighlight(); - this._disposeCutoutViz(); - this.controls.dispose(); - this.renderer.dispose(); - if (this.renderer.domElement.parentNode) { - this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); - } - } + // ===== Engine delegates ===== + + get layers() { return this.engine.layers; } + get layerMeshes() { return this.engine.layerMeshes; } + get selectedLayerIndex() { return this.engine.selectedLayerIndex; } + get gridHelper() { return this.engine.gridHelper; } + get gridVisible() { return this.engine.gridVisible; } + set gridVisible(v) { this.engine.gridVisible = v; } + + loadLayers(...args) { return this.engine.loadLayers(...args); } + setLayerVisibility(...args) { return this.engine.setLayerVisibility(...args); } + setLayerHighlight(...args) { return this.engine.setLayerHighlight(...args); } + selectLayer(...args) { return this.engine.selectLayer(...args); } + moveSelectedZ(...args) { return this.engine.moveSelectedZ(...args); } + toggleGrid() { return this.engine.toggleGrid(); } + homeTopDown(...args) { return this.engine.homeTopDown(...args); } + resetView() { return this.engine.resetView(); } + setControlScheme(...args) { return this.engine.setControlScheme(...args); } + + onLayerSelect(cb) { this.engine.onLayerSelect(cb); } + onCutoutSelect(cb) { this.engine.onCutoutSelect(cb); } + onCutoutHover(cb) { this.engine.onCutoutHover(cb); } + onPlacedCutoutSelect(cb) { this.engine.onPlacedCutoutSelect(cb); } + + // ===== Enclosure mode delegates ===== + + get enclosureMesh() { return this._enc.enclosureMesh; } + get trayMesh() { return this._enc.trayMesh; } + + loadEnclosureGeometry(...args) { return this._enc.loadEnclosureGeometry(...args); } + enterSolidView() { return this._enc.enterSolidView(); } + exitSolidView() { return this._enc.exitSolidView(); } + loadRenderedSTL(...args) { return this._enc.loadRenderedSTL(...args); } + highlightSide(...args) { return this._enc.highlightSide(...args); } + clearSideHighlight() { return this._enc.clearSideHighlight(); } + lookAtSide(...args) { return this._enc.lookAtSide(...args); } + refreshCutouts(...args) { return this._enc.refreshCutouts(...args); } + storeEnclosureContext(...args) { return this._enc.storeEnclosureContext(...args); } + getSelectedCutouts() { return this._enc.getSelectedCutouts(); } + get selectedCutoutIds() { return this._enc.selectedCutoutIds; } + set selectedCutoutIds(v) { this._enc.selectedCutoutIds = v; } + _deselectAllCutouts() { return this._enc._deselectAllCutouts(); } + + // ===== Cutout mode delegates ===== + + get cutoutMode() { return this._cut.cutoutMode; } + get isDadoMode() { return this._cut.isDadoMode; } + get cutouts() { return this._cut.cutouts; } + set cutouts(v) { this._cut.cutouts = v; } + + enterCutoutMode(...args) { return this._cut.enterCutoutMode(...args); } + exitCutoutMode() { return this._cut.exitCutoutMode(); } + enterSidePlacementMode(...args) { return this._cut.enterSidePlacementMode(...args); } + + // ===== Vector wrap mode delegates ===== + + get _externalModelMesh() { return this._vw._externalModelMesh; } + get _projEncGroup() { return this._vw._projEncGroup; } + get _projEncLidMesh() { return this._vw._projEncLidMesh; } + get _projEncTrayMesh() { return this._vw._projEncTrayMesh; } + get _vectorOverlayMesh() { return this._vw._vectorOverlayMesh; } + get _vwGroup() { return this._vw._vwGroup; } + get _ffdGridVisible() { return this._vw._ffdGridVisible; } + + loadExternalModel(...args) { return this._vw.loadExternalModel(...args); } + loadProjectEnclosure(...args) { return this._vw.loadProjectEnclosure(...args); } + toggleProjectEnclosurePart(...args) { return this._vw.toggleProjectEnclosurePart(...args); } + toggleProjectEnclosureAssembly() { return this._vw.toggleProjectEnclosureAssembly(); } + loadVectorOverlay(...args) { return this._vw.loadVectorOverlay(...args); } + setFFDGridVisible(...args) { return this._vw.setFFDGridVisible(...args); } + resetFFDGrid() { return this._vw.resetFFDGrid(); } + setFFDResolution(...args) { return this._vw.setFFDResolution(...args); } + getFFDState() { return this._vw.getFFDState(); } + setFFDState(...args) { return this._vw.setFFDState(...args); } + setVWTranslation(...args) { return this._vw.setVWTranslation(...args); } + setVWRotation(...args) { return this._vw.setVWRotation(...args); } + setVWScale(...args) { return this._vw.setVWScale(...args); } + setVWCameraLock(...args) { return this._vw.setVWCameraLock(...args); } + + // ===== Mode accessors ===== + + get _enc() { return this.engine.getMode('enclosure'); } + get _cut() { return this.engine.getMode('cutout'); } + get _vw() { return this.engine.getMode('vectorwrap'); } + + dispose() { this.engine.dispose(); } } diff --git a/frontend/src/main.js b/frontend/src/main.js index c62be46..549f668 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,6 +2,10 @@ import './style.css'; import { Former3D } from './former3d.js'; import { OpenSCADService } from './openscad-service.js'; +import { buildEnclosureSidebar } from './ui/enclosure-ui.js'; +import { buildVectorWrapSidebar } from './ui/vectorwrap-ui.js'; +import { buildStructuralSidebar } from './ui/structural-ui.js'; +import { buildUnwrapSidebar } from './ui/unwrap-ui.js'; let openscadService = null; @@ -25,10 +29,14 @@ function wails() { return window.go?.main?.App; } // ===== State ===== const state = { + project: null, stencil: { gerberPath: '', outlinePath: '' }, enclosure: { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' }, preview: { activeSide: 0, sessionInfo: null, cutouts: [], boardRect: null }, former: { layers: [], images: {}, scale: 1, offsetX: 0, offsetY: 0, cutoutType: 'cutout', dadoDepth: 0.5 }, + vectorwrap: { svgPath: '', modelPath: '', modelType: '' }, + structural: { svgPath: '', pattern: 'hexagon', cellSize: 10, wallThick: 1.2, height: 20 }, + scanhelper: { pageW: 210, pageH: 297, gridSpacing: 10, pagesWide: 1, pagesTall: 1, dpi: 300 }, }; // ===== Loading ===== @@ -59,18 +67,41 @@ function navigate(page) { const el = $(`#page-${page}`); if (el) show(el); + // Show/hide project-aware nav elements + const modeTabs = $('#nav-mode-tabs'); + const projectName = $('#nav-project-name'); + const openOutput = $('#nav-open-output'); + if (state.project && page !== 'landing') { + if (modeTabs) modeTabs.style.display = ''; + if (projectName) { + projectName.style.display = ''; + projectName.textContent = state.project.name; + } + if (openOutput) openOutput.style.display = ''; + } else { + if (modeTabs) modeTabs.style.display = 'none'; + if (projectName) projectName.style.display = 'none'; + if (page === 'landing' && openOutput) openOutput.style.display = 'none'; + } + // Initialize page content switch (page) { case 'landing': initLanding(); break; + case 'dashboard': initDashboard(); break; case 'stencil': initStencil(); break; case 'enclosure': initEnclosure(); break; case 'preview': initPreview(); break; case 'former': initFormer(); break; + case 'vectorwrap': initVectorWrap(); break; + case 'structural': initStructural(); break; + case 'scanhelper': initScanHelper(); break; + case 'unwrap': initUnwrap(); break; } } // ===== Landing Page ===== async function initLanding() { + state.project = null; const page = $('#page-landing'); const projects = await wails()?.GetRecentProjects() || []; const logoSrc = await wails()?.GetLogoSVGDataURL() || ''; @@ -80,13 +111,20 @@ async function initLanding() { projectsHTML = `
Recent Projects
- ${projects.map(p => ` + ${projects.map(p => { + const modes = []; + if (p.hasEnclosure) modes.push('Enclosure'); + if (p.hasStencil) modes.push('Stencil'); + if (p.hasVectorWrap) modes.push('Vector Wrap'); + if (p.hasStructural) modes.push('Structural'); + if (p.hasScanHelper) modes.push('Scan Helper'); + const modeStr = modes.length > 0 ? modes.join(', ') : 'No modes configured'; + return `
${esc(p.name)} - ${p.type} - ${p.boardW > 0 ? p.boardW.toFixed(1) + ' × ' + p.boardH.toFixed(1) + ' mm' : ''} -
- `).join('')} + ${esc(modeStr)} +
`; + }).join('')} `; } @@ -94,23 +132,99 @@ async function initLanding() { page.innerHTML = `

Former

-

PCB Stencil & Enclosure Generator

+

PCB Stencil, Enclosure & 3D Form Generator

${logoSrc ? `
` : ''}
-
-

New Stencil

-

Generate solder paste stencils from gerber files

+
+

New Project

+

Create a new .former project folder

-
-

New Enclosure

-

Generate PCB enclosures from KiCad projects

+
+

Open Project

+

Open an existing .former project

${projectsHTML} `; } +// ===== Dashboard Page ===== +async function initDashboard() { + const page = $('#page-dashboard'); + const info = await wails()?.GetProjectInfo(); + if (!info) { + navigate('landing'); + return; + } + + const modeCards = [ + { id: 'stencil', label: 'Stencil', desc: 'Solder paste stencils from gerber files', active: info.hasStencil }, + { id: 'enclosure', label: 'Enclosure', desc: 'PCB enclosures from KiCad projects', active: info.hasEnclosure }, + { id: 'vectorwrap', label: 'Vector Wrap', desc: 'Wrap 2D vector art onto 3D surfaces', active: info.hasVectorWrap }, + { id: 'structural', label: 'Structural Fill', desc: 'Pattern-infilled 3D forms from outlines', active: info.hasStructural }, + { id: 'scanhelper', label: 'Scan Helper', desc: 'Calibration grids for scanning objects', active: info.hasScanHelper }, + { id: 'unwrap', label: 'Unwrap', desc: 'Flat SVG template of the unfolded enclosure surface', active: info.hasEnclosure }, + ]; + + page.innerHTML = ` + +
+ ${modeCards.map(m => ` +
+

${m.label}

+

${m.desc}

+ ${m.active ? 'Configured' : ''} +
+ `).join('')} +
+ `; +} + +// ===== Project Lifecycle ===== +async function createNewProject() { + const name = await showInputDialog('New Project', 'Project name:', 'My Project'); + if (name === null) return; + try { + const path = await wails()?.CreateNewProject(name || 'Untitled'); + if (!path) return; + const info = await wails()?.GetProjectInfo(); + if (info) { + state.project = { name: info.name, path: info.path }; + navigate('dashboard'); + } + } catch (e) { + alert('Failed to create project: ' + e); + } +} + +async function openProjectDialog() { + try { + const path = await wails()?.OpenProjectDialog(); + if (!path) return; + const info = await wails()?.GetProjectInfo(); + if (info) { + state.project = { name: info.name, path: info.path }; + navigate('dashboard'); + } + } catch (e) { + alert('Failed to open project: ' + e); + } +} + +async function closeProject() { + try { + await wails()?.CloseProject(); + } catch (_) {} + state.project = null; + navigate('landing'); +} + // ===== Stencil Page ===== function initStencil() { const page = $('#page-stencil'); @@ -118,7 +232,7 @@ function initStencil() { page.innerHTML = `
@@ -259,7 +373,7 @@ function initEnclosure() { page.innerHTML = `
@@ -543,7 +657,6 @@ async function initPreview() {
-
@@ -626,17 +739,6 @@ async function removeCutout(index) { } catch (e) { console.error(e); } } -async function saveProfile() { - const name = prompt('Profile name:'); - if (name === null) return; - try { - await wails().SaveEnclosureProfile(name || ''); - alert('Profile saved!'); - } catch (e) { - alert('Save failed: ' + e); - } -} - async function generateEnclosure() { showLoading('Generating enclosure...'); try { @@ -676,18 +778,553 @@ async function openProject(path) { try { await wails().OpenProject(path); hideLoading(); - navigate('preview'); + const info = await wails()?.GetProjectInfo(); + if (info) { + state.project = { name: info.name, path: info.path }; + navigate('dashboard'); + } } catch (e) { hideLoading(); alert('Failed to open project: ' + e); } } +// ===== Vector Wrap Page ===== +async function initVectorWrap() { + const page = $('#page-vectorwrap'); + state.vectorwrap = { svgPath: '', modelPath: '', modelType: '' }; + + page.innerHTML = ` + +
+
SVG Vector Art
+

Select an SVG file to wrap onto a 3D surface. Inkscape plain SVG 1.1 recommended.

+
+ No file selected + +
+ + +
+
+
3D Model
+ +
+

Select an STL or OpenSCAD file for the target surface.

+
+ No file selected + +
+
+
+
+
+ +
+ `; + + try { + const vwCheck = await wails()?.GetVectorWrapInfo(); + if (vwCheck?.hasProjectEnclosure) { + const label = $('#vw-use-project-label'); + if (label) label.style.display = 'block'; + } + } catch (_) {} +} + +async function selectVectorWrapSVG() { + try { + const path = await wails().SelectFile('Select SVG File', '*.svg'); + if (!path) return; + state.vectorwrap.svgPath = path; + $('#vw-svg-name').textContent = path.split('/').pop(); + $('#vw-svg-name').classList.add('has-file'); + + showLoading('Parsing SVG...'); + const result = await wails().ImportSVGForVectorWrap(path); + hideLoading(); + + const infoEl = $('#vw-svg-info'); + if (infoEl && result) { + infoEl.style.display = 'block'; + const dims = result.width > 0 ? `${result.width.toFixed(1)} × ${result.height.toFixed(1)} mm` : 'dimensions unknown'; + infoEl.textContent = `${dims} | ${result.elements} elements | ${result.layers} layers`; + if (result.layerNames && result.layerNames.length > 0) { + infoEl.textContent += ` (${result.layerNames.join(', ')})`; + } + } + + const warnEl = $('#vw-svg-warnings'); + if (warnEl && result?.warnings?.length > 0) { + warnEl.style.display = 'block'; + warnEl.innerHTML = result.warnings.map(w => + `
⚠ ${esc(w)}
` + ).join(''); + } else if (warnEl) { + warnEl.style.display = 'none'; + } + + updateVWOpenBtn(); + } catch (e) { + hideLoading(); + console.error(e); + alert('SVG import failed: ' + e); + } +} + +async function selectVectorWrapModel() { + try { + const path = await wails().SelectFile('Select 3D Model', '*.stl;*.scad'); + if (!path) return; + + // Uncheck project enclosure if it was active + const cb = $('#vw-use-project-cb'); + if (cb?.checked) { + cb.checked = false; + toggleUseProjectEnclosure(false); + } + + state.vectorwrap.modelPath = path; + state.vectorwrap.modelType = path.toLowerCase().endsWith('.scad') ? 'scad' : 'stl'; + $('#vw-model-name').textContent = path.split('/').pop(); + $('#vw-model-name').classList.add('has-file'); + + showLoading('Importing model...'); + await wails().ImportModelForVectorWrap(path); + hideLoading(); + + updateVWOpenBtn(); + } catch (e) { + hideLoading(); + console.error(e); + alert('Model import failed: ' + e); + } +} + +async function toggleUseProjectEnclosure(checked) { + dbg('toggleUseProjectEnclosure: checked=', checked); + const card = $('#vw-model-card'); + const body = $('#vw-model-body'); + dbg('toggleUseProjectEnclosure: card=', !!card, 'body=', !!body); + if (checked) { + try { + showLoading('Loading project enclosure...'); + dbg('toggleUseProjectEnclosure: calling UseProjectEnclosureForVectorWrap...'); + await wails().UseProjectEnclosureForVectorWrap(); + dbg('toggleUseProjectEnclosure: backend call succeeded'); + hideLoading(); + state.vectorwrap.modelPath = 'project-enclosure'; + state.vectorwrap.modelType = 'project-enclosure'; + if (body) { + body.style.opacity = '0.35'; + body.style.pointerEvents = 'none'; + body.style.textDecoration = 'line-through'; + body.style.textDecorationColor = 'var(--success)'; + } + if (card) card.style.background = 'color-mix(in srgb, var(--bg-surface) 70%, black)'; + const label = $('#vw-use-project-label'); + if (label) label.style.color = 'var(--success)'; + dbg('toggleUseProjectEnclosure: state set, modelPath=', state.vectorwrap.modelPath, 'svgPath=', state.vectorwrap.svgPath); + } catch (e) { + dbg('toggleUseProjectEnclosure: FAILED:', e); + hideLoading(); + const cb = $('#vw-use-project-cb'); + if (cb) cb.checked = false; + alert('Failed to load project enclosure: ' + e); + return; + } + } else { + dbg('toggleUseProjectEnclosure: unchecking, clearing model'); + state.vectorwrap.modelPath = ''; + state.vectorwrap.modelType = ''; + if (body) { + body.style.opacity = ''; + body.style.pointerEvents = ''; + body.style.textDecoration = ''; + body.style.textDecorationColor = ''; + } + if (card) card.style.background = ''; + const label = $('#vw-use-project-label'); + if (label) label.style.color = 'var(--text-secondary)'; + const nameEl = $('#vw-model-name'); + if (nameEl) { + nameEl.textContent = 'No file selected'; + nameEl.classList.remove('has-file'); + } + } + dbg('toggleUseProjectEnclosure: calling updateVWOpenBtn, svgPath=', state.vectorwrap.svgPath, 'modelPath=', state.vectorwrap.modelPath); + updateVWOpenBtn(); +} + +function updateVWOpenBtn() { + const btn = $('#vw-open-btn'); + const enabled = !!(state.vectorwrap.svgPath && state.vectorwrap.modelPath); + dbg('updateVWOpenBtn: svgPath=', state.vectorwrap.svgPath, 'modelPath=', state.vectorwrap.modelPath, 'enabled=', enabled); + if (btn) btn.disabled = !enabled; +} + +async function openVectorWrapFormer() { + if (!state.vectorwrap.svgPath || !state.vectorwrap.modelPath) return; + navigate('former'); +} + +function toggleVWLid() { + if (!former3d) return; + const visible = former3d.toggleProjectEnclosurePart('lid'); + const btn = $('#vw-toggle-lid'); + if (btn) btn.textContent = visible ? 'Hide Lid' : 'Show Lid'; +} + +function toggleVWTray() { + if (!former3d) return; + const visible = former3d.toggleProjectEnclosurePart('tray'); + const btn = $('#vw-toggle-tray'); + if (btn) btn.textContent = visible ? 'Hide Tray' : 'Show Tray'; +} + +function toggleVWAssembly() { + if (!former3d) return; + const assembled = former3d.toggleProjectEnclosureAssembly(); + const btn = $('#vw-toggle-assembly'); + if (btn) btn.textContent = assembled ? 'Take Off Lid' : 'Put Lid On'; +} + +// ===== Unwrap Page ===== +let _unwrapSVG = ''; + +async function initUnwrap() { + const page = $('#page-unwrap'); + _unwrapSVG = ''; + + page.innerHTML = ` + +
+
+
Click "Generate Template" to create the unwrap template.
+
+
+ ${buildUnwrapSidebar()} +
+
+ `; +} + +async function generateUnwrap() { + try { + showLoading('Generating unwrap template...'); + const svg = await wails()?.GetUnwrapSVG(); + hideLoading(); + if (!svg) { + alert('No enclosure session active. Set up an enclosure first.'); + return; + } + _unwrapSVG = svg; + const preview = $('#unwrap-preview'); + if (preview) { + preview.innerHTML = svg; + const svgEl = preview.querySelector('svg'); + if (svgEl) { + svgEl.style.width = '100%'; + svgEl.style.height = '100%'; + svgEl.style.maxHeight = '100%'; + } + } + const exportBtn = $('#unwrap-export-btn'); + if (exportBtn) exportBtn.disabled = false; + } catch (e) { + hideLoading(); + alert('Failed to generate unwrap: ' + e); + } +} + +function exportUnwrapSVG() { + if (!_unwrapSVG) return; + const blob = new Blob([_unwrapSVG], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'enclosure-unwrap.svg'; + a.click(); + URL.revokeObjectURL(url); +} + +async function importUnwrapArtwork() { + try { + const path = await wails()?.SelectFile('Select Artwork SVG', '*.svg'); + if (!path) return; + showLoading('Importing artwork...'); + await wails()?.ImportUnwrapArtwork(path); + hideLoading(); + } catch (e) { + hideLoading(); + alert('Import failed: ' + e); + } +} + +function toggleUnwrapFolds(show) { + const lines = $$('#unwrap-preview .fold-line'); + lines.forEach(l => l.style.display = show ? '' : 'none'); +} + +function toggleUnwrapLabels(show) { + const labels = $$('#unwrap-preview .label'); + labels.forEach(l => l.style.display = show ? '' : 'none'); +} + +function toggleUnwrapCutouts(show) { + const cuts = $$('#unwrap-preview .cutout'); + cuts.forEach(c => c.style.display = show ? '' : 'none'); +} + +// ===== Vector Wrap Camera Lock ===== +function toggleVWCameraLock() { + if (!former3d) return; + state.vectorwrap.cameraLocked = !state.vectorwrap.cameraLocked; + former3d.setVWCameraLock(state.vectorwrap.cameraLocked); + const btn = $('#vw-lock-btn'); + if (btn) { + btn.classList.toggle('locked', state.vectorwrap.cameraLocked); + btn.title = state.vectorwrap.cameraLocked ? 'Unlock camera' : 'Lock camera for surface wrapping'; + } + const icon = $('#vw-lock-icon'); + if (icon) { + icon.textContent = state.vectorwrap.cameraLocked ? '\u{1F512}\uFE0E' : '\u{1F513}\uFE0E'; + } +} + +// ===== Structural Procedures Page ===== +function initStructural() { + const page = $('#page-structural'); + state.structural = { svgPath: '', pattern: 'hexagon', cellSize: 10, wallThick: 1.2, height: 20 }; + + page.innerHTML = ` + +
+
Edge-Cut Outline (SVG)
+

Select an SVG with the outer boundary shape. The outline will be filled with a structural pattern.

+
+ No file selected + +
+ +
+
+
Fill Pattern
+
+ Pattern + +
+
+ Cell Size (mm) + +
+
+ Wall Thickness (mm) + +
+
+ Extrusion Height (mm) + +
+
+ Outer Shell (mm) + +
+
+
+
+ +
+ `; +} + +async function selectStructuralSVG() { + try { + const path = await wails().SelectFile('Select Edge-Cut SVG', '*.svg'); + if (!path) return; + state.structural.svgPath = path; + $('#sp-svg-name').textContent = path.split('/').pop(); + $('#sp-svg-name').classList.add('has-file'); + + showLoading('Parsing SVG...'); + const result = await wails().ImportSVGForStructural(path); + hideLoading(); + + const infoEl = $('#sp-svg-info'); + if (infoEl && result) { + infoEl.style.display = 'block'; + const dims = result.width > 0 ? `${result.width.toFixed(1)} × ${result.height.toFixed(1)} mm` : 'dimensions unknown'; + infoEl.textContent = `${dims} | ${result.elements} elements`; + } + + const btn = $('#sp-gen-btn'); + if (btn) btn.disabled = false; + } catch (e) { + hideLoading(); + alert('SVG import failed: ' + e); + } +} + +async function generateStructural() { + if (!state.structural.svgPath) return; + try { + await wails().UpdateStructuralParams( + $('#sp-pattern')?.value || 'hexagon', + parseFloat($('#sp-cell-size')?.value) || 10, + parseFloat($('#sp-wall-thick')?.value) || 1.2, + parseFloat($('#sp-height')?.value) || 20, + parseFloat($('#sp-shell')?.value) || 1.6, + ); + navigate('former'); + } catch (e) { + alert('Failed: ' + e); + } +} + +// ===== Scan Helper Page ===== +function initScanHelper() { + const page = $('#page-scanhelper'); + const s = state.scanhelper; + + page.innerHTML = ` + +
+
Page Setup
+

Configure the calibration grid sheets. Print them, place or draw the object, then scan and import.

+
+ Page Size + +
+
+ Width (mm) + +
+
+ Height (mm) + +
+
+ Grid Spacing (mm) + +
+
+
+
Multi-Page Stitching
+

For objects larger than one page, print multiple sheets with alignment markers.

+
+ Pages Wide + +
+
+ Pages Tall + +
+
+ Scan DPI + +
+
+
+
+ +
+ `; +} + +function applyScanPagePreset(preset) { + switch (preset) { + case 'a4': + $('#sh-page-w').value = '210'; + $('#sh-page-h').value = '297'; + break; + case 'letter': + $('#sh-page-w').value = '215.9'; + $('#sh-page-h').value = '279.4'; + break; + case 'a3': + $('#sh-page-w').value = '297'; + $('#sh-page-h').value = '420'; + break; + } +} + +async function generateScanSheet() { + try { + await wails().UpdateScanHelperConfig( + parseFloat($('#sh-page-w')?.value) || 210, + parseFloat($('#sh-page-h')?.value) || 297, + parseFloat($('#sh-grid')?.value) || 10, + parseFloat($('#sh-dpi')?.value) || 300, + parseInt($('#sh-pages-w')?.value) || 1, + parseInt($('#sh-pages-t')?.value) || 1, + ); + showLoading('Generating grid sheets...'); + const files = await wails().GenerateScanGrid(); + hideLoading(); + if (files && files.length > 0) { + showScanResult(files); + } else { + alert('No files generated.'); + } + } catch (e) { + hideLoading(); + alert('Failed: ' + e); + } +} + +function showScanResult(files) { + const page = $('#page-scanhelper'); + page.innerHTML = ` + +
+
Generated Files
+
${files.map(f => esc(f)).join('\n')}
+
+
+
+ + +
+ `; +} + // ===== THE FORMER (3D) ===== let former3d = null; // Former3D instance async function initFormer() { const page = $('#page-former'); + const activeMode = await wails()?.GetActiveMode() || ''; + state.former.mode = activeMode; const layers = await wails()?.GetFormerLayers() || []; state.former.layers = layers; state.former.selectedLayer = -1; @@ -702,59 +1339,39 @@ async function initFormer() { state.former._escHandler = null; } + const modeTitle = { + enclosure: 'Enclosure', stencil: 'Stencil', + vectorwrap: 'Vector Wrap', structural: 'Structural Fill', + }[activeMode] || 'Former'; + + // Build sidebar content based on mode + let sidebarContent = ''; + if (activeMode === 'vectorwrap') { + sidebarContent = buildVectorWrapSidebar(modeTitle); + } else if (activeMode === 'structural') { + const sInfo = await wails()?.GetStructuralInfo(); + sidebarContent = buildStructuralSidebar(modeTitle, sInfo, esc); + } else { + sidebarContent = buildEnclosureSidebar(layers, esc); + } + + // Build floating action buttons per mode + let floatingActions = ''; + if (activeMode === 'enclosure' || activeMode === 'stencil' || activeMode === '') { + floatingActions += ''; + } else if (activeMode === 'structural') { + floatingActions += ''; + } + page.innerHTML = `
- - + ${floatingActions}
-
-

Layers

- -
-
- ${layers.map((l, i) => ` -
- - -
- ${esc(l.name)} -
- `).join('')} -
- - - - + ${sidebarContent}
`; @@ -877,6 +1494,81 @@ async function initFormer() { } catch (e) { console.warn('Could not load enclosure 3D data:', e); } + + // Vector Wrap session: load 3D model + SVG overlay + dbg('initFormer: fetching VW info...'); + try { + const vwInfo = await wails()?.GetVectorWrapInfo(); + dbg('initFormer: vwInfo=', JSON.stringify(vwInfo)); + if (vwInfo?.hasSession && former3d) { + dbg('initFormer: VW session active, modelType=', vwInfo.modelType); + if (vwInfo.modelType === 'stl') { + dbg('initFormer: loading STL model...'); + const stlBytes = await wails()?.GetVectorWrapModelSTL(); + if (stlBytes) { + former3d.loadExternalModel(base64ToArrayBuffer(stlBytes)); + } + } else if (vwInfo.modelType === 'scad') { + dbg('initFormer: loading SCAD model...'); + try { + const scadSrc = await wails()?.GetVectorWrapSCADSource(); + dbg('initFormer: scadSrc length=', scadSrc?.length || 0); + if (scadSrc && openscadService) { + showLoading('Compiling SCAD model...'); + if (!openscadService) openscadService = new OpenSCADService(); + const stlBuf = await openscadService.renderSCAD(scadSrc); + hideLoading(); + if (stlBuf) former3d.loadExternalModel(stlBuf); + } + } catch (scadErr) { + hideLoading(); + dbg('initFormer: SCAD compile failed:', scadErr?.message || scadErr); + } + } else if (vwInfo.modelType === 'project-enclosure') { + dbg('initFormer: project-enclosure mode, fetching SCAD...'); + try { + const scadData = await wails()?.GetVectorWrapProjectSCAD(); + dbg('initFormer: scadData=', scadData ? `enc=${scadData.enclosureSCAD?.length || 0}chars tray=${scadData.traySCAD?.length || 0}chars` : 'NULL'); + if (scadData?.enclosureSCAD) { + showLoading('Compiling project enclosure...'); + if (!openscadService) openscadService = new OpenSCADService(); + dbg('initFormer: dispatching WASM renders...'); + const t0 = performance.now(); + const [encBuf, trayBuf] = await Promise.all([ + openscadService.renderSCAD(scadData.enclosureSCAD), + scadData.traySCAD ? openscadService.renderSCAD(scadData.traySCAD) : Promise.resolve(null), + ]); + dbg('initFormer: WASM done in', (performance.now() - t0).toFixed(0), 'ms, encBuf=', encBuf?.byteLength || 0, 'trayBuf=', trayBuf?.byteLength || 0); + hideLoading(); + dbg('initFormer: calling loadProjectEnclosure...'); + former3d.loadProjectEnclosure(encBuf, trayBuf); + const partsPanel = $('#vw-enclosure-parts'); + dbg('initFormer: partsPanel found=', !!partsPanel); + if (partsPanel) partsPanel.style.display = ''; + dbg('initFormer: project enclosure loaded OK'); + } else { + dbg('initFormer: NO enclosureSCAD in scadData — skipping enclosure load'); + } + } catch (peErr) { + dbg('initFormer: project enclosure FAILED:', peErr?.message || peErr); + hideLoading(); + } + } else { + dbg('initFormer: unknown modelType=', vwInfo.modelType, '— no model loaded'); + } + // SVG overlay + dbg('initFormer: SVG overlay check, svgPath=', vwInfo.svgPath || 'NONE'); + if (vwInfo.svgPath) { + const svgUrl = `/api/vectorwrap-svg.png?t=${Date.now()}`; + dbg('initFormer: loading SVG overlay from', svgUrl, 'dims=', vwInfo.svgWidth, 'x', vwInfo.svgHeight); + former3d.loadVectorOverlay(svgUrl, vwInfo.svgWidth, vwInfo.svgHeight); + } + } else { + dbg('initFormer: VW skipped — hasSession=', vwInfo?.hasSession, 'former3d=', !!former3d); + } + } catch (e) { + dbg('initFormer: VW outer catch:', e?.message || e); + } } function selectLayerFromSidebar(index) { @@ -1005,13 +1697,44 @@ async function formerSelectCutoutElement() { } async function formerSelectDadoElement() { - const depthStr = prompt('Dado/engrave depth (mm):', '0.5'); - if (depthStr === null) return; - state.former.dadoDepth = parseFloat(depthStr) || 0.5; + const depth = await showInputDialog('Engrave Depth', 'Dado/engrave depth (mm):', '0.5'); + if (depth === null) return; + state.former.dadoDepth = parseFloat(depth) || 0.5; state.former.cutoutType = 'dado'; await _enterElementSelection(true); } +function showInputDialog(title, label, defaultValue) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;'; + const box = document.createElement('div'); + box.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;min-width:280px;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,0.4);'; + box.innerHTML = ` +
${title}
+
${label}
+ +
+ + +
+ `; + overlay.appendChild(box); + document.body.appendChild(overlay); + const input = box.querySelector('input'); + input.focus(); + input.select(); + const done = (val) => { overlay.remove(); resolve(val); }; + box.querySelector('#input-ok').onclick = () => done(input.value); + box.querySelector('#input-cancel').onclick = () => done(null); + overlay.onclick = e => { if (e.target === overlay) done(null); }; + input.onkeydown = e => { + if (e.key === 'Enter') done(input.value); + if (e.key === 'Escape') done(null); + }; + }); +} + async function _enterElementSelection(isDado) { if (!former3d || state.former.selectedLayer < 0) return; const index = state.former.selectedLayer; @@ -1032,8 +1755,10 @@ async function _enterElementSelection(isDado) { modeLabel.style.color = isDado ? '#f9e2af' : 'var(--accent)'; } const modeHint = cutoutTools?.querySelectorAll('div')[1]; - if (modeHint && isDado) { - modeHint.textContent = 'Click or drag-rectangle to select elements. Esc to exit.'; + if (modeHint) { + modeHint.textContent = isDado + ? 'Click elements or drag-rectangle to define engrave area. Esc to exit.' + : 'Click or drag-rectangle to select elements. Esc to exit.'; } try { @@ -1053,12 +1778,59 @@ async function _enterElementSelection(isDado) { } } -function formerCutoutAll() { +async function formerCutoutAll() { if (!former3d || state.former.selectedLayer < 0) return; - const layer = state.former.layers[state.former.selectedLayer]; - console.log('Cutout all for layer:', layer?.name); - // Mark the entire layer as a cutout region - alert(`Marked entire "${layer?.name}" layer as cutout.`); + const index = state.former.selectedLayer; + const layer = state.former.layers[index]; + + let elements; + try { + elements = await wails()?.GetLayerElements(index); + if (!elements || elements.length === 0) { + alert(`No graphic elements found on layer "${layer?.name || index}".`); + return; + } + } catch (e) { + console.error('GetLayerElements failed:', e); + alert('Could not load layer elements: ' + e); + return; + } + + const info = await wails()?.GetSessionInfo(); + const sides = info?.sides || []; + const result = await showSurfacePicker(sides); + if (!result) return; + + try { + if (result.surface === 'top' || result.surface === 'bottom') { + const plane = result.surface === 'top' ? 'lid' : 'tray'; + await wails()?.AddLidCutouts(elements, plane, false, 0, ''); + console.log(`Added ${elements.length} cutout elements to ${result.surface}`); + } else if (result.surface === 'side') { + const side = sides.find(s => s.num === result.sideNum); + if (side && info) { + const projected = elements.map(el => + projectElementToSide(el, side, info.dpi, info.minX, info.maxY) + ); + const placements = await former3d.enterSidePlacementMode(projected, result.sideNum); + if (placements) { + for (const p of placements) { + await wails()?.AddCutout({ + id: '', surface: 'side', sideNum: result.sideNum, + x: p.x, y: p.y, w: p.width, h: p.height, + r: 0, isDado: false, depth: 0, sourceLayer: '', + shape: p.shape || 'rect', + }); + } + console.log(`Added ${placements.length} side cutouts to side ${result.sideNum}`); + } + } + } + await refreshCutoutViz(); + } catch (e) { + console.error('Failed to save cutouts:', e); + alert('Failed to save cutouts: ' + e); + } } async function formerExitCutoutMode() { @@ -1067,6 +1839,8 @@ async function formerExitCutoutMode() { const selected = [...former3d.cutouts]; const isDado = state.former.cutoutType === 'dado'; const depth = isDado ? state.former.dadoDepth : 0; + const layerIdx = state.former.selectedLayer; + const gerberSource = (isDado && layerIdx >= 0) ? (state.former.layers[layerIdx]?.sourceFile || '') : ''; former3d.exitCutoutMode(); @@ -1079,7 +1853,7 @@ async function formerExitCutoutMode() { try { if (result.surface === 'top' || result.surface === 'bottom') { const plane = result.surface === 'top' ? 'lid' : 'tray'; - await wails()?.AddLidCutouts(selected, plane, isDado, depth); + await wails()?.AddLidCutouts(selected, plane, isDado, depth, gerberSource); console.log(`Added ${selected.length} ${isDado ? 'dado' : 'cutout'} elements to ${result.surface}`); } else if (result.surface === 'side') { const side = sides.find(s => s.num === result.sideNum); @@ -1095,6 +1869,7 @@ async function formerExitCutoutMode() { id: '', surface: 'side', sideNum: result.sideNum, x: p.x, y: p.y, w: p.width, h: p.height, r: 0, isDado, depth, sourceLayer: '', + shape: p.shape || 'rect', }); } console.log(`Added ${placements.length} side cutouts to side ${result.sideNum}`); @@ -1125,12 +1900,12 @@ function showSurfacePicker(sides) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;'; const box = document.createElement('div'); - box.style.cssText = 'background:var(--bg-card,#1e1e2e);border:1px solid var(--border-light,#45475a);border-radius:12px;padding:24px;min-width:300px;text-align:center;'; + box.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;min-width:300px;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,0.4);'; let sidesHTML = ''; if (sides && sides.length > 0) { sidesHTML = ` -
Sides
+
Sides
${sides.map((s, i) => { const col = surfacePickerColors[i % surfacePickerColors.length]; @@ -1141,10 +1916,10 @@ function showSurfacePicker(sides) { } box.innerHTML = ` -
Apply cutout to which surface?
+
Apply cutout to which surface?
- - + +
${sidesHTML}
@@ -1198,7 +1973,7 @@ function projectElementToSide(el, side, dpi, minX, maxY) { const projW = Math.abs(w * ux) + Math.abs(h * uy); const projH = Math.abs(w * uy) + Math.abs(h * ux); - return { x: posAlongSide - projW / 2, width: projW, height: projH }; + return { x: posAlongSide - projW / 2, width: projW, height: projH, shape: el.shape || 'rect' }; } async function refreshCutoutViz() { @@ -1221,7 +1996,7 @@ function showCutoutEditPanel(cutout) { panel.style.display = 'block'; const surfaceLabel = cutout.surface === 'side' ? `Side ${cutout.sideNum}` : - cutout.surface === 'top' ? 'Top (Lid)' : 'Bottom (Tray)'; + cutout.surface === 'top' ? 'Top (Lid)' : 'Bottom (Enclosure)'; panel.innerHTML = `
Edit Cutout — ${surfaceLabel}
@@ -1443,6 +2218,96 @@ async function formerRenderAndView() { } } +// ===== Vector Wrap overlay controls ===== +function setVWOverlayOpacity(val) { + if (!former3d?._vectorOverlayMesh) return; + former3d._vectorOverlayMesh.material.opacity = val / 100; +} + +function setVWOverlayZ(val) { + if (!former3d?._vwGroup) return; + former3d._vwGroup.position.z = parseFloat(val); +} + +function applyVWTransform() { + if (!former3d) return; + const tx = parseFloat($('#vw-tx')?.value) || 0; + const ty = parseFloat($('#vw-ty')?.value) || 0; + const rot = parseFloat($('#vw-rot')?.value) || 0; + const sx = parseFloat($('#vw-sx')?.value) || 1; + const sy = parseFloat($('#vw-sy')?.value) || 1; + former3d.setVWTranslation(tx, ty); + former3d.setVWRotation(rot); + former3d.setVWScale(sx, sy); +} + +function applyVWGridRes() { + if (!former3d) return; + const cols = parseInt($('#vw-grid-cols')?.value) || 4; + const rows = parseInt($('#vw-grid-rows')?.value) || 4; + former3d.setFFDResolution( + Math.max(2, Math.min(16, cols)), + Math.max(2, Math.min(16, rows)) + ); +} + +function toggleVWGrid() { + if (!former3d) return; + const visible = !(former3d._ffdGridVisible ?? true); + former3d.setFFDGridVisible(visible); + const btn = $('#vw-grid-toggle'); + if (btn) btn.textContent = visible ? 'Hide Grid' : 'Show Grid'; +} + +function resetVWGrid() { + if (!former3d) return; + former3d.resetFFDGrid(); + // Reset transform inputs too + const fields = { 'vw-tx': 0, 'vw-ty': 0, 'vw-rot': 0, 'vw-sx': 1, 'vw-sy': 1 }; + for (const [id, val] of Object.entries(fields)) { + const el = $(`#${id}`); + if (el) el.value = val; + } + applyVWTransform(); +} + +// ===== Structural Fill controls ===== +async function updateStructuralLive() { + try { + await wails()?.UpdateStructuralParams( + $('#sf-pattern')?.value || 'hexagon', + parseFloat($('#sf-cell')?.value) || 10, + parseFloat($('#sf-wall')?.value) || 1.2, + parseFloat($('#sf-height')?.value) || 20, + 1.6, + ); + } catch (_) {} +} + +async function renderStructuralPreview() { + if (!former3d) return; + showLoading('Generating structural fill...'); + try { + const scadSrc = await wails()?.GenerateStructuralSCAD(); + if (!scadSrc) { + hideLoading(); + alert('No SCAD source generated.'); + return; + } + showLoading('Running OpenSCAD WASM...'); + if (!openscadService) openscadService = new OpenSCADService(); + const stlBuf = await openscadService.renderSCAD(scadSrc); + hideLoading(); + if (stlBuf) { + former3d.loadExternalModel(stlBuf); + } + } catch (e) { + hideLoading(); + console.error('Structural render failed:', e); + alert('Render failed: ' + e); + } +} + function formerReturnToEditor() { if (!former3d) return; former3d.exitSolidView(); @@ -1809,7 +2674,7 @@ function drawSideFace() { ctx.clearRect(0, 0, canvas.width, canvas.height); // Wall face rectangle - ctx.fillStyle = '#45475a'; + ctx.fillStyle = '#444746'; ctx.strokeStyle = sideColors[(state.preview.activeSide - 1) % sideColors.length]; ctx.lineWidth = 2; ctx.fillRect(offX, offY, dims.width * sc, dims.height * sc); @@ -1823,17 +2688,28 @@ function drawSideFace() { // Draw cutouts for this side const cutouts = state.preview.cutouts || []; - ctx.fillStyle = '#1e1e2e'; + ctx.fillStyle = '#131314'; cutouts.forEach(c => { if (c.side !== state.preview.activeSide) return; - drawRoundedRect(ctx, - offX + c.x * sc, - offY + (dims.height - c.y - c.h) * sc, - c.w * sc, c.h * sc, c.r * sc); + const cx = offX + c.x * sc; + const cy = offY + (dims.height - c.y - c.h) * sc; + const cw = c.w * sc; + const ch = c.h * sc; + if (c.shape === 'circle') { + const r = Math.max(cw, ch) / 2; + ctx.beginPath(); + ctx.arc(cx + cw / 2, cy + ch / 2, r, 0, Math.PI * 2); + ctx.fill(); + } else if (c.shape === 'obround') { + const cr = Math.min(cw, ch) / 2; + drawRoundedRect(ctx, cx, cy, cw, ch, cr); + } else { + drawRoundedRect(ctx, cx, cy, cw, ch, c.r * sc); + } }); // mm grid labels - ctx.fillStyle = '#6c7086'; + ctx.fillStyle = '#8e918f'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; const step = Math.ceil(dims.width / 10); @@ -1866,19 +2742,40 @@ function esc(str) { return d.innerHTML; } +function base64ToArrayBuffer(b64) { + if (b64 instanceof ArrayBuffer) return b64; + if (typeof b64 !== 'string') { + // Already a typed array or array-like + return new Uint8Array(b64).buffer; + } + const bin = atob(b64); + const len = bin.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +} + // ===== Expose globals for onclick handlers ===== Object.assign(window, { - navigate, openProject, + navigate, openProject, createNewProject, openProjectDialog, closeProject, state, selectStencilGerber, selectStencilOutline, clearStencilOutline, generateStencil, selectGbrjob, selectGerberFolder, addGerberFile, clearGerbers, selectDrill, clearDrill, buildEnclosure, selectSide, centerX, centerY, presetUSBC, addCutout, removeCutout, - saveProfile, generateEnclosure, autoAlignUSB, - toggleLayerVis, toggleLayerHL, resetFormerView, + generateEnclosure, autoAlignUSB, + toggleLayerVis, toggleLayerHL, selectLayerFromSidebar, resetFormerView, formerMoveZ, formerSelectCutoutElement, formerSelectDadoElement, formerCutoutAll, formerExitCutoutMode, formerToggleGrid, formerRenderAndView, formerReturnToEditor, openOutputFolder, refreshCutoutViz, deleteSelectedCutouts, duplicateSelectedCutouts, toggleSettings, toggleSettingGrid, toggleTraditionalControls, + selectVectorWrapSVG, selectVectorWrapModel, openVectorWrapFormer, + toggleUseProjectEnclosure, toggleVWLid, toggleVWTray, toggleVWAssembly, + setVWOverlayOpacity, setVWOverlayZ, applyVWTransform, applyVWGridRes, toggleVWGrid, resetVWGrid, + selectStructuralSVG, generateStructural, updateStructuralLive, renderStructuralPreview, + applyScanPagePreset, generateScanSheet, showScanResult, + generateUnwrap, exportUnwrapSVG, importUnwrapArtwork, + toggleUnwrapFolds, toggleUnwrapLabels, toggleUnwrapCutouts, + toggleVWCameraLock, }); // ===== Init ===== diff --git a/frontend/src/style.css b/frontend/src/style.css index 4fb72e2..72b7516 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -86,6 +86,13 @@ body { user-select: none; } +.nav-project-name { + font-size: 13px; + color: var(--text-subtle); + padding-left: 8px; + -webkit-app-region: no-drag; +} + .nav-btn { -webkit-app-region: no-drag; } @@ -260,6 +267,20 @@ body { font-weight: 600; } +/* Dashboard grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +@media (max-width: 640px) { + .dashboard-grid { + grid-template-columns: 1fr 1fr; + } +} + /* Cards */ .card { background: var(--bg-surface); @@ -821,4 +842,56 @@ select.form-input { display: flex; gap: 8px; flex-wrap: wrap; +} + +/* Unwrap page */ +.unwrap-layout { + display: flex; + gap: 16px; + height: calc(100vh - 140px); +} + +.unwrap-preview { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.unwrap-preview svg { + max-width: 100%; + max-height: 100%; + background: #fff; + border-radius: var(--radius-sm); +} + +.unwrap-controls { + width: 220px; + flex-shrink: 0; +} + +.unwrap-sidebar { + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 16px; +} + +/* VW camera lock button */ +.sidebar-icon-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + +.sidebar-icon-btn.locked { + background: var(--accent-dim); + border-color: var(--text-secondary); + color: var(--text-primary); } \ No newline at end of file diff --git a/frontend/src/ui/enclosure-ui.js b/frontend/src/ui/enclosure-ui.js new file mode 100644 index 0000000..2b5e20c --- /dev/null +++ b/frontend/src/ui/enclosure-ui.js @@ -0,0 +1,48 @@ +export function buildEnclosureSidebar(layers, esc) { + return ` +
+

Layers

+ +
+
+ ${layers.map((l, i) => ` +
+ + +
+ ${esc(l.name)} +
+ `).join('')} +
+ + + + + `; +} diff --git a/frontend/src/ui/structural-ui.js b/frontend/src/ui/structural-ui.js new file mode 100644 index 0000000..5406ea3 --- /dev/null +++ b/frontend/src/ui/structural-ui.js @@ -0,0 +1,45 @@ +export function buildStructuralSidebar(modeTitle, sInfo, esc) { + return ` +
+

${modeTitle}

+ +
+
+
+ ${sInfo?.svgPath ? esc(sInfo.svgPath.split('/').pop()) : 'No SVG'} | + ${sInfo?.svgWidth?.toFixed(1) || '?'} × ${sInfo?.svgHeight?.toFixed(1) || '?'} mm +
+
+ Pattern + +
+
+ Cell (mm) + +
+
+ Wall (mm) + +
+
+ Height (mm) + +
+
+ +
+
+ + `; +} diff --git a/frontend/src/ui/unwrap-ui.js b/frontend/src/ui/unwrap-ui.js new file mode 100644 index 0000000..1a814cc --- /dev/null +++ b/frontend/src/ui/unwrap-ui.js @@ -0,0 +1,21 @@ +export function buildUnwrapSidebar() { + return ` +
+
Unwrap Template
+ + + + +
Display
+ + + +
+ `; +} diff --git a/frontend/src/ui/vectorwrap-ui.js b/frontend/src/ui/vectorwrap-ui.js new file mode 100644 index 0000000..241b730 --- /dev/null +++ b/frontend/src/ui/vectorwrap-ui.js @@ -0,0 +1,75 @@ +export function buildVectorWrapSidebar(modeTitle) { + return ` +
+

${modeTitle}

+ +
+
+
Drag blue control points to deform the overlay.
+ +
Display
+
+ Opacity + +
+
+ Z Offset + +
+ +
Transform
+
+ X + + Y + +
+
+ Rotate + + deg +
+
+ Scale X + + Y + +
+ +
Surface Wrap
+
+ +
+ +
Mesh Deform Grid
+
+ Cols + + x + + +
+
+ + +
+ + +
+ + `; +} diff --git a/instance.go b/instance.go index e449305..42456b0 100644 --- a/instance.go +++ b/instance.go @@ -79,6 +79,7 @@ func (inst *InstanceData) MigrateCutouts() []Cutout { Height: lc.MaxY - lc.MinY, IsDado: lc.IsDado, Depth: lc.Depth, + Shape: lc.Shape, }) } return result diff --git a/pattern.go b/pattern.go new file mode 100644 index 0000000..d522389 --- /dev/null +++ b/pattern.go @@ -0,0 +1,333 @@ +package main + +import ( + "fmt" + "math" + "strings" +) + +// GenerateStructuralSCAD produces an OpenSCAD source string that extrudes +// the SVG outline filled with a structural pattern. +func GenerateStructuralSCADString(session *StructuralSession) (string, error) { + if session == nil || session.SVGDoc == nil { + return "", fmt.Errorf("no structural session") + } + + outline := extractOutlinePolygon(session.SVGDoc) + if len(outline) < 3 { + return "", fmt.Errorf("SVG has no usable outline polygon (need at least 3 points)") + } + + var b strings.Builder + b.WriteString("// Structural Fill — Generated by Former\n") + b.WriteString(fmt.Sprintf("// Pattern: %s, Cell: %.1fmm, Wall: %.1fmm, Height: %.1fmm\n\n", + session.Pattern, session.CellSize, session.WallThick, session.Height)) + + // Write outline polygon module + writePolygonModule(&b, "outline_shape", outline) + + // Write pattern module + bbox := polygonBBox(outline) + switch session.Pattern { + case "hexagon": + writeHexPattern(&b, bbox, session.CellSize, session.WallThick) + case "triangle": + writeTrianglePattern(&b, bbox, session.CellSize, session.WallThick) + case "diamond": + writeDiamondPattern(&b, bbox, session.CellSize, session.WallThick) + case "grid": + writeGridPattern(&b, bbox, session.CellSize, session.WallThick) + case "gyroid": + writeGyroidPattern(&b, bbox, session.CellSize, session.WallThick) + default: + writeHexPattern(&b, bbox, session.CellSize, session.WallThick) + } + + // Assembly: shell + infill, extruded + shellT := session.ShellThick + if shellT <= 0 { + shellT = session.WallThick + } + b.WriteString(fmt.Sprintf("\n// Assembly\nlinear_extrude(height = %.2f, convexity = 10) {\n", session.Height)) + b.WriteString(fmt.Sprintf(" // Outer shell\n difference() {\n outline_shape();\n offset(r = -%.2f) outline_shape();\n }\n", shellT)) + b.WriteString(" // Internal pattern clipped to outline\n intersection() {\n outline_shape();\n fill_pattern();\n }\n") + b.WriteString("}\n") + + return b.String(), nil +} + +// extractOutlinePolygon pulls the largest closed polygon from the SVG document. +// It flattens all elements' segments into point sequences and picks the one +// with the most points that forms a closed path. +func extractOutlinePolygon(doc *SVGDocument) [][2]float64 { + var best [][2]float64 + + for _, el := range doc.Elements { + pts := segmentsToPoints(el.Segments, el.Transform) + if len(pts) > len(best) { + best = pts + } + } + + return best +} + +// segmentsToPoints flattens path segments into a coordinate list, +// applying the element transform. +func segmentsToPoints(segs []PathSegment, xf [6]float64) [][2]float64 { + var pts [][2]float64 + + transform := func(x, y float64) (float64, float64) { + return xf[0]*x + xf[2]*y + xf[4], xf[1]*x + xf[3]*y + xf[5] + } + + for _, seg := range segs { + switch seg.Command { + case 'M', 'L': + if len(seg.Args) >= 2 { + tx, ty := transform(seg.Args[0], seg.Args[1]) + pts = append(pts, [2]float64{tx, ty}) + } + case 'C': + if len(seg.Args) >= 6 { + // Sample cubic bezier at endpoints + midpoint + tx, ty := transform(seg.Args[4], seg.Args[5]) + pts = append(pts, [2]float64{tx, ty}) + } + case 'Q', 'S': + if len(seg.Args) >= 4 { + tx, ty := transform(seg.Args[2], seg.Args[3]) + pts = append(pts, [2]float64{tx, ty}) + } + case 'A': + if len(seg.Args) >= 7 { + tx, ty := transform(seg.Args[5], seg.Args[6]) + pts = append(pts, [2]float64{tx, ty}) + } + case 'T': + if len(seg.Args) >= 2 { + tx, ty := transform(seg.Args[0], seg.Args[1]) + pts = append(pts, [2]float64{tx, ty}) + } + case 'Z': + // Close path — no new point needed + } + } + + return pts +} + +func polygonBBox(poly [][2]float64) [4]float64 { + if len(poly) == 0 { + return [4]float64{} + } + minX, minY := poly[0][0], poly[0][1] + maxX, maxY := minX, minY + for _, p := range poly[1:] { + if p[0] < minX { + minX = p[0] + } + if p[0] > maxX { + maxX = p[0] + } + if p[1] < minY { + minY = p[1] + } + if p[1] > maxY { + maxY = p[1] + } + } + return [4]float64{minX, minY, maxX, maxY} +} + +func writePolygonModule(b *strings.Builder, name string, poly [][2]float64) { + b.WriteString(fmt.Sprintf("module %s() {\n polygon(points=[\n", name)) + for i, p := range poly { + comma := "," + if i == len(poly)-1 { + comma = "" + } + b.WriteString(fmt.Sprintf(" [%.4f, %.4f]%s\n", p[0], p[1], comma)) + } + b.WriteString(" ]);\n}\n\n") +} + +// Hexagonal honeycomb pattern +func writeHexPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) { + r := cellSize / 2.0 + ri := r - wallThick/2.0 + if ri < 0.1 { + ri = 0.1 + } + dx := cellSize * 1.5 + dy := cellSize * math.Sqrt(3) / 2.0 + + b.WriteString("module fill_pattern() {\n") + b.WriteString(fmt.Sprintf(" r = %.4f;\n", r)) + b.WriteString(fmt.Sprintf(" ri = %.4f;\n", ri)) + b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick)) + + b.WriteString(" difference() {\n") + b.WriteString(" union() {\n") + + startX := bbox[0] - cellSize + startY := bbox[1] - cellSize + endX := bbox[2] + cellSize + endY := bbox[3] + cellSize + row := 0 + for y := startY; y <= endY; y += dy { + offsetX := 0.0 + if row%2 == 1 { + offsetX = cellSize * 0.75 + } + for x := startX + offsetX; x <= endX; x += dx { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) circle(r=r, $fn=6);\n", x, y)) + } + row++ + } + + b.WriteString(" }\n") + b.WriteString(" // Subtract smaller hexagons to create walls\n") + b.WriteString(" union() {\n") + + row = 0 + for y := startY; y <= endY; y += dy { + offsetX := 0.0 + if row%2 == 1 { + offsetX = cellSize * 0.75 + } + for x := startX + offsetX; x <= endX; x += dx { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) circle(r=ri, $fn=6);\n", x, y)) + } + row++ + } + + b.WriteString(" }\n") + b.WriteString(" }\n") + b.WriteString("}\n\n") +} + +// Triangle grid pattern +func writeTrianglePattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) { + b.WriteString("module fill_pattern() {\n") + b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize)) + b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick)) + + // Horizontal lines + startY := bbox[1] - cellSize + endY := bbox[3] + cellSize + h := cellSize * math.Sqrt(3) / 2.0 + + b.WriteString(" union() {\n") + for y := startY; y <= endY; y += h { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([%.4f, wall]);\n", + bbox[0]-cellSize, y-wallThick/2, bbox[2]-bbox[0]+2*cellSize)) + } + // Diagonal lines (60 degrees) + for x := bbox[0] - (bbox[3]-bbox[1])*2; x <= bbox[2]+(bbox[3]-bbox[1])*2; x += cellSize { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(60) square([wall, %.4f]);\n", + x, bbox[1]-cellSize, (bbox[3]-bbox[1]+2*cellSize)*2)) + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(-60) square([wall, %.4f]);\n", + x, bbox[1]-cellSize, (bbox[3]-bbox[1]+2*cellSize)*2)) + } + b.WriteString(" }\n") + b.WriteString("}\n\n") +} + +// Diamond/rhombus lattice +func writeDiamondPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) { + b.WriteString("module fill_pattern() {\n") + b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize)) + b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick)) + extentX := bbox[2] - bbox[0] + 2*cellSize + extentY := bbox[3] - bbox[1] + 2*cellSize + diag := math.Sqrt(extentX*extentX + extentY*extentY) + + b.WriteString(" union() {\n") + // 45-degree lines in both directions + for d := -diag; d <= diag; d += cellSize { + cx := (bbox[0] + bbox[2]) / 2.0 + cy := (bbox[1] + bbox[3]) / 2.0 + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(45) translate([0, %.4f]) square([wall, %.4f], center=true);\n", + cx, cy, d, diag*2)) + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) rotate(-45) translate([0, %.4f]) square([wall, %.4f], center=true);\n", + cx, cy, d, diag*2)) + } + b.WriteString(" }\n") + b.WriteString("}\n\n") +} + +// Simple rectangular grid +func writeGridPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) { + b.WriteString("module fill_pattern() {\n") + b.WriteString(fmt.Sprintf(" cell = %.4f;\n", cellSize)) + b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick)) + b.WriteString(" union() {\n") + + for x := bbox[0] - cellSize; x <= bbox[2]+cellSize; x += cellSize { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([wall, %.4f]);\n", + x-wallThick/2, bbox[1]-cellSize, bbox[3]-bbox[1]+2*cellSize)) + } + for y := bbox[1] - cellSize; y <= bbox[3]+cellSize; y += cellSize { + b.WriteString(fmt.Sprintf(" translate([%.4f, %.4f]) square([%.4f, wall]);\n", + bbox[0]-cellSize, y-wallThick/2, bbox[2]-bbox[0]+2*cellSize)) + } + + b.WriteString(" }\n") + b.WriteString("}\n\n") +} + +// Gyroid approximation (sinusoidal cross-section) +func writeGyroidPattern(b *strings.Builder, bbox [4]float64, cellSize, wallThick float64) { + b.WriteString("module fill_pattern() {\n") + b.WriteString(fmt.Sprintf(" wall = %.4f;\n", wallThick)) + + // Approximate gyroid cross-section with a dense polygon path of sine waves + // Two perpendicular sets of sinusoidal walls + period := cellSize + amplitude := cellSize / 2.0 + steps := 40 + + b.WriteString(" union() {\n") + + // Horizontal sine waves + for baseY := bbox[1] - cellSize; baseY <= bbox[3]+cellSize; baseY += period { + b.WriteString(" polygon(points=[") + // Forward path (top edge) + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + x := bbox[0] - cellSize + t*(bbox[2]-bbox[0]+2*cellSize) + y := baseY + amplitude*math.Sin(2*math.Pi*t*(bbox[2]-bbox[0]+2*cellSize)/period) + wallThick/2 + b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y)) + } + // Reverse path (bottom edge) + for i := steps; i >= 0; i-- { + t := float64(i) / float64(steps) + x := bbox[0] - cellSize + t*(bbox[2]-bbox[0]+2*cellSize) + y := baseY + amplitude*math.Sin(2*math.Pi*t*(bbox[2]-bbox[0]+2*cellSize)/period) - wallThick/2 + b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y)) + } + b.WriteString("]);\n") + } + + // Vertical sine waves + for baseX := bbox[0] - cellSize; baseX <= bbox[2]+cellSize; baseX += period { + b.WriteString(" polygon(points=[") + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + y := bbox[1] - cellSize + t*(bbox[3]-bbox[1]+2*cellSize) + x := baseX + amplitude*math.Sin(2*math.Pi*t*(bbox[3]-bbox[1]+2*cellSize)/period) + wallThick/2 + b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y)) + } + for i := steps; i >= 0; i-- { + t := float64(i) / float64(steps) + y := bbox[1] - cellSize + t*(bbox[3]-bbox[1]+2*cellSize) + x := baseX + amplitude*math.Sin(2*math.Pi*t*(bbox[3]-bbox[1]+2*cellSize)/period) - wallThick/2 + b.WriteString(fmt.Sprintf("[%.4f,%.4f],", x, y)) + } + b.WriteString("]);\n") + } + + b.WriteString(" }\n") + b.WriteString("}\n\n") +} diff --git a/project.go b/project.go new file mode 100644 index 0000000..5e4bf54 --- /dev/null +++ b/project.go @@ -0,0 +1,134 @@ +package main + +import "time" + +// ProjectData is the top-level structure serialized to project.json inside a .former directory. +type ProjectData struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Version int `json:"version"` + + Stencil *StencilData `json:"stencil,omitempty"` + Enclosure *EnclosureData `json:"enclosure,omitempty"` + VectorWrap *VectorWrapData `json:"vectorWrap,omitempty"` + Structural *StructuralData `json:"structural,omitempty"` + ScanHelper *ScanHelperData `json:"scanHelper,omitempty"` + + Settings ProjectSettings `json:"settings"` +} + +type ProjectSettings struct { + ShowGrid bool `json:"showGrid"` + TraditionalControls bool `json:"traditionalControls"` +} + +type StencilData struct { + GerberFile string `json:"gerberFile,omitempty"` + OutlineFile string `json:"outlineFile,omitempty"` + StencilHeight float64 `json:"stencilHeight"` + WallHeight float64 `json:"wallHeight"` + WallThickness float64 `json:"wallThickness"` + DPI float64 `json:"dpi"` + Exports []string `json:"exports"` +} + +type EnclosureData struct { + GerberFiles map[string]string `json:"gerberFiles"` + DrillPath string `json:"drillPath,omitempty"` + NPTHPath string `json:"npthPath,omitempty"` + EdgeCutsFile string `json:"edgeCutsFile"` + CourtyardFile string `json:"courtyardFile,omitempty"` + SoldermaskFile string `json:"soldermaskFile,omitempty"` + FabFile string `json:"fabFile,omitempty"` + Config EnclosureConfig `json:"config"` + Exports []string `json:"exports"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + ProjectName string `json:"projectName,omitempty"` + Cutouts []Cutout `json:"cutouts,omitempty"` + SideCutouts []SideCutout `json:"sideCutouts,omitempty"` + LidCutouts []LidCutout `json:"lidCutouts,omitempty"` +} + +// MigrateCutouts returns the unified cutouts list, converting legacy fields if needed. +func (ed *EnclosureData) MigrateCutouts() []Cutout { + if len(ed.Cutouts) > 0 { + return ed.Cutouts + } + var result []Cutout + for _, sc := range ed.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, + }) + } + for _, lc := range ed.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, + Shape: lc.Shape, + }) + } + return result +} + +type VectorWrapData struct { + SVGFile string `json:"svgFile,omitempty"` + ModelFile string `json:"modelFile,omitempty"` + ModelType string `json:"modelType,omitempty"` + SVGWidth float64 `json:"svgWidth"` + SVGHeight float64 `json:"svgHeight"` + GridCols int `json:"gridCols,omitempty"` + GridRows int `json:"gridRows,omitempty"` + GridPoints [][2]float64 `json:"gridPoints,omitempty"` + TranslateX float64 `json:"translateX"` + TranslateY float64 `json:"translateY"` + Rotation float64 `json:"rotation"` + ScaleX float64 `json:"scaleX"` + ScaleY float64 `json:"scaleY"` + Opacity float64 `json:"opacity"` + ZOffset float64 `json:"zOffset"` +} + +type StructuralData struct { + SVGFile string `json:"svgFile,omitempty"` + Pattern string `json:"pattern"` + CellSize float64 `json:"cellSize"` + WallThick float64 `json:"wallThick"` + Height float64 `json:"height"` + ShellThick float64 `json:"shellThick"` +} + +type ScanHelperData struct { + PageWidth float64 `json:"pageWidth"` + PageHeight float64 `json:"pageHeight"` + GridSpacing float64 `json:"gridSpacing"` + PagesWide int `json:"pagesWide"` + PagesTall int `json:"pagesTall"` + DPI float64 `json:"dpi"` +} + +// RecentEntry tracks a recently-opened project for the landing page. +type RecentEntry struct { + Path string `json:"path"` + Name string `json:"name"` + LastOpened time.Time `json:"lastOpened"` +} diff --git a/scad.go b/scad.go index 00946ed..0dfeabd 100644 --- a/scad.go +++ b/scad.go @@ -85,7 +85,7 @@ func approximateArc(x1, y1, x2, y2, iVal, jVal float64, mode string) [][2]float6 // 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) { +func writeApertureFlash2D(f io.Writer, gf *GerberFile, ap Aperture, x, y, lw float64, indent string) { switch ap.Type { case "C": if len(ap.Modifiers) > 0 { @@ -191,7 +191,7 @@ func writeApertureFlash2D(f *os.File, gf *GerberFile, ap Aperture, x, y, lw floa } // writeMacroPrimitive2D emits a single macro primitive as 2D SCAD geometry. -func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, indent string) { +func writeMacroPrimitive2D(f io.Writer, prim MacroPrimitive, params []float64, indent string) { switch prim.Code { case 1: // Circle: Exposure, Diameter, CenterX, CenterY if len(prim.Modifiers) >= 4 { @@ -277,7 +277,7 @@ func writeMacroPrimitive2D(f *os.File, prim MacroPrimitive, params []float64, in } // 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) { +func writeApertureLinearDraw2D(f io.Writer, ap Aperture, x1, y1, x2, y2 float64, indent string) { switch ap.Type { case "C": if len(ap.Modifiers) > 0 { @@ -308,7 +308,7 @@ func writeApertureLinearDraw2D(f *os.File, ap Aperture, x1, y1, x2, y2 float64, // 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) { +func writeGerberShapes2D(f io.Writer, gf *GerberFile, lw float64, indent string) { curX, curY := 0.0, 0.0 curDCode := 0 interpolationMode := "G01" @@ -789,29 +789,73 @@ func writeNativeSCADTo(f io.Writer, isTray bool, outlineVertices [][2]float64, c if w < 0.01 || h < 0.01 { continue } + + // Text engrave dado: use actual gerber shapes clipped to the selection region + if lc.IsDado && lc.GerberFile != nil { + var z, cutH float64 + if lc.Plane == "lid" { + z = totalH - lc.Depth/2.0 + cutH = lc.Depth + 0.1 + } else { + z = lc.Depth/2.0 - 0.05 + cutH = lc.Depth + 0.1 + } + fmt.Fprintf(f, " // Text engrave dado (depth=%.2f)\n", lc.Depth) + fmt.Fprintf(f, " translate([0, 0, %f]) intersection() {\n", z-cutH/2.0) + fmt.Fprintf(f, " translate([%f, %f, 0]) cube([%f, %f, %f]);\n", + lc.MinX, lc.MinY, w, h, cutH) + fmt.Fprintf(f, " linear_extrude(height=%f) {\n", cutH) + writeGerberShapes2D(f, lc.GerberFile, 0, " ") + fmt.Fprintf(f, " }\n") + fmt.Fprintf(f, " }\n") + continue + } + + isCircle := lc.Shape == "circle" + isObround := lc.Shape == "obround" + r := w / 2.0 + if h > w { + r = h / 2.0 + } + // writeLidCut emits the appropriate 3D shape for a lid/tray cutout + writeLidCut := func(cx, cy, z, cutH float64) { + if isCircle { + fmt.Fprintf(f, " translate([%f, %f, %f]) cylinder(h=%f, r=%f, center=true);\n", + cx, cy, z, cutH, r) + } else if isObround { + or := math.Min(w, h) / 2.0 + fmt.Fprintf(f, " translate([%f, %f, %f]) linear_extrude(height=%f, center=true) hull() {\n", + cx, cy, z, cutH) + if w >= h { + d := (w - h) / 2.0 + fmt.Fprintf(f, " translate([%f, 0]) circle(r=%f);\n", d, or) + fmt.Fprintf(f, " translate([%f, 0]) circle(r=%f);\n", -d, or) + } else { + d := (h - w) / 2.0 + fmt.Fprintf(f, " translate([0, %f]) circle(r=%f);\n", d, or) + fmt.Fprintf(f, " translate([0, %f]) circle(r=%f);\n", -d, or) + } + fmt.Fprintf(f, " }\n") + } else { + fmt.Fprintf(f, " translate([%f, %f, %f]) cube([%f, %f, %f], center=true);\n", + cx, cy, z, w, h, cutH) + } + } 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) + writeLidCut(cx, cy, totalH-lc.Depth/2.0, 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) + writeLidCut(cx, cy, totalH-lidThick/2.0, 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) + writeLidCut(cx, cy, lc.Depth/2.0-0.05, 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) + writeLidCut(cx, cy, trayFloor/2.0, trayFloor+0.2) } } } diff --git a/scangrid.go b/scangrid.go new file mode 100644 index 0000000..c2fd027 --- /dev/null +++ b/scangrid.go @@ -0,0 +1,183 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// GenerateScanGridSVG creates printable calibration grid SVG files. +// Returns the list of generated file paths. +func GenerateScanGridSVG(cfg *ScanHelperConfig, outputDir string) ([]string, error) { + if cfg == nil { + return nil, fmt.Errorf("no scan helper config") + } + + os.MkdirAll(outputDir, 0755) + + pageW := cfg.PageWidth + pageH := cfg.PageHeight + grid := cfg.GridSpacing + if grid <= 0 { + grid = 10 + } + + margin := 15.0 // mm margin for printer bleed + markerSize := 5.0 + + var files []string + + for row := 0; row < cfg.PagesTall; row++ { + for col := 0; col < cfg.PagesWide; col++ { + filename := fmt.Sprintf("scan_grid_%dx%d.svg", col+1, row+1) + if cfg.PagesWide == 1 && cfg.PagesTall == 1 { + filename = "scan_grid.svg" + } + path := filepath.Join(outputDir, filename) + + svg, err := renderGridPage(pageW, pageH, margin, grid, markerSize, col, row, cfg.PagesWide, cfg.PagesTall) + if err != nil { + return files, err + } + + if err := os.WriteFile(path, []byte(svg), 0644); err != nil { + return files, fmt.Errorf("write %s: %v", filename, err) + } + files = append(files, path) + } + } + + return files, nil +} + +func renderGridPage(pageW, pageH, margin, gridSpacing, markerSize float64, col, row, totalCols, totalRows int) (string, error) { + var b strings.Builder + + b.WriteString(fmt.Sprintf(` + +`, pageW, pageH, pageW, pageH)) + + // White background + b.WriteString(fmt.Sprintf(` +`, pageW, pageH)) + + // Printable area + areaX := margin + areaY := margin + areaW := pageW - 2*margin + areaH := pageH - 2*margin + + // Thin border around printable area + b.WriteString(fmt.Sprintf(` +`, areaX, areaY, areaW, areaH)) + + // Grid lines + b.WriteString(`` + "\n") + for x := areaX; x <= areaX+areaW+0.01; x += gridSpacing { + b.WriteString(fmt.Sprintf(` `, x, areaY, x, areaY+areaH)) + b.WriteString("\n") + } + for y := areaY; y <= areaY+areaH+0.01; y += gridSpacing { + b.WriteString(fmt.Sprintf(` `, areaX, y, areaX+areaW, y)) + b.WriteString("\n") + } + b.WriteString("\n") + + // Major grid lines every 5 cells + majorSpacing := gridSpacing * 5 + b.WriteString(`` + "\n") + for x := areaX; x <= areaX+areaW+0.01; x += majorSpacing { + b.WriteString(fmt.Sprintf(` `, x, areaY, x, areaY+areaH)) + b.WriteString("\n") + } + for y := areaY; y <= areaY+areaH+0.01; y += majorSpacing { + b.WriteString(fmt.Sprintf(` `, areaX, y, areaX+areaW, y)) + b.WriteString("\n") + } + b.WriteString("\n") + + // Corner registration marks (L-shaped fiducials) + writeCornerMark(&b, areaX, areaY, markerSize, 1, 1) + writeCornerMark(&b, areaX+areaW, areaY, markerSize, -1, 1) + writeCornerMark(&b, areaX, areaY+areaH, markerSize, 1, -1) + writeCornerMark(&b, areaX+areaW, areaY+areaH, markerSize, -1, -1) + + // Center crosshair + cx, cy := pageW/2, pageH/2 + b.WriteString(fmt.Sprintf(``, + cx-markerSize, cy, cx+markerSize, cy)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + cx, cy-markerSize, cx, cy+markerSize)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + cx, cy)) + b.WriteString("\n") + + // Multi-page alignment markers (only if multiple pages) + if totalCols > 1 || totalRows > 1 { + writeAlignmentMarkers(&b, areaX, areaY, areaW, areaH, markerSize) + + // Page coordinate label + b.WriteString(fmt.Sprintf(`Page %d,%d of %dx%d`, + areaX+areaW, areaY-2, col+1, row+1, totalCols, totalRows)) + b.WriteString("\n") + } + + // Scale reference bar (10mm) + refY := areaY + areaH + 5 + b.WriteString(fmt.Sprintf(``, + areaX, refY, areaX+10, refY)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + areaX, refY-1.5, areaX, refY+1.5)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + areaX+10, refY-1.5, areaX+10, refY+1.5)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(`10mm`, + areaX+5, refY+3.5)) + b.WriteString("\n") + + // Grid spacing label + b.WriteString(fmt.Sprintf(`Grid: %.0fmm`, + areaX+15, refY+0.8, gridSpacing)) + b.WriteString("\n") + + b.WriteString("\n") + return b.String(), nil +} + +func writeCornerMark(b *strings.Builder, x, y, size, dx, dy float64) { + // L-shaped corner mark + b.WriteString(fmt.Sprintf(``, + x, y, x+size*dx, y)) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + x, y, x, y+size*dy)) + b.WriteString("\n") + // Small filled circle at corner + b.WriteString(fmt.Sprintf(``, x, y)) + b.WriteString("\n") +} + +func writeAlignmentMarkers(b *strings.Builder, x, y, w, h, size float64) { + // Edge midpoint crosses for stitching alignment + edges := [][2]float64{ + {x + w/2, y}, // top center + {x + w/2, y + h}, // bottom center + {x, y + h/2}, // left center + {x + w, y + h/2}, // right center + } + for _, e := range edges { + b.WriteString(fmt.Sprintf(``, + e[0]-size/2, e[1], e[0]+size/2, e[1])) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(``, + e[0], e[1]-size/2, e[0], e[1]+size/2)) + b.WriteString("\n") + } +} diff --git a/session.go b/session.go index 9c2c45d..5fb1f2c 100644 --- a/session.go +++ b/session.go @@ -275,7 +275,7 @@ func GenerateEnclosureOutputs(session *EnclosureSession, cutouts []Cutout, outpu os.MkdirAll(outputDir, 0755) // Split unified cutouts into legacy types for STL/SCAD generation - sideCutouts, lidCutouts := SplitCutouts(cutouts) + sideCutouts, lidCutouts := SplitCutouts(cutouts, session.AllLayerGerbers) id := randomID() var generatedFiles []string diff --git a/storage.go b/storage.go index aa962a8..cb6387e 100644 --- a/storage.go +++ b/storage.go @@ -5,6 +5,7 @@ import ( "fmt" "image" "image/png" + "log" "os" "path/filepath" "sort" @@ -29,184 +30,262 @@ func formerProfilesDir() string { return filepath.Join(formerBaseDir(), "profiles") } +func formerProjectsDir() string { + return filepath.Join(formerBaseDir(), "projects") +} + +func formerRecentPath() string { + return filepath.Join(formerBaseDir(), "recent.json") +} + func ensureFormerDirs() { - td := formerTempDir() - sd := formerSessionsDir() - pd := formerProfilesDir() - debugLog("ensureFormerDirs: temp=%s sessions=%s profiles=%s", td, sd, pd) - if err := os.MkdirAll(td, 0755); err != nil { - debugLog(" ERROR creating temp dir: %v", err) - } - if err := os.MkdirAll(sd, 0755); err != nil { - debugLog(" ERROR creating sessions dir: %v", err) - } - if err := os.MkdirAll(pd, 0755); err != nil { - debugLog(" ERROR creating profiles dir: %v", err) + for _, d := range []string{formerTempDir(), formerProjectsDir()} { + os.MkdirAll(d, 0755) } } -// ProjectEntry represents a saved project on disk +// ======== .former Project Persistence ======== + +// CreateProject creates a new .former directory at the given path with an empty project.json. +func CreateProject(path string) (*ProjectData, error) { + if !strings.HasSuffix(path, ".former") { + path += ".former" + } + if err := os.MkdirAll(path, 0755); err != nil { + return nil, fmt.Errorf("create project dir: %v", err) + } + for _, sub := range []string{"stencil", "enclosure", "vectorwrap", "structural", "scanhelper"} { + os.MkdirAll(filepath.Join(path, sub), 0755) + } + + proj := &ProjectData{ + ID: randomID(), + Name: strings.TrimSuffix(filepath.Base(path), ".former"), + CreatedAt: time.Now(), + Version: 1, + Settings: ProjectSettings{ShowGrid: true}, + } + if err := SaveProject(path, proj); err != nil { + return nil, err + } + AddRecentProject(path, proj.Name) + return proj, nil +} + +// SaveProject atomically writes project.json inside a .former directory. +func SaveProject(path string, data *ProjectData) error { + raw, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + tmpPath := filepath.Join(path, "project.json.tmp") + if err := os.WriteFile(tmpPath, raw, 0644); err != nil { + return err + } + return os.Rename(tmpPath, filepath.Join(path, "project.json")) +} + +// LoadProjectData reads project.json from a .former directory. +func LoadProjectData(path string) (*ProjectData, error) { + raw, err := os.ReadFile(filepath.Join(path, "project.json")) + if err != nil { + return nil, err + } + var proj ProjectData + if err := json.Unmarshal(raw, &proj); err != nil { + return nil, err + } + return &proj, nil +} + +// ProjectSubdir returns the mode-specific subdirectory within a .former project. +func ProjectSubdir(projectPath, mode string) string { + return filepath.Join(projectPath, mode) +} + +// ======== Recent Projects Tracking ======== + +// ListRecentProjects reads ~/former/recent.json and returns entries (newest first). +func ListRecentProjects() []RecentEntry { + raw, err := os.ReadFile(formerRecentPath()) + if err != nil { + return nil + } + var entries []RecentEntry + json.Unmarshal(raw, &entries) + + // Filter out entries whose paths no longer exist + var valid []RecentEntry + for _, e := range entries { + if _, err := os.Stat(filepath.Join(e.Path, "project.json")); err == nil { + valid = append(valid, e) + } + } + return valid +} + +// AddRecentProject prepends a project to the recent list, deduplicates, caps at 20. +func AddRecentProject(path, name string) { + entries := ListRecentProjects() + entry := RecentEntry{Path: path, Name: name, LastOpened: time.Now()} + + var deduped []RecentEntry + deduped = append(deduped, entry) + for _, e := range entries { + if e.Path != path { + deduped = append(deduped, e) + } + } + if len(deduped) > 20 { + deduped = deduped[:20] + } + + raw, _ := json.MarshalIndent(deduped, "", " ") + os.WriteFile(formerRecentPath(), raw, 0644) +} + +// ======== Migration from Old Sessions/Profiles ======== + +// MigrateOldProjects converts ~/former/sessions/* and ~/former/profiles/* into .former projects. +// Non-destructive: renames old dirs to .migrated suffix. +func MigrateOldProjects() { + for _, pair := range [][2]string{ + {formerSessionsDir(), "session"}, + {formerProfilesDir(), "profile"}, + } { + dir := pair[0] + if _, err := os.Stat(dir); os.IsNotExist(err) { + continue + } + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + migrated := 0 + for _, de := range entries { + if !de.IsDir() { + continue + } + oldPath := filepath.Join(dir, de.Name()) + jsonPath := filepath.Join(oldPath, "former.json") + raw, err := os.ReadFile(jsonPath) + if err != nil { + continue + } + var inst InstanceData + if json.Unmarshal(raw, &inst) != nil { + continue + } + + name := inst.ProjectName + if inst.Name != "" { + name = inst.Name + } + if name == "" { + name = "Untitled" + } + safeName := sanitizeDirName(name) + if safeName == "" { + safeName = "untitled" + } + + projPath := filepath.Join(formerProjectsDir(), safeName+".former") + // Avoid overwriting + if _, err := os.Stat(projPath); err == nil { + projPath = filepath.Join(formerProjectsDir(), safeName+"-"+inst.ID[:8]+".former") + } + + proj := &ProjectData{ + ID: inst.ID, + Name: name, + CreatedAt: inst.CreatedAt, + Version: 1, + Settings: ProjectSettings{ShowGrid: true}, + Enclosure: &EnclosureData{ + GerberFiles: inst.GerberFiles, + DrillPath: inst.DrillPath, + NPTHPath: inst.NPTHPath, + EdgeCutsFile: inst.EdgeCutsFile, + CourtyardFile: inst.CourtyardFile, + SoldermaskFile: inst.SoldermaskFile, + FabFile: inst.FabFile, + Config: inst.Config, + Exports: inst.Exports, + BoardW: inst.BoardW, + BoardH: inst.BoardH, + ProjectName: inst.ProjectName, + Cutouts: inst.MigrateCutouts(), + }, + } + + if err := os.MkdirAll(projPath, 0755); err != nil { + continue + } + encDir := filepath.Join(projPath, "enclosure") + os.MkdirAll(encDir, 0755) + + // Copy gerber files into enclosure subdir + newGerberFiles := make(map[string]string) + for origName := range inst.GerberFiles { + srcPath := filepath.Join(oldPath, origName) + dstPath := filepath.Join(encDir, origName) + if CopyFile(srcPath, dstPath) == nil { + newGerberFiles[origName] = origName + } + } + proj.Enclosure.GerberFiles = newGerberFiles + + // Copy drill files + if inst.DrillPath != "" { + src := filepath.Join(oldPath, inst.DrillPath) + dst := filepath.Join(encDir, inst.DrillPath) + CopyFile(src, dst) + } + if inst.NPTHPath != "" { + src := filepath.Join(oldPath, inst.NPTHPath) + dst := filepath.Join(encDir, inst.NPTHPath) + CopyFile(src, dst) + } + + // Copy thumbnail + thumbSrc := filepath.Join(oldPath, "thumbnail.png") + thumbDst := filepath.Join(projPath, "thumbnail.png") + CopyFile(thumbSrc, thumbDst) + + // Create other mode subdirs + for _, sub := range []string{"stencil", "vectorwrap", "structural", "scanhelper"} { + os.MkdirAll(filepath.Join(projPath, sub), 0755) + } + + if SaveProject(projPath, proj) == nil { + AddRecentProject(projPath, name) + migrated++ + } + } + + if migrated > 0 { + migratedDir := dir + ".migrated" + if err := os.Rename(dir, migratedDir); err != nil { + log.Printf("migration: could not rename %s to %s: %v", dir, migratedDir, err) + } else { + log.Printf("Migrated %d old %ss from %s", migrated, pair[1], dir) + } + } + } +} + +// ======== Legacy Support (used during migration and by RestoreEnclosureProject) ======== + +// ProjectEntry represents a saved project on disk (legacy format) type ProjectEntry struct { Path string - Type string // "session" or "profile" + Type string 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) { +// LoadLegacyProject reads former.json from a legacy project directory +func LoadLegacyProject(projectDir string) (*InstanceData, error) { raw, err := os.ReadFile(filepath.Join(projectDir, "former.json")) if err != nil { return nil, err @@ -218,31 +297,25 @@ func LoadProject(projectDir string) (*InstanceData, error) { return &inst, nil } -// UpdateProjectCutouts writes updated cutouts to an existing project's former.json -func UpdateProjectCutouts(projectDir string, cutouts []Cutout) error { - if projectDir == "" { - return nil +// RestoreEnclosureFromProject rebuilds an EnclosureSession from a .former project's enclosure data. +func RestoreEnclosureFromProject(projectPath string, encData *EnclosureData) (string, *EnclosureSession, error) { + encDir := filepath.Join(projectPath, "enclosure") + inst := &InstanceData{ + ID: encData.ProjectName, + GerberFiles: encData.GerberFiles, + DrillPath: encData.DrillPath, + NPTHPath: encData.NPTHPath, + EdgeCutsFile: encData.EdgeCutsFile, + CourtyardFile: encData.CourtyardFile, + SoldermaskFile: encData.SoldermaskFile, + FabFile: encData.FabFile, + Config: encData.Config, + Exports: encData.Exports, + BoardW: encData.BoardW, + BoardH: encData.BoardH, + ProjectName: encData.ProjectName, } - inst, err := LoadProject(projectDir) - if err != nil { - return err - } - inst.Cutouts = cutouts - // Clear legacy fields so they don't conflict - inst.SideCutouts = nil - inst.LidCutouts = nil - data, err := json.MarshalIndent(inst, "", " ") - if err != nil { - return err - } - return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644) -} - -// 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) + return restoreSessionFromDir(inst, encDir) } // DeleteProject removes a project directory entirely @@ -250,20 +323,6 @@ 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")) @@ -274,6 +333,24 @@ func SaveThumbnail(projectDir string, img image.Image) error { return png.Encode(f, img) } +// ListProjectOutputFiles returns files in a mode's subdirectory. +func ListProjectOutputFiles(projectPath, mode string) []string { + dir := filepath.Join(projectPath, mode) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + files = append(files, filepath.Join(dir, e.Name())) + } + sort.Strings(files) + return files +} + func sanitizeDirName(name string) string { name = strings.Map(func(r rune) rune { if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { diff --git a/svg_parse.go b/svg_parse.go new file mode 100644 index 0000000..a15a6fd --- /dev/null +++ b/svg_parse.go @@ -0,0 +1,878 @@ +package main + +import ( + "encoding/xml" + "fmt" + "math" + "os" + "regexp" + "strconv" + "strings" + "unicode" +) + +type SVGDocument struct { + Width float64 + Height float64 + ViewBox [4]float64 + Elements []SVGElement + Groups []SVGGroup + Warnings []string + RawSVG []byte +} + +type SVGElement struct { + Type string + PathData string + Segments []PathSegment + Transform [6]float64 + Fill string + Stroke string + StrokeW float64 + GroupID int + BBox Bounds +} + +type PathSegment struct { + Command byte + Args []float64 +} + +type SVGGroup struct { + ID string + Label string + Transform [6]float64 + Children []int + Visible bool +} + +func ParseSVG(path string) (*SVGDocument, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read SVG: %v", err) + } + + doc := &SVGDocument{ + RawSVG: data, + } + + decoder := xml.NewDecoder(strings.NewReader(string(data))) + var groupStack []int + currentGroupID := -1 + + for { + tok, err := decoder.Token() + if err != nil { + break + } + + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "svg": + doc.parseSVGRoot(t) + + case "g": + g := parseGroupAttrs(t) + gid := len(doc.Groups) + doc.Groups = append(doc.Groups, g) + if currentGroupID >= 0 { + doc.Groups[currentGroupID].Children = append(doc.Groups[currentGroupID].Children, gid) + } + groupStack = append(groupStack, currentGroupID) + currentGroupID = gid + + case "path": + el := doc.parsePathElement(t, currentGroupID) + doc.Elements = append(doc.Elements, el) + + case "rect": + el := doc.parseRectElement(t, currentGroupID) + doc.Elements = append(doc.Elements, el) + + case "circle": + el := doc.parseCircleElement(t, currentGroupID) + doc.Elements = append(doc.Elements, el) + + case "ellipse": + el := doc.parseEllipseElement(t, currentGroupID) + doc.Elements = append(doc.Elements, el) + + case "line": + el := doc.parseLineElement(t, currentGroupID) + doc.Elements = append(doc.Elements, el) + + case "polyline": + el := doc.parsePolyElement(t, currentGroupID, "polyline") + doc.Elements = append(doc.Elements, el) + + case "polygon": + el := doc.parsePolyElement(t, currentGroupID, "polygon") + doc.Elements = append(doc.Elements, el) + + case "text": + doc.Warnings = appendUnique(doc.Warnings, "Convert text to paths before importing") + decoder.Skip() + + case "image": + doc.Warnings = appendUnique(doc.Warnings, "Embedded raster images () are not supported") + decoder.Skip() + } + + case xml.EndElement: + if t.Name.Local == "g" && len(groupStack) > 0 { + currentGroupID = groupStack[len(groupStack)-1] + groupStack = groupStack[:len(groupStack)-1] + } + } + } + + for i := range doc.Elements { + doc.Elements[i].BBox = computeElementBBox(&doc.Elements[i]) + } + + return doc, nil +} + +// identityTransform returns [a,b,c,d,e,f] for the identity matrix +func identityTransform() [6]float64 { + return [6]float64{1, 0, 0, 1, 0, 0} +} + +func (doc *SVGDocument) parseSVGRoot(el xml.StartElement) { + for _, a := range el.Attr { + switch a.Name.Local { + case "width": + doc.Width = parseLengthMM(a.Value) + case "height": + doc.Height = parseLengthMM(a.Value) + case "viewBox": + parts := splitFloats(a.Value) + if len(parts) >= 4 { + doc.ViewBox = [4]float64{parts[0], parts[1], parts[2], parts[3]} + } + case "version": + if a.Value != "" && a.Value != "1.1" { + doc.Warnings = appendUnique(doc.Warnings, "SVG version is "+a.Value+"; for best results re-export as plain SVG 1.1 from Inkscape") + } + } + } + + if doc.Width == 0 && doc.ViewBox[2] > 0 { + doc.Width = doc.ViewBox[2] * (25.4 / 96.0) + } + if doc.Height == 0 && doc.ViewBox[3] > 0 { + doc.Height = doc.ViewBox[3] * (25.4 / 96.0) + } +} + +func parseGroupAttrs(el xml.StartElement) SVGGroup { + g := SVGGroup{ + Transform: identityTransform(), + Visible: true, + } + for _, a := range el.Attr { + switch { + case a.Name.Local == "id": + g.ID = a.Value + case a.Name.Local == "transform": + g.Transform = parseTransform(a.Value) + case a.Name.Local == "label" && a.Name.Space == "http://www.inkscape.org/namespaces/inkscape": + g.Label = a.Value + case a.Name.Local == "style": + if strings.Contains(a.Value, "display:none") || strings.Contains(a.Value, "display: none") { + g.Visible = false + } + case a.Name.Local == "display": + if a.Value == "none" { + g.Visible = false + } + } + } + return g +} + +func (doc *SVGDocument) baseElement(el xml.StartElement, groupID int) SVGElement { + e := SVGElement{ + Transform: identityTransform(), + GroupID: groupID, + } + for _, a := range el.Attr { + switch a.Name.Local { + case "transform": + e.Transform = parseTransform(a.Value) + case "fill": + e.Fill = a.Value + case "stroke": + e.Stroke = a.Value + case "stroke-width": + e.StrokeW, _ = strconv.ParseFloat(a.Value, 64) + case "style": + e.parseStyleAttr(a.Value) + } + } + return e +} + +func (e *SVGElement) parseStyleAttr(style string) { + for _, part := range strings.Split(style, ";") { + kv := strings.SplitN(strings.TrimSpace(part), ":", 2) + if len(kv) != 2 { + continue + } + k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + switch k { + case "fill": + e.Fill = v + case "stroke": + e.Stroke = v + case "stroke-width": + e.StrokeW, _ = strconv.ParseFloat(v, 64) + } + } +} + +func (doc *SVGDocument) parsePathElement(el xml.StartElement, groupID int) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = "path" + for _, a := range el.Attr { + if a.Name.Local == "d" { + e.PathData = a.Value + e.Segments = parsePath(a.Value) + } + } + return e +} + +func (doc *SVGDocument) parseRectElement(el xml.StartElement, groupID int) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = "rect" + var x, y, w, h, rx, ry float64 + for _, a := range el.Attr { + switch a.Name.Local { + case "x": + x, _ = strconv.ParseFloat(a.Value, 64) + case "y": + y, _ = strconv.ParseFloat(a.Value, 64) + case "width": + w, _ = strconv.ParseFloat(a.Value, 64) + case "height": + h, _ = strconv.ParseFloat(a.Value, 64) + case "rx": + rx, _ = strconv.ParseFloat(a.Value, 64) + case "ry": + ry, _ = strconv.ParseFloat(a.Value, 64) + } + } + e.Segments = rectToPath(x, y, w, h, rx, ry) + return e +} + +func (doc *SVGDocument) parseCircleElement(el xml.StartElement, groupID int) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = "circle" + var cx, cy, r float64 + for _, a := range el.Attr { + switch a.Name.Local { + case "cx": + cx, _ = strconv.ParseFloat(a.Value, 64) + case "cy": + cy, _ = strconv.ParseFloat(a.Value, 64) + case "r": + r, _ = strconv.ParseFloat(a.Value, 64) + } + } + e.Segments = circleToPath(cx, cy, r) + return e +} + +func (doc *SVGDocument) parseEllipseElement(el xml.StartElement, groupID int) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = "ellipse" + var cx, cy, rx, ry float64 + for _, a := range el.Attr { + switch a.Name.Local { + case "cx": + cx, _ = strconv.ParseFloat(a.Value, 64) + case "cy": + cy, _ = strconv.ParseFloat(a.Value, 64) + case "rx": + rx, _ = strconv.ParseFloat(a.Value, 64) + case "ry": + ry, _ = strconv.ParseFloat(a.Value, 64) + } + } + e.Segments = ellipseToPath(cx, cy, rx, ry) + return e +} + +func (doc *SVGDocument) parseLineElement(el xml.StartElement, groupID int) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = "line" + var x1, y1, x2, y2 float64 + for _, a := range el.Attr { + switch a.Name.Local { + case "x1": + x1, _ = strconv.ParseFloat(a.Value, 64) + case "y1": + y1, _ = strconv.ParseFloat(a.Value, 64) + case "x2": + x2, _ = strconv.ParseFloat(a.Value, 64) + case "y2": + y2, _ = strconv.ParseFloat(a.Value, 64) + } + } + e.Segments = lineToPath(x1, y1, x2, y2) + return e +} + +func (doc *SVGDocument) parsePolyElement(el xml.StartElement, groupID int, typ string) SVGElement { + e := doc.baseElement(el, groupID) + e.Type = typ + for _, a := range el.Attr { + if a.Name.Local == "points" { + pts := splitFloats(a.Value) + if typ == "polygon" { + e.Segments = polygonToPath(pts) + } else { + e.Segments = polylineToPath(pts) + } + } + } + return e +} + +// Shape-to-path converters + +func rectToPath(x, y, w, h, rx, ry float64) []PathSegment { + if rx == 0 && ry == 0 { + return []PathSegment{ + {Command: 'M', Args: []float64{x, y}}, + {Command: 'L', Args: []float64{x + w, y}}, + {Command: 'L', Args: []float64{x + w, y + h}}, + {Command: 'L', Args: []float64{x, y + h}}, + {Command: 'Z'}, + } + } + if rx == 0 { + rx = ry + } + if ry == 0 { + ry = rx + } + if rx > w/2 { + rx = w / 2 + } + if ry > h/2 { + ry = h / 2 + } + return []PathSegment{ + {Command: 'M', Args: []float64{x + rx, y}}, + {Command: 'L', Args: []float64{x + w - rx, y}}, + {Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w, y + ry}}, + {Command: 'L', Args: []float64{x + w, y + h - ry}}, + {Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + w - rx, y + h}}, + {Command: 'L', Args: []float64{x + rx, y + h}}, + {Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x, y + h - ry}}, + {Command: 'L', Args: []float64{x, y + ry}}, + {Command: 'A', Args: []float64{rx, ry, 0, 0, 1, x + rx, y}}, + {Command: 'Z'}, + } +} + +func circleToPath(cx, cy, r float64) []PathSegment { + return ellipseToPath(cx, cy, r, r) +} + +func ellipseToPath(cx, cy, rx, ry float64) []PathSegment { + return []PathSegment{ + {Command: 'M', Args: []float64{cx - rx, cy}}, + {Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx + rx, cy}}, + {Command: 'A', Args: []float64{rx, ry, 0, 1, 0, cx - rx, cy}}, + {Command: 'Z'}, + } +} + +func lineToPath(x1, y1, x2, y2 float64) []PathSegment { + return []PathSegment{ + {Command: 'M', Args: []float64{x1, y1}}, + {Command: 'L', Args: []float64{x2, y2}}, + } +} + +func polylineToPath(coords []float64) []PathSegment { + if len(coords) < 4 { + return nil + } + segs := []PathSegment{ + {Command: 'M', Args: []float64{coords[0], coords[1]}}, + } + for i := 2; i+1 < len(coords); i += 2 { + segs = append(segs, PathSegment{Command: 'L', Args: []float64{coords[i], coords[i+1]}}) + } + return segs +} + +func polygonToPath(coords []float64) []PathSegment { + segs := polylineToPath(coords) + if len(segs) > 0 { + segs = append(segs, PathSegment{Command: 'Z'}) + } + return segs +} + +// Transform parsing + +var transformFuncRe = regexp.MustCompile(`(\w+)\s*\(([^)]*)\)`) + +func parseTransform(attr string) [6]float64 { + result := identityTransform() + matches := transformFuncRe.FindAllStringSubmatch(attr, -1) + for _, m := range matches { + fn := m[1] + args := splitFloats(m[2]) + var t [6]float64 + switch fn { + case "matrix": + if len(args) >= 6 { + t = [6]float64{args[0], args[1], args[2], args[3], args[4], args[5]} + } else { + continue + } + case "translate": + tx := 0.0 + ty := 0.0 + if len(args) >= 1 { + tx = args[0] + } + if len(args) >= 2 { + ty = args[1] + } + t = [6]float64{1, 0, 0, 1, tx, ty} + case "scale": + sx := 1.0 + sy := 1.0 + if len(args) >= 1 { + sx = args[0] + } + if len(args) >= 2 { + sy = args[1] + } else { + sy = sx + } + t = [6]float64{sx, 0, 0, sy, 0, 0} + case "rotate": + if len(args) < 1 { + continue + } + angle := args[0] * math.Pi / 180.0 + c, s := math.Cos(angle), math.Sin(angle) + if len(args) >= 3 { + cx, cy := args[1], args[2] + t = [6]float64{c, s, -s, c, cx - c*cx + s*cy, cy - s*cx - c*cy} + } else { + t = [6]float64{c, s, -s, c, 0, 0} + } + case "skewX": + if len(args) < 1 { + continue + } + t = [6]float64{1, 0, math.Tan(args[0] * math.Pi / 180.0), 1, 0, 0} + case "skewY": + if len(args) < 1 { + continue + } + t = [6]float64{1, math.Tan(args[0] * math.Pi / 180.0), 0, 1, 0, 0} + default: + continue + } + result = composeTransforms(result, t) + } + return result +} + +func composeTransforms(a, b [6]float64) [6]float64 { + return [6]float64{ + a[0]*b[0] + a[2]*b[1], + a[1]*b[0] + a[3]*b[1], + a[0]*b[2] + a[2]*b[3], + a[1]*b[2] + a[3]*b[3], + a[0]*b[4] + a[2]*b[5] + a[4], + a[1]*b[4] + a[3]*b[5] + a[5], + } +} + +// Path d attribute parser + +func parsePath(d string) []PathSegment { + var segments []PathSegment + tokens := tokenizePath(d) + if len(tokens) == 0 { + return nil + } + + var curX, curY float64 + var startX, startY float64 + var lastCmd byte + + i := 0 + for i < len(tokens) { + tok := tokens[i] + cmd := byte(0) + + if len(tok) == 1 && isPathCommand(tok[0]) { + cmd = tok[0] + i++ + } else if lastCmd != 0 { + cmd = lastCmd + if cmd == 'M' { + cmd = 'L' + } else if cmd == 'm' { + cmd = 'l' + } + } else { + break + } + + rel := cmd >= 'a' && cmd <= 'z' + upper := cmd + if rel { + upper = cmd - 32 + } + + switch upper { + case 'M': + if i+1 >= len(tokens) { + break + } + x, y := parseF(tokens[i]), parseF(tokens[i+1]) + i += 2 + if rel { + x += curX + y += curY + } + curX, curY = x, y + startX, startY = x, y + segments = append(segments, PathSegment{Command: 'M', Args: []float64{x, y}}) + + case 'L': + if i+1 >= len(tokens) { + break + } + x, y := parseF(tokens[i]), parseF(tokens[i+1]) + i += 2 + if rel { + x += curX + y += curY + } + curX, curY = x, y + segments = append(segments, PathSegment{Command: 'L', Args: []float64{x, y}}) + + case 'H': + if i >= len(tokens) { + break + } + x := parseF(tokens[i]) + i++ + if rel { + x += curX + } + curX = x + segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}}) + + case 'V': + if i >= len(tokens) { + break + } + y := parseF(tokens[i]) + i++ + if rel { + y += curY + } + curY = y + segments = append(segments, PathSegment{Command: 'L', Args: []float64{curX, curY}}) + + case 'C': + if i+5 >= len(tokens) { + break + } + args := make([]float64, 6) + for j := 0; j < 6; j++ { + args[j] = parseF(tokens[i+j]) + } + i += 6 + if rel { + for j := 0; j < 6; j += 2 { + args[j] += curX + args[j+1] += curY + } + } + curX, curY = args[4], args[5] + segments = append(segments, PathSegment{Command: 'C', Args: args}) + + case 'S': + if i+3 >= len(tokens) { + break + } + args := make([]float64, 4) + for j := 0; j < 4; j++ { + args[j] = parseF(tokens[i+j]) + } + i += 4 + if rel { + for j := 0; j < 4; j += 2 { + args[j] += curX + args[j+1] += curY + } + } + curX, curY = args[2], args[3] + segments = append(segments, PathSegment{Command: 'S', Args: args}) + + case 'Q': + if i+3 >= len(tokens) { + break + } + args := make([]float64, 4) + for j := 0; j < 4; j++ { + args[j] = parseF(tokens[i+j]) + } + i += 4 + if rel { + for j := 0; j < 4; j += 2 { + args[j] += curX + args[j+1] += curY + } + } + curX, curY = args[2], args[3] + segments = append(segments, PathSegment{Command: 'Q', Args: args}) + + case 'T': + if i+1 >= len(tokens) { + break + } + x, y := parseF(tokens[i]), parseF(tokens[i+1]) + i += 2 + if rel { + x += curX + y += curY + } + curX, curY = x, y + segments = append(segments, PathSegment{Command: 'T', Args: []float64{x, y}}) + + case 'A': + if i+6 >= len(tokens) { + break + } + rx := parseF(tokens[i]) + ry := parseF(tokens[i+1]) + rot := parseF(tokens[i+2]) + largeArc := parseF(tokens[i+3]) + sweep := parseF(tokens[i+4]) + x := parseF(tokens[i+5]) + y := parseF(tokens[i+6]) + i += 7 + if rel { + x += curX + y += curY + } + curX, curY = x, y + segments = append(segments, PathSegment{Command: 'A', Args: []float64{rx, ry, rot, largeArc, sweep, x, y}}) + + case 'Z': + curX, curY = startX, startY + segments = append(segments, PathSegment{Command: 'Z'}) + + default: + i++ + } + + lastCmd = cmd + } + + return segments +} + +func isPathCommand(c byte) bool { + return strings.ContainsRune("MmLlHhVvCcSsQqTtAaZz", rune(c)) +} + +func tokenizePath(d string) []string { + var tokens []string + var buf strings.Builder + flush := func() { + s := buf.String() + if s != "" { + tokens = append(tokens, s) + buf.Reset() + } + } + + for i := 0; i < len(d); i++ { + c := d[i] + if isPathCommand(c) { + flush() + tokens = append(tokens, string(c)) + } else if c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r' { + flush() + } else if c == '-' && buf.Len() > 0 { + // Negative sign starts new number unless after 'e'/'E' (exponent) + s := buf.String() + lastChar := s[len(s)-1] + if lastChar != 'e' && lastChar != 'E' { + flush() + } + buf.WriteByte(c) + } else if c == '.' && strings.Contains(buf.String(), ".") { + flush() + buf.WriteByte(c) + } else { + buf.WriteByte(c) + } + } + flush() + return tokens +} + +func parseF(s string) float64 { + v, _ := strconv.ParseFloat(s, 64) + return v +} + +// Unit conversion + +func parseLengthMM(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + unit := "" + numStr := s + for _, u := range []string{"mm", "cm", "in", "pt", "px"} { + if strings.HasSuffix(s, u) { + unit = u + numStr = strings.TrimSpace(s[:len(s)-len(u)]) + break + } + } + // Strip non-numeric trailing chars + numStr = strings.TrimRightFunc(numStr, func(r rune) bool { + return !unicode.IsDigit(r) && r != '.' && r != '-' && r != '+' + }) + v, _ := strconv.ParseFloat(numStr, 64) + + switch unit { + case "mm": + return v + case "cm": + return v * 10.0 + case "in": + return v * 25.4 + case "pt": + return v * (25.4 / 72.0) + case "px", "": + return v * (25.4 / 96.0) + } + return v * (25.4 / 96.0) +} + +// Bounding box computation (approximate, from segments) + +func computeElementBBox(el *SVGElement) Bounds { + b := Bounds{MinX: math.MaxFloat64, MinY: math.MaxFloat64, MaxX: -math.MaxFloat64, MaxY: -math.MaxFloat64} + + expandPt := func(x, y float64) { + tx := el.Transform[0]*x + el.Transform[2]*y + el.Transform[4] + ty := el.Transform[1]*x + el.Transform[3]*y + el.Transform[5] + if tx < b.MinX { + b.MinX = tx + } + if tx > b.MaxX { + b.MaxX = tx + } + if ty < b.MinY { + b.MinY = ty + } + if ty > b.MaxY { + b.MaxY = ty + } + } + + for _, seg := range el.Segments { + switch seg.Command { + case 'M', 'L', 'T': + if len(seg.Args) >= 2 { + expandPt(seg.Args[0], seg.Args[1]) + } + case 'C': + if len(seg.Args) >= 6 { + expandPt(seg.Args[0], seg.Args[1]) + expandPt(seg.Args[2], seg.Args[3]) + expandPt(seg.Args[4], seg.Args[5]) + } + case 'S', 'Q': + if len(seg.Args) >= 4 { + expandPt(seg.Args[0], seg.Args[1]) + expandPt(seg.Args[2], seg.Args[3]) + } + case 'A': + if len(seg.Args) >= 7 { + expandPt(seg.Args[5], seg.Args[6]) + expandPt(seg.Args[5]-seg.Args[0], seg.Args[6]-seg.Args[1]) + expandPt(seg.Args[5]+seg.Args[0], seg.Args[6]+seg.Args[1]) + } + } + } + + if b.MinX == math.MaxFloat64 { + return Bounds{} + } + return b +} + +// Helpers + +func splitFloats(s string) []float64 { + s = strings.Map(func(r rune) rune { + if r == ',' { + return ' ' + } + return r + }, s) + var result []float64 + for _, part := range strings.Fields(s) { + v, err := strconv.ParseFloat(part, 64) + if err == nil { + result = append(result, v) + } + } + return result +} + +func appendUnique(warnings []string, msg string) []string { + for _, w := range warnings { + if w == msg { + return warnings + } + } + return append(warnings, msg) +} + +func (doc *SVGDocument) LayerCount() int { + count := 0 + for _, g := range doc.Groups { + if g.Label != "" { + count++ + } + } + return count +} + +func (doc *SVGDocument) VisibleLayerNames() []string { + var names []string + for _, g := range doc.Groups { + if g.Label != "" && g.Visible { + names = append(names, g.Label) + } + } + return names +} diff --git a/test_shapes_test.go b/test_shapes_test.go new file mode 100644 index 0000000..d250614 --- /dev/null +++ b/test_shapes_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "os" + "testing" +) + +func TestShapeExtraction(t *testing.T) { + files := []string{ + "temp/3efe010e39240608501455e800058a3f_EIS4-F_Cu.gbr", + "temp/3efe010e39240608501455e800058a3f_EIS4-F_Silkscreen.gbr", + "temp/3efe010e39240608501455e800058a3f_EIS4-F_Paste.gbr", + "temp/3efe010e39240608501455e800058a3f_EIS4-F_Fab.gbr", + "temp/3efe010e39240608501455e800058a3f_EIS4-User_Drawings.gbr", + } + for _, f := range files { + if _, err := os.Stat(f); err != nil { + continue + } + gf, err := ParseGerber(f) + if err != nil { + fmt.Printf("%s: parse error: %v\n", f, err) + continue + } + bounds := gf.CalculateBounds() + elems := ExtractElementBBoxes(gf, 508, &bounds) + shapes := map[string]int{} + types := map[string]int{} + for _, e := range elems { + shapes[e.Shape]++ + types[e.Type]++ + } + fmt.Printf("%s: %d elements, shapes=%v types=%v\n", f, len(elems), shapes, types) + count := 0 + for _, e := range elems { + if e.Type == "pad" && count < 5 { + fmt.Printf(" pad id=%d shape=%s footprint=%s w=%.1f h=%.1f\n", + e.ID, e.Shape, e.Footprint, e.MaxX-e.MinX, e.MaxY-e.MinY) + count++ + } + } + } +} diff --git a/unwrap.go b/unwrap.go new file mode 100644 index 0000000..4055934 --- /dev/null +++ b/unwrap.go @@ -0,0 +1,387 @@ +package main + +import ( + "fmt" + "math" + "strings" +) + +type TabSlot struct { + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` +} + +type UnwrapPanel struct { + Label string `json:"label"` + FaceType string `json:"faceType"` // "lid", "tray", "side" + SideNum int `json:"sideNum"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Angle float64 `json:"angle"` + Cutouts []Cutout `json:"cutouts"` + TabSlots []TabSlot `json:"tabSlots"` + // Polygon outline for non-rectangular panels (lid/tray) + Polygon [][2]float64 `json:"polygon,omitempty"` +} + +type UnwrapLayout struct { + Panels []UnwrapPanel `json:"panels"` + TotalW float64 `json:"totalW"` + TotalH float64 `json:"totalH"` + WallThick float64 `json:"wallThick"` +} + +// offsetPolygon moves each vertex outward along the average of its two adjacent edge normals. +func offsetPolygon(poly [][2]float64, dist float64) [][2]float64 { + n := len(poly) + if n < 3 { + return poly + } + // Remove closing duplicate if present + last := poly[n-1] + first := poly[0] + closed := math.Abs(last[0]-first[0]) < 0.01 && math.Abs(last[1]-first[1]) < 0.01 + if closed { + n-- + } + + // Compute centroid to determine winding + cx, cy := 0.0, 0.0 + for i := 0; i < n; i++ { + cx += poly[i][0] + cy += poly[i][1] + } + cx /= float64(n) + cy /= float64(n) + + // Compute signed area to detect winding direction + area := 0.0 + for i := 0; i < n; i++ { + j := (i + 1) % n + area += poly[i][0]*poly[j][1] - poly[j][0]*poly[i][1] + } + sign := 1.0 + if area > 0 { + sign = -1.0 + } + + out := make([][2]float64, n) + for i := 0; i < n; i++ { + prev := (i - 1 + n) % n + next := (i + 1) % n + + // Edge vectors + e1x := poly[i][0] - poly[prev][0] + e1y := poly[i][1] - poly[prev][1] + e2x := poly[next][0] - poly[i][0] + e2y := poly[next][1] - poly[i][1] + + // Outward normals (perpendicular, pointing away from center) + n1x := -e1y * sign + n1y := e1x * sign + n2x := -e2y * sign + n2y := e2x * sign + + // Normalize + l1 := math.Sqrt(n1x*n1x + n1y*n1y) + l2 := math.Sqrt(n2x*n2x + n2y*n2y) + if l1 > 0 { + n1x /= l1 + n1y /= l1 + } + if l2 > 0 { + n2x /= l2 + n2y /= l2 + } + + // Average normal + ax := (n1x + n2x) / 2 + ay := (n1y + n2y) / 2 + al := math.Sqrt(ax*ax + ay*ay) + if al < 1e-9 { + out[i] = poly[i] + continue + } + ax /= al + ay /= al + + // Half-angle cosine + dot := n1x*n2x + n1y*n2y + halfAngle := math.Acos(math.Max(-1, math.Min(1, dot))) / 2 + cosHalf := math.Cos(halfAngle) + if cosHalf < 0.1 { + cosHalf = 0.1 + } + + scale := dist / cosHalf + out[i] = [2]float64{poly[i][0] + ax*scale, poly[i][1] + ay*scale} + } + + // Close the polygon + out = append(out, out[0]) + return out +} + +// ComputeUnwrapLayout generates a flat unfolded net of the enclosure's outer surface. +func ComputeUnwrapLayout(session *EnclosureSession, cutouts []Cutout) *UnwrapLayout { + cfg := session.Config + wt := cfg.WallThickness + clearance := cfg.Clearance + offset := clearance + 2*wt + + outlinePoly := ExtractPolygonFromGerber(session.OutlineGf) + if len(outlinePoly) < 3 { + return nil + } + + lidPoly := offsetPolygon(outlinePoly, offset) + + // Bounding box of the lid polygon + lidMinX, lidMinY := math.Inf(1), math.Inf(1) + lidMaxX, lidMaxY := math.Inf(-1), math.Inf(-1) + for _, p := range lidPoly { + if p[0] < lidMinX { lidMinX = p[0] } + if p[1] < lidMinY { lidMinY = p[1] } + if p[0] > lidMaxX { lidMaxX = p[0] } + if p[1] > lidMaxY { lidMaxY = p[1] } + } + lidW := lidMaxX - lidMinX + lidH := lidMaxY - lidMinY + + wallH := cfg.WallHeight + cfg.PCBThickness + 1.0 + + sides := session.Sides + if len(sides) == 0 { + return nil + } + + // Normalize lid polygon to origin for the SVG layout + normLidPoly := make([][2]float64, len(lidPoly)) + for i, p := range lidPoly { + normLidPoly[i] = [2]float64{p[0] - lidMinX, p[1] - lidMinY} + } + + // Classify cutouts by surface + sideCutouts, lidCutouts := SplitCutouts(cutouts, nil) + _ = lidCutouts + + // Pry tab positions + pryW := 8.0 + pryH := 1.5 + + // Find longest side for tray attachment + longestIdx := 0 + longestLen := 0.0 + for i, s := range sides { + if s.Length > longestLen { + longestLen = s.Length + longestIdx = i + } + } + + // Layout: lid at center, sides folded out from lid edges + // We place lid at a starting position, then attach side panels along each edge. + // Simple cross layout: lid centered, sides extending outward. + + margin := 5.0 + layoutX := margin + layoutY := margin + + var panels []UnwrapPanel + + // Lid panel + lidPanel := UnwrapPanel{ + Label: "L", + FaceType: "lid", + X: layoutX, + Y: layoutY + wallH, // sides above will fold up + Width: lidW, + Height: lidH, + Polygon: normLidPoly, + } + + // Map lid cutouts + for _, c := range cutouts { + if c.Surface == "top" { + mapped := c + mapped.X = c.X - lidMinX + lidPanel.X + mapped.Y = c.Y - lidMinY + lidPanel.Y + lidPanel.Cutouts = append(lidPanel.Cutouts, mapped) + } + } + panels = append(panels, lidPanel) + + // Side panels: attach along each edge of the lid + // For simplicity, we place sides in a strip below the lid, left to right + sideX := margin + sideY := lidPanel.Y + lidH // below the lid + + for i, s := range sides { + sp := UnwrapPanel{ + Label: fmt.Sprintf("S%d", s.Num), + FaceType: "side", + SideNum: s.Num, + X: sideX, + Y: sideY, + Width: s.Length, + Height: wallH, + Angle: s.Angle, + } + + // Map side cutouts to this panel + for _, sc := range sideCutouts { + if sc.Side == s.Num { + mapped := Cutout{ + ID: fmt.Sprintf("unwrap-s%d", sc.Side), + Surface: "side", + SideNum: sc.Side, + X: sc.X + sp.X, + Y: sc.Y + sp.Y, + Width: sc.Width, + Height: sc.Height, + } + sp.Cutouts = append(sp.Cutouts, mapped) + } + } + + // Tab slots on pry tab sides + if s.Num == 1 || s.Num == len(sides) { + slot := TabSlot{ + X: sp.Width/2 - pryW/2, + Y: 0, + W: pryW, + H: pryH, + } + sp.TabSlots = append(sp.TabSlots, slot) + } + + panels = append(panels, sp) + + // If this is the longest side, attach tray below it + if i == longestIdx { + trayPanel := UnwrapPanel{ + Label: "T", + FaceType: "tray", + X: sideX, + Y: sideY + wallH, + Width: lidW, + Height: lidH, + Polygon: normLidPoly, + } + for _, c := range cutouts { + if c.Surface == "bottom" { + mapped := c + mapped.X = c.X - lidMinX + trayPanel.X + mapped.Y = c.Y - lidMinY + trayPanel.Y + trayPanel.Cutouts = append(trayPanel.Cutouts, mapped) + } + } + panels = append(panels, trayPanel) + } + + sideX += s.Length + 2.0 + } + + // Compute total layout bounds + totalW := margin + totalH := margin + for _, p := range panels { + right := p.X + p.Width + bottom := p.Y + p.Height + if right > totalW { totalW = right } + if bottom > totalH { totalH = bottom } + } + totalW += margin + totalH += margin + + return &UnwrapLayout{ + Panels: panels, + TotalW: totalW, + TotalH: totalH, + WallThick: wt, + } +} + +// GenerateUnwrapSVG produces an SVG string of the unfolded enclosure net. +func GenerateUnwrapSVG(layout *UnwrapLayout) string { + if layout == nil { + return "" + } + + var b strings.Builder + b.WriteString(fmt.Sprintf(``+"\n", + layout.TotalW, layout.TotalH, layout.TotalW, layout.TotalH)) + + b.WriteString(`\n") + + for _, p := range layout.Panels { + b.WriteString(fmt.Sprintf(``+"\n", + p.Label, p.FaceType, p.SideNum, p.X, p.Y, p.Width, p.Height)) + + // Panel outline + if len(p.Polygon) > 2 { + b.WriteString(`\n") + } else { + b.WriteString(fmt.Sprintf(``+"\n", + p.X, p.Y, p.Width, p.Height)) + } + + // Cutouts + for _, c := range p.Cutouts { + if c.Shape == "circle" { + r := c.Width / 2 + b.WriteString(fmt.Sprintf(``+"\n", + c.X+r, c.Y+r, r)) + } else { + b.WriteString(fmt.Sprintf(``+"\n", + c.X, c.Y, c.Width, c.Height)) + } + } + + // Tab slots + for _, t := range p.TabSlots { + b.WriteString(fmt.Sprintf(``+"\n", + p.X+t.X, p.Y+t.Y, t.W, t.H)) + } + + // Label + cx := p.X + p.Width/2 + cy := p.Y + p.Height/2 + b.WriteString(fmt.Sprintf(`%s`+"\n", cx, cy, p.Label)) + + b.WriteString("\n") + } + + // Fold lines between lid and side panels + for _, p := range layout.Panels { + if p.FaceType == "side" { + b.WriteString(fmt.Sprintf(``+"\n", + p.X, p.Y, p.X+p.Width, p.Y)) + } + if p.FaceType == "tray" { + b.WriteString(fmt.Sprintf(``+"\n", + p.X, p.Y, p.X+p.Width, p.Y)) + } + } + + b.WriteString("") + return b.String() +} diff --git a/vectorwrap.go b/vectorwrap.go new file mode 100644 index 0000000..59ec389 --- /dev/null +++ b/vectorwrap.go @@ -0,0 +1,38 @@ +package main + +import "image" + +// VectorWrapSession holds state for the Vector Wrap workflow: +// wrapping 2D vector art onto 3D surfaces. +type VectorWrapSession struct { + SVGDoc *SVGDocument + SVGPath string + SVGImage image.Image + ModelPath string + ModelType string // "stl", "scad", or "project-enclosure" + ModelSTL []byte // raw binary STL data + EnclosureSCAD string // populated when ModelType == "project-enclosure" + TraySCAD string +} + +// StructuralSession holds state for the Structural Procedures workflow: +// procedural pattern infill of edge-cut shapes for 3D printing. +type StructuralSession struct { + SVGDoc *SVGDocument + SVGPath string + Pattern string // e.g. "hexagon", "triangle", "diamond", "voronoi" + CellSize float64 // mm + WallThick float64 // mm + Height float64 // extrusion height mm + ShellThick float64 // outer shell thickness mm +} + +// ScanHelperConfig holds configuration for generating printable scan grids. +type ScanHelperConfig struct { + PageWidth float64 // mm (e.g. 210 for A4) + PageHeight float64 // mm (e.g. 297 for A4) + GridSpacing float64 // mm between calibration markers + PagesWide int // number of pages horizontally when stitching + PagesTall int // number of pages vertically when stitching + DPI float64 // target scan DPI +}