Skip to content

Commit 0ed8e01

Browse files
authored
extrusion improvements (#165)
1 parent 88b45fd commit 0ed8e01

8 files changed

Lines changed: 1170 additions & 45 deletions

File tree

‎modeling/extrude/frame_test.go‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package extrude
2+
3+
import (
4+
"math"
5+
"testing"
6+
7+
"github.com/EliCDavis/vector/vector3"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// The frame may only rotate as much as the path itself turns. Deriving per
13+
// from the cross product of neighbouring segments instead flips it a full
14+
// 180 degrees where a straight run meets a bend.
15+
func TestPathFramesTurnOnlyAsMuchAsThePath(t *testing.T) {
16+
straight := []vector3.Float64{}
17+
for i := 0; i <= 6; i++ {
18+
straight = append(straight, vector3.New(0., float64(i)*0.5, 0.))
19+
}
20+
21+
helix := []vector3.Float64{}
22+
for i := 0; i <= 24; i++ {
23+
a := float64(i) * 0.4
24+
helix = append(helix, vector3.New(math.Cos(a)*2, float64(i)*0.2, math.Sin(a)*2))
25+
}
26+
27+
loop, saddle, trefoil := []vector3.Float64{}, []vector3.Float64{}, []vector3.Float64{}
28+
for i := 0; i < 24; i++ {
29+
a := math.Pi * 2 * float64(i) / 24
30+
loop = append(loop, vector3.New(math.Cos(a)*3, 0, math.Sin(a)*3))
31+
saddle = append(saddle, vector3.New(math.Cos(a)*3, math.Sin(2*a)*1.2, math.Sin(a)*3))
32+
trefoil = append(trefoil, vector3.New(
33+
math.Sin(a)+2*math.Sin(2*a), -math.Sin(3*a), math.Cos(a)-2*math.Cos(2*a)))
34+
}
35+
36+
for name, tc := range map[string]struct {
37+
path []vector3.Float64
38+
closed bool
39+
}{
40+
"straight run": {straight, false},
41+
"straight run into a bend": {[]vector3.Float64{
42+
vector3.New(0., 0., 0.), vector3.New(0., 1., 0.), vector3.New(0., 2., 0.),
43+
vector3.New(1., 3., 0.), vector3.New(2.5, 3.5, 0.), vector3.New(4.5, 3.5, 0.),
44+
}, false},
45+
"collinear but not axis aligned": {[]vector3.Float64{
46+
vector3.New(0., 0., 0.),
47+
vector3.New(0., 1.3001528736574441, -0.37084847944351573),
48+
vector3.New(0., 2.6003057473148883, -0.7416969588870315),
49+
}, false},
50+
"helix": {helix, false},
51+
"closed loop": {loop, true},
52+
"closed non planar loop": {saddle, true},
53+
"closed trefoil": {trefoil, true},
54+
} {
55+
t.Run(name, func(t *testing.T) {
56+
frames := pathFrames(tc.path, tc.closed)
57+
require.Len(t, frames, len(tc.path))
58+
59+
for i, f := range frames {
60+
require.InDeltaf(t, 1, f.per.Length(), 1e-9, "ring %d per is not a unit vector", i)
61+
require.InDeltaf(t, 1, f.dir.Length(), 1e-9, "ring %d dir is not a unit vector", i)
62+
assert.InDeltaf(t, 0, f.per.Dot(f.dir), 1e-9, "ring %d per is not square to dir", i)
63+
64+
// A closed path has to hold at the seam too, where the last
65+
// ring meets the first.
66+
previous := i - 1
67+
if i == 0 {
68+
if !tc.closed {
69+
continue
70+
}
71+
previous = len(frames) - 1
72+
}
73+
74+
// A closed loop cannot be swept without any twist at all: the
75+
// leftover is topological. It only has to be shared out, not
76+
// dumped on the segment that closes the loop.
77+
allowed := 1e-9
78+
if tc.closed {
79+
allowed = 1 * math.Pi / 180
80+
}
81+
82+
turned := frames[previous].dir.Angle(f.dir)
83+
twisted := frames[previous].per.Angle(f.per)
84+
assert.LessOrEqualf(t, twisted, turned+allowed,
85+
"ring %d twisted %.2f degrees while the path only turned %.2f",
86+
i, twisted*180/math.Pi, turned*180/math.Pi)
87+
}
88+
})
89+
}
90+
}

‎modeling/extrude/line.go‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ func directionsOfLinePoints(points []LinePoint) []vector3.Float64 {
2323
return directionOfPoints(pointVec)
2424
}
2525

26+
func facingUp(v, up vector3.Float64) vector3.Float64 {
27+
if v.Length() == 0 {
28+
return up
29+
}
30+
v = v.Normalized()
31+
if v.Dot(up) < 0 {
32+
return v.Scale(-1)
33+
}
34+
return v
35+
}
36+
2637
func Line(linePoints []LinePoint) modeling.Mesh {
2738
if len(linePoints) < 2 {
2839
panic("extruding a line requires 2 or more points")
@@ -44,8 +55,8 @@ func Line(linePoints []LinePoint) modeling.Mesh {
4455
leftNormal := p.Up
4556

4657
if p.Width != 0 {
47-
rightNormal = rightPoint.Sub(p.Point).Normalized().Cross(directions[i]).Scale(-1)
48-
leftNormal = leftPoint.Sub(p.Point).Normalized().Cross(directions[i]).Scale(-1)
58+
rightNormal = facingUp(rightPoint.Sub(p.Point).Cross(directions[i]), p.Up)
59+
leftNormal = facingUp(leftPoint.Sub(p.Point).Cross(directions[i]), p.Up)
4960
}
5061

5162
vertices = append(

‎modeling/extrude/normals_test.go‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package extrude_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/EliCDavis/polyform/modeling"
7+
"github.com/EliCDavis/polyform/modeling/extrude"
8+
"github.com/EliCDavis/vector/vector2"
9+
"github.com/EliCDavis/vector/vector3"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// A vertex normal is averaged across the faces that share it, so it drifts
15+
// away from any one of them. Reaching a right angle means it stopped being a
16+
// normal: ProjectFace used to hand back the ring's edge tangent, and Line
17+
// negated both flanks when only one needed it.
18+
func requireNormalsAgreeWithFaces(t *testing.T, m modeling.Mesh) {
19+
t.Helper()
20+
21+
require.True(t, m.HasFloat3Attribute(modeling.NormalAttribute), "no normal attribute")
22+
idx := m.Indices()
23+
pos := m.Float3Attribute(modeling.PositionAttribute)
24+
nrm := m.Float3Attribute(modeling.NormalAttribute)
25+
require.Equal(t, pos.Len(), nrm.Len())
26+
27+
for i := 0; i < idx.Len(); i += 3 {
28+
a, b, c := pos.At(idx.At(i)), pos.At(idx.At(i+1)), pos.At(idx.At(i+2))
29+
face := b.Sub(a).Cross(c.Sub(a))
30+
if face.Length() == 0 {
31+
continue
32+
}
33+
face = face.Normalized()
34+
35+
for k := 0; k < 3; k++ {
36+
vertex := idx.At(i + k)
37+
normal := nrm.At(vertex)
38+
require.NotZerof(t, normal.Length(), "vertex %d has a zero normal", vertex)
39+
assert.Positivef(t, face.Dot(normal.Normalized()),
40+
"vertex %d carries a normal at or past a right angle to its face", vertex)
41+
}
42+
}
43+
}
44+
45+
func TestExtrudersEmitUsableNormals(t *testing.T) {
46+
square := []vector2.Float64{
47+
vector2.New(-1., -1.), vector2.New(1., -1.),
48+
vector2.New(1., 1.), vector2.New(-1., 1.),
49+
}
50+
bent := []vector3.Float64{
51+
vector3.New(0., 0., 0.), vector3.New(0., 2., 0.), vector3.New(1.5, 3.5, 0.),
52+
}
53+
54+
outline, err := extrude.Outline{Shape: square, Path: bent}.Extrude()
55+
require.NoError(t, err)
56+
57+
for name, mesh := range map[string]modeling.Mesh{
58+
"shape": extrude.Shape(square, bent),
59+
"closed shape": extrude.ClosedShape(square, []vector3.Float64{
60+
vector3.New(2., 0., 0.), vector3.New(-1., 0., 1.7), vector3.New(-1., 0., -1.7),
61+
}),
62+
"polygon": extrude.Polygon(8, []extrude.ExtrusionPoint{
63+
{Point: vector3.New(0., 0., 0.), Thickness: 1},
64+
{Point: vector3.New(0., 2., 0.), Thickness: 1},
65+
{Point: vector3.New(1.5, 3.5, 0.), Thickness: .7},
66+
}),
67+
"circle": extrude.Circle{Radius: 1, Resolution: 12, Path: bent}.Extrude(),
68+
"line": extrude.Line([]extrude.LinePoint{
69+
{Point: vector3.New(0., 0., 0.), Up: vector3.Up[float64](), Width: 1, Height: .2},
70+
{Point: vector3.New(0., 0., 2.), Up: vector3.Up[float64](), Width: 1, Height: .2},
71+
{Point: vector3.New(1., 0., 4.), Up: vector3.Up[float64](), Width: 1, Height: .2},
72+
}),
73+
"screw": lathedTube(t),
74+
"outline": outline,
75+
} {
76+
t.Run(name, func(t *testing.T) { requireNormalsAgreeWithFaces(t, mesh) })
77+
}
78+
}
79+
80+
// The outline decides which way is out, so the same shape wound either way
81+
// has to produce normals pointing the same direction.
82+
func TestProjectFaceFacesOutwardEitherWinding(t *testing.T) {
83+
square := []vector2.Float64{
84+
vector2.New(-1., -1.), vector2.New(1., -1.),
85+
vector2.New(1., 1.), vector2.New(-1., 1.),
86+
}
87+
backwards := make([]vector2.Float64, len(square))
88+
for i, p := range square {
89+
backwards[len(square)-1-i] = p
90+
}
91+
92+
center := vector3.New(0., 0., 0.)
93+
up := vector3.Up[float64]()
94+
perpendicular := vector3.Forward[float64]()
95+
96+
points, normals := extrude.ProjectFace(center, up, perpendicular, square)
97+
flippedPoints, flippedNormals := extrude.ProjectFace(center, up, perpendicular, backwards)
98+
99+
for i, p := range points {
100+
assert.Positivef(t, normals[i].Dot(p.Sub(center).Normalized()), "vertex %d points inward", i)
101+
}
102+
for i, p := range flippedPoints {
103+
assert.Positivef(t, flippedNormals[i].Dot(p.Sub(center).Normalized()),
104+
"reversed winding, vertex %d points inward", i)
105+
}
106+
}

0 commit comments

Comments
 (0)