There's been a lot of changes, still in heavy early development so forgive me for not being great with the commit messages yet. I'm more apologizing in advance than for the present meaningless commit message - they'll get worse for a while.

This commit is contained in:
pszsh 2026-03-02 19:52:07 -08:00
parent 6bf857e58c
commit 79c32fd583
6 changed files with 458 additions and 81 deletions

136
app.go
View File

@ -69,6 +69,7 @@ func (s *ImageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type ProjectInfoJS struct { type ProjectInfoJS struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"` Path string `json:"path"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
HasStencil bool `json:"hasStencil"` HasStencil bool `json:"hasStencil"`
@ -76,12 +77,25 @@ type ProjectInfoJS struct {
HasVectorWrap bool `json:"hasVectorWrap"` HasVectorWrap bool `json:"hasVectorWrap"`
HasStructural bool `json:"hasStructural"` HasStructural bool `json:"hasStructural"`
HasScanHelper bool `json:"hasScanHelper"` HasScanHelper bool `json:"hasScanHelper"`
HasObject bool `json:"hasObject"`
BoardW float64 `json:"boardW"` BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"` BoardH float64 `json:"boardH"`
ShowGrid bool `json:"showGrid"` ShowGrid bool `json:"showGrid"`
TraditionalControls bool `json:"traditionalControls"` TraditionalControls bool `json:"traditionalControls"`
} }
type EnclosureSetupJS struct {
GbrjobFile string `json:"gbrjobFile"`
GerberFiles []string `json:"gerberFiles"`
DrillPath string `json:"drillPath"`
NPTHPath string `json:"npthPath"`
WallThick float64 `json:"wallThick"`
WallHeight float64 `json:"wallHeight"`
Clearance float64 `json:"clearance"`
DPI float64 `json:"dpi"`
SourceDir string `json:"sourceDir"`
}
type SessionInfoJS struct { type SessionInfoJS struct {
ProjectName string `json:"projectName"` ProjectName string `json:"projectName"`
BoardW float64 `json:"boardW"` BoardW float64 `json:"boardW"`
@ -184,9 +198,14 @@ func (a *App) GetRecentProjects() []ProjectInfoJS {
if err != nil { if err != nil {
continue continue
} }
pType := proj.Type
if pType == "" {
pType = "pcb"
}
info := ProjectInfoJS{ info := ProjectInfoJS{
ID: proj.ID, ID: proj.ID,
Name: proj.Name, Name: proj.Name,
Type: pType,
Path: e.Path, Path: e.Path,
CreatedAt: e.LastOpened.Format(time.RFC3339), CreatedAt: e.LastOpened.Format(time.RFC3339),
ShowGrid: proj.Settings.ShowGrid, ShowGrid: proj.Settings.ShowGrid,
@ -209,6 +228,9 @@ func (a *App) GetRecentProjects() []ProjectInfoJS {
if proj.ScanHelper != nil { if proj.ScanHelper != nil {
info.HasScanHelper = true info.HasScanHelper = true
} }
if proj.Object != nil {
info.HasObject = true
}
result = append(result, info) result = append(result, info)
} }
return result return result
@ -747,10 +769,13 @@ func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) {
// ======== Project Lifecycle ======== // ======== Project Lifecycle ========
func (a *App) CreateNewProject(name string) (string, error) { func (a *App) CreateNewProject(name, projectType string) (string, error) {
if name == "" { if name == "" {
name = "Untitled" name = "Untitled"
} }
if projectType == "" {
projectType = "pcb"
}
safeName := sanitizeDirName(name) safeName := sanitizeDirName(name)
if safeName == "" { if safeName == "" {
safeName = "untitled" safeName = "untitled"
@ -767,6 +792,7 @@ func (a *App) CreateNewProject(name string) (string, error) {
return "", err return "", err
} }
proj.Name = name proj.Name = name
proj.Type = projectType
SaveProject(path, proj) SaveProject(path, proj)
a.mu.Lock() a.mu.Lock()
@ -878,9 +904,14 @@ func (a *App) GetProjectInfo() *ProjectInfoJS {
return nil return nil
} }
p := a.project p := a.project
projType := p.Type
if projType == "" {
projType = "pcb"
}
info := &ProjectInfoJS{ info := &ProjectInfoJS{
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Type: projType,
Path: a.projectPath, Path: a.projectPath,
CreatedAt: p.CreatedAt.Format(time.RFC3339), CreatedAt: p.CreatedAt.Format(time.RFC3339),
ShowGrid: p.Settings.ShowGrid, ShowGrid: p.Settings.ShowGrid,
@ -903,6 +934,9 @@ func (a *App) GetProjectInfo() *ProjectInfoJS {
if p.ScanHelper != nil { if p.ScanHelper != nil {
info.HasScanHelper = true info.HasScanHelper = true
} }
if p.Object != nil {
info.HasObject = true
}
return info return info
} }
@ -1251,6 +1285,77 @@ func (a *App) GetActiveMode() string {
return "" return ""
} }
// ======== Enclosure Reconfigure ========
func (a *App) GetEnclosureSetupData() *EnclosureSetupJS {
a.mu.RLock()
defer a.mu.RUnlock()
if a.project == nil || a.project.Enclosure == nil {
return nil
}
enc := a.project.Enclosure
var gerberFiles []string
for name := range enc.GerberFiles {
gerberFiles = append(gerberFiles, name)
}
// Reconstruct gbrjob file from gerber files map
var gbrjob string
for name := range enc.GerberFiles {
if strings.HasSuffix(strings.ToLower(name), ".gbrjob") {
gbrjob = name
break
}
}
return &EnclosureSetupJS{
GbrjobFile: gbrjob,
GerberFiles: gerberFiles,
DrillPath: enc.DrillPath,
NPTHPath: enc.NPTHPath,
WallThick: enc.Config.WallThickness,
WallHeight: enc.Config.WallHeight,
Clearance: enc.Config.Clearance,
DPI: enc.Config.DPI,
}
}
// ======== 3D Object Setup (Surface Wrap projects) ========
func (a *App) ConfigureProjectObject(modelPath string) error {
if modelPath == "" {
return fmt.Errorf("no model file selected")
}
a.mu.Lock()
if a.project == nil {
a.mu.Unlock()
return fmt.Errorf("no project open")
}
projPath := a.projectPath
a.mu.Unlock()
baseName := filepath.Base(modelPath)
dstDir := filepath.Join(projPath, "vectorwrap")
os.MkdirAll(dstDir, 0755)
dstPath := filepath.Join(dstDir, baseName)
if err := CopyFile(modelPath, dstPath); err != nil {
return fmt.Errorf("copy model: %v", err)
}
modelType := "stl"
if strings.HasSuffix(strings.ToLower(modelPath), ".scad") {
modelType = "scad"
}
a.mu.Lock()
a.project.Object = &ObjectData{
ModelFile: baseName,
ModelType: modelType,
}
a.mu.Unlock()
a.autosaveProject()
return nil
}
// ======== Vector Wrap ======== // ======== Vector Wrap ========
type SVGValidationResultJS struct { type SVGValidationResultJS struct {
@ -1623,16 +1728,27 @@ func (a *App) GetUnwrapLayout() (*UnwrapLayout, error) {
return layout, nil return layout, nil
} }
func (a *App) ImportUnwrapArtwork(svgContent string) error { func (a *App) SaveUnwrapSVG() (string, error) {
a.mu.Lock() svg, err := a.GetUnwrapSVG()
defer a.mu.Unlock() if err != nil {
return "", err
if a.vectorWrapSession == nil {
a.vectorWrapSession = &VectorWrapSession{}
} }
// Store the artwork SVG for use with surface wrapping
a.vectorWrapSession.EnclosureSCAD = svgContent outDir, err := a.GetOutputDir()
return nil if err != nil {
return "", err
}
os.MkdirAll(outDir, 0755)
outPath := filepath.Join(outDir, "enclosure-unwrap.svg")
if err := os.WriteFile(outPath, []byte(svg), 0644); err != nil {
return "", fmt.Errorf("write SVG: %v", err)
}
return outPath, nil
}
func (a *App) OpenFileExternal(path string) error {
return exec.Command("open", path).Start()
} }
// ======== Structural Procedures ======== // ======== Structural Procedures ========

View File

@ -48,6 +48,7 @@
<section id="page-structural" class="page"></section> <section id="page-structural" class="page"></section>
<section id="page-scanhelper" class="page"></section> <section id="page-scanhelper" class="page"></section>
<section id="page-unwrap" class="page"></section> <section id="page-unwrap" class="page"></section>
<section id="page-objectsetup" class="page"></section>
<section id="page-former" class="page page-former"></section> <section id="page-former" class="page page-former"></section>
</main> </main>
</div> </div>

View File

@ -96,6 +96,7 @@ function navigate(page) {
case 'structural': initStructural(); break; case 'structural': initStructural(); break;
case 'scanhelper': initScanHelper(); break; case 'scanhelper': initScanHelper(); break;
case 'unwrap': initUnwrap(); break; case 'unwrap': initUnwrap(); break;
case 'objectsetup': initObjectSetup(); break;
} }
} }
@ -112,16 +113,19 @@ async function initLanding() {
<div class="section-title">Recent Projects</div> <div class="section-title">Recent Projects</div>
<div class="project-list"> <div class="project-list">
${projects.map(p => { ${projects.map(p => {
const typeLabel = p.type === 'surfacewrap' ? 'Surface Wrap' : 'PCB';
const modes = []; const modes = [];
if (p.hasEnclosure) modes.push('Enclosure'); if (p.hasEnclosure) modes.push('Enclosure');
if (p.hasStencil) modes.push('Stencil'); if (p.hasStencil) modes.push('Stencil');
if (p.hasVectorWrap) modes.push('Vector Wrap'); if (p.hasVectorWrap) modes.push('Vector Wrap');
if (p.hasStructural) modes.push('Structural'); if (p.hasStructural) modes.push('Structural');
if (p.hasScanHelper) modes.push('Scan Helper'); if (p.hasScanHelper) modes.push('Scan Helper');
if (p.hasObject) modes.push('3D Object');
const modeStr = modes.length > 0 ? modes.join(', ') : 'No modes configured'; const modeStr = modes.length > 0 ? modes.join(', ') : 'No modes configured';
return ` return `
<div class="project-item" onclick="openProject('${p.path.replace(/'/g, "\\'")}')"> <div class="project-item" onclick="openProject('${p.path.replace(/'/g, "\\'")}')">
<span class="project-name">${esc(p.name)}</span> <span class="project-name">${esc(p.name)}</span>
<span class="project-type-badge ${p.type === 'surfacewrap' ? 'badge-sw' : 'badge-pcb'}">${typeLabel}</span>
<span class="project-meta">${esc(modeStr)}</span> <span class="project-meta">${esc(modeStr)}</span>
</div>`; </div>`;
}).join('')} }).join('')}
@ -158,30 +162,57 @@ async function initDashboard() {
return; return;
} }
const modeCards = [ const isSW = info.type === 'surfacewrap';
{ id: 'stencil', label: 'Stencil', desc: 'Solder paste stencils from gerber files', active: info.hasStencil }, const hasObj = info.hasObject;
{ 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 }, let modeCards;
{ id: 'structural', label: 'Structural Fill', desc: 'Pattern-infilled 3D forms from outlines', active: info.hasStructural }, if (isSW) {
{ id: 'scanhelper', label: 'Scan Helper', desc: 'Calibration grids for scanning objects', active: info.hasScanHelper }, modeCards = [
{ id: 'unwrap', label: 'Unwrap', desc: 'Flat SVG template of the unfolded enclosure surface', active: info.hasEnclosure }, { id: 'objectsetup', label: '3D Object', desc: 'Provide STL/SCAD target mesh', active: hasObj, required: !hasObj },
]; { id: 'vectorwrap', label: 'Vector Wrap', desc: 'Wrap 2D vector art onto 3D surfaces', active: info.hasVectorWrap, disabled: !hasObj },
{ id: 'unwrap', label: 'Unwrap', desc: 'Flat SVG template of the unfolded surface', active: false, disabled: !hasObj },
{ 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 },
];
} else {
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 = ` page.innerHTML = `
<div class="page-header"> <div class="page-header">
<h2>${esc(info.name)}</h2> <h2>${esc(info.name)}</h2>
<span class="project-type-badge ${isSW ? 'badge-sw' : 'badge-pcb'}" style="margin-left:8px">${isSW ? 'Surface Wrap' : 'PCB'}</span>
<div class="spacer" style="flex:1"></div> <div class="spacer" style="flex:1"></div>
<button class="btn btn-sm" onclick="openOutputFolder()">Open Folder</button> <button class="btn btn-sm" onclick="openOutputFolder()">Open Folder</button>
<button class="btn btn-sm" onclick="closeProject()">Close Project</button> <button class="btn btn-sm" onclick="closeProject()">Close Project</button>
</div> </div>
<div class="dashboard-grid"> <div class="dashboard-grid">
${modeCards.map(m => ` ${modeCards.map(m => {
if (m.disabled) {
return `
<div class="action-card card-disabled">
<h3>${m.label}</h3>
<p>${m.desc}</p>
<span class="badge badge-muted" style="margin-top:8px">Requires 3D Object</span>
</div>`;
}
let badge = '';
if (m.required) badge = '<span class="badge badge-warn" style="margin-top:8px">Required</span>';
else if (m.active) badge = '<span class="badge" style="margin-top:8px">Configured</span>';
return `
<div class="action-card" onclick="navigate('${m.id}')"> <div class="action-card" onclick="navigate('${m.id}')">
<h3>${m.label}</h3> <h3>${m.label}</h3>
<p>${m.desc}</p> <p>${m.desc}</p>
${m.active ? '<span class="badge" style="margin-top:8px">Configured</span>' : ''} ${badge}
</div> </div>`;
`).join('')} }).join('')}
</div> </div>
`; `;
} }
@ -190,12 +221,14 @@ async function initDashboard() {
async function createNewProject() { async function createNewProject() {
const name = await showInputDialog('New Project', 'Project name:', 'My Project'); const name = await showInputDialog('New Project', 'Project name:', 'My Project');
if (name === null) return; if (name === null) return;
const projectType = await showTypeDialog();
if (projectType === null) return;
try { try {
const path = await wails()?.CreateNewProject(name || 'Untitled'); const path = await wails()?.CreateNewProject(name || 'Untitled', projectType);
if (!path) return; if (!path) return;
const info = await wails()?.GetProjectInfo(); const info = await wails()?.GetProjectInfo();
if (info) { if (info) {
state.project = { name: info.name, path: info.path }; state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard'); navigate('dashboard');
} }
} catch (e) { } catch (e) {
@ -203,13 +236,46 @@ async function createNewProject() {
} }
} }
function showTypeDialog() {
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:400px;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,0.4);';
box.innerHTML = `
<div style="font-size:14px;font-weight:500;margin-bottom:16px;color:var(--text-primary)">Project Type</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="action-card type-option" data-type="pcb" style="cursor:pointer;padding:16px;text-align:center">
<h3 style="margin:0 0 6px;font-size:13px">PCB / KiCad</h3>
<p style="margin:0;font-size:11px;color:var(--text-subtle)">Enclosures and stencils from gerber files</p>
</div>
<div class="action-card type-option" data-type="surfacewrap" style="cursor:pointer;padding:16px;text-align:center">
<h3 style="margin:0 0 6px;font-size:13px">Surface Wrap</h3>
<p style="margin:0;font-size:11px;color:var(--text-subtle)">Wrap 2D art onto 3D objects</p>
</div>
</div>
<div style="margin-top:12px">
<button class="btn btn-sm" id="type-cancel">Cancel</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const done = (val) => { overlay.remove(); resolve(val); };
box.querySelectorAll('.type-option').forEach(card => {
card.onclick = () => done(card.dataset.type);
});
box.querySelector('#type-cancel').onclick = () => done(null);
overlay.onclick = e => { if (e.target === overlay) done(null); };
});
}
async function openProjectDialog() { async function openProjectDialog() {
try { try {
const path = await wails()?.OpenProjectDialog(); const path = await wails()?.OpenProjectDialog();
if (!path) return; if (!path) return;
const info = await wails()?.GetProjectInfo(); const info = await wails()?.GetProjectInfo();
if (info) { if (info) {
state.project = { name: info.name, path: info.path }; state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard'); navigate('dashboard');
} }
} catch (e) { } catch (e) {
@ -367,19 +433,51 @@ function showStencilResult(files) {
} }
// ===== Enclosure Page ===== // ===== Enclosure Page =====
function initEnclosure() { async function initEnclosure(prefill) {
const page = $('#page-enclosure'); const page = $('#page-enclosure');
// If session exists and no explicit reconfigure request, go to preview
if (!prefill) {
const sessionCheck = await wails()?.GetSessionInfo();
if (sessionCheck?.hasSession) {
navigate('preview');
return;
}
}
state.enclosure = { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' }; state.enclosure = { gbrjobPath: '', gerberPaths: [], drillPath: '', npthPath: '', sourceDir: '' };
// Pre-fill from existing config when reconfiguring
if (prefill) {
state.enclosure.gbrjobPath = prefill.gbrjobFile || '';
state.enclosure.gerberPaths = prefill.gerberFiles || [];
state.enclosure.drillPath = prefill.drillPath || '';
state.enclosure.npthPath = prefill.npthPath || '';
state.enclosure.sourceDir = prefill.sourceDir || '';
}
const gbrjobLabel = state.enclosure.gbrjobPath ? state.enclosure.gbrjobPath.split('/').pop() : 'No file selected';
const gbrjobClass = state.enclosure.gbrjobPath ? ' has-file' : '';
const gerberLabel = state.enclosure.gerberPaths.length > 0 ? `${state.enclosure.gerberPaths.length} files` : 'No files selected';
const gerberClass = state.enclosure.gerberPaths.length > 0 ? ' has-file' : '';
const drillLabel = state.enclosure.drillPath ? state.enclosure.drillPath.split('/').pop() : 'No file selected';
const drillClass = state.enclosure.drillPath ? ' has-file' : '';
const npthLabel = state.enclosure.npthPath ? state.enclosure.npthPath.split('/').pop() : 'No file selected';
const npthClass = state.enclosure.npthPath ? ' has-file' : '';
const wt = prefill?.wallThick || 1.5;
const wh = prefill?.wallHeight || 10.0;
const cl = prefill?.clearance || 0.3;
const dp = prefill?.dpi || 600;
page.innerHTML = ` page.innerHTML = `
<div class="page-header"> <div class="page-header">
<button class="btn" onclick="navigate(state.project ? 'dashboard' : 'landing')"> Back</button> <button class="btn" onclick="navigate(state.project ? 'dashboard' : 'landing')"> Back</button>
<h2>Enclosure Generator</h2> <h2>Enclosure Generator${prefill ? ' — Reconfigure' : ''}</h2>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Gerber Job File (.gbrjob)</div> <div class="card-title">Gerber Job File (.gbrjob)</div>
<div class="file-row"> <div class="file-row">
<span class="file-name" id="enc-gbrjob-name">No file selected</span> <span class="file-name${gbrjobClass}" id="enc-gbrjob-name">${esc(gbrjobLabel)}</span>
<button class="btn btn-sm" onclick="selectGbrjob()">Select File...</button> <button class="btn btn-sm" onclick="selectGbrjob()">Select File...</button>
</div> </div>
</div> </div>
@ -387,7 +485,7 @@ function initEnclosure() {
<div class="card-title">Gerber Files</div> <div class="card-title">Gerber Files</div>
<p style="font-size:12px;color:var(--text-subtle);margin-bottom:8px">Select a folder to auto-discover, or add files individually.</p> <p style="font-size:12px;color:var(--text-subtle);margin-bottom:8px">Select a folder to auto-discover, or add files individually.</p>
<div class="file-row" style="margin-bottom:8px"> <div class="file-row" style="margin-bottom:8px">
<span class="file-name" id="enc-gerber-count">No files selected</span> <span class="file-name${gerberClass}" id="enc-gerber-count">${esc(gerberLabel)}</span>
</div> </div>
<div style="display:flex;gap:6px;flex-wrap:wrap"> <div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-sm" onclick="selectGerberFolder()">Select Folder...</button> <button class="btn btn-sm" onclick="selectGerberFolder()">Select Folder...</button>
@ -399,7 +497,7 @@ function initEnclosure() {
<div class="card"> <div class="card">
<div class="card-title">PTH Drill File (Optional)</div> <div class="card-title">PTH Drill File (Optional)</div>
<div class="file-row"> <div class="file-row">
<span class="file-name" id="enc-drill-name">No file selected</span> <span class="file-name${drillClass}" id="enc-drill-name">${esc(drillLabel)}</span>
<button class="btn btn-sm" onclick="selectDrill('pth')">Select...</button> <button class="btn btn-sm" onclick="selectDrill('pth')">Select...</button>
<button class="btn btn-sm" onclick="clearDrill('pth')">Clear</button> <button class="btn btn-sm" onclick="clearDrill('pth')">Clear</button>
</div> </div>
@ -407,7 +505,7 @@ function initEnclosure() {
<div class="card"> <div class="card">
<div class="card-title">NPTH Drill File (Optional)</div> <div class="card-title">NPTH Drill File (Optional)</div>
<div class="file-row"> <div class="file-row">
<span class="file-name" id="enc-npth-name">No file selected</span> <span class="file-name${npthClass}" id="enc-npth-name">${esc(npthLabel)}</span>
<button class="btn btn-sm" onclick="selectDrill('npth')">Select...</button> <button class="btn btn-sm" onclick="selectDrill('npth')">Select...</button>
<button class="btn btn-sm" onclick="clearDrill('npth')">Clear</button> <button class="btn btn-sm" onclick="clearDrill('npth')">Clear</button>
</div> </div>
@ -417,19 +515,19 @@ function initEnclosure() {
<div class="card-title">Parameters</div> <div class="card-title">Parameters</div>
<div class="form-row"> <div class="form-row">
<span class="form-label">Wall Thickness (mm)</span> <span class="form-label">Wall Thickness (mm)</span>
<input class="form-input" id="enc-wall-thick" type="number" step="0.1" value="1.5"> <input class="form-input" id="enc-wall-thick" type="number" step="0.1" value="${wt}">
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-label">Wall Height (mm)</span> <span class="form-label">Wall Height (mm)</span>
<input class="form-input" id="enc-wall-height" type="number" step="0.1" value="10.0"> <input class="form-input" id="enc-wall-height" type="number" step="0.1" value="${wh}">
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-label">Clearance (mm)</span> <span class="form-label">Clearance (mm)</span>
<input class="form-input" id="enc-clearance" type="number" step="0.1" value="0.3"> <input class="form-input" id="enc-clearance" type="number" step="0.1" value="${cl}">
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="form-label">DPI</span> <span class="form-label">DPI</span>
<input class="form-input" id="enc-dpi" type="number" step="1" value="600"> <input class="form-input" id="enc-dpi" type="number" step="1" value="${dp}">
</div> </div>
</div> </div>
<div class="card"> <div class="card">
@ -577,6 +675,27 @@ async function buildEnclosure() {
} }
} }
async function reconfigureEnclosure() {
const setup = await wails()?.GetEnclosureSetupData();
if (!setup) {
navigate('enclosure');
return;
}
// Navigate to enclosure page with pre-fill data — bypasses the session check
$$('.page').forEach(p => hide(p));
const el = $('#page-enclosure');
if (el) show(el);
const modeTabs = $('#nav-mode-tabs');
const projectName = $('#nav-project-name');
const openOutput = $('#nav-open-output');
if (state.project) {
if (modeTabs) modeTabs.style.display = '';
if (projectName) { projectName.style.display = ''; projectName.textContent = state.project.name; }
if (openOutput) openOutput.style.display = '';
}
initEnclosure(setup);
}
// ===== Enclosure Preview Page ===== // ===== Enclosure Preview Page =====
async function initPreview() { async function initPreview() {
const page = $('#page-preview'); const page = $('#page-preview');
@ -593,8 +712,10 @@ async function initPreview() {
page.innerHTML = ` page.innerHTML = `
<div class="page-header"> <div class="page-header">
<button class="btn" onclick="navigate('enclosure')"> Back</button> <button class="btn" onclick="navigate('dashboard')"> Back</button>
<h2>Enclosure Preview ${esc(info.projectName)}</h2> <h2>Enclosure Preview ${esc(info.projectName)}</h2>
<div class="spacer" style="flex:1"></div>
<button class="btn btn-sm" onclick="reconfigureEnclosure()">Reconfigure</button>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Board Preview ${info.boardW.toFixed(1)} × ${info.boardH.toFixed(1)} mm | ${info.sides?.length || 0} sides</div> <div class="card-title">Board Preview ${info.boardW.toFixed(1)} × ${info.boardH.toFixed(1)} mm | ${info.sides?.length || 0} sides</div>
@ -780,7 +901,7 @@ async function openProject(path) {
hideLoading(); hideLoading();
const info = await wails()?.GetProjectInfo(); const info = await wails()?.GetProjectInfo();
if (info) { if (info) {
state.project = { name: info.name, path: info.path }; state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard'); navigate('dashboard');
} }
} catch (e) { } catch (e) {
@ -793,6 +914,28 @@ async function openProject(path) {
async function initVectorWrap() { async function initVectorWrap() {
const page = $('#page-vectorwrap'); const page = $('#page-vectorwrap');
state.vectorwrap = { svgPath: '', modelPath: '', modelType: '' }; state.vectorwrap = { svgPath: '', modelPath: '', modelType: '' };
const isSW = state.project?.type === 'surfacewrap';
let modelCardHTML;
if (isSW) {
// Surface Wrap: model comes from 3D Object setup, just show its name
const info = await wails()?.GetProjectInfo();
const objName = info?.hasObject ? 'Configured via 3D Object' : 'Not configured';
modelCardHTML = `
<div class="card" id="vw-model-card">
<div class="card-title">3D Model</div>
<div style="font-size:12px;color:var(--success)">${esc(objName)}</div>
</div>`;
} else {
// PCB: use project enclosure directly, no external model option
modelCardHTML = `
<div class="card" id="vw-model-card">
<div class="card-title">3D Model</div>
<div id="vw-model-body">
<div id="vw-enc-status" style="font-size:12px;color:var(--text-subtle)">Checking project enclosure...</div>
</div>
</div>`;
}
page.innerHTML = ` page.innerHTML = `
<div class="page-header"> <div class="page-header">
@ -809,33 +952,49 @@ async function initVectorWrap() {
<div id="vw-svg-info" style="display:none;margin-top:8px;font-size:12px;color:var(--text-secondary)"></div> <div id="vw-svg-info" style="display:none;margin-top:8px;font-size:12px;color:var(--text-secondary)"></div>
<div id="vw-svg-warnings" style="display:none;margin-top:6px"></div> <div id="vw-svg-warnings" style="display:none;margin-top:6px"></div>
</div> </div>
<div class="card" id="vw-model-card"> ${modelCardHTML}
<div class="card-title">3D Model</div>
<label id="vw-use-project-label" style="display:none;margin-bottom:10px;cursor:pointer;font-size:12px;color:var(--text-secondary);user-select:none">
<input type="checkbox" id="vw-use-project-cb" onchange="toggleUseProjectEnclosure(this.checked)" style="margin-right:6px;vertical-align:middle">
Use project's enclosure
</label>
<div id="vw-model-body">
<p style="font-size:12px;color:var(--text-subtle);margin-bottom:8px">Select an STL or OpenSCAD file for the target surface.</p>
<div class="file-row">
<span class="file-name" id="vw-model-name">No file selected</span>
<button class="btn btn-sm" onclick="selectVectorWrapModel()">Select Model...</button>
</div>
</div>
</div>
<div class="action-bar"> <div class="action-bar">
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn btn-primary" onclick="openVectorWrapFormer()" id="vw-open-btn" disabled>Open in Former</button> <button class="btn btn-primary" onclick="openVectorWrapFormer()" id="vw-open-btn" disabled>Open in Former</button>
</div> </div>
`; `;
try { // PCB mode: auto-load project enclosure
const vwCheck = await wails()?.GetVectorWrapInfo(); if (!isSW) {
if (vwCheck?.hasProjectEnclosure) { try {
const label = $('#vw-use-project-label'); const vwCheck = await wails()?.GetVectorWrapInfo();
if (label) label.style.display = 'block'; const statusEl = $('#vw-enc-status');
if (vwCheck?.hasProjectEnclosure) {
showLoading('Loading project enclosure...');
await wails().UseProjectEnclosureForVectorWrap();
hideLoading();
state.vectorwrap.modelPath = 'project-enclosure';
state.vectorwrap.modelType = 'project-enclosure';
if (statusEl) {
statusEl.textContent = 'Using project enclosure';
statusEl.style.color = 'var(--success)';
}
} else if (statusEl) {
statusEl.textContent = 'No enclosure configured — set up Enclosure first';
statusEl.style.color = 'var(--warning)';
}
} catch (e) {
hideLoading();
const statusEl = $('#vw-enc-status');
if (statusEl) {
statusEl.textContent = 'Failed to load enclosure: ' + e;
statusEl.style.color = 'var(--error)';
}
} }
} catch (_) {} } else {
// Surface Wrap: auto-set model state if object is configured
const info = await wails()?.GetProjectInfo();
if (info?.hasObject) {
state.vectorwrap.modelPath = 'project-object';
state.vectorwrap.modelType = 'project-object';
}
}
updateVWOpenBtn();
} }
async function selectVectorWrapSVG() { async function selectVectorWrapSVG() {
@ -995,6 +1154,58 @@ function toggleVWAssembly() {
if (btn) btn.textContent = assembled ? 'Take Off Lid' : 'Put Lid On'; if (btn) btn.textContent = assembled ? 'Take Off Lid' : 'Put Lid On';
} }
// ===== 3D Object Setup Page (Surface Wrap projects) =====
let _objectModelPath = '';
function initObjectSetup() {
const page = $('#page-objectsetup');
_objectModelPath = '';
page.innerHTML = `
<div class="page-header">
<button class="btn" onclick="navigate('dashboard')"> Back</button>
<h2>3D Object Setup</h2>
</div>
<div class="card">
<div class="card-title">STL / SCAD Model File</div>
<p style="font-size:12px;color:var(--text-subtle);margin-bottom:8px">Select the 3D model to use as the target surface.</p>
<div class="file-row">
<span class="file-name" id="obj-model-name">No file selected</span>
<button class="btn btn-sm" onclick="selectObjectModel()">Select File...</button>
</div>
</div>
<div class="action-bar">
<div class="spacer"></div>
<button class="btn btn-primary" onclick="configureObject()" id="obj-configure-btn" disabled>Configure Object </button>
</div>
`;
}
async function selectObjectModel() {
try {
const path = await wails().SelectFile('Select 3D Model', '*.stl;*.scad');
if (!path) return;
_objectModelPath = path;
$('#obj-model-name').textContent = path.split('/').pop();
$('#obj-model-name').classList.add('has-file');
const btn = $('#obj-configure-btn');
if (btn) btn.disabled = false;
} catch (e) { console.error(e); }
}
async function configureObject() {
if (!_objectModelPath) return;
showLoading('Configuring 3D object...');
try {
await wails().ConfigureProjectObject(_objectModelPath);
hideLoading();
navigate('dashboard');
} catch (e) {
hideLoading();
alert('Failed to configure object: ' + e);
}
}
// ===== Unwrap Page ===== // ===== Unwrap Page =====
let _unwrapSVG = ''; let _unwrapSVG = '';
@ -1018,15 +1229,20 @@ async function initUnwrap() {
`; `;
} }
let _unwrapSavedPath = '';
async function generateUnwrap() { async function generateUnwrap() {
try { try {
showLoading('Generating unwrap template...'); showLoading('Generating unwrap template...');
const svg = await wails()?.GetUnwrapSVG(); const path = await wails()?.SaveUnwrapSVG();
hideLoading(); hideLoading();
if (!svg) { if (!path) {
alert('No enclosure session active. Set up an enclosure first.'); alert('No enclosure session active. Set up an enclosure first.');
return; return;
} }
_unwrapSavedPath = path;
const svg = await wails()?.GetUnwrapSVG();
_unwrapSVG = svg; _unwrapSVG = svg;
const preview = $('#unwrap-preview'); const preview = $('#unwrap-preview');
if (preview) { if (preview) {
@ -1038,35 +1254,36 @@ async function generateUnwrap() {
svgEl.style.maxHeight = '100%'; svgEl.style.maxHeight = '100%';
} }
} }
const exportBtn = $('#unwrap-export-btn');
if (exportBtn) exportBtn.disabled = false; const status = $('#unwrap-status');
if (status) {
status.style.display = 'block';
status.textContent = 'Saved: ' + path.split('/').pop();
}
const openBtn = $('#unwrap-open-btn');
if (openBtn) openBtn.disabled = false;
const folderBtn = $('#unwrap-folder-btn');
if (folderBtn) folderBtn.disabled = false;
} catch (e) { } catch (e) {
hideLoading(); hideLoading();
alert('Failed to generate unwrap: ' + e); alert('Failed to generate unwrap: ' + e);
} }
} }
function exportUnwrapSVG() { async function openUnwrapExternal() {
if (!_unwrapSVG) return; if (!_unwrapSavedPath) return;
const blob = new Blob([_unwrapSVG], { type: 'image/svg+xml' }); try {
const url = URL.createObjectURL(blob); await wails()?.OpenFileExternal(_unwrapSavedPath);
const a = document.createElement('a'); } catch (e) {
a.href = url; alert('Failed to open file: ' + e);
a.download = 'enclosure-unwrap.svg'; }
a.click();
URL.revokeObjectURL(url);
} }
async function importUnwrapArtwork() { async function openUnwrapFolder() {
try { try {
const path = await wails()?.SelectFile('Select Artwork SVG', '*.svg'); await wails()?.OpenOutputFolder();
if (!path) return;
showLoading('Importing artwork...');
await wails()?.ImportUnwrapArtwork(path);
hideLoading();
} catch (e) { } catch (e) {
hideLoading(); alert('Failed to open folder: ' + e);
alert('Import failed: ' + e);
} }
} }
@ -2773,9 +2990,10 @@ Object.assign(window, {
setVWOverlayOpacity, setVWOverlayZ, applyVWTransform, applyVWGridRes, toggleVWGrid, resetVWGrid, setVWOverlayOpacity, setVWOverlayZ, applyVWTransform, applyVWGridRes, toggleVWGrid, resetVWGrid,
selectStructuralSVG, generateStructural, updateStructuralLive, renderStructuralPreview, selectStructuralSVG, generateStructural, updateStructuralLive, renderStructuralPreview,
applyScanPagePreset, generateScanSheet, showScanResult, applyScanPagePreset, generateScanSheet, showScanResult,
generateUnwrap, exportUnwrapSVG, importUnwrapArtwork, generateUnwrap, openUnwrapExternal, openUnwrapFolder,
toggleUnwrapFolds, toggleUnwrapLabels, toggleUnwrapCutouts, toggleUnwrapFolds, toggleUnwrapLabels, toggleUnwrapCutouts,
toggleVWCameraLock, toggleVWCameraLock,
selectObjectModel, configureObject, reconfigureEnclosure,
}); });
// ===== Init ===== // ===== Init =====

View File

@ -266,6 +266,38 @@ body {
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-weight: 600; font-weight: 600;
} }
.badge-warn {
color: var(--warning, #f9e2af);
border-color: var(--warning, #f9e2af);
}
.badge-muted {
color: var(--text-subtle);
border-color: var(--border);
opacity: 0.7;
}
.card-disabled {
pointer-events: none;
opacity: 0.35;
}
.project-type-badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
vertical-align: middle;
}
.badge-pcb {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
}
.badge-sw {
background: color-mix(in srgb, var(--success, #a6e3a1) 15%, transparent);
color: var(--success, #a6e3a1);
}
/* Dashboard grid */ /* Dashboard grid */
.dashboard-grid { .dashboard-grid {

View File

@ -3,8 +3,9 @@ export function buildUnwrapSidebar() {
<div class="unwrap-sidebar"> <div class="unwrap-sidebar">
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:0 0 8px;letter-spacing:0.5px">Unwrap Template</div> <div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:0 0 8px;letter-spacing:0.5px">Unwrap Template</div>
<button class="btn btn-sm" onclick="generateUnwrap()" id="unwrap-gen-btn" style="width:100%;margin-bottom:6px">Generate Template</button> <button class="btn btn-sm" onclick="generateUnwrap()" id="unwrap-gen-btn" style="width:100%;margin-bottom:6px">Generate Template</button>
<button class="btn btn-sm" onclick="exportUnwrapSVG()" id="unwrap-export-btn" style="width:100%;margin-bottom:6px" disabled>Export SVG</button> <button class="btn btn-sm" onclick="openUnwrapExternal()" id="unwrap-open-btn" style="width:100%;margin-bottom:6px" disabled>Open in Default App</button>
<button class="btn btn-sm" onclick="importUnwrapArtwork()" id="unwrap-import-btn" style="width:100%;margin-bottom:12px">Import Artwork</button> <button class="btn btn-sm" onclick="openUnwrapFolder()" id="unwrap-folder-btn" style="width:100%;margin-bottom:12px" disabled>Open Containing Folder</button>
<div id="unwrap-status" style="display:none;font-size:11px;color:var(--success);margin-bottom:8px"></div>
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:8px 0 4px;letter-spacing:0.5px">Display</div> <div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:8px 0 4px;letter-spacing:0.5px">Display</div>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0"> <label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">

View File

@ -6,6 +6,7 @@ import "time"
type ProjectData struct { type ProjectData struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type,omitempty"` // "pcb" or "surfacewrap"; empty = legacy pcb
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Version int `json:"version"` Version int `json:"version"`
@ -14,10 +15,18 @@ type ProjectData struct {
VectorWrap *VectorWrapData `json:"vectorWrap,omitempty"` VectorWrap *VectorWrapData `json:"vectorWrap,omitempty"`
Structural *StructuralData `json:"structural,omitempty"` Structural *StructuralData `json:"structural,omitempty"`
ScanHelper *ScanHelperData `json:"scanHelper,omitempty"` ScanHelper *ScanHelperData `json:"scanHelper,omitempty"`
Object *ObjectData `json:"object,omitempty"`
Settings ProjectSettings `json:"settings"` Settings ProjectSettings `json:"settings"`
} }
// ObjectData stores the 3D object reference for Surface Wrap projects.
type ObjectData struct {
ModelFile string `json:"modelFile,omitempty"`
ModelType string `json:"modelType,omitempty"` // "stl" or "scad"
WallThickness float64 `json:"wallThickness"`
}
type ProjectSettings struct { type ProjectSettings struct {
ShowGrid bool `json:"showGrid"` ShowGrid bool `json:"showGrid"`
TraditionalControls bool `json:"traditionalControls"` TraditionalControls bool `json:"traditionalControls"`