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
This commit is contained in:
parent
e8da7fb77f
commit
6bf857e58c
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
17
enclosure.go
17
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
|
||||
|
|
|
|||
291
former.go
291
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,41 +236,55 @@ 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)
|
||||
rpx := r * dpi / 25.4
|
||||
|
||||
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)
|
||||
rpx := r * dpi / 25.4
|
||||
minPx := px1
|
||||
if px2 < minPx {
|
||||
minPx = px2
|
||||
}
|
||||
maxPx := px1
|
||||
if px2 > maxPx {
|
||||
maxPx = px2
|
||||
}
|
||||
minPy := py1
|
||||
if py2 < minPy {
|
||||
minPy = py2
|
||||
}
|
||||
maxPy := py1
|
||||
if py2 > maxPy {
|
||||
maxPy = py2
|
||||
}
|
||||
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,
|
||||
|
|
@ -132,12 +292,95 @@ func ExtractElementBBoxes(gf *GerberFile, dpi float64, bounds *Bounds) []Element
|
|||
MaxX: maxPx + rpx,
|
||||
MaxY: maxPy + rpx,
|
||||
Type: "trace",
|
||||
Shape: "rect",
|
||||
Footprint: cmd.Footprint,
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Navigation (includes macOS draggable titlebar region) -->
|
||||
<nav id="nav" style="--wails-draggable:drag">
|
||||
<span class="nav-brand" onclick="navigate('landing')">Former</span>
|
||||
<span class="nav-project-name" id="nav-project-name" style="display:none"></span>
|
||||
<div class="nav-spacer"></div>
|
||||
<div id="nav-mode-tabs" style="display:none">
|
||||
<button class="nav-btn" onclick="navigate('stencil')">Stencil</button>
|
||||
<button class="nav-btn" onclick="navigate('enclosure')">Enclosure</button>
|
||||
<button class="nav-btn" onclick="navigate('vectorwrap')">Vector Wrap</button>
|
||||
<button class="nav-btn" onclick="navigate('structural')">Structural</button>
|
||||
<button class="nav-btn" onclick="navigate('scanhelper')">Scan Helper</button>
|
||||
<button class="nav-btn" onclick="navigate('unwrap')">Unwrap</button>
|
||||
</div>
|
||||
<div class="nav-settings-wrap" style="--wails-draggable:no-drag">
|
||||
<button class="nav-btn nav-settings-btn" onclick="toggleSettings()" title="Settings">⚙</button>
|
||||
<div class="settings-panel" id="settings-panel" style="display:none">
|
||||
|
|
@ -30,14 +36,18 @@
|
|||
<button class="nav-btn" onclick="openOutputFolder()" id="nav-open-output" style="display:none">Open Output</button>
|
||||
</nav>
|
||||
|
||||
<!-- Pages -->
|
||||
<main id="main">
|
||||
<section id="page-landing" class="page active"></section>
|
||||
<section id="page-dashboard" class="page"></section>
|
||||
<section id="page-stencil" class="page"></section>
|
||||
<section id="page-enclosure" class="page"></section>
|
||||
<section id="page-preview" class="page"></section>
|
||||
<section id="page-stencil-result" class="page"></section>
|
||||
<section id="page-enclosure-result" class="page"></section>
|
||||
<section id="page-vectorwrap" class="page"></section>
|
||||
<section id="page-structural" class="page"></section>
|
||||
<section id="page-scanhelper" class="page"></section>
|
||||
<section id="page-unwrap" class="page"></section>
|
||||
<section id="page-former" class="page page-former"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export function createStructuralMode() {
|
||||
return {
|
||||
name: 'structural',
|
||||
engine: null,
|
||||
|
||||
install(engine) {
|
||||
this.engine = engine;
|
||||
},
|
||||
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
|
@ -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; }
|
||||
File diff suppressed because it is too large
Load Diff
1099
frontend/src/main.js
1099
frontend/src/main.js
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
|
|
@ -822,3 +843,55 @@ select.form-input {
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
export function buildEnclosureSidebar(layers, esc) {
|
||||
return `
|
||||
<div class="former-sidebar-header">
|
||||
<h3>Layers</h3>
|
||||
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
|
||||
</div>
|
||||
<div class="former-layers" id="former-layers">
|
||||
${layers.map((l, i) => `
|
||||
<div class="former-layer-row ${l.highlight ? 'highlighted' : ''}" id="layer-row-${i}" onclick="selectLayerFromSidebar(${i})">
|
||||
<button class="layer-vis-btn ${l.visible ? 'active' : ''}" id="layer-vis-${i}" onclick="event.stopPropagation();toggleLayerVis(${i})" title="Toggle visibility">${l.visible ? '👁' : '○'}</button>
|
||||
<button class="layer-hl-btn ${l.highlight ? 'active' : ''}" id="layer-hl-${i}" onclick="event.stopPropagation();toggleLayerHL(${i})" title="Highlight">◉</button>
|
||||
<div class="layer-swatch" style="background:${l.colorHex}"></div>
|
||||
<span class="layer-name" id="layer-name-${i}">${esc(l.name)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="former-selection-tools" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light)">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">Selected: <span id="former-sel-name">—</span></div>
|
||||
<div style="font-size:10px;color:var(--text-subtle);margin-bottom:6px">Z Position</div>
|
||||
<div class="z-joystick" id="z-joystick">
|
||||
<div class="z-joystick-track"></div>
|
||||
<span class="z-joystick-label-l">−</span>
|
||||
<span class="z-joystick-label-r">+</span>
|
||||
<div class="z-joystick-knob" id="z-joystick-knob"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px">
|
||||
<button class="btn btn-sm" onclick="formerSelectCutoutElement()" style="border-color:var(--accent);color:var(--accent)">Select Cutout Element</button>
|
||||
<button class="btn btn-sm" onclick="formerSelectDadoElement()" style="border-color:#f9e2af;color:#f9e2af">Engrave Text</button>
|
||||
<button class="btn btn-sm" onclick="formerCutoutAll()" style="border-color:var(--warning);color:var(--warning)">Cutout All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="former-cutout-tools" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(137,180,250,0.08)">
|
||||
<div style="font-size:12px;color:var(--accent);margin-bottom:4px;font-weight:600">Cutout Selection Mode</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:6px">Click elements to select/deselect. Esc to exit.</div>
|
||||
<div id="former-cutout-status" style="font-size:11px;color:var(--text-subtle);margin-bottom:6px">0 elements selected</div>
|
||||
<button class="btn btn-sm" onclick="formerExitCutoutMode()" style="width:100%">Done</button>
|
||||
</div>
|
||||
<div id="former-cutout-edit" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(137,180,250,0.06)"></div>
|
||||
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
|
||||
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
|
||||
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-sm" onclick="formerReturnToEditor()" style="flex:1">Return to Editor</button>
|
||||
<button class="btn btn-sm" onclick="openOutputFolder()" style="flex:1;border-color:var(--accent);color:var(--accent)">Open Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
export function buildStructuralSidebar(modeTitle, sInfo, esc) {
|
||||
return `
|
||||
<div class="former-sidebar-header">
|
||||
<h3>${modeTitle}</h3>
|
||||
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
|
||||
</div>
|
||||
<div style="padding:12px 16px">
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||||
${sInfo?.svgPath ? esc(sInfo.svgPath.split('/').pop()) : 'No SVG'} |
|
||||
${sInfo?.svgWidth?.toFixed(1) || '?'} × ${sInfo?.svgHeight?.toFixed(1) || '?'} mm
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:70px">Pattern</span>
|
||||
<select class="form-input" id="sf-pattern" style="font-size:11px" onchange="updateStructuralLive()">
|
||||
${['hexagon','triangle','diamond','voronoi','grid','gyroid'].map(p =>
|
||||
`<option value="${p}" ${p === sInfo?.pattern ? 'selected' : ''}>${p}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:70px">Cell (mm)</span>
|
||||
<input class="form-input" id="sf-cell" type="number" step="0.5" value="${sInfo?.cellSize || 10}" style="font-size:11px" onchange="updateStructuralLive()">
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:70px">Wall (mm)</span>
|
||||
<input class="form-input" id="sf-wall" type="number" step="0.1" value="${sInfo?.wallThick || 1.2}" style="font-size:11px" onchange="updateStructuralLive()">
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:70px">Height (mm)</span>
|
||||
<input class="form-input" id="sf-height" type="number" step="1" value="${sInfo?.height || 20}" style="font-size:11px" onchange="updateStructuralLive()">
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn btn-sm btn-primary" onclick="renderStructuralPreview()" style="width:100%">Render Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
|
||||
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
|
||||
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-sm" onclick="formerReturnToEditor()" style="flex:1">Return to Editor</button>
|
||||
<button class="btn btn-sm" onclick="openOutputFolder()" style="flex:1;border-color:var(--accent);color:var(--accent)">Open Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export function buildUnwrapSidebar() {
|
||||
return `
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<input type="checkbox" checked id="unwrap-show-folds" onchange="toggleUnwrapFolds(this.checked)"> Fold lines
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">
|
||||
<input type="checkbox" checked id="unwrap-show-labels" onchange="toggleUnwrapLabels(this.checked)"> Labels
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-secondary);cursor:pointer;margin:4px 0">
|
||||
<input type="checkbox" checked id="unwrap-show-cutouts" onchange="toggleUnwrapCutouts(this.checked)"> Cutouts
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
export function buildVectorWrapSidebar(modeTitle) {
|
||||
return `
|
||||
<div class="former-sidebar-header">
|
||||
<h3>${modeTitle}</h3>
|
||||
<button class="btn btn-sm" onclick="navigate(state.project ? 'dashboard' : 'landing')">Close</button>
|
||||
</div>
|
||||
<div style="padding:12px 16px">
|
||||
<div style="font-size:11px;color:var(--text-subtle);margin-bottom:10px">Drag blue control points to deform the overlay.</div>
|
||||
|
||||
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:8px 0 4px;letter-spacing:0.5px">Display</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">Opacity</span>
|
||||
<input type="range" min="0" max="100" value="70" id="vw-opacity" oninput="setVWOverlayOpacity(this.value)" style="flex:1">
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">Z Offset</span>
|
||||
<input type="range" min="-50" max="100" value="2" id="vw-zoffset" oninput="setVWOverlayZ(this.value)" style="flex:1">
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Transform</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">X</span>
|
||||
<input class="form-input" id="vw-tx" type="number" step="1" value="0" style="font-size:11px;width:60px" onchange="applyVWTransform()">
|
||||
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">Y</span>
|
||||
<input class="form-input" id="vw-ty" type="number" step="1" value="0" style="font-size:11px;width:60px" onchange="applyVWTransform()">
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">Rotate</span>
|
||||
<input class="form-input" id="vw-rot" type="number" step="5" value="0" style="font-size:11px;flex:1" onchange="applyVWTransform()">
|
||||
<span style="font-size:10px;color:var(--text-subtle);margin-left:4px">deg</span>
|
||||
</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">Scale X</span>
|
||||
<input class="form-input" id="vw-sx" type="number" step="0.1" value="1" style="font-size:11px;width:60px" onchange="applyVWTransform()">
|
||||
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">Y</span>
|
||||
<input class="form-input" id="vw-sy" type="number" step="0.1" value="1" style="font-size:11px;width:60px" onchange="applyVWTransform()">
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Surface Wrap</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:8px">
|
||||
<button class="btn btn-sm sidebar-icon-btn" id="vw-lock-btn" onclick="toggleVWCameraLock()" title="Lock camera for surface wrapping" style="flex:1">
|
||||
<span id="vw-lock-icon">🔓︎</span> Lock Camera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Mesh Deform Grid</div>
|
||||
<div class="form-row" style="margin:4px 0">
|
||||
<span class="form-label" style="font-size:11px;min-width:60px">Cols</span>
|
||||
<input class="form-input" id="vw-grid-cols" type="number" min="2" max="16" step="1" value="4" style="font-size:11px;width:50px">
|
||||
<span class="form-label" style="font-size:11px;min-width:20px;text-align:center">x</span>
|
||||
<input class="form-input" id="vw-grid-rows" type="number" min="2" max="16" step="1" value="4" style="font-size:11px;width:50px">
|
||||
<button class="btn btn-sm" onclick="applyVWGridRes()" style="font-size:10px;padding:2px 6px;margin-left:4px">Set</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-top:6px">
|
||||
<button class="btn btn-sm" onclick="toggleVWGrid()" id="vw-grid-toggle" style="flex:1">Hide Grid</button>
|
||||
<button class="btn btn-sm" onclick="resetVWGrid()" style="flex:1;border-color:var(--warning);color:var(--warning)">Reset</button>
|
||||
</div>
|
||||
|
||||
<div id="vw-enclosure-parts" style="display:none">
|
||||
<div style="font-size:10px;color:var(--text-subtle);text-transform:uppercase;margin:12px 0 4px;letter-spacing:0.5px">Enclosure Parts</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm" id="vw-toggle-lid" onclick="toggleVWLid()" style="flex:1">Hide Lid</button>
|
||||
<button class="btn btn-sm" id="vw-toggle-tray" onclick="toggleVWTray()" style="flex:1">Hide Tray</button>
|
||||
</div>
|
||||
<div style="margin-top:4px">
|
||||
<button class="btn btn-sm" id="vw-toggle-assembly" onclick="toggleVWAssembly()" style="width:100%">Take Off Lid</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="former-render-result" style="display:none;padding:8px 16px;border-top:1px solid var(--border-light);background:rgba(166,227,161,0.08)">
|
||||
<div style="font-size:12px;color:var(--success);margin-bottom:4px;font-weight:600">Render Complete</div>
|
||||
<div id="former-render-files" style="font-size:11px;color:var(--text-secondary);margin-bottom:6px;font-family:var(--font-mono);line-height:1.5"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
76
scad.go
76
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
width="%.1fmm" height="%.1fmm"
|
||||
viewBox="0 0 %.1f %.1f">
|
||||
`, pageW, pageH, pageW, pageH))
|
||||
|
||||
// White background
|
||||
b.WriteString(fmt.Sprintf(`<rect x="0" y="0" width="%.1f" height="%.1f" fill="white"/>
|
||||
`, 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(`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="none" stroke="#ccc" stroke-width="0.3"/>
|
||||
`, areaX, areaY, areaW, areaH))
|
||||
|
||||
// Grid lines
|
||||
b.WriteString(`<g stroke="#ddd" stroke-width="0.15">` + "\n")
|
||||
for x := areaX; x <= areaX+areaW+0.01; x += gridSpacing {
|
||||
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, areaY, x, areaY+areaH))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for y := areaY; y <= areaY+areaH+0.01; y += gridSpacing {
|
||||
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, areaX, y, areaX+areaW, y))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("</g>\n")
|
||||
|
||||
// Major grid lines every 5 cells
|
||||
majorSpacing := gridSpacing * 5
|
||||
b.WriteString(`<g stroke="#aaa" stroke-width="0.3">` + "\n")
|
||||
for x := areaX; x <= areaX+areaW+0.01; x += majorSpacing {
|
||||
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, x, areaY, x, areaY+areaH))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for y := areaY; y <= areaY+areaH+0.01; y += majorSpacing {
|
||||
b.WriteString(fmt.Sprintf(` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f"/>`, areaX, y, areaX+areaW, y))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("</g>\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(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.3"/>`,
|
||||
cx-markerSize, cy, cx+markerSize, cy))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.3"/>`,
|
||||
cx, cy-markerSize, cx, cy+markerSize))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<circle cx="%.2f" cy="%.2f" r="1" fill="none" stroke="black" stroke-width="0.3"/>`,
|
||||
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(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="3" fill="#999" text-anchor="end">Page %d,%d of %dx%d</text>`,
|
||||
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(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
||||
areaX, refY, areaX+10, refY))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
||||
areaX, refY-1.5, areaX, refY+1.5))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
||||
areaX+10, refY-1.5, areaX+10, refY+1.5))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2.5" fill="black" text-anchor="middle">10mm</text>`,
|
||||
areaX+5, refY+3.5))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Grid spacing label
|
||||
b.WriteString(fmt.Sprintf(`<text x="%.2f" y="%.2f" font-family="monospace" font-size="2" fill="#999">Grid: %.0fmm</text>`,
|
||||
areaX+15, refY+0.8, gridSpacing))
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("</svg>\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func writeCornerMark(b *strings.Builder, x, y, size, dx, dy float64) {
|
||||
// L-shaped corner mark
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
||||
x, y, x+size*dx, y))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.5"/>`,
|
||||
x, y, x, y+size*dy))
|
||||
b.WriteString("\n")
|
||||
// Small filled circle at corner
|
||||
b.WriteString(fmt.Sprintf(`<circle cx="%.2f" cy="%.2f" r="0.8" fill="black"/>`, 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(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.4"/>`,
|
||||
e[0]-size/2, e[1], e[0]+size/2, e[1]))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="black" stroke-width="0.4"/>`,
|
||||
e[0], e[1]-size/2, e[0], e[1]+size/2))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
441
storage.go
441
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
|
||||
type ProjectEntry struct {
|
||||
Path string
|
||||
Type string // "session" or "profile"
|
||||
Data InstanceData
|
||||
ModTime time.Time
|
||||
// ======== .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)
|
||||
}
|
||||
|
||||
// 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"
|
||||
proj := &ProjectData{
|
||||
ID: randomID(),
|
||||
Name: strings.TrimSuffix(filepath.Base(path), ".former"),
|
||||
CreatedAt: time.Now(),
|
||||
Version: 1,
|
||||
Settings: ProjectSettings{ShowGrid: true},
|
||||
}
|
||||
id := inst.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
if err := SaveProject(path, proj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
AddRecentProject(path, proj.Name)
|
||||
return proj, 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, "", " ")
|
||||
// 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
|
||||
}
|
||||
return os.WriteFile(filepath.Join(projectDir, "former.json"), data, 0644)
|
||||
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"))
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
|
||||
var entries []ProjectEntry
|
||||
for _, de := range dirEntries {
|
||||
if !de.IsDir() {
|
||||
// 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
|
||||
}
|
||||
jsonPath := filepath.Join(dir, de.Name(), "former.json")
|
||||
info, err := os.Stat(jsonPath)
|
||||
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 err := json.Unmarshal(raw, &inst); err != nil {
|
||||
if json.Unmarshal(raw, &inst) != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ProjectEntry{
|
||||
Path: filepath.Join(dir, de.Name()),
|
||||
Type: projType,
|
||||
Data: inst,
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
|
||||
name := inst.ProjectName
|
||||
if inst.Name != "" {
|
||||
name = inst.Name
|
||||
}
|
||||
return entries, nil
|
||||
if name == "" {
|
||||
name = "Untitled"
|
||||
}
|
||||
safeName := sanitizeDirName(name)
|
||||
if safeName == "" {
|
||||
safeName = "untitled"
|
||||
}
|
||||
|
||||
// LoadProject reads former.json from a project directory
|
||||
func LoadProject(projectDir string) (*InstanceData, error) {
|
||||
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
|
||||
Data InstanceData
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// 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 == '|' {
|
||||
|
|
|
|||
|
|
@ -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 (<image>) 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
|
||||
}
|
||||
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.2f %.2f" width="%.2fmm" height="%.2fmm">`+"\n",
|
||||
layout.TotalW, layout.TotalH, layout.TotalW, layout.TotalH))
|
||||
|
||||
b.WriteString(`<style>`)
|
||||
b.WriteString(`.panel { fill: none; stroke: #333; stroke-width: 0.3; }`)
|
||||
b.WriteString(`.cutout { fill: #e0e0e0; stroke: #999; stroke-width: 0.2; }`)
|
||||
b.WriteString(`.tab-slot { fill: #ccc; stroke: #999; stroke-width: 0.15; }`)
|
||||
b.WriteString(`.fold-line { stroke: #aaa; stroke-width: 0.15; stroke-dasharray: 2,1; }`)
|
||||
b.WriteString(`.label { font-family: sans-serif; font-size: 4px; fill: #666; text-anchor: middle; dominant-baseline: central; }`)
|
||||
b.WriteString("</style>\n")
|
||||
|
||||
for _, p := range layout.Panels {
|
||||
b.WriteString(fmt.Sprintf(`<g id="panel-%s" data-face="%s" data-side="%d" data-x="%.2f" data-y="%.2f" data-w="%.2f" data-h="%.2f">`+"\n",
|
||||
p.Label, p.FaceType, p.SideNum, p.X, p.Y, p.Width, p.Height))
|
||||
|
||||
// Panel outline
|
||||
if len(p.Polygon) > 2 {
|
||||
b.WriteString(`<path class="panel" d="`)
|
||||
for i, pt := range p.Polygon {
|
||||
cmd := "L"
|
||||
if i == 0 {
|
||||
cmd = "M"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%.3f,%.3f ", cmd, pt[0]+p.X, pt[1]+p.Y))
|
||||
}
|
||||
b.WriteString("Z\" />\n")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`<rect class="panel" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\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(`<circle class="cutout" cx="%.3f" cy="%.3f" r="%.3f" />`+"\n",
|
||||
c.X+r, c.Y+r, r))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`<rect class="cutout" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\n",
|
||||
c.X, c.Y, c.Width, c.Height))
|
||||
}
|
||||
}
|
||||
|
||||
// Tab slots
|
||||
for _, t := range p.TabSlots {
|
||||
b.WriteString(fmt.Sprintf(`<rect class="tab-slot" x="%.3f" y="%.3f" width="%.3f" height="%.3f" />`+"\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(`<text class="label" x="%.2f" y="%.2f">%s</text>`+"\n", cx, cy, p.Label))
|
||||
|
||||
b.WriteString("</g>\n")
|
||||
}
|
||||
|
||||
// Fold lines between lid and side panels
|
||||
for _, p := range layout.Panels {
|
||||
if p.FaceType == "side" {
|
||||
b.WriteString(fmt.Sprintf(`<line class="fold-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" />`+"\n",
|
||||
p.X, p.Y, p.X+p.Width, p.Y))
|
||||
}
|
||||
if p.FaceType == "tray" {
|
||||
b.WriteString(fmt.Sprintf(`<line class="fold-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" />`+"\n",
|
||||
p.X, p.Y, p.X+p.Width, p.Y))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("</svg>")
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue