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 {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
HasStencil bool `json:"hasStencil"`
@ -76,12 +77,25 @@ type ProjectInfoJS struct {
HasVectorWrap bool `json:"hasVectorWrap"`
HasStructural bool `json:"hasStructural"`
HasScanHelper bool `json:"hasScanHelper"`
HasObject bool `json:"hasObject"`
BoardW float64 `json:"boardW"`
BoardH float64 `json:"boardH"`
ShowGrid bool `json:"showGrid"`
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 {
ProjectName string `json:"projectName"`
BoardW float64 `json:"boardW"`
@ -184,9 +198,14 @@ func (a *App) GetRecentProjects() []ProjectInfoJS {
if err != nil {
continue
}
pType := proj.Type
if pType == "" {
pType = "pcb"
}
info := ProjectInfoJS{
ID: proj.ID,
Name: proj.Name,
Type: pType,
Path: e.Path,
CreatedAt: e.LastOpened.Format(time.RFC3339),
ShowGrid: proj.Settings.ShowGrid,
@ -209,6 +228,9 @@ func (a *App) GetRecentProjects() []ProjectInfoJS {
if proj.ScanHelper != nil {
info.HasScanHelper = true
}
if proj.Object != nil {
info.HasObject = true
}
result = append(result, info)
}
return result
@ -747,10 +769,13 @@ func (a *App) GenerateEnclosureOutputs() (*GenerateResultJS, error) {
// ======== Project Lifecycle ========
func (a *App) CreateNewProject(name string) (string, error) {
func (a *App) CreateNewProject(name, projectType string) (string, error) {
if name == "" {
name = "Untitled"
}
if projectType == "" {
projectType = "pcb"
}
safeName := sanitizeDirName(name)
if safeName == "" {
safeName = "untitled"
@ -767,6 +792,7 @@ func (a *App) CreateNewProject(name string) (string, error) {
return "", err
}
proj.Name = name
proj.Type = projectType
SaveProject(path, proj)
a.mu.Lock()
@ -878,9 +904,14 @@ func (a *App) GetProjectInfo() *ProjectInfoJS {
return nil
}
p := a.project
projType := p.Type
if projType == "" {
projType = "pcb"
}
info := &ProjectInfoJS{
ID: p.ID,
Name: p.Name,
Type: projType,
Path: a.projectPath,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
ShowGrid: p.Settings.ShowGrid,
@ -903,6 +934,9 @@ func (a *App) GetProjectInfo() *ProjectInfoJS {
if p.ScanHelper != nil {
info.HasScanHelper = true
}
if p.Object != nil {
info.HasObject = true
}
return info
}
@ -1251,6 +1285,77 @@ func (a *App) GetActiveMode() string {
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 ========
type SVGValidationResultJS struct {
@ -1623,16 +1728,27 @@ func (a *App) GetUnwrapLayout() (*UnwrapLayout, error) {
return layout, nil
}
func (a *App) ImportUnwrapArtwork(svgContent string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.vectorWrapSession == nil {
a.vectorWrapSession = &VectorWrapSession{}
func (a *App) SaveUnwrapSVG() (string, error) {
svg, err := a.GetUnwrapSVG()
if err != nil {
return "", err
}
// Store the artwork SVG for use with surface wrapping
a.vectorWrapSession.EnclosureSCAD = svgContent
return nil
outDir, err := a.GetOutputDir()
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 ========

View File

@ -48,6 +48,7 @@
<section id="page-structural" class="page"></section>
<section id="page-scanhelper" 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>
</main>
</div>

View File

@ -96,6 +96,7 @@ function navigate(page) {
case 'structural': initStructural(); break;
case 'scanhelper': initScanHelper(); 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="project-list">
${projects.map(p => {
const typeLabel = p.type === 'surfacewrap' ? 'Surface Wrap' : 'PCB';
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');
if (p.hasObject) modes.push('3D Object');
const modeStr = modes.length > 0 ? modes.join(', ') : 'No modes configured';
return `
<div class="project-item" onclick="openProject('${p.path.replace(/'/g, "\\'")}')">
<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>
</div>`;
}).join('')}
@ -158,30 +162,57 @@ async function initDashboard() {
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 },
];
const isSW = info.type === 'surfacewrap';
const hasObj = info.hasObject;
let modeCards;
if (isSW) {
modeCards = [
{ 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 = `
<div class="page-header">
<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>
<button class="btn btn-sm" onclick="openOutputFolder()">Open Folder</button>
<button class="btn btn-sm" onclick="closeProject()">Close Project</button>
</div>
<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}')">
<h3>${m.label}</h3>
<p>${m.desc}</p>
${m.active ? '<span class="badge" style="margin-top:8px">Configured</span>' : ''}
</div>
`).join('')}
${badge}
</div>`;
}).join('')}
</div>
`;
}
@ -190,12 +221,14 @@ async function initDashboard() {
async function createNewProject() {
const name = await showInputDialog('New Project', 'Project name:', 'My Project');
if (name === null) return;
const projectType = await showTypeDialog();
if (projectType === null) return;
try {
const path = await wails()?.CreateNewProject(name || 'Untitled');
const path = await wails()?.CreateNewProject(name || 'Untitled', projectType);
if (!path) return;
const info = await wails()?.GetProjectInfo();
if (info) {
state.project = { name: info.name, path: info.path };
state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard');
}
} 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() {
try {
const path = await wails()?.OpenProjectDialog();
if (!path) return;
const info = await wails()?.GetProjectInfo();
if (info) {
state.project = { name: info.name, path: info.path };
state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard');
}
} catch (e) {
@ -367,19 +433,51 @@ function showStencilResult(files) {
}
// ===== Enclosure Page =====
function initEnclosure() {
async function initEnclosure(prefill) {
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: '' };
// 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 = `
<div class="page-header">
<button class="btn" onclick="navigate(state.project ? 'dashboard' : 'landing')"> Back</button>
<h2>Enclosure Generator</h2>
<h2>Enclosure Generator${prefill ? ' — Reconfigure' : ''}</h2>
</div>
<div class="card">
<div class="card-title">Gerber Job File (.gbrjob)</div>
<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>
</div>
</div>
@ -387,7 +485,7 @@ function initEnclosure() {
<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>
<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 style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-sm" onclick="selectGerberFolder()">Select Folder...</button>
@ -399,7 +497,7 @@ function initEnclosure() {
<div class="card">
<div class="card-title">PTH Drill File (Optional)</div>
<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="clearDrill('pth')">Clear</button>
</div>
@ -407,7 +505,7 @@ function initEnclosure() {
<div class="card">
<div class="card-title">NPTH Drill File (Optional)</div>
<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="clearDrill('npth')">Clear</button>
</div>
@ -417,19 +515,19 @@ function initEnclosure() {
<div class="card-title">Parameters</div>
<div class="form-row">
<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 class="form-row">
<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 class="form-row">
<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 class="form-row">
<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 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 =====
async function initPreview() {
const page = $('#page-preview');
@ -593,8 +712,10 @@ async function initPreview() {
page.innerHTML = `
<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>
<div class="spacer" style="flex:1"></div>
<button class="btn btn-sm" onclick="reconfigureEnclosure()">Reconfigure</button>
</div>
<div class="card">
<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();
const info = await wails()?.GetProjectInfo();
if (info) {
state.project = { name: info.name, path: info.path };
state.project = { name: info.name, path: info.path, type: info.type };
navigate('dashboard');
}
} catch (e) {
@ -793,6 +914,28 @@ async function openProject(path) {
async function initVectorWrap() {
const page = $('#page-vectorwrap');
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 = `
<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-warnings" style="display:none;margin-top:6px"></div>
</div>
<div class="card" id="vw-model-card">
<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>
${modelCardHTML}
<div class="action-bar">
<div class="spacer"></div>
<button class="btn btn-primary" onclick="openVectorWrapFormer()" id="vw-open-btn" disabled>Open in Former</button>
</div>
`;
try {
const vwCheck = await wails()?.GetVectorWrapInfo();
if (vwCheck?.hasProjectEnclosure) {
const label = $('#vw-use-project-label');
if (label) label.style.display = 'block';
// PCB mode: auto-load project enclosure
if (!isSW) {
try {
const vwCheck = await wails()?.GetVectorWrapInfo();
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() {
@ -995,6 +1154,58 @@ function toggleVWAssembly() {
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 =====
let _unwrapSVG = '';
@ -1018,15 +1229,20 @@ async function initUnwrap() {
`;
}
let _unwrapSavedPath = '';
async function generateUnwrap() {
try {
showLoading('Generating unwrap template...');
const svg = await wails()?.GetUnwrapSVG();
const path = await wails()?.SaveUnwrapSVG();
hideLoading();
if (!svg) {
if (!path) {
alert('No enclosure session active. Set up an enclosure first.');
return;
}
_unwrapSavedPath = path;
const svg = await wails()?.GetUnwrapSVG();
_unwrapSVG = svg;
const preview = $('#unwrap-preview');
if (preview) {
@ -1038,35 +1254,36 @@ async function generateUnwrap() {
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) {
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 openUnwrapExternal() {
if (!_unwrapSavedPath) return;
try {
await wails()?.OpenFileExternal(_unwrapSavedPath);
} catch (e) {
alert('Failed to open file: ' + e);
}
}
async function importUnwrapArtwork() {
async function openUnwrapFolder() {
try {
const path = await wails()?.SelectFile('Select Artwork SVG', '*.svg');
if (!path) return;
showLoading('Importing artwork...');
await wails()?.ImportUnwrapArtwork(path);
hideLoading();
await wails()?.OpenOutputFolder();
} catch (e) {
hideLoading();
alert('Import failed: ' + e);
alert('Failed to open folder: ' + e);
}
}
@ -2773,9 +2990,10 @@ Object.assign(window, {
setVWOverlayOpacity, setVWOverlayZ, applyVWTransform, applyVWGridRes, toggleVWGrid, resetVWGrid,
selectStructuralSVG, generateStructural, updateStructuralLive, renderStructuralPreview,
applyScanPagePreset, generateScanSheet, showScanResult,
generateUnwrap, exportUnwrapSVG, importUnwrapArtwork,
generateUnwrap, openUnwrapExternal, openUnwrapFolder,
toggleUnwrapFolds, toggleUnwrapLabels, toggleUnwrapCutouts,
toggleVWCameraLock,
selectObjectModel, configureObject, reconfigureEnclosure,
});
// ===== Init =====

View File

@ -266,6 +266,38 @@ body {
letter-spacing: 0.5px;
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 {

View File

@ -3,8 +3,9 @@ export function buildUnwrapSidebar() {
<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>
<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="importUnwrapArtwork()" id="unwrap-import-btn" style="width:100%;margin-bottom:12px">Import Artwork</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="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>
<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 {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type,omitempty"` // "pcb" or "surfacewrap"; empty = legacy pcb
CreatedAt time.Time `json:"createdAt"`
Version int `json:"version"`
@ -14,10 +15,18 @@ type ProjectData struct {
VectorWrap *VectorWrapData `json:"vectorWrap,omitempty"`
Structural *StructuralData `json:"structural,omitempty"`
ScanHelper *ScanHelperData `json:"scanHelper,omitempty"`
Object *ObjectData `json:"object,omitempty"`
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 {
ShowGrid bool `json:"showGrid"`
TraditionalControls bool `json:"traditionalControls"`