1401 lines
37 KiB
Go
1401 lines
37 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// FaceAdjacency describes which faces share edges.
|
||
// Key: face number, Value: list of {neighbor face num, edge index on this face, edge index on neighbor}.
|
||
type FaceAdjacency map[int][]EdgeLink
|
||
|
||
type EdgeLink struct {
|
||
Neighbor int // neighbor face number
|
||
EdgeIdx int // edge index on this face (0 = edge from vertex 0→1)
|
||
NeighborEdge int // edge index on the neighbor face
|
||
}
|
||
|
||
// ReconstructedObject holds the 3D reconstruction from traced faces.
|
||
type ReconstructedObject struct {
|
||
Faces []TracedFace
|
||
Adjacency FaceAdjacency
|
||
Vertices3D [][3]float64 // unique 3D vertices
|
||
FaceIndices [][]int // per-face vertex index lists
|
||
SCAD string // generated SCAD source
|
||
}
|
||
|
||
// faceEdgeInfo holds parallelogram parameters extracted from a traced face.
|
||
type faceEdgeInfo struct {
|
||
len1, len2 float64 // edge lengths, len1 >= len2
|
||
angle float64 // angle between them in radians, normalized to (0, π/2]
|
||
}
|
||
|
||
// InferCuboid reconstructs a 6-faced solid from traced face outlines.
|
||
// Fits quadrilateral corners to each face, extracts edge lengths and angles,
|
||
// and builds a parallelepiped (generalized cuboid with non-right angles).
|
||
func InferCuboid(faces []TracedFace, totalFaces int) (*ReconstructedObject, string, error) {
|
||
if totalFaces != 6 {
|
||
return nil, "", fmt.Errorf("cuboid inference requires 6-faced object")
|
||
}
|
||
if len(faces) < 1 {
|
||
return nil, "", fmt.Errorf("need at least 1 face")
|
||
}
|
||
|
||
var infos []faceEdgeInfo
|
||
for _, f := range faces {
|
||
info := measureFaceEdges(f.Outline)
|
||
infos = append(infos, info)
|
||
debugLog("InferCuboid: face %d → %.1f × %.1f mm, angle=%.1f°",
|
||
f.FaceNum, info.len1, info.len2, info.angle*180/math.Pi)
|
||
}
|
||
|
||
if len(faces) < 6 {
|
||
var dims [][2]float64
|
||
for _, info := range infos {
|
||
dims = append(dims, [2]float64{info.len1, info.len2})
|
||
}
|
||
W, H, D := fitCuboidPartial(dims)
|
||
return buildRectCuboidResult(W, H, D, faces)
|
||
}
|
||
|
||
assign := fitParallelepipedAssignment(infos)
|
||
debugLog("InferCuboid: assignment err=%.1f, W=%.1f H=%.1f D=%.1f",
|
||
assign.err, assign.W, assign.H, assign.D)
|
||
debugLog("InferCuboid: angles AB=%.1f° AC=%.1f° BC=%.1f°",
|
||
assign.angleAB*180/math.Pi, assign.angleAC*180/math.Pi, assign.angleBC*180/math.Pi)
|
||
|
||
va, vb, vc := reconstructEdgeVectors(assign)
|
||
debugLog("InferCuboid: vectors a=(%.2f,%.2f,%.2f) b=(%.2f,%.2f,%.2f) c=(%.2f,%.2f,%.2f)",
|
||
va[0], va[1], va[2], vb[0], vb[1], vb[2], vc[0], vc[1], vc[2])
|
||
|
||
verts := parallelepipedVerts(va, vb, vc)
|
||
faceIdxs := parallelepipedFaceIndices()
|
||
scad := buildParallelepipedSCAD(verts, faceIdxs)
|
||
|
||
obj := &ReconstructedObject{
|
||
Faces: faces,
|
||
Vertices3D: verts,
|
||
FaceIndices: faceIdxs,
|
||
SCAD: scad,
|
||
}
|
||
msg := fmt.Sprintf("parallelepiped: %.1f × %.1f × %.1f mm (angles %.0f° %.0f° %.0f°) from %d faces",
|
||
assign.W, assign.H, assign.D,
|
||
assign.angleAB*180/math.Pi, assign.angleAC*180/math.Pi, assign.angleBC*180/math.Pi,
|
||
len(faces))
|
||
return obj, msg, nil
|
||
}
|
||
|
||
// GuessFacePairing returns the auto-detected best pairing of 6 faces into
|
||
// 3 opposite pairs (WH, WD, HD). Returns face numbers (not indices).
|
||
func GuessFacePairing(faces []TracedFace) *FacePairingJS {
|
||
if len(faces) < 6 {
|
||
return nil
|
||
}
|
||
var infos []faceEdgeInfo
|
||
for _, f := range faces {
|
||
infos = append(infos, measureFaceEdges(f.Outline))
|
||
}
|
||
assign := fitParallelepipedAssignment(infos)
|
||
return &FacePairingJS{
|
||
Pair1: [2]int{faces[assign.whPair[0]].FaceNum, faces[assign.whPair[1]].FaceNum},
|
||
Pair2: [2]int{faces[assign.wdPair[0]].FaceNum, faces[assign.wdPair[1]].FaceNum},
|
||
Pair3: [2]int{faces[assign.hdPair[0]].FaceNum, faces[assign.hdPair[1]].FaceNum},
|
||
}
|
||
}
|
||
|
||
// quadMeasure holds full edge info for a traced quad (not reduced to parallelogram).
|
||
type quadMeasure struct {
|
||
corners [4][2]float64
|
||
edges [4]float64 // |c0→c1|, |c1→c2|, |c2→c3|, |c3→c0|
|
||
height float64 // perpendicular distance between the most-parallel pair of opposite edges
|
||
topLen float64 // shorter of the two parallel edges (top of trapezoid)
|
||
botLen float64 // longer of the two parallel edges (bottom of trapezoid)
|
||
isRect bool // both opposite edge pairs roughly equal
|
||
}
|
||
|
||
func measureQuad(outline [][2]float64) quadMeasure {
|
||
c := fitQuadCorners(outline)
|
||
var q quadMeasure
|
||
q.corners = c
|
||
for i := 0; i < 4; i++ {
|
||
j := (i + 1) % 4
|
||
dx := c[j][0] - c[i][0]
|
||
dy := c[j][1] - c[i][1]
|
||
q.edges[i] = math.Sqrt(dx*dx + dy*dy)
|
||
}
|
||
|
||
// Check how parallel each pair of opposite edges is.
|
||
// Pair A: edge 0 (c0→c1) vs edge 2 (c2→c3)
|
||
// Pair B: edge 1 (c1→c2) vs edge 3 (c3→c0)
|
||
perpDist := func(a0, a1, b0, b1 [2]float64) float64 {
|
||
// Average perpendicular distance between two line segments
|
||
dx := a1[0] - a0[0]
|
||
dy := a1[1] - a0[1]
|
||
l := math.Sqrt(dx*dx + dy*dy)
|
||
if l < 1e-9 {
|
||
return 0
|
||
}
|
||
nx, ny := -dy/l, dx/l
|
||
d1 := (b0[0]-a0[0])*nx + (b0[1]-a0[1])*ny
|
||
d2 := (b1[0]-a0[0])*nx + (b1[1]-a0[1])*ny
|
||
return math.Abs(d1+d2) / 2
|
||
}
|
||
|
||
hA := perpDist(c[0], c[1], c[3], c[2])
|
||
hB := perpDist(c[1], c[2], c[0], c[3])
|
||
|
||
// A trapezoid has one pair of parallel sides: pick the pair with more similar directions
|
||
dir := func(a, b [2]float64) [2]float64 {
|
||
dx := b[0] - a[0]
|
||
dy := b[1] - a[1]
|
||
l := math.Sqrt(dx*dx + dy*dy)
|
||
if l < 1e-9 {
|
||
return [2]float64{1, 0}
|
||
}
|
||
return [2]float64{dx / l, dy / l}
|
||
}
|
||
d0 := dir(c[0], c[1])
|
||
d2 := dir(c[2], c[3])
|
||
d1 := dir(c[1], c[2])
|
||
d3 := dir(c[3], c[0])
|
||
// Parallelism = |dot product| (1 = perfectly parallel)
|
||
parA := math.Abs(d0[0]*d2[0] + d0[1]*d2[1])
|
||
parB := math.Abs(d1[0]*d3[0] + d1[1]*d3[1])
|
||
|
||
diff02 := math.Abs(q.edges[0]-q.edges[2]) / math.Max(q.edges[0], q.edges[2]+1e-9)
|
||
diff13 := math.Abs(q.edges[1]-q.edges[3]) / math.Max(q.edges[1], q.edges[3]+1e-9)
|
||
q.isRect = diff02 < 0.15 && diff13 < 0.15
|
||
|
||
if parA >= parB {
|
||
// edges 0,2 are the parallel pair (top/bottom of trapezoid)
|
||
q.height = hA
|
||
if q.edges[0] >= q.edges[2] {
|
||
q.botLen, q.topLen = q.edges[0], q.edges[2]
|
||
} else {
|
||
q.botLen, q.topLen = q.edges[2], q.edges[0]
|
||
}
|
||
} else {
|
||
// edges 1,3 are the parallel pair
|
||
q.height = hB
|
||
if q.edges[1] >= q.edges[3] {
|
||
q.botLen, q.topLen = q.edges[1], q.edges[3]
|
||
} else {
|
||
q.botLen, q.topLen = q.edges[3], q.edges[1]
|
||
}
|
||
}
|
||
return q
|
||
}
|
||
|
||
// assignWH determines which of two edge measurements (e1, e2) is width vs height
|
||
// by matching against existing measurements. If no prior measurements, returns (larger, smaller).
|
||
func assignWH(e1, e2, priorW, priorH float64) (w, h float64) {
|
||
if priorW == 0 && priorH == 0 {
|
||
if e1 >= e2 {
|
||
return e1, e2
|
||
}
|
||
return e2, e1
|
||
}
|
||
err12 := sqDiff(e1, priorW) + sqDiff(e2, priorH)
|
||
err21 := sqDiff(e2, priorW) + sqDiff(e1, priorH)
|
||
if err12 <= err21 {
|
||
return e1, e2
|
||
}
|
||
return e2, e1
|
||
}
|
||
|
||
// InferCuboidWithPairing reconstructs a general hexahedron from traced faces.
|
||
// Every face contributes equally — side trapezoids, top/bottom rectangles all
|
||
// provide measurements of the 5 unknowns: wBot, hBot, wTop, hTop, Z.
|
||
// Pair 1 = front/back, Pair 2 = left/right, Pair 3 = top/bottom.
|
||
func InferCuboidWithPairing(faces []TracedFace, pairing FacePairingJS) (*ReconstructedObject, string, error) {
|
||
faceMap := map[int]TracedFace{}
|
||
for _, f := range faces {
|
||
faceMap[f.FaceNum] = f
|
||
}
|
||
|
||
getQuad := func(fn int) (quadMeasure, bool) {
|
||
f, ok := faceMap[fn]
|
||
if !ok || len(f.Outline) < 3 {
|
||
return quadMeasure{}, false
|
||
}
|
||
return measureQuad(f.Outline), true
|
||
}
|
||
|
||
avg := func(v []float64) float64 {
|
||
if len(v) == 0 {
|
||
return 0
|
||
}
|
||
s := 0.0
|
||
for _, x := range v {
|
||
s += x
|
||
}
|
||
return s / float64(len(v))
|
||
}
|
||
|
||
// Collect ALL measurements of each unknown from every face, based on its role.
|
||
// Front/back face (trapezoid): parallel edges → wBot, wTop
|
||
// Left/right face (trapezoid): parallel edges → hBot, hTop
|
||
// Top face (rectangle): edges → wTop, hTop
|
||
// Bottom face (rectangle): edges → wBot, hBot
|
||
var wBm, wTm, hBm, hTm []float64
|
||
var fbSlants, lrSlants []float64
|
||
|
||
// Front/back faces: parallel edges measure wBot and wTop
|
||
for _, fn := range [2]int{pairing.Pair1[0], pairing.Pair1[1]} {
|
||
if fn == 0 {
|
||
continue
|
||
}
|
||
q, ok := getQuad(fn)
|
||
if !ok {
|
||
continue
|
||
}
|
||
wBm = append(wBm, q.botLen)
|
||
wTm = append(wTm, q.topLen)
|
||
fbSlants = append(fbSlants, q.height)
|
||
debugLog(" face %d (front/back): wB=%.1f wT=%.1f slant=%.1f", fn, q.botLen, q.topLen, q.height)
|
||
}
|
||
|
||
// Left/right faces: parallel edges measure hBot and hTop
|
||
for _, fn := range [2]int{pairing.Pair2[0], pairing.Pair2[1]} {
|
||
if fn == 0 {
|
||
continue
|
||
}
|
||
q, ok := getQuad(fn)
|
||
if !ok {
|
||
continue
|
||
}
|
||
hBm = append(hBm, q.botLen)
|
||
hTm = append(hTm, q.topLen)
|
||
lrSlants = append(lrSlants, q.height)
|
||
debugLog(" face %d (left/right): hB=%.1f hT=%.1f slant=%.1f", fn, q.botLen, q.topLen, q.height)
|
||
}
|
||
|
||
// Top/bottom faces: rectangles contributing wBot/wTop and hBot/hTop
|
||
tbNums := [2]int{pairing.Pair3[0], pairing.Pair3[1]}
|
||
seen := map[int]bool{}
|
||
var tbQuads []quadMeasure
|
||
for _, fn := range tbNums {
|
||
if fn == 0 || seen[fn] {
|
||
continue
|
||
}
|
||
seen[fn] = true
|
||
q, ok := getQuad(fn)
|
||
if !ok {
|
||
continue
|
||
}
|
||
tbQuads = append(tbQuads, q)
|
||
debugLog(" face %d (top/bottom): edges=[%.1f, %.1f, %.1f, %.1f]",
|
||
fn, q.edges[0], q.edges[1], q.edges[2], q.edges[3])
|
||
}
|
||
|
||
// Incorporate top/bottom face measurements.
|
||
// A TB face is a rectangle — its two edge pair averages are two of {wBot, hBot, wTop, hTop}.
|
||
// Use side face measurements (if any) to determine which edge is w vs h.
|
||
if len(tbQuads) == 2 {
|
||
q1, q2 := tbQuads[0], tbQuads[1]
|
||
a1 := q1.edges[0] * q1.edges[1]
|
||
a2 := q2.edges[0] * q2.edges[1]
|
||
bot, top := q1, q2
|
||
if a2 > a1 {
|
||
bot, top = q2, q1
|
||
}
|
||
be1 := (bot.edges[0] + bot.edges[2]) / 2
|
||
be2 := (bot.edges[1] + bot.edges[3]) / 2
|
||
te1 := (top.edges[0] + top.edges[2]) / 2
|
||
te2 := (top.edges[1] + top.edges[3]) / 2
|
||
|
||
// Determine w vs h assignment by matching to side face data
|
||
bw, bh := assignWH(be1, be2, avg(wBm), avg(hBm))
|
||
tw, th := assignWH(te1, te2, avg(wTm), avg(hTm))
|
||
wBm = append(wBm, bw)
|
||
hBm = append(hBm, bh)
|
||
wTm = append(wTm, tw)
|
||
hTm = append(hTm, th)
|
||
debugLog(" TB 2 faces: bot=%.1f×%.1f top=%.1f×%.1f", bw, bh, tw, th)
|
||
} else if len(tbQuads) == 1 {
|
||
q := tbQuads[0]
|
||
e1 := (q.edges[0] + q.edges[2]) / 2
|
||
e2 := (q.edges[1] + q.edges[3]) / 2
|
||
|
||
// Same face for both top and bottom — it represents one of them.
|
||
// Add as both-end constraint only if we don't have side data for that dimension.
|
||
w, h := assignWH(e1, e2, avg(wBm), avg(hBm))
|
||
if len(wBm) == 0 {
|
||
wBm = append(wBm, w)
|
||
}
|
||
if len(wTm) == 0 {
|
||
wTm = append(wTm, w)
|
||
}
|
||
if len(hBm) == 0 {
|
||
hBm = append(hBm, h)
|
||
}
|
||
if len(hTm) == 0 {
|
||
hTm = append(hTm, h)
|
||
}
|
||
debugLog(" TB 1 face: w=%.1f h=%.1f (fill only missing dims)", w, h)
|
||
}
|
||
|
||
wBot := avg(wBm)
|
||
wTop := avg(wTm)
|
||
hBot := avg(hBm)
|
||
hTop := avg(hTm)
|
||
|
||
debugLog(" averaged: wBot=%.1f wTop=%.1f hBot=%.1f hTop=%.1f", wBot, wTop, hBot, hTop)
|
||
|
||
// Solve Z from side trapezoid slant heights.
|
||
// Front/back: slantHeight² = Z² + ((hBot-hTop)/2)²
|
||
// Left/right: slantHeight² = Z² + ((wBot-wTop)/2)²
|
||
var Z float64
|
||
zCount := 0
|
||
|
||
fbAvg := avg(fbSlants)
|
||
if fbAvg > 0 {
|
||
hDiff := (hBot - hTop) / 2
|
||
zSq := fbAvg*fbAvg - hDiff*hDiff
|
||
if zSq > 0 {
|
||
Z += math.Sqrt(zSq)
|
||
} else {
|
||
Z += fbAvg
|
||
}
|
||
zCount++
|
||
debugLog(" Z from front/back: %.1f (slant=%.1f, hDiff=%.1f)", Z, fbAvg, hDiff)
|
||
}
|
||
lrAvg := avg(lrSlants)
|
||
if lrAvg > 0 {
|
||
wDiff := (wBot - wTop) / 2
|
||
zSq := lrAvg*lrAvg - wDiff*wDiff
|
||
if zSq > 0 {
|
||
Z += math.Sqrt(zSq)
|
||
} else {
|
||
Z += lrAvg
|
||
}
|
||
zCount++
|
||
debugLog(" Z from left/right: %.1f (slant=%.1f, wDiff=%.1f)", Z/float64(zCount), lrAvg, wDiff)
|
||
}
|
||
|
||
if zCount > 0 {
|
||
Z /= float64(zCount)
|
||
} else if wBot > 0 {
|
||
Z = math.Min(wBot, hBot) / 2
|
||
}
|
||
|
||
debugLog("InferCuboidWithPairing: frustum bot=%.1f×%.1f top=%.1f×%.1f Z=%.1f",
|
||
wBot, hBot, wTop, hTop, Z)
|
||
|
||
// Build vertices: bottom centered at z=0, top centered at z=Z
|
||
verts := [][3]float64{
|
||
{-wBot / 2, -hBot / 2, 0},
|
||
{wBot / 2, -hBot / 2, 0},
|
||
{wBot / 2, hBot / 2, 0},
|
||
{-wBot / 2, hBot / 2, 0},
|
||
{-wTop / 2, -hTop / 2, Z},
|
||
{wTop / 2, -hTop / 2, Z},
|
||
{wTop / 2, hTop / 2, Z},
|
||
{-wTop / 2, hTop / 2, Z},
|
||
}
|
||
faceIdxs := [][]int{
|
||
{3, 2, 1, 0}, // bottom (outward normal = -Z)
|
||
{4, 5, 6, 7}, // top (outward normal = +Z)
|
||
{0, 1, 5, 4}, // front
|
||
{2, 3, 7, 6}, // back
|
||
{3, 0, 4, 7}, // left
|
||
{1, 2, 6, 5}, // right
|
||
}
|
||
|
||
scad := buildHexahedronSCAD(verts, faceIdxs, wBot, hBot, wTop, hTop, Z)
|
||
|
||
obj := &ReconstructedObject{
|
||
Faces: faces,
|
||
Vertices3D: verts,
|
||
FaceIndices: faceIdxs,
|
||
SCAD: scad,
|
||
}
|
||
msg := fmt.Sprintf("frustum: bottom %.1f×%.1f, top %.1f×%.1f, height %.1f mm",
|
||
wBot, hBot, wTop, hTop, Z)
|
||
return obj, msg, nil
|
||
}
|
||
|
||
func buildHexahedronSCAD(verts [][3]float64, faceIdxs [][]int, wBot, hBot, wTop, hTop, Z float64) string {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("// Frustum: bottom %.2f×%.2f, top %.2f×%.2f, height %.2f\n\n",
|
||
wBot, hBot, wTop, hTop, Z))
|
||
b.WriteString("polyhedron(\n points = [\n")
|
||
for i, v := range verts {
|
||
comma := ","
|
||
if i == len(verts)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%.4f, %.4f, %.4f]%s\n", v[0], v[1], v[2], comma))
|
||
}
|
||
b.WriteString(" ],\n faces = [\n")
|
||
for i, face := range faceIdxs {
|
||
comma := ","
|
||
if i == len(faceIdxs)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(" [")
|
||
for j, idx := range face {
|
||
if j > 0 {
|
||
b.WriteString(", ")
|
||
}
|
||
b.WriteString(fmt.Sprintf("%d", idx))
|
||
}
|
||
b.WriteString("]" + comma + "\n")
|
||
}
|
||
b.WriteString(" ]\n);\n")
|
||
return b.String()
|
||
}
|
||
|
||
func buildRectCuboidResult(W, H, D float64, faces []TracedFace) (*ReconstructedObject, string, error) {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("// Cuboid from %d of 6 traced faces\n", len(faces)))
|
||
b.WriteString(fmt.Sprintf("// Dimensions: %.2f × %.2f × %.2f mm\n\n", W, H, D))
|
||
b.WriteString(fmt.Sprintf("translate([%.4f, %.4f, %.4f])\n", -W/2, -H/2, -D/2))
|
||
b.WriteString(fmt.Sprintf(" cube([%.4f, %.4f, %.4f]);\n", W, H, D))
|
||
|
||
verts, faceIdxs := cuboidGeometry(W, H, D)
|
||
allFaces := cuboidTracedFaces(W, H, D, faces)
|
||
obj := &ReconstructedObject{
|
||
Faces: allFaces, Vertices3D: verts, FaceIndices: faceIdxs, SCAD: b.String(),
|
||
}
|
||
msg := fmt.Sprintf("cuboid inferred: %.1f × %.1f × %.1f mm from %d of 6 faces", W, H, D, len(faces))
|
||
return obj, msg, nil
|
||
}
|
||
|
||
// measureFaceEdges fits a quadrilateral to the outline and returns edge info.
|
||
func measureFaceEdges(outline [][2]float64) faceEdgeInfo {
|
||
corners := fitQuadCorners(outline)
|
||
e1 := [2]float64{corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]}
|
||
e2 := [2]float64{corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]}
|
||
|
||
l1 := math.Sqrt(e1[0]*e1[0] + e1[1]*e1[1])
|
||
l2 := math.Sqrt(e2[0]*e2[0] + e2[1]*e2[1])
|
||
if l1 < 1e-9 || l2 < 1e-9 {
|
||
return faceEdgeInfo{l1, l2, math.Pi / 2}
|
||
}
|
||
|
||
cosA := (e1[0]*e2[0] + e1[1]*e2[1]) / (l1 * l2)
|
||
angle := math.Acos(math.Max(-1, math.Min(1, cosA)))
|
||
if angle > math.Pi/2 {
|
||
angle = math.Pi - angle
|
||
}
|
||
|
||
if l1 < l2 {
|
||
l1, l2 = l2, l1
|
||
}
|
||
return faceEdgeInfo{l1, l2, angle}
|
||
}
|
||
|
||
// fitQuadCorners finds the 4 dominant corners of a roughly quadrilateral outline.
|
||
func fitQuadCorners(outline [][2]float64) [4][2]float64 {
|
||
n := len(outline)
|
||
if n <= 4 {
|
||
var corners [4][2]float64
|
||
for i := 0; i < 4; i++ {
|
||
corners[i] = outline[i%n]
|
||
}
|
||
return corners
|
||
}
|
||
|
||
// Diameter: two farthest points
|
||
maxDist := 0.0
|
||
c0, c2 := 0, 0
|
||
for i := 0; i < n; i++ {
|
||
for j := i + 1; j < n; j++ {
|
||
dx := outline[i][0] - outline[j][0]
|
||
dy := outline[i][1] - outline[j][1]
|
||
d := dx*dx + dy*dy
|
||
if d > maxDist {
|
||
maxDist = d
|
||
c0, c2 = i, j
|
||
}
|
||
}
|
||
}
|
||
|
||
// Perpendicular extremes from the diameter line
|
||
dx := outline[c2][0] - outline[c0][0]
|
||
dy := outline[c2][1] - outline[c0][1]
|
||
lineLen := math.Sqrt(dx*dx + dy*dy)
|
||
if lineLen < 1e-9 {
|
||
return [4][2]float64{outline[0], outline[n/4], outline[n/2], outline[3*n/4]}
|
||
}
|
||
nx, ny := -dy/lineLen, dx/lineLen
|
||
|
||
maxPos, maxNeg := 0.0, 0.0
|
||
c1, c3 := c0, c0
|
||
for i, p := range outline {
|
||
d := (p[0]-outline[c0][0])*nx + (p[1]-outline[c0][1])*ny
|
||
if d > maxPos {
|
||
maxPos = d
|
||
c1 = i
|
||
}
|
||
if d < maxNeg {
|
||
maxNeg = d
|
||
c3 = i
|
||
}
|
||
}
|
||
|
||
// Order CCW by angle from centroid
|
||
pts := [4]int{c0, c1, c2, c3}
|
||
var cx, cy float64
|
||
for _, idx := range pts {
|
||
cx += outline[idx][0]
|
||
cy += outline[idx][1]
|
||
}
|
||
cx /= 4
|
||
cy /= 4
|
||
|
||
type ca struct {
|
||
idx int
|
||
angle float64
|
||
}
|
||
cas := [4]ca{}
|
||
for i, idx := range pts {
|
||
cas[i] = ca{idx, math.Atan2(outline[idx][1]-cy, outline[idx][0]-cx)}
|
||
}
|
||
sort.Slice(cas[:], func(i, j int) bool { return cas[i].angle < cas[j].angle })
|
||
|
||
return [4][2]float64{
|
||
outline[cas[0].idx], outline[cas[1].idx],
|
||
outline[cas[2].idx], outline[cas[3].idx],
|
||
}
|
||
}
|
||
|
||
type ppAssignment struct {
|
||
W, H, D float64
|
||
angleAB, angleAC, angleBC float64
|
||
err float64
|
||
whPair, wdPair, hdPair [2]int // indices into the faceEdgeInfo slice
|
||
}
|
||
|
||
// FacePairingJS is the JSON-friendly pairing for the frontend.
|
||
type FacePairingJS struct {
|
||
Pair1 [2]int `json:"pair1"` // WH: face numbers
|
||
Pair2 [2]int `json:"pair2"` // WD: face numbers
|
||
Pair3 [2]int `json:"pair3"` // HD: face numbers
|
||
}
|
||
|
||
// fitParallelepipedAssignment brute-forces all 90 partitions of 6 faces into
|
||
// 3 opposite pairs (WH, WD, HD), returning the assignment with minimum error.
|
||
func fitParallelepipedAssignment(infos []faceEdgeInfo) ppAssignment {
|
||
best := ppAssignment{err: math.Inf(1)}
|
||
|
||
for a := 0; a < 6; a++ {
|
||
for b := a + 1; b < 6; b++ {
|
||
var rem [4]int
|
||
ri := 0
|
||
for k := 0; k < 6; k++ {
|
||
if k != a && k != b {
|
||
rem[ri] = k
|
||
ri++
|
||
}
|
||
}
|
||
for ci := 0; ci < 4; ci++ {
|
||
for di := ci + 1; di < 4; di++ {
|
||
var hd [2]int
|
||
hi := 0
|
||
for k := 0; k < 4; k++ {
|
||
if k != ci && k != di {
|
||
hd[hi] = rem[k]
|
||
hi++
|
||
}
|
||
}
|
||
|
||
W := (infos[a].len1 + infos[b].len1 +
|
||
infos[rem[ci]].len1 + infos[rem[di]].len1) / 4
|
||
H := (infos[a].len2 + infos[b].len2 +
|
||
infos[hd[0]].len1 + infos[hd[1]].len1) / 4
|
||
D := (infos[rem[ci]].len2 + infos[rem[di]].len2 +
|
||
infos[hd[0]].len2 + infos[hd[1]].len2) / 4
|
||
|
||
err := sqDiff(infos[a].len1, W) + sqDiff(infos[a].len2, H) +
|
||
sqDiff(infos[b].len1, W) + sqDiff(infos[b].len2, H) +
|
||
sqDiff(infos[rem[ci]].len1, W) + sqDiff(infos[rem[ci]].len2, D) +
|
||
sqDiff(infos[rem[di]].len1, W) + sqDiff(infos[rem[di]].len2, D) +
|
||
sqDiff(infos[hd[0]].len1, H) + sqDiff(infos[hd[0]].len2, D) +
|
||
sqDiff(infos[hd[1]].len1, H) + sqDiff(infos[hd[1]].len2, D)
|
||
|
||
if err < best.err {
|
||
aAB := (infos[a].angle + infos[b].angle) / 2
|
||
aAC := (infos[rem[ci]].angle + infos[rem[di]].angle) / 2
|
||
aBC := (infos[hd[0]].angle + infos[hd[1]].angle) / 2
|
||
best = ppAssignment{
|
||
W: W, H: H, D: D,
|
||
angleAB: aAB, angleAC: aAC, angleBC: aBC,
|
||
err: err,
|
||
whPair: [2]int{a, b},
|
||
wdPair: [2]int{rem[ci], rem[di]},
|
||
hdPair: [2]int{hd[0], hd[1]},
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// reconstructEdgeVectors builds 3D edge vectors from dimensions and angles.
|
||
// Places a along x-axis, b in xy-plane, c with z-component.
|
||
func reconstructEdgeVectors(a ppAssignment) ([3]float64, [3]float64, [3]float64) {
|
||
va := [3]float64{a.W, 0, 0}
|
||
|
||
vb := [3]float64{a.H * math.Cos(a.angleAB), a.H * math.Sin(a.angleAB), 0}
|
||
|
||
cx := a.D * math.Cos(a.angleAC)
|
||
sinAB := math.Sin(a.angleAB)
|
||
cy := 0.0
|
||
if math.Abs(sinAB) > 1e-9 {
|
||
cy = (a.D*math.Cos(a.angleBC) - math.Cos(a.angleAB)*cx) / sinAB
|
||
}
|
||
cz2 := a.D*a.D - cx*cx - cy*cy
|
||
cz := 0.0
|
||
if cz2 > 1e-9 {
|
||
cz = math.Sqrt(cz2)
|
||
} else if cz2 > -1 {
|
||
// Slightly negative from rounding — clamp to flat
|
||
cz = 0
|
||
}
|
||
vc := [3]float64{cx, cy, cz}
|
||
|
||
return va, vb, vc
|
||
}
|
||
|
||
func parallelepipedVerts(a, b, c [3]float64) [][3]float64 {
|
||
half := [3]float64{
|
||
(a[0] + b[0] + c[0]) / 2,
|
||
(a[1] + b[1] + c[1]) / 2,
|
||
(a[2] + b[2] + c[2]) / 2,
|
||
}
|
||
o := [3]float64{-half[0], -half[1], -half[2]}
|
||
|
||
add := func(base [3]float64, vecs ...[3]float64) [3]float64 {
|
||
r := base
|
||
for _, v := range vecs {
|
||
r[0] += v[0]
|
||
r[1] += v[1]
|
||
r[2] += v[2]
|
||
}
|
||
return r
|
||
}
|
||
|
||
return [][3]float64{
|
||
o, // 0: origin corner
|
||
add(o, a), // 1: +a
|
||
add(o, a, b), // 2: +a+b
|
||
add(o, b), // 3: +b
|
||
add(o, c), // 4: +c
|
||
add(o, a, c), // 5: +a+c
|
||
add(o, a, b, c), // 6: +a+b+c
|
||
add(o, b, c), // 7: +b+c
|
||
}
|
||
}
|
||
|
||
func parallelepipedFaceIndices() [][]int {
|
||
return [][]int{
|
||
{0, 3, 2, 1}, // -c face (ab)
|
||
{4, 5, 6, 7}, // +c face
|
||
{0, 1, 5, 4}, // -b face (ac)
|
||
{2, 3, 7, 6}, // +b face
|
||
{0, 4, 7, 3}, // -a face (bc)
|
||
{1, 2, 6, 5}, // +a face
|
||
}
|
||
}
|
||
|
||
func buildParallelepipedSCAD(verts [][3]float64, faces [][]int) string {
|
||
var b strings.Builder
|
||
b.WriteString("// Parallelepiped reconstructed from traced faces\n\n")
|
||
b.WriteString("polyhedron(\n points = [\n")
|
||
for i, v := range verts {
|
||
comma := ","
|
||
if i == len(verts)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%.4f, %.4f, %.4f]%s\n", v[0], v[1], v[2], comma))
|
||
}
|
||
b.WriteString(" ],\n faces = [\n")
|
||
for i, f := range faces {
|
||
comma := ","
|
||
if i == len(faces)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%d, %d, %d, %d]%s\n", f[0], f[1], f[2], f[3], comma))
|
||
}
|
||
b.WriteString(" ]\n);\n")
|
||
return b.String()
|
||
}
|
||
|
||
func sqDiff(a, b float64) float64 {
|
||
d := a - b
|
||
return d * d
|
||
}
|
||
|
||
// fitCuboidPartial handles 2-5 traced faces using pairwise dimension matching.
|
||
func fitCuboidPartial(faceDims [][2]float64) (float64, float64, float64) {
|
||
if len(faceDims) >= 2 {
|
||
W, H, D, ok := matchCuboidDims(faceDims[0], faceDims[1])
|
||
if ok {
|
||
dims := []float64{W, H, D}
|
||
sort.Float64s(dims)
|
||
return dims[2], dims[1], dims[0]
|
||
}
|
||
}
|
||
w, h := faceDims[0][0], faceDims[0][1]
|
||
return w, h, math.Sqrt(w * h)
|
||
}
|
||
|
||
// fitRectDimensions extracts approximate width and height of a traced face
|
||
// using PCA to find the principal axes, then measuring extent along each.
|
||
func fitRectDimensions(outline [][2]float64) (float64, float64) {
|
||
if len(outline) < 3 {
|
||
return 0, 0
|
||
}
|
||
|
||
var cx, cy float64
|
||
for _, p := range outline {
|
||
cx += p[0]
|
||
cy += p[1]
|
||
}
|
||
n := float64(len(outline))
|
||
cx /= n
|
||
cy /= n
|
||
|
||
var cxx, cxy, cyy float64
|
||
for _, p := range outline {
|
||
dx := p[0] - cx
|
||
dy := p[1] - cy
|
||
cxx += dx * dx
|
||
cxy += dx * dy
|
||
cyy += dy * dy
|
||
}
|
||
|
||
trace := cxx + cyy
|
||
det := cxx*cyy - cxy*cxy
|
||
disc := math.Sqrt(math.Max(0, trace*trace/4-det))
|
||
|
||
var ux, uy float64
|
||
if math.Abs(cxy) > 1e-9 {
|
||
lambda1 := trace/2 + disc
|
||
ux = cxy
|
||
uy = lambda1 - cxx
|
||
} else if cxx >= cyy {
|
||
ux, uy = 1, 0
|
||
} else {
|
||
ux, uy = 0, 1
|
||
}
|
||
mag := math.Sqrt(ux*ux + uy*uy)
|
||
if mag > 1e-9 {
|
||
ux /= mag
|
||
uy /= mag
|
||
}
|
||
|
||
var minA, maxA, minB, maxB float64
|
||
for i, p := range outline {
|
||
dx := p[0] - cx
|
||
dy := p[1] - cy
|
||
a := dx*ux + dy*uy
|
||
pb := -dx*uy + dy*ux
|
||
if i == 0 {
|
||
minA, maxA = a, a
|
||
minB, maxB = pb, pb
|
||
} else {
|
||
minA = math.Min(minA, a)
|
||
maxA = math.Max(maxA, a)
|
||
minB = math.Min(minB, pb)
|
||
maxB = math.Max(maxB, pb)
|
||
}
|
||
}
|
||
return maxA - minA, maxB - minB
|
||
}
|
||
|
||
// matchCuboidDims finds the shared dimension between two rectangular faces.
|
||
// Returns (dimA, shared, dimB, matched) where the cuboid is dimA × shared × dimB.
|
||
func matchCuboidDims(a, b [2]float64) (float64, float64, float64, bool) {
|
||
tolerance := 0.25
|
||
|
||
type match struct {
|
||
d1, shared, d2, err float64
|
||
}
|
||
var best *match
|
||
|
||
for i := 0; i < 2; i++ {
|
||
for j := 0; j < 2; j++ {
|
||
avg := (a[i] + b[j]) / 2
|
||
if avg < 1 {
|
||
continue
|
||
}
|
||
relErr := math.Abs(a[i]-b[j]) / avg
|
||
if relErr < tolerance {
|
||
if best == nil || relErr < best.err {
|
||
m := match{a[1-i], avg, b[1-j], relErr}
|
||
best = &m
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if best == nil {
|
||
return 0, 0, 0, false
|
||
}
|
||
return best.d1, best.shared, best.d2, true
|
||
}
|
||
|
||
func cuboidTracedFaces(W, H, D float64, traced []TracedFace) []TracedFace {
|
||
tracedMap := map[int]TracedFace{}
|
||
for _, f := range traced {
|
||
tracedMap[f.FaceNum] = f
|
||
}
|
||
|
||
// Face pairs: 1,2=W×H 3,4=W×D 5,6=H×D
|
||
pairDims := [3][2]float64{{W, H}, {W, D}, {H, D}}
|
||
|
||
var all []TracedFace
|
||
for i := 1; i <= 6; i++ {
|
||
if f, ok := tracedMap[i]; ok {
|
||
all = append(all, f)
|
||
continue
|
||
}
|
||
pair := (i - 1) / 2
|
||
w, h := pairDims[pair][0], pairDims[pair][1]
|
||
all = append(all, TracedFace{
|
||
FaceNum: i,
|
||
Outline: [][2]float64{
|
||
{-w / 2, -h / 2}, {w / 2, -h / 2},
|
||
{w / 2, h / 2}, {-w / 2, h / 2},
|
||
},
|
||
})
|
||
}
|
||
return all
|
||
}
|
||
|
||
func cuboidGeometry(W, H, D float64) ([][3]float64, [][]int) {
|
||
w, h, d := W/2, H/2, D/2
|
||
verts := [][3]float64{
|
||
{-w, -h, -d}, {w, -h, -d}, {w, h, -d}, {-w, h, -d},
|
||
{-w, -h, d}, {w, -h, d}, {w, h, d}, {-w, h, d},
|
||
}
|
||
faces := [][]int{
|
||
{0, 3, 2, 1},
|
||
{4, 5, 6, 7},
|
||
{0, 1, 5, 4},
|
||
{2, 3, 7, 6},
|
||
{0, 4, 7, 3},
|
||
{1, 2, 6, 5},
|
||
}
|
||
return verts, faces
|
||
}
|
||
|
||
// ReconstructPrism generates a prism from a single traced face cross-section.
|
||
func ReconstructPrism(face TracedFace, depth float64) (*ReconstructedObject, error) {
|
||
if len(face.Outline) < 3 {
|
||
return nil, fmt.Errorf("face needs at least 3 vertices, got %d", len(face.Outline))
|
||
}
|
||
|
||
debugLog("ReconstructPrism: face %d, %d vertices, depth=%.1f",
|
||
face.FaceNum, len(face.Outline), depth)
|
||
|
||
obj := &ReconstructedObject{
|
||
Faces: []TracedFace{face},
|
||
}
|
||
obj.SCAD = obj.generatePrismSCAD(depth)
|
||
return obj, nil
|
||
}
|
||
|
||
func (obj *ReconstructedObject) generatePrismSCAD(depth float64) string {
|
||
var b strings.Builder
|
||
face := obj.Faces[0]
|
||
|
||
centered := centerOutline(face.Outline)
|
||
|
||
b.WriteString("// Prism extruded from traced face cross-section\n\n")
|
||
b.WriteString(fmt.Sprintf("linear_extrude(height=%.4f)\n", depth))
|
||
b.WriteString(" polygon(points=[\n")
|
||
for j, pt := range centered {
|
||
comma := ","
|
||
if j == len(centered)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%.4f, %.4f]%s\n", pt[0], pt[1], comma))
|
||
}
|
||
b.WriteString(" ]);\n")
|
||
|
||
return b.String()
|
||
}
|
||
|
||
// ReconstructFromFaces takes traced face outlines and adjacency info,
|
||
// folds them into 3D, and generates SCAD.
|
||
func ReconstructFromFaces(faces []TracedFace, adj FaceAdjacency) (*ReconstructedObject, error) {
|
||
if len(faces) < 1 {
|
||
return nil, fmt.Errorf("need at least 1 face")
|
||
}
|
||
|
||
obj := &ReconstructedObject{
|
||
Faces: faces,
|
||
Adjacency: adj,
|
||
}
|
||
|
||
if adj != nil && len(adj) > 0 {
|
||
if err := obj.foldFaces(); err != nil {
|
||
debugLog("ReconstructFromFaces: fold failed: %v, falling back to flat", err)
|
||
obj.buildFlat()
|
||
}
|
||
} else {
|
||
obj.buildFlat()
|
||
}
|
||
|
||
obj.SCAD = obj.generateSCAD()
|
||
return obj, nil
|
||
}
|
||
|
||
// buildFlat places all faces flat on the XY plane, spaced apart.
|
||
func (obj *ReconstructedObject) buildFlat() {
|
||
var allVerts [][3]float64
|
||
var faceIdxs [][]int
|
||
|
||
offsetX := 0.0
|
||
for _, face := range obj.Faces {
|
||
var maxW float64
|
||
var idxs []int
|
||
for _, pt := range face.Outline {
|
||
idx := len(allVerts)
|
||
allVerts = append(allVerts, [3]float64{pt[0] + offsetX, pt[1], 0})
|
||
idxs = append(idxs, idx)
|
||
if math.Abs(pt[0])*2 > maxW {
|
||
maxW = math.Abs(pt[0]) * 2
|
||
}
|
||
}
|
||
faceIdxs = append(faceIdxs, idxs)
|
||
offsetX += maxW + 10 // 10mm gap between faces
|
||
}
|
||
|
||
obj.Vertices3D = allVerts
|
||
obj.FaceIndices = faceIdxs
|
||
}
|
||
|
||
// foldFaces reconstructs 3D positions by folding faces along shared edges.
|
||
// Places face 1 flat, then BFS-folds adjacent faces.
|
||
func (obj *ReconstructedObject) foldFaces() error {
|
||
if len(obj.Faces) == 0 {
|
||
return fmt.Errorf("no faces")
|
||
}
|
||
|
||
type placedFace struct {
|
||
faceNum int
|
||
verts3D [][3]float64 // 3D positions of this face's vertices
|
||
}
|
||
|
||
faceMap := map[int]TracedFace{}
|
||
for _, f := range obj.Faces {
|
||
faceMap[f.FaceNum] = f
|
||
}
|
||
|
||
placed := map[int]*placedFace{}
|
||
|
||
// Place first face flat on XY plane at z=0
|
||
first := obj.Faces[0]
|
||
pf := &placedFace{faceNum: first.FaceNum}
|
||
for _, pt := range first.Outline {
|
||
pf.verts3D = append(pf.verts3D, [3]float64{pt[0], pt[1], 0})
|
||
}
|
||
placed[first.FaceNum] = pf
|
||
|
||
// BFS fold
|
||
queue := []int{first.FaceNum}
|
||
for len(queue) > 0 {
|
||
cur := queue[0]
|
||
queue = queue[1:]
|
||
|
||
links, ok := obj.Adjacency[cur]
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
curPlaced := placed[cur]
|
||
curFace := faceMap[cur]
|
||
|
||
for _, link := range links {
|
||
if _, done := placed[link.Neighbor]; done {
|
||
continue
|
||
}
|
||
nbFace, exists := faceMap[link.Neighbor]
|
||
if !exists {
|
||
continue
|
||
}
|
||
|
||
// Shared edge on current face
|
||
nv := len(curFace.Outline)
|
||
if link.EdgeIdx >= nv {
|
||
continue
|
||
}
|
||
e0 := link.EdgeIdx
|
||
e1 := (link.EdgeIdx + 1) % nv
|
||
|
||
// 3D positions of shared edge endpoints (from already-placed face)
|
||
p0 := curPlaced.verts3D[e0]
|
||
p1 := curPlaced.verts3D[e1]
|
||
|
||
// Shared edge on neighbor face
|
||
nnv := len(nbFace.Outline)
|
||
if link.NeighborEdge >= nnv {
|
||
continue
|
||
}
|
||
ne0 := link.NeighborEdge
|
||
ne1 := (link.NeighborEdge + 1) % nnv
|
||
|
||
// 2D positions of shared edge on neighbor (should match lengths)
|
||
nb2d0 := nbFace.Outline[ne0]
|
||
nb2d1 := nbFace.Outline[ne1]
|
||
|
||
// Fold the neighbor: align its shared edge to the 3D edge,
|
||
// then rotate 90° outward (default dihedral = 90° for box-like objects)
|
||
nbPlaced := foldFaceOntoEdge(nbFace.Outline, ne0, ne1, nb2d0, nb2d1, p0, p1, curPlaced.verts3D, e0, e1, curFace.Outline)
|
||
placed[link.Neighbor] = &placedFace{faceNum: link.Neighbor, verts3D: nbPlaced}
|
||
queue = append(queue, link.Neighbor)
|
||
}
|
||
}
|
||
|
||
// Collect all vertices and face indices
|
||
var allVerts [][3]float64
|
||
var faceIdxs [][]int
|
||
vertIdx := 0
|
||
|
||
for _, face := range obj.Faces {
|
||
pf, ok := placed[face.FaceNum]
|
||
if !ok {
|
||
// unplaced face — lay flat offset
|
||
var idxs []int
|
||
for _, pt := range face.Outline {
|
||
allVerts = append(allVerts, [3]float64{pt[0], pt[1], -50})
|
||
idxs = append(idxs, vertIdx)
|
||
vertIdx++
|
||
}
|
||
faceIdxs = append(faceIdxs, idxs)
|
||
continue
|
||
}
|
||
var idxs []int
|
||
for _, v := range pf.verts3D {
|
||
allVerts = append(allVerts, v)
|
||
idxs = append(idxs, vertIdx)
|
||
vertIdx++
|
||
}
|
||
faceIdxs = append(faceIdxs, idxs)
|
||
}
|
||
|
||
obj.Vertices3D = allVerts
|
||
obj.FaceIndices = faceIdxs
|
||
return nil
|
||
}
|
||
|
||
// foldFaceOntoEdge positions a neighbor face's 2D vertices into 3D space,
|
||
// aligning its shared edge to the given 3D edge and folding 90° outward.
|
||
func foldFaceOntoEdge(
|
||
nbOutline [][2]float64, ne0, ne1 int, nb2d0, nb2d1 [2]float64,
|
||
p0, p1 [3]float64,
|
||
curVerts [][3]float64, ce0, ce1 int,
|
||
curOutline [][2]float64,
|
||
) [][3]float64 {
|
||
// Edge vector in 3D
|
||
edgeVec := sub3(p1, p0)
|
||
edgeLen := norm3(edgeVec)
|
||
if edgeLen < 1e-9 {
|
||
edgeLen = 1
|
||
}
|
||
edgeDir := scale3(edgeVec, 1.0/edgeLen)
|
||
|
||
// Current face's outward normal: compute from face vertices
|
||
// Use cross product of two edges of the current face
|
||
curNormal := computeFaceNormal(curVerts)
|
||
|
||
// Outward direction: perpendicular to edge, in the plane of the current face,
|
||
// pointing away from the current face center
|
||
inPlane := cross3(curNormal, edgeDir)
|
||
inPlane = normalize3(inPlane)
|
||
|
||
// The fold direction (90° fold): the neighbor face extends perpendicular
|
||
// to the current face. The "up" direction for the new face is the current face's normal.
|
||
// For a 90° dihedral (box), the new face's plane normal = -inPlane
|
||
foldNormal := curNormal // the direction "up" from the edge
|
||
|
||
// Build coordinate frame at edge: edgeDir, foldNormal, inPlane
|
||
// Neighbor face's local 2D system:
|
||
// - x-axis along the edge
|
||
// - y-axis perpendicular (folded: along foldNormal for 90° fold)
|
||
|
||
// Compute 2D alignment: neighbor's edge ne0→ne1 maps to p0→p1
|
||
nb2dEdge := [2]float64{nb2d1[0] - nb2d0[0], nb2d1[1] - nb2d0[1]}
|
||
nb2dEdgeLen := math.Sqrt(nb2dEdge[0]*nb2dEdge[0] + nb2dEdge[1]*nb2dEdge[1])
|
||
if nb2dEdgeLen < 1e-9 {
|
||
nb2dEdgeLen = 1
|
||
}
|
||
nb2dEdgeDir := [2]float64{nb2dEdge[0] / nb2dEdgeLen, nb2dEdge[1] / nb2dEdgeLen}
|
||
nb2dNormDir := [2]float64{-nb2dEdgeDir[1], nb2dEdgeDir[0]} // 90° CCW
|
||
|
||
result := make([][3]float64, len(nbOutline))
|
||
for i, pt := range nbOutline {
|
||
// Express point relative to ne0 in the edge-aligned 2D frame
|
||
dx := pt[0] - nb2d0[0]
|
||
dy := pt[1] - nb2d0[1]
|
||
along := dx*nb2dEdgeDir[0] + dy*nb2dEdgeDir[1]
|
||
perp := dx*nb2dNormDir[0] + dy*nb2dNormDir[1]
|
||
|
||
// Map to 3D: along → edgeDir, perp → foldNormal (90° fold)
|
||
scale := edgeLen / nb2dEdgeLen
|
||
result[i] = [3]float64{
|
||
p0[0] + along*scale*edgeDir[0] + perp*scale*foldNormal[0],
|
||
p0[1] + along*scale*edgeDir[1] + perp*scale*foldNormal[1],
|
||
p0[2] + along*scale*edgeDir[2] + perp*scale*foldNormal[2],
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func computeFaceNormal(verts [][3]float64) [3]float64 {
|
||
if len(verts) < 3 {
|
||
return [3]float64{0, 0, 1}
|
||
}
|
||
v0 := sub3(verts[1], verts[0])
|
||
v1 := sub3(verts[2], verts[0])
|
||
n := cross3(v0, v1)
|
||
return normalize3(n)
|
||
}
|
||
|
||
// --- SCAD generation ---
|
||
|
||
func (obj *ReconstructedObject) generateSCAD() string {
|
||
var b strings.Builder
|
||
|
||
b.WriteString("// Reconstructed object from traced face templates\n\n")
|
||
|
||
hasFolding := obj.Adjacency != nil && len(obj.Adjacency) > 0
|
||
|
||
// Face polygon modules — centered at origin
|
||
for _, face := range obj.Faces {
|
||
centered := centerOutline(face.Outline)
|
||
b.WriteString(fmt.Sprintf("module face_%d() {\n", face.FaceNum))
|
||
b.WriteString(" polygon(points=[\n")
|
||
for j, pt := range centered {
|
||
comma := ","
|
||
if j == len(centered)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%.4f, %.4f]%s\n", pt[0], pt[1], comma))
|
||
}
|
||
b.WriteString(" ]);\n")
|
||
b.WriteString("}\n\n")
|
||
}
|
||
|
||
if hasFolding && len(obj.Vertices3D) > 0 && len(obj.FaceIndices) > 0 {
|
||
obj.writePolyhedronSCAD(&b)
|
||
} else {
|
||
// No adjacency — show faces side by side as thin extrusions
|
||
offsetX := 0.0
|
||
for _, face := range obj.Faces {
|
||
centered := centerOutline(face.Outline)
|
||
w := outlineWidth(centered)
|
||
b.WriteString(fmt.Sprintf("translate([%.4f, 0, 0])\n", offsetX))
|
||
b.WriteString(fmt.Sprintf(" linear_extrude(height=2) face_%d();\n\n", face.FaceNum))
|
||
offsetX += w + 5
|
||
_ = centered
|
||
}
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func centerOutline(pts [][2]float64) [][2]float64 {
|
||
if len(pts) == 0 {
|
||
return pts
|
||
}
|
||
var cx, cy float64
|
||
for _, p := range pts {
|
||
cx += p[0]
|
||
cy += p[1]
|
||
}
|
||
n := float64(len(pts))
|
||
cx /= n
|
||
cy /= n
|
||
out := make([][2]float64, len(pts))
|
||
for i, p := range pts {
|
||
out[i] = [2]float64{p[0] - cx, p[1] - cy}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func outlineWidth(pts [][2]float64) float64 {
|
||
if len(pts) == 0 {
|
||
return 0
|
||
}
|
||
minX, maxX := pts[0][0], pts[0][0]
|
||
for _, p := range pts[1:] {
|
||
if p[0] < minX {
|
||
minX = p[0]
|
||
}
|
||
if p[0] > maxX {
|
||
maxX = p[0]
|
||
}
|
||
}
|
||
return maxX - minX
|
||
}
|
||
|
||
func (obj *ReconstructedObject) placedVerts(faceIdx int) [][3]float64 {
|
||
if faceIdx >= len(obj.FaceIndices) {
|
||
return nil
|
||
}
|
||
var verts [][3]float64
|
||
for _, idx := range obj.FaceIndices[faceIdx] {
|
||
if idx < len(obj.Vertices3D) {
|
||
verts = append(verts, obj.Vertices3D[idx])
|
||
}
|
||
}
|
||
return verts
|
||
}
|
||
|
||
func (obj *ReconstructedObject) writePolyhedronSCAD(b *strings.Builder) {
|
||
// Merge vertices that are within tolerance
|
||
const eps = 0.01
|
||
type mergedVert struct {
|
||
pos [3]float64
|
||
}
|
||
var merged []mergedVert
|
||
idxMap := make([]int, len(obj.Vertices3D))
|
||
|
||
for i, v := range obj.Vertices3D {
|
||
found := -1
|
||
for j, m := range merged {
|
||
d := sub3(v, m.pos)
|
||
if norm3(d) < eps {
|
||
found = j
|
||
break
|
||
}
|
||
}
|
||
if found >= 0 {
|
||
idxMap[i] = found
|
||
} else {
|
||
idxMap[i] = len(merged)
|
||
merged = append(merged, mergedVert{pos: v})
|
||
}
|
||
}
|
||
|
||
b.WriteString("polyhedron(\n")
|
||
b.WriteString(" points=[\n")
|
||
for i, m := range merged {
|
||
comma := ","
|
||
if i == len(merged)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(fmt.Sprintf(" [%.4f, %.4f, %.4f]%s\n", m.pos[0], m.pos[1], m.pos[2], comma))
|
||
}
|
||
b.WriteString(" ],\n")
|
||
b.WriteString(" faces=[\n")
|
||
for i, idxs := range obj.FaceIndices {
|
||
comma := ","
|
||
if i == len(obj.FaceIndices)-1 {
|
||
comma = ""
|
||
}
|
||
b.WriteString(" [")
|
||
for j, idx := range idxs {
|
||
if j > 0 {
|
||
b.WriteString(", ")
|
||
}
|
||
b.WriteString(fmt.Sprintf("%d", idxMap[idx]))
|
||
}
|
||
b.WriteString("]" + comma + "\n")
|
||
}
|
||
b.WriteString(" ]\n")
|
||
b.WriteString(");\n")
|
||
}
|
||
|
||
// GenerateReconstructedSCAD writes the SCAD file to the project output directory.
|
||
func GenerateReconstructedSCAD(obj *ReconstructedObject, outputDir string) (string, error) {
|
||
os.MkdirAll(outputDir, 0755)
|
||
path := filepath.Join(outputDir, "reconstructed.scad")
|
||
if err := os.WriteFile(path, []byte(obj.SCAD), 0644); err != nil {
|
||
return "", fmt.Errorf("write SCAD: %w", err)
|
||
}
|
||
debugLog("GenerateReconstructedSCAD: wrote %s (%d bytes)", path, len(obj.SCAD))
|
||
return path, nil
|
||
}
|
||
|
||
// Validate3N6 checks if the extracted faces satisfy the 3n-6 constraint.
|
||
// n = number of unique vertices, e = number of edges, f = number of faces.
|
||
// Euler: V - E + F = 2 for convex polyhedra.
|
||
func Validate3N6(faces []TracedFace, adj FaceAdjacency) (bool, string) {
|
||
if len(faces) < 4 {
|
||
return false, fmt.Sprintf("need at least 4 faces for a closed solid, got %d", len(faces))
|
||
}
|
||
|
||
nFaces := len(faces)
|
||
totalVerts := 0
|
||
for _, f := range faces {
|
||
totalVerts += len(f.Outline)
|
||
}
|
||
|
||
// Count edges from adjacency
|
||
edgeCount := 0
|
||
if adj != nil {
|
||
seen := map[[2]int]bool{}
|
||
for fnum, links := range adj {
|
||
for _, link := range links {
|
||
key := [2]int{min(fnum, link.Neighbor), max(fnum, link.Neighbor)}
|
||
if !seen[key] {
|
||
seen[key] = true
|
||
edgeCount++
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Estimate: each face contributes len(outline) edges, each shared by 2 faces
|
||
edgeCount = totalVerts / 2
|
||
}
|
||
|
||
// Estimate unique vertices (shared vertices at edges)
|
||
// For a closed polyhedron: V = E - F + 2 (Euler's formula)
|
||
eulerV := edgeCount - nFaces + 2
|
||
|
||
// 3n-6 degrees of freedom for n vertices
|
||
dof := 3*eulerV - 6
|
||
measurements := totalVerts * 2 // 2 coordinates per vertex per face
|
||
|
||
msg := fmt.Sprintf("F=%d, E=%d, V=%d(euler), DOF=3*%d-6=%d, measurements=%d",
|
||
nFaces, edgeCount, eulerV, eulerV, dof, measurements)
|
||
|
||
if measurements >= dof {
|
||
return true, msg + " — sufficiently constrained"
|
||
}
|
||
return false, msg + " — underconstrained"
|
||
}
|
||
|
||
// --- 3D vector helpers ---
|
||
|
||
func sub3(a, b [3]float64) [3]float64 {
|
||
return [3]float64{a[0] - b[0], a[1] - b[1], a[2] - b[2]}
|
||
}
|
||
|
||
func cross3(a, b [3]float64) [3]float64 {
|
||
return [3]float64{
|
||
a[1]*b[2] - a[2]*b[1],
|
||
a[2]*b[0] - a[0]*b[2],
|
||
a[0]*b[1] - a[1]*b[0],
|
||
}
|
||
}
|
||
|
||
func norm3(v [3]float64) float64 {
|
||
return math.Sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
|
||
}
|
||
|
||
func scale3(v [3]float64, s float64) [3]float64 {
|
||
return [3]float64{v[0] * s, v[1] * s, v[2] * s}
|
||
}
|
||
|
||
func normalize3(v [3]float64) [3]float64 {
|
||
n := norm3(v)
|
||
if n < 1e-12 {
|
||
return [3]float64{0, 0, 1}
|
||
}
|
||
return scale3(v, 1.0/n)
|
||
}
|