diff --git a/.gitignore b/.gitignore index 56e162b..51e0346 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Exclude CPU and memory profiles *.prof +*.pprof *.png # Exclude all png files except for test files diff --git a/README.md b/README.md index 1a3631c..4b8d946 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Advantages of soypat/sdf: - Uses gonum's `spatial` package - `sdfx` has own vector types with methods which [hurt code legibility](https://github.com/deadsy/sdfx/issues/48) - `spatial` types from gonum library with correct Triangle degeneracy calculation. `deadsy/sdfx`'s Degenerate calculation is incorrect. +- Idiomatic [`thread`](./form3/obj3/thread/thread.go) package. Define arbitrary threads with ease using `Threader` interface. + - `deadsy/sdfx` defines threads with strings i.e. `"M16x2"`. `sdf` Defines threads with types corresponding to standards. i.e: `thread.ISO{D:16, P:2}`, which defines an M16x2 ISO thread. ## Contributing diff --git a/examples/README.md b/examples/README.md index fb2d751..246c894 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,7 +25,7 @@ Click on image to go to code directory. | Example | Execution Time | File size | |---|---|---| -|[ATX Bench power supply mod](atx-bench-supply)|0.5s|4540kB| +|[ATX Bench power supply mod](atx-bench-supply)|1s|4540kB| [![ATX Bench power supply mod](fig/atx-bench-supply.png)](atx-bench-supply) diff --git a/examples/atx-bench-supply/atx.go b/examples/atx-bench-supply/atx.go index 5c8408f..3488e46 100644 --- a/examples/atx-bench-supply/atx.go +++ b/examples/atx-bench-supply/atx.go @@ -59,12 +59,12 @@ func main() { // Begin working on regulated step-down block regOut = sdf.Array2D(bananaPlugSmall, sdf.V2i{2, 1}, r2.Vec{bananaSpacing, bananaSpacing}) - bplugX := bbSize(regBlock.Bounds()).X + bplugX := bbSize(regOut.Bounds()).X vDisp := sdf.Transform2D(voltageDisplay, sdf.Translate2d(r2.Vec{bplugX / 2, vDispH/2 + bananaSpacing/2})) regOut = sdf.Union2D(regOut, vDisp) regOut = sdf.Transform2D(regOut, sdf.Translate2d(r2.Vec{-atxW/2 - bplugX/2 + vDispW/2 + 12, atxH/2 - 12 - vDispH/2 - bananaSpacing})) // Create mound for step up outputs. - regSz := bbSize(regBlock.Bounds()) + regSz := bbSize(regOut.Bounds()) regBlock = form2.Box(r2.Vec{regSz.X + regBlockMargin, regSz.Y + regBlockMargin}, regBlockMargin/2) regBlock = sdf.Transform2D(regBlock, sdf.Translate2d(bbCenter(regOut.Bounds()))) regBlock = sdf.Difference2D(regBlock, regOut) diff --git a/examples/npt-flange/flange.go b/examples/npt-flange/flange.go index 0b5be28..2328a9f 100644 --- a/examples/npt-flange/flange.go +++ b/examples/npt-flange/flange.go @@ -3,7 +3,7 @@ package main import ( "github.com/soypat/sdf" form3 "github.com/soypat/sdf/form3/must3" - "github.com/soypat/sdf/form3/obj3" + "github.com/soypat/sdf/form3/obj3/thread" "github.com/soypat/sdf/render" "gonum.org/v1/gonum/spatial/r3" ) @@ -14,18 +14,19 @@ const ( internalDiameter = 1.5 / 2. flangeH = 7 / 25.4 flangeD = 60. / 25.4 - thread = "npt_1/2" // internal diameter scaling. plaScale = 1.03 ) func main() { var ( + npt thread.NPT flange sdf.SDF3 ) - pipe, err := obj3.Nut(obj3.NutParms{ - Thread: thread, - Style: obj3.CylinderCircular, + npt.SetFromNominal(1.0 / 2.0) + pipe, err := thread.Nut(thread.NutParms{ + Thread: npt, + Style: thread.NutCircular, }) if err != nil { panic(err) diff --git a/form2/obj2/thread.go b/form2/obj2/thread.go deleted file mode 100644 index e7d1d3b..0000000 --- a/form2/obj2/thread.go +++ /dev/null @@ -1,314 +0,0 @@ -package obj2 - -import ( - "fmt" - "log" - "math" - - "github.com/soypat/sdf" - "github.com/soypat/sdf/form2/must2" -) - -// Screws -// Screws are made by taking a 2D thread profile, rotating it about the z-axis and -// spiralling it upwards as we move along z. -// -// The 2D thread profiles are a polygon of a single thread centered on the y-axis with -// the x-axis as the screw axis. Most thread profiles are symmetric about the y-axis -// but a few aren't (E.g. buttress threads) so in general we build the profile of -// an entire pitch period. -// -// This code doesn't deal with thread tolerancing. If you want threads to fit properly -// the radius of the thread will need to be tweaked (+/-) to give internal/external thread -// clearance. - -// Thread Database - lookup standard screw threads by name - -// ThreadParameters stores the values that define a thread. -type ThreadParameters struct { - Name string // name of screw thread - Radius float64 // nominal major radius of screw - Pitch float64 // thread to thread distance of screw - Taper float64 // thread taper (radians) - HexFlat2Flat float64 // hex head flat to flat distance - Units string // "inch" or "mm" -} - -type threadDatabase map[string]ThreadParameters - -var threadDB = initThreadLookup() - -// UTSAdd adds a Unified Thread Standard to the thread database. -// diameter is screw major diameter. -// tpi is threads per inch. -// ftof is hex head flat to flat distance. -func (m threadDatabase) UTSAdd(name string, diameter float64, tpi float64, ftof float64) { - if ftof <= 0 { - log.Panicf("bad flat to flat distance for thread \"%s\"", name) - } - t := ThreadParameters{} - t.Name = name - t.Radius = diameter / 2.0 - t.Pitch = 1.0 / tpi - t.HexFlat2Flat = ftof - t.Units = "inch" - m[name] = t -} - -// ISOAdd adds an ISO Thread Standard to the thread database. -func (m threadDatabase) ISOAdd( - name string, // thread name - diameter float64, // screw major diamater - pitch float64, // thread pitch - ftof float64, // hex head flat to flat distance -) { - if ftof <= 0 { - log.Panicf("bad flat to flat distance for thread \"%s\"", name) - } - t := ThreadParameters{} - t.Name = name - t.Radius = diameter / 2.0 - t.Pitch = pitch - t.HexFlat2Flat = ftof - t.Units = "mm" - m[name] = t -} - -// NPTAdd adds an National Pipe Thread to the thread database. -func (m threadDatabase) NPTAdd( - name string, // thread name - diameter float64, // screw major diameter - tpi float64, // threads per inch - ftof float64, // hex head flat to flat distance -) { - if ftof <= 0 { - log.Panicf("bad flat to flat distance for thread \"%s\"", name) - } - t := ThreadParameters{} - t.Name = name - t.Radius = diameter / 2.0 - t.Pitch = 1.0 / tpi - t.Taper = math.Atan(1.0 / 32.0) - t.HexFlat2Flat = ftof - t.Units = "inch" - m[name] = t -} - -// initThreadLookup adds a collection of standard threads to the thread database. -func initThreadLookup() threadDatabase { - m := make(threadDatabase) - // UTS Coarse - m.UTSAdd("unc_1/4", 1.0/4.0, 20, 7.0/16.0) - m.UTSAdd("unc_5/16", 5.0/16.0, 18, 1.0/2.0) - m.UTSAdd("unc_3/8", 3.0/8.0, 16, 9.0/16.0) - m.UTSAdd("unc_7/16", 7.0/16.0, 14, 5.0/8.0) - m.UTSAdd("unc_1/2", 1.0/2.0, 13, 3.0/4.0) - m.UTSAdd("unc_9/16", 9.0/16.0, 12, 13.0/16.0) - m.UTSAdd("unc_5/8", 5.0/8.0, 11, 15.0/16.0) - m.UTSAdd("unc_3/4", 3.0/4.0, 10, 9.0/8.0) - m.UTSAdd("unc_7/8", 7.0/8.0, 9, 21.0/16.0) - m.UTSAdd("unc_1", 1.0, 8, 3.0/2.0) - // UTS Fine - m.UTSAdd("unf_1/4", 1.0/4.0, 28, 7.0/16.0) - m.UTSAdd("unf_5/16", 5.0/16.0, 24, 1.0/2.0) - m.UTSAdd("unf_3/8", 3.0/8.0, 24, 9.0/16.0) - m.UTSAdd("unf_7/16", 7.0/16.0, 20, 5.0/8.0) - m.UTSAdd("unf_1/2", 1.0/2.0, 20, 3.0/4.0) - m.UTSAdd("unf_9/16", 9.0/16.0, 18, 13.0/16.0) - m.UTSAdd("unf_5/8", 5.0/8.0, 18, 15.0/16.0) - m.UTSAdd("unf_3/4", 3.0/4.0, 16, 9.0/8.0) - m.UTSAdd("unf_7/8", 7.0/8.0, 14, 21.0/16.0) - m.UTSAdd("unf_1", 1.0, 12, 3.0/2.0) - - // National Pipe Thread. Face to face distance taken from ASME B16.11 Plug Manufacturer (mm) - m.NPTAdd("npt_1/8", 0.405, 27, 11.2*InchesPerMillimetre) - m.NPTAdd("npt_1/4", 0.540, 18, 15.7*InchesPerMillimetre) - m.NPTAdd("npt_3/8", 0.675, 18, 17.5*InchesPerMillimetre) - m.NPTAdd("npt_1/2", 0.840, 14, 22.4*InchesPerMillimetre) - m.NPTAdd("npt_3/4", 1.050, 14, 26.9*InchesPerMillimetre) - m.NPTAdd("npt_1", 1.315, 11.5, 35.1*InchesPerMillimetre) - m.NPTAdd("npt_1_1/4", 1.660, 11.5, 44.5*InchesPerMillimetre) - m.NPTAdd("npt_1_1/2", 1.900, 11.5, 50.8*InchesPerMillimetre) - m.NPTAdd("npt_2", 2.375, 11.5, 63.5*InchesPerMillimetre) - m.NPTAdd("npt_2_1/2", 2.875, 8, 76.2*InchesPerMillimetre) - m.NPTAdd("npt_3", 3.500, 8, 88.9*InchesPerMillimetre) - m.NPTAdd("npt_4", 4.500, 8, 117.3*InchesPerMillimetre) - - // ISO Coarse - m.ISOAdd("M1x0.25", 1, 0.25, 1.75) // ftof? - m.ISOAdd("M1.2x0.25", 1.2, 0.25, 2.0) // ftof? - m.ISOAdd("M1.6x0.35", 1.6, 0.35, 3.2) - m.ISOAdd("M2x0.4", 2, 0.4, 4) - m.ISOAdd("M2.5x0.45", 2.5, 0.45, 5) - m.ISOAdd("M3x0.5", 3, 0.5, 6) - m.ISOAdd("M4x0.7", 4, 0.7, 7) - m.ISOAdd("M5x0.8", 5, 0.8, 8) - m.ISOAdd("M6x1", 6, 1, 10) - m.ISOAdd("M8x1.25", 8, 1.25, 13) - m.ISOAdd("M10x1.5", 10, 1.5, 17) - m.ISOAdd("M12x1.75", 12, 1.75, 19) - m.ISOAdd("M16x2", 16, 2, 24) - m.ISOAdd("M20x2.5", 20, 2.5, 30) - m.ISOAdd("M24x3", 24, 3, 36) - m.ISOAdd("M30x3.5", 30, 3.5, 46) - m.ISOAdd("M36x4", 36, 4, 55) - m.ISOAdd("M42x4.5", 42, 4.5, 65) - m.ISOAdd("M48x5", 48, 5, 75) - m.ISOAdd("M56x5.5", 56, 5.5, 85) - m.ISOAdd("M64x6", 64, 6, 95) - // ISO Fine - m.ISOAdd("M1x0.2", 1, 0.2, 1.75) // ftof? - m.ISOAdd("M1.2x0.2", 1.2, 0.2, 2.0) // ftof? - m.ISOAdd("M1.6x0.2", 1.6, 0.2, 3.2) - m.ISOAdd("M2x0.25", 2, 0.25, 4) - m.ISOAdd("M2.5x0.35", 2.5, 0.35, 5) - m.ISOAdd("M3x0.35", 3, 0.35, 6) - m.ISOAdd("M4x0.5", 4, 0.5, 7) - m.ISOAdd("M5x0.5", 5, 0.5, 8) - m.ISOAdd("M6x0.75", 6, 0.75, 10) - m.ISOAdd("M8x1", 8, 1, 13) - m.ISOAdd("M10x1.25", 10, 1.25, 17) - m.ISOAdd("M12x1.5", 12, 1.5, 19) - m.ISOAdd("M16x1.5", 16, 1.5, 24) - m.ISOAdd("M20x2", 20, 2, 30) - m.ISOAdd("M24x2", 24, 2, 36) - m.ISOAdd("M30x2", 30, 2, 46) - m.ISOAdd("M36x3", 36, 3, 55) - m.ISOAdd("M42x3", 42, 3, 65) - m.ISOAdd("M48x3", 48, 3, 75) - m.ISOAdd("M56x4", 56, 4, 85) - m.ISOAdd("M64x4", 64, 4, 95) - return m -} - -// ThreadLookup lookups the parameters for a thread by name. -func ThreadLookup(name string) (ThreadParameters, error) { - if t, ok := threadDB[name]; ok { - return t, nil - } - return ThreadParameters{}, fmt.Errorf("thread \"%s\" not found", name) -} - -// HexRadius returns the hex head radius. -func (t *ThreadParameters) HexRadius() float64 { - return t.HexFlat2Flat / (2.0 * math.Cos(30*math.Pi/180)) -} - -// HexHeight returns the hex head height (empirical). -func (t *ThreadParameters) HexHeight() float64 { - return 2.0 * t.HexRadius() * (5.0 / 12.0) -} - -// Thread Profiles - -// AcmeThread returns the 2d profile for an acme thread. -// radius is radius of thread. pitch is thread-to-thread distance. -func AcmeThread(radius float64, pitch float64) sdf.SDF2 { - - h := radius - 0.5*pitch - theta := d2r(29.0 / 2.0) - delta := 0.25 * pitch * math.Tan(theta) - xOfs0 := 0.25*pitch - delta - xOfs1 := 0.25*pitch + delta - - acme := must2.NewPolygon() - acme.Add(radius, 0) - acme.Add(radius, h) - acme.Add(xOfs1, h) - acme.Add(xOfs0, radius) - acme.Add(-xOfs0, radius) - acme.Add(-xOfs1, h) - acme.Add(-radius, h) - acme.Add(-radius, 0) - - return must2.Polygon(acme.Vertices()) -} - -// ISOThread returns the 2d profile for an ISO/UTS thread. -// https://en.wikipedia.org/wiki/ISO_metric_screw_thread -// https://en.wikipedia.org/wiki/Unified_Thread_Standard -// radius is radius of thread. pitch is thread-to-thread distance. -// external (or internal) thread -func ISOThread(radius float64, pitch float64, external bool) sdf.SDF2 { - theta := d2r(30.0) - h := pitch / (2.0 * math.Tan(theta)) - rMajor := radius - r0 := rMajor - (7.0/8.0)*h - - iso := must2.NewPolygon() - if external { - rRoot := (pitch / 8.0) / math.Cos(theta) - xOfs := (1.0 / 16.0) * pitch - iso.Add(pitch, 0) - iso.Add(pitch, r0+h) - iso.Add(pitch/2.0, r0).Smooth(rRoot, 5) - iso.Add(xOfs, rMajor) - iso.Add(-xOfs, rMajor) - iso.Add(-pitch/2.0, r0).Smooth(rRoot, 5) - iso.Add(-pitch, r0+h) - iso.Add(-pitch, 0) - } else { - rMinor := r0 + (1.0/4.0)*h - rCrest := (pitch / 16.0) / math.Cos(theta) - xOfs := (1.0 / 8.0) * pitch - iso.Add(pitch, 0) - iso.Add(pitch, rMinor) - iso.Add(pitch/2-xOfs, rMinor) - iso.Add(0, r0+h).Smooth(rCrest, 5) - iso.Add(-pitch/2+xOfs, rMinor) - iso.Add(-pitch, rMinor) - iso.Add(-pitch, 0) - } - return must2.Polygon(iso.Vertices()) -} - -// ANSIButtressThread returns the 2d profile for an ANSI 45/7 buttress thread. -// https://en.wikipedia.org/wiki/Buttress_thread -// AMSE B1.9-1973 -// radius is radius of thread. pitch is thread-to-thread distance. -func ANSIButtressThread(radius float64, pitch float64) sdf.SDF2 { - t0 := math.Tan(d2r(45.0)) - t1 := math.Tan(d2r(7.0)) - b := 0.6 // thread engagement - - h0 := pitch / (t0 + t1) - h1 := ((b / 2.0) * pitch) + (0.5 * h0) - hp := pitch / 2.0 - - tp := must2.NewPolygon() - tp.Add(pitch, 0) - tp.Add(pitch, radius) - tp.Add(hp-((h0-h1)*t1), radius) - tp.Add(t0*h0-hp, radius-h1).Smooth(0.0714*pitch, 5) - tp.Add((h0-h1)*t0-hp, radius) - tp.Add(-pitch, radius) - tp.Add(-pitch, 0) - - return must2.Polygon(tp.Vertices()) -} - -// PlasticButtressThread returns the 2d profile for a screw top style plastic buttress thread. -// Similar to ANSI 45/7 - but with more corner rounding -// radius is radius of thread. pitch is thread-to-thread distance. -func PlasticButtressThread(radius float64, pitch float64) sdf.SDF2 { - t0 := math.Tan(d2r(45.0)) - t1 := math.Tan(d2r(7.0)) - b := 0.6 // thread engagement - - h0 := pitch / (t0 + t1) - h1 := ((b / 2.0) * pitch) + (0.5 * h0) - hp := pitch / 2.0 - - tp := must2.NewPolygon() - tp.Add(pitch, 0) - tp.Add(pitch, radius) - tp.Add(hp-((h0-h1)*t1), radius).Smooth(0.05*pitch, 5) - tp.Add(t0*h0-hp, radius-h1).Smooth(0.15*pitch, 5) - tp.Add((h0-h1)*t0-hp, radius).Smooth(0.15*pitch, 5) - tp.Add(-pitch, radius) - tp.Add(-pitch, 0) - - return must2.Polygon(tp.Vertices()) -} -func d2r(degrees float64) float64 { return degrees * math.Pi / 180. } -func r2d(radians float64) float64 { return radians / math.Pi * 180. } diff --git a/form3/obj3/bolt.go b/form3/obj3/bolt.go deleted file mode 100644 index 439482c..0000000 --- a/form3/obj3/bolt.go +++ /dev/null @@ -1,75 +0,0 @@ -package obj3 - -import ( - "github.com/soypat/sdf" - "github.com/soypat/sdf/form2/obj2" - form3 "github.com/soypat/sdf/form3/must3" - "gonum.org/v1/gonum/spatial/r3" -) - -// Bolts: Screws, nuts etc. - -// BoltParms defines the parameters for a bolt. -type BoltParms struct { - Thread string // name of thread - Style CylinderStyle // head style "hex" or "knurl" - Tolerance float64 // subtract from external thread radius - TotalLength float64 // threaded length + shank length - ShankLength float64 // non threaded length -} - -// Bolt returns a simple bolt suitable for 3d printing. -func Bolt(k BoltParms) (s sdf.SDF3, err error) { - // validate parameters - t, err := obj2.ThreadLookup(k.Thread) - if err != nil { - panic(err) - } - if k.TotalLength < 0 { - panic("TotalLength < 0") - } - if k.ShankLength < 0 { - panic("ShankLength < 0") - } - if k.Tolerance < 0 { - panic("Tolerance < 0") - } - - // head - var head sdf.SDF3 - hr := t.HexRadius() - hh := t.HexHeight() - switch k.Style { - case CylinderHex: - head, _ = HexHead(hr, hh, "b") - case CylinderKnurl: - head, _ = KnurledHead(hr, hh, hr*0.25) - default: - panic("unknown style for bolt " + k.Style.String()) - } - - // shank - shankLength := k.ShankLength + hh/2 - shankOffset := shankLength / 2 - var shank sdf.SDF3 = form3.Cylinder(shankLength, t.Radius, hh*0.08) - shank = sdf.Transform3D(shank, sdf.Translate3D(r3.Vec{0, 0, shankOffset})) - - // external thread - threadLength := k.TotalLength - k.ShankLength - if threadLength < 0 { - threadLength = 0 - } - var thread sdf.SDF3 - if threadLength != 0 { - r := t.Radius - k.Tolerance - threadOffset := threadLength/2 + shankLength - isoThread := obj2.ISOThread(r, t.Pitch, true) - thread = form3.Screw(isoThread, threadLength, t.Taper, t.Pitch, 1) - // chamfer the thread - thread = form3.ChamferedCylinder(thread, 0, 0.5) - - thread = sdf.Transform3D(thread, sdf.Translate3D(r3.Vec{0, 0, threadOffset})) - } - - return sdf.Union3D(head, shank, thread), nil // TODO error handling -} diff --git a/form3/obj3/nut.go b/form3/obj3/nut.go deleted file mode 100644 index 39b6654..0000000 --- a/form3/obj3/nut.go +++ /dev/null @@ -1,47 +0,0 @@ -package obj3 - -import ( - "github.com/soypat/sdf" - "github.com/soypat/sdf/form2/obj2" - form3 "github.com/soypat/sdf/form3/must3" -) - -// NutParms defines the parameters for a nut. -type NutParms struct { - Thread string // name of thread - Style CylinderStyle - Tolerance float64 // add to internal thread radius -} - -// Nut returns a simple nut suitable for 3d printing. -func Nut(k NutParms) (s sdf.SDF3, err error) { - if k.Tolerance < 0 { - panic("Tolerance < 0") - } - // validate parameters - t, err := obj2.ThreadLookup(k.Thread) - if err != nil { - panic(err) - } - - // nut body - var nut sdf.SDF3 - nr := t.HexRadius() - nh := t.HexHeight() - switch k.Style { - case CylinderHex: // TODO error handling - nut, _ = HexHead(nr, nh, "tb") - case CylinderKnurl: - nut, _ = KnurledHead(nr, nh, nr*0.25) - case CylinderCircular: - nut = form3.Cylinder(nh, nr*1.1, 0) - default: - panic("passed argument CylinderStyle not defined for Nut") - } - - // internal thread - isoThread := obj2.ISOThread(t.Radius+k.Tolerance, t.Pitch, false) - - thread := form3.Screw(isoThread, nh, t.Taper, t.Pitch, 1) - return sdf.Difference3D(nut, thread), err // TODO error handling -} diff --git a/form3/obj3/obj3.go b/form3/obj3/obj3.go deleted file mode 100644 index 5ce60d8..0000000 --- a/form3/obj3/obj3.go +++ /dev/null @@ -1,24 +0,0 @@ -package obj3 - -type CylinderStyle int - -const ( - _ CylinderStyle = iota - CylinderCircular - CylinderHex - CylinderKnurl -) - -func (c CylinderStyle) String() (str string) { - switch c { - case CylinderCircular: - str = "circular" - case CylinderHex: - str = "hex" - case CylinderKnurl: - str = "knurl" - default: - str = "unknown" - } - return str -} diff --git a/form3/obj3/standoff.go b/form3/obj3/standoff.go index 1c0cb29..e0a2b36 100644 --- a/form3/obj3/standoff.go +++ b/form3/obj3/standoff.go @@ -52,7 +52,7 @@ func pillarWeb(k StandoffParams) sdf.SDF3 { w.Add(0, k.WebHeight) p := form2.Polygon(w.Vertices()) s := sdf.Extrude3D(p, k.WebWidth) - m := sdf.Translate3D(r3.Vec{0, 0, -0.5 * k.PillarHeight}).Mul(sdf.RotateX(d2r(90.0))) + m := sdf.Translate3D(r3.Vec{0, 0, -0.5 * k.PillarHeight}).Mul(sdf.RotateX(90.0 * math.Pi / 180.)) return sdf.Transform3D(s, m) } diff --git a/form3/obj3/thread/acme.go b/form3/obj3/thread/acme.go new file mode 100644 index 0000000..0c9e044 --- /dev/null +++ b/form3/obj3/thread/acme.go @@ -0,0 +1,45 @@ +package thread + +import ( + "math" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form2/must2" +) + +// Acme is a trapezoidal thread form. https://en.wikipedia.org/wiki/Trapezoidal_thread_form +type Acme struct { + // D is the thread nominal diameter. + D float64 + // P is the thread pitch. + P float64 +} + +var _ Threader = Acme{} // Compile time check of interface implementation. + +func (acme Acme) Parameters() Parameters { + return basic{D: acme.D, P: acme.P}.Parameters() +} + +// AcmeThread returns the 2d profile for an acme thread. +// radius is radius of thread. pitch is thread-to-thread distance. +func (acme Acme) Thread() (sdf.SDF2, error) { + radius := acme.D / 2 + h := radius - 0.5*acme.P + theta := (29.0 / 2.0) * math.Pi / 180.0 + delta := 0.25 * acme.P * math.Tan(theta) + xOfs0 := 0.25*acme.P - delta + xOfs1 := 0.25*acme.P + delta + + poly := must2.NewPolygon() + poly.Add(radius, 0) + poly.Add(radius, h) + poly.Add(xOfs1, h) + poly.Add(xOfs0, radius) + poly.Add(-xOfs0, radius) + poly.Add(-xOfs1, h) + poly.Add(-radius, h) + poly.Add(-radius, 0) + + return must2.Polygon(poly.Vertices()), nil +} diff --git a/form3/obj3/thread/ansibuttress.go b/form3/obj3/thread/ansibuttress.go new file mode 100644 index 0000000..5cecc24 --- /dev/null +++ b/form3/obj3/thread/ansibuttress.go @@ -0,0 +1,46 @@ +package thread + +import ( + "math" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form2/must2" +) + +type ANSIButtress struct { + // D is the thread nominal diameter. + D float64 + // P is the thread pitch. + P float64 +} + +var _ Threader = ANSIButtress{} // Compile time check of interface implementation. + +func (butt ANSIButtress) Parameters() Parameters { + return basic{D: butt.D, P: butt.P}.Parameters() +} + +// ANSIButtressThread returns the 2d profile for an ANSI 45/7 buttress thread. +// https://en.wikipedia.org/wiki/Buttress_thread +// AMSE B1.9-1973 +// radius is radius of thread. pitch is thread-to-thread distance. +func (ansi ANSIButtress) Thread() (sdf.SDF2, error) { + radius := ansi.D / 2 + t0 := math.Tan(45.0 * math.Pi / 180) + t1 := math.Tan(7.0 * math.Pi / 180) + b := 0.6 // thread engagement + + h0 := ansi.P / (t0 + t1) + h1 := ((b / 2.0) * ansi.P) + (0.5 * h0) + hp := ansi.P / 2.0 + + tp := must2.NewPolygon() + tp.Add(ansi.P, 0) + tp.Add(ansi.P, radius) + tp.Add(hp-((h0-h1)*t1), radius) + tp.Add(t0*h0-hp, radius-h1).Smooth(0.0714*ansi.P, 5) + tp.Add((h0-h1)*t0-hp, radius) + tp.Add(-ansi.P, radius) + tp.Add(-ansi.P, 0) + return must2.Polygon(tp.Vertices()), nil +} diff --git a/form3/obj3/thread/basic.go b/form3/obj3/thread/basic.go new file mode 100644 index 0000000..2c06ebc --- /dev/null +++ b/form3/obj3/thread/basic.go @@ -0,0 +1,52 @@ +package thread + +import "math" + +// basic is a building block for most threads. +type basic struct { + // D is the thread nominal diameter [mm]. + D float64 + // P is the thread pitch [mm]. + P float64 +} + +func (b basic) Parameters() Parameters { + radius := b.D / 2 + return Parameters{ + Name: "basic", + Radius: radius, + Pitch: b.P, + Starts: 1, + Taper: 0, + HexF2F: metricf2f(radius), + } +} + +// Metric hex Flat to flat dimension [mm]. +var metricF2FTable = []float64{1.75, 2, 3.2, 4, 5, 6, 7, 8, 10, 13, 17, 19, 24, 30, 36, 46, 55, 65, 75, 85, 95} + +// metricf2f gets a reasonable hex flat-to-flat dimension +// for a metric screw of nominal radius. +func metricf2f(radius float64) float64 { + var estF2F float64 + switch { + case radius < 1.2/2: + estF2F = 3.2 * radius + case radius < 3.8/2: + estF2F = 4.5 * radius + case radius < 4.2/2: + estF2F = 4. * radius + default: + estF2F = 3.5 * radius + } + if math.Abs(radius-56/2) < 1 { + estF2F = 86 + } + for i := len(metricF2FTable) - 1; i >= 0; i-- { + v := metricF2FTable[i] + if estF2F-1e-2 > v { + return v + } + } + return metricF2FTable[0] +} diff --git a/form3/obj3/thread/bolt.go b/form3/obj3/thread/bolt.go new file mode 100644 index 0000000..e6a8459 --- /dev/null +++ b/form3/obj3/thread/bolt.go @@ -0,0 +1,75 @@ +package thread + +import ( + "errors" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form3/must3" + "gonum.org/v1/gonum/spatial/r3" +) + +// BoltParms defines the parameters for a bolt. +type BoltParms struct { + Thread Threader + Style NutStyle // head style "hex" or "knurl" + Tolerance float64 // subtract from external thread radius + TotalLength float64 // threaded length + shank length + ShankLength float64 // non threaded length +} + +// Bolt returns a simple bolt suitable for 3d printing. +func Bolt(k BoltParms) (s sdf.SDF3, err error) { + switch { + case k.Thread == nil: + err = errors.New("nil Threader") + case k.TotalLength < 0: + err = errors.New("total length < 0") + case k.ShankLength >= k.TotalLength: + err = errors.New("shank length must be less than total length") + case k.ShankLength < 0: + err = errors.New("shank length < 0") + case k.Tolerance < 0: + err = errors.New("tolerance < 0") + } + param := k.Thread.Parameters() + // head + var head sdf.SDF3 + + hr := param.HexRadius() + hh := param.HexHeight() + if hr <= 0 || hh <= 0 { + return nil, errors.New("bad hex head dimension") + } + switch k.Style { + case NutHex: + head, _ = HexHead(hr, hh, "b") + case NutKnurl: + head, _ = KnurledHead(hr, hh, hr*0.25) + default: + return nil, errors.New("unknown style for bolt: " + k.Style.String()) + } + + // shank + shankLength := k.ShankLength + hh/2 + shankOffset := shankLength / 2 + var shank sdf.SDF3 = must3.Cylinder(shankLength, param.Radius, hh*0.08) + shank = sdf.Transform3D(shank, sdf.Translate3D(r3.Vec{X: 0, Y: 0, Z: shankOffset})) + + // external thread + threadLength := k.TotalLength - k.ShankLength + if threadLength < 0 { + threadLength = 0 + } + var thread sdf.SDF3 + if threadLength != 0 { + thread, err = Screw(threadLength, k.Thread) + if err != nil { + return nil, err + } + // chamfer the thread + thread = must3.ChamferedCylinder(thread, 0, 0.5) + threadOffset := threadLength/2 + shankLength + thread = sdf.Transform3D(thread, sdf.Translate3D(r3.Vec{X: 0, Y: 0, Z: threadOffset})) + } + return sdf.Union3D(head, shank, thread), nil +} diff --git a/form3/obj3/hex.go b/form3/obj3/thread/hexhead.go similarity index 65% rename from form3/obj3/hex.go rename to form3/obj3/thread/hexhead.go index 9d5e363..937ab59 100644 --- a/form3/obj3/hex.go +++ b/form3/obj3/thread/hexhead.go @@ -1,11 +1,11 @@ -package obj3 +package thread import ( "math" "github.com/soypat/sdf" - form2 "github.com/soypat/sdf/form2/must2" - form3 "github.com/soypat/sdf/form3/must3" + "github.com/soypat/sdf/form2" + "github.com/soypat/sdf/form3" "gonum.org/v1/gonum/spatial/r3" ) @@ -16,20 +16,30 @@ import ( func HexHead(radius float64, height float64, round string) (s sdf.SDF3, err error) { // basic hex body cornerRound := radius * 0.08 - hex2d := form2.Polygon(form2.Nagon(6, radius-cornerRound)) + nagon, err := form2.Nagon(6, radius-cornerRound) + if err != nil { + return nil, err + } + hex2d, err := form2.Polygon(nagon) + if err != nil { + return nil, err + } hex2d = sdf.Offset2D(hex2d, cornerRound) var hex3d sdf.SDF3 = sdf.Extrude3D(hex2d, height) // round out the top and/or bottom as required if round != "" { topRound := radius * 1.6 - d := radius * math.Cos(d2r(30)) - sphere3d := form3.Sphere(topRound) + d := radius * math.Cos(30.0*math.Pi/180.0) + sphere3d, err := form3.Sphere(topRound) + if err != nil { + return nil, err + } zOfs := math.Sqrt(topRound*topRound-d*d) - height/2 if round == "t" || round == "tb" { - hex3d = sdf.Intersect3D(hex3d, sdf.Transform3D(sphere3d, sdf.Translate3D(r3.Vec{0, 0, -zOfs}))) + hex3d = sdf.Intersect3D(hex3d, sdf.Transform3D(sphere3d, sdf.Translate3D(r3.Vec{X: 0, Y: 0, Z: -zOfs}))) } if round == "b" || round == "tb" { - hex3d = sdf.Intersect3D(hex3d, sdf.Transform3D(sphere3d, sdf.Translate3D(r3.Vec{0, 0, zOfs}))) + hex3d = sdf.Intersect3D(hex3d, sdf.Transform3D(sphere3d, sdf.Translate3D(r3.Vec{X: 0, Y: 0, Z: zOfs}))) } } return hex3d, nil // TODO error handling. diff --git a/form3/obj3/thread/iso.go b/form3/obj3/thread/iso.go new file mode 100644 index 0000000..44fc9ed --- /dev/null +++ b/form3/obj3/thread/iso.go @@ -0,0 +1,61 @@ +package thread + +import ( + "math" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form2/must2" +) + +// ISO is a standardized thread. +// Pitch is usually the number following the diameter +// i.e: for M16x2 the pitch is 2mm +type ISO struct { + // D is the thread nominal diameter [mm]. + D float64 + // P is the thread pitch [mm]. + P float64 + // Is external or internal thread. Ext set to true means external thread. + Ext bool +} + +var _ Threader = ISO{} // Compile time check of interface implementation. + +func (iso ISO) Parameters() Parameters { + b := basic{D: iso.D, P: iso.P} + return b.Parameters() +} + +func (iso ISO) Thread() (sdf.SDF2, error) { + radius := iso.D / 2 + theta := 30.0 * math.Pi / 180. + h := iso.P / (2.0 * math.Tan(theta)) + rMajor := radius + r0 := rMajor - (7.0/8.0)*h + + poly := must2.NewPolygon() + if iso.Ext { + rRoot := (iso.P / 8.0) / math.Cos(theta) + xOfs := (1.0 / 16.0) * iso.P + poly.Add(iso.P, 0) + poly.Add(iso.P, r0+h) + poly.Add(iso.P/2.0, r0).Smooth(rRoot, 5) + poly.Add(xOfs, rMajor) + poly.Add(-xOfs, rMajor) + poly.Add(-iso.P/2.0, r0).Smooth(rRoot, 5) + poly.Add(-iso.P, r0+h) + poly.Add(-iso.P, 0) + } else { + rMinor := r0 + (1.0/4.0)*h + rCrest := (iso.P / 16.0) / math.Cos(theta) + xOfs := (1.0 / 8.0) * iso.P + poly.Add(iso.P, 0) + poly.Add(iso.P, rMinor) + poly.Add(iso.P/2-xOfs, rMinor) + poly.Add(0, r0+h).Smooth(rCrest, 5) + poly.Add(-iso.P/2+xOfs, rMinor) + poly.Add(-iso.P, rMinor) + poly.Add(-iso.P, 0) + } + return must2.Polygon(poly.Vertices()), nil +} diff --git a/form3/obj3/knurl.go b/form3/obj3/thread/knurl.go similarity index 65% rename from form3/obj3/knurl.go rename to form3/obj3/thread/knurl.go index 56f77bb..f9603f9 100644 --- a/form3/obj3/knurl.go +++ b/form3/obj3/thread/knurl.go @@ -1,11 +1,11 @@ -package obj3 +package thread import ( "math" "github.com/soypat/sdf" - form2 "github.com/soypat/sdf/form2/must2" - form3 "github.com/soypat/sdf/form3/must3" + "github.com/soypat/sdf/form2" + "github.com/soypat/sdf/form3" ) // Knurled Cylinders @@ -20,10 +20,11 @@ type KnurlParams struct { Pitch float64 // knurl pitch Height float64 // knurl height Theta float64 // knurl helix angle + starts int } -// knurlProfile returns a 2D knurl profile. -func knurlProfile(k KnurlParams) sdf.SDF2 { +// Thread implements the Threader interface. +func (k KnurlParams) Thread() (sdf.SDF2, error) { knurl := form2.NewPolygon() knurl.Add(k.Pitch/2, 0) knurl.Add(k.Pitch/2, k.Radius) @@ -34,8 +35,16 @@ func knurlProfile(k KnurlParams) sdf.SDF2 { return form2.Polygon(knurl.Vertices()) } +// Parameters implements the Threader interface. +func (k KnurlParams) Parameters() Parameters { + p := ISO{D: k.Radius * 2, P: k.Pitch, Ext: true}.Parameters() + p.Starts = k.starts + return p +} + // Knurl returns a knurled cylinder. func Knurl(k KnurlParams) (s sdf.SDF3, err error) { + // TODO fix error handling. if k.Length <= 0 { panic("Length <= 0") } @@ -51,17 +60,22 @@ func Knurl(k KnurlParams) (s sdf.SDF3, err error) { if k.Theta < 0 { panic("Theta < 0") } - if k.Theta >= d2r(90) { + if k.Theta >= 90.*math.Pi/180. { panic("Theta >= 90") } // Work out the number of starts using the desired helix angle. - n := int(2 * math.Pi * k.Radius * math.Tan(k.Theta) / k.Pitch) - // build the knurl profile. - knurl2d := knurlProfile(k) + k.starts = int(2 * math.Pi * k.Radius * math.Tan(k.Theta) / k.Pitch) // create the left/right hand spirals - knurl0_3d := form3.Screw(knurl2d, k.Length, 0, k.Pitch, n) - knurl1_3d := form3.Screw(knurl2d, k.Length, 0, k.Pitch, -n) - return sdf.Intersect3D(knurl0_3d, knurl1_3d), err // TODO error handling + knurl0_3d, err := Screw(k.Length, k) + if err != nil { + return nil, err + } + k.starts *= -1 + knurl1_3d, err := Screw(k.Length, k) + if err != nil { + return nil, err + } + return sdf.Intersect3D(knurl0_3d, knurl1_3d), nil } // KnurledHead returns a generic cylindrical knurled head. @@ -73,15 +87,15 @@ func KnurledHead(radius float64, height float64, pitch float64) (s sdf.SDF3, err Radius: radius, Pitch: pitch, Height: pitch * 0.3, - Theta: d2r(45), + Theta: 45.0 * math.Pi / 180, } knurl, err := Knurl(k) if err != nil { return s, err } - cylinder := form3.Cylinder(height, radius, cylinderRound) + cylinder, err := form3.Cylinder(height, radius, cylinderRound) + if err != nil { + return nil, err + } return sdf.Union3D(cylinder, knurl), err } - -func d2r(degrees float64) float64 { return degrees * math.Pi / 180. } -func r2d(radians float64) float64 { return radians / math.Pi * 180. } diff --git a/form3/obj3/thread/legacy_test.go b/form3/obj3/thread/legacy_test.go new file mode 100644 index 0000000..324419f --- /dev/null +++ b/form3/obj3/thread/legacy_test.go @@ -0,0 +1,234 @@ +package thread + +import ( + "fmt" + "log" + "math" + "sort" + "strings" + "testing" +) + +func TestMetricF2F(t *testing.T) { + var threads byF2F + for _, v := range threadDB { + threads = append(threads, v) + } + sort.Sort(threads) + // lookup := make(map[float64]struct{}) + for _, v := range threads { + if v.Name[0] != 'M' { + continue + } + v.Name = strings.Replace(v.Name, ".", "", 1) + f2f := v.HexFlat2Flat * v.toMM() + radius := v.Radius * v.toMM() + estf2f := metricf2f(radius) + if estf2f != f2f { + t.Errorf("%s\tf2f=%.3g\test=%.3g", v.Name, f2f, estf2f) + } + // t.Logf("%s\tk=%.3g\tr=%.3g\tf2f=%.3g\testf2f=%.3g", v.Name, f2f/radius, radius, f2f, estf2f) + } +} + +type byRadius []threadParameters +type byF2F []threadParameters +type byName []threadParameters + +func (b byName) Less(i, j int) bool { return b[i].Name < b[j].Name } +func (b byName) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byName) Len() int { return len(b) } +func (b byRadius) Less(i, j int) bool { + return b[i].Radius*b[i].toMM() < b[j].Radius*b[j].toMM() +} +func (b byRadius) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byRadius) Len() int { return len(b) } +func (b byF2F) Less(i, j int) bool { + return b[i].HexFlat2Flat*b[i].toMM() < b[j].HexFlat2Flat*b[j].toMM() +} +func (b byF2F) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byF2F) Len() int { return len(b) } + +// Thread Database - lookup standard screw threads by name + +// threadParameters stores the values that define a thread. +type threadParameters struct { + Name string // name of screw thread + Radius float64 // nominal major radius of screw + Pitch float64 // thread to thread distance of screw + Taper float64 // thread taper (radians) + HexFlat2Flat float64 // hex head flat to flat distance + Units string // "inch" or "mm" +} + +func (t threadParameters) toMM() float64 { + if t.Units == "inches" || t.Units == "inch" { + return 25.4 + } + return 1.0 +} + +type threadDatabase map[string]threadParameters + +var threadDB = initThreadLookup() + +// UTSAdd adds a Unified Thread Standard to the thread database. +// diameter is screw major diameter. +// tpi is threads per inch. +// ftof is hex head flat to flat distance. +func (m threadDatabase) UTSAdd(name string, diameter float64, tpi float64, ftof float64) { + if ftof <= 0 { + log.Panicf("bad flat to flat distance for thread \"%s\"", name) + } + t := threadParameters{} + t.Name = name + t.Radius = diameter / 2.0 + t.Pitch = 1.0 / tpi + t.HexFlat2Flat = ftof + t.Units = "inch" + m[name] = t +} + +// ISOAdd adds an ISO Thread Standard to the thread database. +func (m threadDatabase) ISOAdd( + name string, // thread name + diameter float64, // screw major diamater + pitch float64, // thread pitch + ftof float64, // hex head flat to flat distance +) { + if ftof <= 0 { + log.Panicf("bad flat to flat distance for thread \"%s\"", name) + } + t := threadParameters{} + t.Name = name + t.Radius = diameter / 2.0 + t.Pitch = pitch + t.HexFlat2Flat = ftof + t.Units = "mm" + m[name] = t +} + +// NPTAdd adds an National Pipe Thread to the thread database. +func (m threadDatabase) NPTAdd( + name string, // thread name + diameter float64, // screw major diameter + tpi float64, // threads per inch + ftof float64, // hex head flat to flat distance +) { + if ftof <= 0 { + log.Panicf("bad flat to flat distance for thread \"%s\"", name) + } + t := threadParameters{} + t.Name = name + t.Radius = diameter / 2.0 + t.Pitch = 1.0 / tpi + t.Taper = math.Atan(1.0 / 32.0) + t.HexFlat2Flat = ftof + t.Units = "inch" + m[name] = t +} + +// initThreadLookup adds a collection of standard threads to the thread database. +func initThreadLookup() threadDatabase { + m := make(threadDatabase) + // UTS Coarse + m.UTSAdd("unc_1/4", 1.0/4.0, 20, 7.0/16.0) + m.UTSAdd("unc_5/16", 5.0/16.0, 18, 1.0/2.0) + m.UTSAdd("unc_3/8", 3.0/8.0, 16, 9.0/16.0) + m.UTSAdd("unc_7/16", 7.0/16.0, 14, 5.0/8.0) + m.UTSAdd("unc_1/2", 1.0/2.0, 13, 3.0/4.0) + m.UTSAdd("unc_9/16", 9.0/16.0, 12, 13.0/16.0) + m.UTSAdd("unc_5/8", 5.0/8.0, 11, 15.0/16.0) + m.UTSAdd("unc_3/4", 3.0/4.0, 10, 9.0/8.0) + m.UTSAdd("unc_7/8", 7.0/8.0, 9, 21.0/16.0) + m.UTSAdd("unc_1", 1.0, 8, 3.0/2.0) + // UTS Fine + m.UTSAdd("unf_1/4", 1.0/4.0, 28, 7.0/16.0) + m.UTSAdd("unf_5/16", 5.0/16.0, 24, 1.0/2.0) + m.UTSAdd("unf_3/8", 3.0/8.0, 24, 9.0/16.0) + m.UTSAdd("unf_7/16", 7.0/16.0, 20, 5.0/8.0) + m.UTSAdd("unf_1/2", 1.0/2.0, 20, 3.0/4.0) + m.UTSAdd("unf_9/16", 9.0/16.0, 18, 13.0/16.0) + m.UTSAdd("unf_5/8", 5.0/8.0, 18, 15.0/16.0) + m.UTSAdd("unf_3/4", 3.0/4.0, 16, 9.0/8.0) + m.UTSAdd("unf_7/8", 7.0/8.0, 14, 21.0/16.0) + m.UTSAdd("unf_1", 1.0, 12, 3.0/2.0) + const inchesPerMM = 1.0 / 25.4 + // National Pipe Thread. Face to face distance taken from ASME B16.11 Plug Manufacturer (mm) + m.NPTAdd("npt_1/8", 0.405, 27, 11.2*inchesPerMM) + m.NPTAdd("npt_1/4", 0.540, 18, 15.7*inchesPerMM) + m.NPTAdd("npt_3/8", 0.675, 18, 17.5*inchesPerMM) + m.NPTAdd("npt_1/2", 0.840, 14, 22.4*inchesPerMM) + m.NPTAdd("npt_3/4", 1.050, 14, 26.9*inchesPerMM) + m.NPTAdd("npt_1", 1.315, 11.5, 35.1*inchesPerMM) + m.NPTAdd("npt_1_1/4", 1.660, 11.5, 44.5*inchesPerMM) + m.NPTAdd("npt_1_1/2", 1.900, 11.5, 50.8*inchesPerMM) + m.NPTAdd("npt_2", 2.375, 11.5, 63.5*inchesPerMM) + m.NPTAdd("npt_2_1/2", 2.875, 8, 76.2*inchesPerMM) + m.NPTAdd("npt_3", 3.500, 8, 88.9*inchesPerMM) + m.NPTAdd("npt_4", 4.500, 8, 117.3*inchesPerMM) + + // ISO Coarse + m.ISOAdd("M1x0.25", 1, 0.25, 1.75) // ftof? + m.ISOAdd("M1.2x0.25", 1.2, 0.25, 2.0) // ftof? + m.ISOAdd("M1.6x0.35", 1.6, 0.35, 3.2) + m.ISOAdd("M2x0.4", 2, 0.4, 4) + m.ISOAdd("M2.5x0.45", 2.5, 0.45, 5) + m.ISOAdd("M3x0.5", 3, 0.5, 6) + m.ISOAdd("M4x0.7", 4, 0.7, 7) + m.ISOAdd("M5x0.8", 5, 0.8, 8) + m.ISOAdd("M6x1", 6, 1, 10) + m.ISOAdd("M8x1.25", 8, 1.25, 13) + m.ISOAdd("M10x1.5", 10, 1.5, 17) + m.ISOAdd("M12x1.75", 12, 1.75, 19) + m.ISOAdd("M16x2", 16, 2, 24) + m.ISOAdd("M20x2.5", 20, 2.5, 30) + m.ISOAdd("M24x3", 24, 3, 36) + m.ISOAdd("M30x3.5", 30, 3.5, 46) + m.ISOAdd("M36x4", 36, 4, 55) + m.ISOAdd("M42x4.5", 42, 4.5, 65) + m.ISOAdd("M48x5", 48, 5, 75) + m.ISOAdd("M56x5.5", 56, 5.5, 85) + m.ISOAdd("M64x6", 64, 6, 95) + // ISO Fine + m.ISOAdd("M1x0.2", 1, 0.2, 1.75) // ftof? + m.ISOAdd("M1.2x0.2", 1.2, 0.2, 2.0) // ftof? + m.ISOAdd("M1.6x0.2", 1.6, 0.2, 3.2) + m.ISOAdd("M2x0.25", 2, 0.25, 4) + m.ISOAdd("M2.5x0.35", 2.5, 0.35, 5) + m.ISOAdd("M3x0.35", 3, 0.35, 6) + m.ISOAdd("M4x0.5", 4, 0.5, 7) + m.ISOAdd("M5x0.5", 5, 0.5, 8) + m.ISOAdd("M6x0.75", 6, 0.75, 10) + m.ISOAdd("M8x1", 8, 1, 13) + m.ISOAdd("M10x1.25", 10, 1.25, 17) + m.ISOAdd("M12x1.5", 12, 1.5, 19) + m.ISOAdd("M16x1.5", 16, 1.5, 24) + m.ISOAdd("M20x2", 20, 2, 30) + m.ISOAdd("M24x2", 24, 2, 36) + m.ISOAdd("M30x2", 30, 2, 46) + m.ISOAdd("M36x3", 36, 3, 55) + m.ISOAdd("M42x3", 42, 3, 65) + m.ISOAdd("M48x3", 48, 3, 75) + m.ISOAdd("M56x4", 56, 4, 85) + m.ISOAdd("M64x4", 64, 4, 95) + return m +} + +// lookup lookups the parameters for a thread by name. +func lookup(name string) (threadParameters, error) { + if t, ok := threadDB[name]; ok { + return t, nil + } + return threadParameters{}, fmt.Errorf("thread \"%s\" not found", name) +} + +// HexRadius returns the hex head radius. +func (t *threadParameters) HexRadius() float64 { + return t.HexFlat2Flat / (2.0 * math.Cos(30*math.Pi/180)) +} + +// HexHeight returns the hex head height (empirical). +func (t *threadParameters) HexHeight() float64 { + return 2.0 * t.HexRadius() * (5.0 / 12.0) +} diff --git a/form3/obj3/thread/npt.go b/form3/obj3/thread/npt.go new file mode 100644 index 0000000..b01f270 --- /dev/null +++ b/form3/obj3/thread/npt.go @@ -0,0 +1,72 @@ +package thread + +import ( + "errors" + "math" + + "github.com/soypat/sdf" +) + +type NPT struct { + // D is the thread nominal diameter. + D float64 + // threads per inch. 1.0/TPI gives pitch. + TPI float64 + // Flat-to-flat hex distance. + // Can be set by SetFromNominal with a standard value. + F2F float64 +} + +var _ Threader = NPT{} // Compile time check of interface implementation. + +func (npt NPT) Parameters() Parameters { + p := ISO{D: npt.D, P: 1.0 / npt.TPI}.Parameters() + p.Name = "NPT" + p.Taper = math.Atan(1.0 / 32.0) // standard NPT taper. + if npt.F2F > 0 { + p.HexF2F = npt.F2F + } + return p +} + +func (npt NPT) Thread() (sdf.SDF2, error) { + return ISO{D: npt.D, P: 1.0 / npt.TPI}.Thread() +} + +type nptSpec struct { + N float64 // Nominal measurement (usually a fraction of inch) + D float64 // screw major diameter + tpi float64 // threads per inch + ftof float64 // hex head flat to flat distance +} + +var nptLookupTable = []nptSpec{ + {N: 1.0 / 8.0, D: 0.405, tpi: 27, ftof: 11.2 / 25.4}, + {N: 1.0 / 4.0, D: 0.540, tpi: 18, ftof: 15.7 / 25.4}, + {N: 3.0 / 8.0, D: 0.675, tpi: 18, ftof: 17.5 / 25.4}, + {N: 1.0 / 2.0, D: 0.840, tpi: 14, ftof: 22.4 / 25.4}, + {N: 3.0 / 4.0, D: 1.050, tpi: 14, ftof: 26.9 / 25.4}, + {N: 1.0, D: 1.315, tpi: 11.5, ftof: 35.1 / 25.4}, + {N: 1 + 1.0/4.0, D: 1.660, tpi: 11.5, ftof: 44.5 / 25.4}, + {N: 1 + 1.0/2.0, D: 1.900, tpi: 11.5, ftof: 50.8 / 25.4}, + {N: 2, D: 2.375, tpi: 11.5, ftof: 63.5 / 25.4}, + {N: 2 + 1.0/2.0, D: 2.875, tpi: 8, ftof: 76.2 / 25.4}, + {N: 3, D: 3.500, tpi: 8, ftof: 88.9 / 25.4}, + {N: 4, D: 4.500, tpi: 8, ftof: 117.3 / 25.4}, +} + +// SetFromNominal sets NPT thread dimensions from a nominal measurement +// which usually takes the form of inch fractions. i.e: +// npt.SetFromNominal(1.0/8.0) // sets NPT 1/8 +func (npt *NPT) SetFromNominal(nominalDimension float64) error { + const lookupTol = 1. / 32. + for _, a := range nptLookupTable { + if math.Abs(a.N-nominalDimension) < lookupTol { + npt.D = a.D + npt.F2F = a.ftof + npt.TPI = a.tpi + return nil + } + } + return errors.New("nominal measurement not found") +} diff --git a/form3/obj3/thread/nut.go b/form3/obj3/thread/nut.go new file mode 100644 index 0000000..94da430 --- /dev/null +++ b/form3/obj3/thread/nut.go @@ -0,0 +1,79 @@ +package thread + +import ( + "errors" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form3/must3" +) + +type NutStyle int + +const ( + _ NutStyle = iota + NutCircular + NutHex + NutKnurl +) + +func (c NutStyle) String() (str string) { + switch c { + case NutCircular: + str = "circular" + case NutHex: + str = "hex" + case NutKnurl: + str = "knurl" + default: + str = "unknown" + } + return str +} + +// NutParms defines the parameters for a nut. +type NutParms struct { + Thread Threader + Style NutStyle + Tolerance float64 // add to internal thread radius +} + +// Nut returns a simple nut suitable for 3d printing. +func Nut(k NutParms) (s sdf.SDF3, err error) { + switch { + case k.Thread == nil: + err = errors.New("nil threader") + case k.Tolerance < 0: + err = errors.New("tolerance < 0") + } + if err != nil { + return nil, err + } + + params := k.Thread.Parameters() + // nut body + var nut sdf.SDF3 + nr := params.HexRadius() + nh := params.HexHeight() + if nr <= 0 || nh <= 0 { + return nil, errors.New("bad hex nut dimensions") + } + switch k.Style { + case NutHex: // TODO error handling + nut, err = HexHead(nr, nh, "tb") + case NutKnurl: + nut, err = KnurledHead(nr, nh, nr*0.25) + case NutCircular: + nut = must3.Cylinder(nh, nr*1.1, 0) + default: + err = errors.New("passed argument CylinderStyle not defined for Nut") + } + if err != nil { + return nil, err + } + // internal thread + thread, err := Screw(nh, k.Thread) + if err != nil { + return nil, err + } + return sdf.Difference3D(nut, thread), nil +} diff --git a/form3/obj3/thread/parameters.go b/form3/obj3/thread/parameters.go new file mode 100644 index 0000000..80182e9 --- /dev/null +++ b/form3/obj3/thread/parameters.go @@ -0,0 +1,26 @@ +package thread + +import "math" + +type Parameters struct { + Name string // name of screw thread + Radius float64 // nominal major radius of screw + Pitch float64 // thread to thread distance of screw + Starts int // number of threads + Taper float64 // thread taper (radians) + HexF2F float64 // hex head flat to flat distance +} + +// HexRadius returns the hex head radius. +func (t Parameters) HexRadius() float64 { + return t.HexF2F / (2.0 * math.Cos(30*math.Pi/180)) +} + +// HexHeight returns the hex head height (empirical). +func (t Parameters) HexHeight() float64 { + return 2.0 * t.HexRadius() * (5.0 / 12.0) +} + +// Imperial hex Flat to flat dimension [mm]. +// Face to face distance taken from ASME B16.11 Plug Manufacturer (mm) +// var imperialF2FTable = []float64{11.2, 15.7, 17.5, 22.4, 26.9, 35.1, 44.5, 50.8, 63.5, 76.2, 88.9, 117.3} diff --git a/form3/obj3/thread/plasticbuttress.go b/form3/obj3/thread/plasticbuttress.go new file mode 100644 index 0000000..70cd084 --- /dev/null +++ b/form3/obj3/thread/plasticbuttress.go @@ -0,0 +1,45 @@ +package thread + +import ( + "math" + + "github.com/soypat/sdf" + "github.com/soypat/sdf/form2/must2" +) + +type PlasticButtress struct { + // D is the thread nominal diameter. + D float64 + // P is the thread pitch. + P float64 +} + +var _ Threader = PlasticButtress{} // Compile time check of interface implementation. + +func (butt PlasticButtress) Parameters() Parameters { + return basic{D: butt.D, P: butt.P}.Parameters() +} + +// Thread returns the 2d profile for a screw top style plastic buttress thread. +// Similar to ANSI 45/7 - but with more corner rounding +// radius is radius of thread. pitch is thread-to-thread distance. +func (butt PlasticButtress) Thread() (sdf.SDF2, error) { + radius := butt.D / 2 + t0 := math.Tan(45.0 * math.Pi / 180) + t1 := math.Tan(7.0 * math.Pi / 180) + b := 0.6 // thread engagement + + h0 := butt.P / (t0 + t1) + h1 := ((b / 2.0) * butt.P) + (0.5 * h0) + hp := butt.P / 2.0 + + tp := must2.NewPolygon() + tp.Add(butt.P, 0) + tp.Add(butt.P, radius) + tp.Add(hp-((h0-h1)*t1), radius).Smooth(0.05*butt.P, 5) + tp.Add(t0*h0-hp, radius-h1).Smooth(0.15*butt.P, 5) + tp.Add((h0-h1)*t0-hp, radius).Smooth(0.15*butt.P, 5) + tp.Add(-butt.P, radius) + tp.Add(-butt.P, 0) + return must2.Polygon(tp.Vertices()), nil +} diff --git a/form3/must3/screw.go b/form3/obj3/thread/thread.go similarity index 54% rename from form3/must3/screw.go rename to form3/obj3/thread/thread.go index 661d1b8..e72f1cf 100644 --- a/form3/must3/screw.go +++ b/form3/obj3/thread/thread.go @@ -1,6 +1,7 @@ -package must3 +package thread import ( + "errors" "math" "github.com/soypat/sdf" @@ -8,6 +9,29 @@ import ( "gonum.org/v1/gonum/spatial/r3" ) +// Screws +// Screws are made by taking a 2D thread profile, rotating it about the z-axis and +// spiralling it upwards as we move along z. +// +// The 2D thread profiles are a polygon of a single thread centered on the y-axis with +// the x-axis as the screw axis. Most thread profiles are symmetric about the y-axis +// but a few aren't (E.g. buttress threads) so in general we build the profile of +// an entire pitch period. +// +// This code doesn't deal with thread tolerancing. If you want threads to fit properly +// the radius of the thread will need to be tweaked (+/-) to give internal/external thread +// clearance. + +type Threader interface { + Thread() (sdf.SDF2, error) + Parameters() Parameters +} + +type ScrewParameters struct { + Length float64 + Taper float64 +} + // screw is a 3d screw form. type screw struct { thread sdf.SDF2 // 2D thread profile @@ -24,36 +48,32 @@ type screw struct { // - thread taper angle (radians) // - pitch thread to thread distance // - number of thread starts (< 0 for left hand threads) -func Screw(thread sdf.SDF2, length float64, taper float64, pitch float64, starts int) sdf.SDF3 { +func Screw(length float64, thread Threader) (sdf.SDF3, error) { if thread == nil { - panic("thread == nil") + return nil, errors.New("nil threader") } if length <= 0 { - panic("length <= 0") + return nil, errors.New("need greater than zero length") } - if taper < 0 { - panic("taper < 0") - } - if taper >= math.Pi*0.5 { - panic("taper >= Pi * 0.5") - } - if pitch <= 0 { - panic("pitch <= 0") + tsdf, err := thread.Thread() + if err != nil { + return nil, err } + params := thread.Parameters() s := screw{} - s.thread = thread - s.pitch = pitch + s.thread = tsdf + s.pitch = params.Pitch s.length = length / 2 - s.taper = taper - s.lead = -pitch * float64(starts) + s.taper = params.Taper + s.lead = -s.pitch * float64(params.Starts) // Work out the bounding box. // The max-y axis of the sdf2 bounding box is the radius of the thread. bb := s.thread.Bounds() r := bb.Max.Y // add the taper increment - r += s.length * math.Tan(taper) - s.bb = r3.Box{r3.Vec{X: -r, Y: -r, Z: -s.length}, r3.Vec{X: r, Y: r, Z: s.length}} - return &s + r += s.length * math.Tan(s.taper) + s.bb = r3.Box{Min: r3.Vec{X: -r, Y: -r, Z: -s.length}, Max: r3.Vec{X: r, Y: r, Z: s.length}} + return &s, nil } // Evaluate returns the minimum distance to a 3d screw form. @@ -82,3 +102,9 @@ func (s *screw) Evaluate(p r3.Vec) float64 { func (s *screw) Bounds() r3.Box { return s.bb } + +func sawTooth(x, period float64) float64 { + x += period / 2 + t := x / period + return period*(t-math.Floor(t)) - period/2 +} diff --git a/form3/obj3/thread/uts.go b/form3/obj3/thread/uts.go new file mode 100644 index 0000000..fb671bc --- /dev/null +++ b/form3/obj3/thread/uts.go @@ -0,0 +1,25 @@ +package thread + +import "github.com/soypat/sdf" + +// Unified thread standard. +// Example: UNC 1/4 with external threading would be +// UTS{D:1.0/4.0, TPI:20, Ext: true} +type UTS struct { + D float64 + TPI float64 + // External or internal thread. + Ext bool +} + +var _ Threader = UTS{} // Interface implementation. + +func (uts UTS) Parameters() Parameters { + p := basic{D: uts.D, P: 1.0 / uts.TPI}.Parameters() + // TODO(soypat) add imperial hex flat-to-flat. See NPT for what that could look like. + return p +} + +func (uts UTS) Thread() (sdf.SDF2, error) { + return ISO{D: uts.D, P: 1.0 / uts.TPI, Ext: uts.Ext}.Thread() +} diff --git a/form3/screw.go b/form3/screw.go deleted file mode 100644 index 75a0a2f..0000000 --- a/form3/screw.go +++ /dev/null @@ -1,25 +0,0 @@ -package form3 - -import ( - "runtime/debug" - - "github.com/soypat/sdf" - "github.com/soypat/sdf/form3/must3" -) - -// screw returns a screw SDF3. -// - length of screw -// - thread taper angle (radians) -// - pitch thread to thread distance -// - number of thread starts (< 0 for left hand threads) -func screw(thread sdf.SDF2, length, taper, pitch float64, starts int) (s sdf.SDF3, err error) { - defer func() { - if a := recover(); a != nil { - err = &shapeErr{ - panicObj: a, - stack: string(debug.Stack()), - } - } - }() - return must3.Screw(thread, length, taper, pitch, starts), err -} diff --git a/render/3mf_test.go b/render/3mf_test.go index 2988359..48663ef 100644 --- a/render/3mf_test.go +++ b/render/3mf_test.go @@ -1,6 +1,7 @@ package render_test import ( + "os" "testing" "github.com/soypat/sdf/form3" @@ -10,6 +11,7 @@ import ( func Test3MF(t *testing.T) { const path = "box.3mf" + defer os.Remove(path) box, _ := form3.Box(r3.Vec{X: 1, Y: 1, Z: 1}, .1) err := render.Create3MF(path, render.NewOctreeRenderer(box, 10)) if err != nil { diff --git a/render/form3_test.go b/render/form3_test.go index 99f3a97..b91d39b 100644 --- a/render/form3_test.go +++ b/render/form3_test.go @@ -10,7 +10,7 @@ import ( "github.com/soypat/sdf" form2 "github.com/soypat/sdf/form2/must2" form3 "github.com/soypat/sdf/form3/must3" - "github.com/soypat/sdf/form3/obj3" + "github.com/soypat/sdf/form3/obj3/thread" "github.com/soypat/sdf/internal/d3" "github.com/soypat/sdf/render" "gonum.org/v1/gonum/spatial/r3" @@ -126,14 +126,24 @@ func hexToSTL(t testing.TB, filename string) { } func boltToSTL(t testing.TB, filename string) { - object, _ := obj3.Bolt(obj3.BoltParms{ - Thread: "M16x2", - Style: obj3.CylinderHex, + object, err := thread.Bolt(thread.BoltParms{ + Thread: thread.ISO{D: 16, P: 2}, // M16x2 + Style: thread.NutHex, Tolerance: 0.1, TotalLength: 60.0, ShankLength: 10.0, }) - err := render.CreateSTL(filename, render.NewOctreeRenderer(object, quality)) + if err != nil { + t.Fatal(err) + } + // object, _ = obj3.Bolt(obj3.BoltParms{ + // Thread: "M16x2", + // Style: obj3.CylinderHex, + // Tolerance: 0.1, + // TotalLength: 60.0, + // ShankLength: 10.0, + // }) + err = render.CreateSTL(filename, render.NewOctreeRenderer(object, quality)) if err != nil { t.Fatal(err) } diff --git a/render/internal_test.go b/render/internal_test.go index 12f8ef0..4383eeb 100644 --- a/render/internal_test.go +++ b/render/internal_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/soypat/sdf/form3/obj3" + "github.com/soypat/sdf/form3/obj3/thread" "github.com/soypat/sdf/internal/d3" "gonum.org/v1/gonum/spatial/r3" ) @@ -28,9 +28,9 @@ func TestSTLWriteReadback(t *testing.T) { quality = 200 tol = 1e-5 ) - s0, _ := obj3.Bolt(obj3.BoltParms{ - Thread: "M16x2", - Style: obj3.CylinderHex, + s0, _ := thread.Bolt(thread.BoltParms{ + Thread: thread.ISO{D: 16, P: 2}, // M16x2 + Style: thread.NutHex, Tolerance: 0.1, TotalLength: 40., ShankLength: 10.0, diff --git a/render/kdrender.go b/render/kdrender.go deleted file mode 100644 index c4a4ed6..0000000 --- a/render/kdrender.go +++ /dev/null @@ -1,206 +0,0 @@ -package render - -import ( - "math" - - "github.com/soypat/sdf" - "github.com/soypat/sdf/internal/d3" - "gonum.org/v1/gonum/spatial/kdtree" - "gonum.org/v1/gonum/spatial/r3" -) - -var ( - _ sdf.SDF3 = kdSDF{} - _ kdtree.Interface = kdTriangles{} - _ kdtree.Bounder = kdTriangles{} -) - -func NewKDSDF(model []Triangle3) sdf.SDF3 { - mykd := make(kdTriangles, len(model)) - // var min, max r3.Vec - for i := range mykd { - tri := kdTriangle(model[i]) - mykd[i] = tri - // triMin := d3.MinElem(tri.V[2], d3.MinElem(tri.V[0], tri.V[1])) - // triMax := d3.MaxElem(tri.V[2], d3.MaxElem(tri.V[0], tri.V[1])) - // min = d3.MinElem(triMin, min) - // max = d3.MaxElem(triMax, max) - } - tree := kdtree.New(mykd, true) - // tree.Root.Bounding = &kdtree.Bounding{ - // Min: kdTriangle{V: [3]r3.Vec{min, min, min}}, - // Max: kdTriangle{V: [3]r3.Vec{max, max, max}}, - // } - return kdSDF{ - tree: *tree, - } -} - -type kdSDF struct { - tree kdtree.Tree -} - -func (s kdSDF) Evaluate(v r3.Vec) float64 { - const eps = 1e-3 - // do some ad-hoc math with the triangle normal ???? - triangle := s.Nearest(v) - minDist := math.MaxFloat64 - // Find closest vertex - closest := r3.Vec{} - for i := 0; i < 3; i++ { - vDist := r3.Norm(r3.Sub(v, triangle[i])) - if vDist < minDist { - closest = triangle[i] - minDist = vDist - } - } - if minDist < eps { - return 0 - } - pointDir := r3.Sub(v, closest) - n := triangle.Normal() - alpha := math.Acos(r3.Cos(n, pointDir)) - return math.Copysign(minDist, math.Pi/2-alpha) -} - -// Get nearest triangle to point. -func (s kdSDF) Nearest(v r3.Vec) kdTriangle { - got, _ := s.tree.Nearest(kdTriangle{v, v, v}) - // do some ad-hoc math with the triangle normal ???? - return got.(kdTriangle) -} - -func (s kdSDF) Bounds() r3.Box { - bb := s.tree.Root.Bounding - if bb == nil { - panic("got nil bounding box?") - } - tMin := bb.Min.(kdTriangle) - tMax := bb.Max.(kdTriangle) - return r3.Box{ - Min: d3.MinElem(tMin[2], d3.MinElem(tMin[0], tMin[1])), - Max: d3.MaxElem(tMax[2], d3.MaxElem(tMax[0], tMax[1])), - } -} - -type kdTriangles []kdTriangle - -type kdTriangle Triangle3 - -func (k kdTriangles) Index(i int) kdtree.Comparable { - return k[i] -} - -// Len returns the length of the list. -func (k kdTriangles) Len() int { return len(k) } - -// Pivot partitions the list based on the dimension specified. -func (k kdTriangles) Pivot(d kdtree.Dim) int { - p := kdPlane{dim: int(d), triangles: k} - return kdtree.Partition(p, kdtree.MedianOfMedians(p)) - return 0 -} - -// Slice returns a slice of the list using zero-based half -// open indexing equivalent to built-in slice indexing. -func (k kdTriangles) Slice(start, end int) kdtree.Interface { - return k[start:end] -} - -func (k kdTriangles) Bounds() *kdtree.Bounding { - max := r3.Vec{-math.MaxFloat64, -math.MaxFloat64, -math.MaxFloat64} - min := r3.Vec{math.MaxFloat64, math.MaxFloat64, math.MaxFloat64} - for _, tri := range k { - tbounds := tri.Bounds() - tmin := tbounds.Min.(kdTriangle) - tmax := tbounds.Max.(kdTriangle) - min = d3.MinElem(min, tmin[0]) - max = d3.MaxElem(max, tmax[0]) - } - return &kdtree.Bounding{ - Min: kdTriangle{min, min, min}, - Max: kdTriangle{max, max, max}, - } -} - -// Compare returns the signed distance of a from the plane passing through -// b and perpendicular to the dimension d. -// -// Given c = a.Compare(b, d): -// c = a_d - b_d -func (a kdTriangle) Compare(b kdtree.Comparable, d kdtree.Dim) float64 { - return kdComp(a, b.(kdTriangle), int(d)) -} - -// Dims returns the number of dimensions described in the Comparable. -func (k kdTriangle) Dims() int { - return 3 -} - -// Distance returns the squared Euclidean distance between the receiver and -// the parameter. -func (a kdTriangle) Distance(b kdtree.Comparable) float64 { - return kdDist(a, b.(kdTriangle)) -} - -func (a kdTriangle) Bounds() *kdtree.Bounding { - min := d3.MinElem(a[2], d3.MinElem(a[0], a[1])) - max := d3.MaxElem(a[2], d3.MaxElem(a[0], a[1])) - return &kdtree.Bounding{ - Min: kdTriangle{min, min, min}, - Max: kdTriangle{max, max, max}, - } -} - -func (a kdTriangle) Normal() r3.Vec { - v := Triangle3(a) - return v.Normal() -} - -// c = a.dim - b.dim -func kdComp(a, b kdTriangle, dim int) (c float64) { - switch dim { - case 0: - c = (a[0].X + a[1].X + a[2].X) - (b[0].X + b[1].X + b[2].X) - case 1: - c = (a[0].Y + a[1].Y + a[2].Y) - (b[0].Y + b[1].Y + b[2].Y) - case 2: - c = (a[0].Z + a[1].Z + a[2].Z) - (b[0].Z + b[1].Z + b[2].Z) - } - return c / 3 -} - -// returns euclidean squared norm distance between triangle centroids. -func kdDist(a, b kdTriangle) (c float64) { - ac := kdCentroid(a) - bc := kdCentroid(b) - return r3.Norm2(r3.Sub(ac, bc)) -} - -func kdCentroid(a kdTriangle) r3.Vec { - v := r3.Vec{ - X: a[0].X + a[1].X + a[2].X, - Y: a[0].Y + a[1].Y + a[2].Y, - Z: a[0].Z + a[1].Z + a[2].Z, - } - return r3.Scale(1./3., v) -} - -type kdPlane struct { - dim int - triangles kdTriangles -} - -func (p kdPlane) Less(i, j int) bool { - return kdComp(p.triangles[i], p.triangles[j], p.dim) < 0 -} -func (p kdPlane) Swap(i, j int) { - p.triangles[i], p.triangles[j] = p.triangles[j], p.triangles[i] -} -func (p kdPlane) Len() int { - return len(p.triangles) -} -func (p kdPlane) Slice(start, end int) kdtree.SortSlicer { - p.triangles = p.triangles[start:end] - return p -} diff --git a/render/kdrender_test.go b/render/kdrender_test.go index be781d2..bc96601 100644 --- a/render/kdrender_test.go +++ b/render/kdrender_test.go @@ -1,11 +1,14 @@ -package render_test +package render import ( + "math" "testing" "time" + "github.com/soypat/sdf" "github.com/soypat/sdf/form3" - "github.com/soypat/sdf/render" + "github.com/soypat/sdf/internal/d3" + "gonum.org/v1/gonum/spatial/kdtree" "gonum.org/v1/gonum/spatial/r3" ) @@ -23,20 +26,216 @@ func TestKDSDF(t *testing.T) { // t.Fatal(err) // } // stlToPNG(t, "kd_before.stl", "kd_before.png", defaultView) - model, err := render.RenderAll(render.NewOctreeRenderer(s, quality)) + model, err := RenderAll(NewOctreeRenderer(s, quality)) if err != nil { t.Fatal(err) } - t.Error(len(model), "triangles") - kdf := render.NewKDSDF(model) - t.Error(kdf.Bounds()) + t.Log(len(model), "triangles") + kdf := NewKDSDF(model) + t.Log(kdf.Bounds()) start := time.Now() outside := kdf.Evaluate(r3.Vec{2, 0, 0}) // evaluate point outside bounds inside := kdf.Evaluate(r3.Vec{0, 0, 0}) // evaluate point inside bounds surface := kdf.Evaluate(r3.Vec{1, 0, 0}) // evaluate point on surface - t.Errorf("outside:%.2g, inside:%.2g, surface:%.2g in %s", outside, inside, surface, time.Since(start)) + t.Logf("outside:%.2g, inside:%.2g, surface:%.2g in %s", outside, inside, surface, time.Since(start)) // render.CreateSTL("kd_after.stl", render.NewOctreeRenderer(sdf, quality/6)) // stlToPNG(t, "kd_after.stl", "kd_after.png", defaultView) } + +var ( + _ sdf.SDF3 = kdSDF{} + _ kdtree.Interface = kdTriangles{} + _ kdtree.Bounder = kdTriangles{} +) + +func NewKDSDF(model []Triangle3) sdf.SDF3 { + mykd := make(kdTriangles, len(model)) + // var min, max r3.Vec + for i := range mykd { + tri := kdTriangle(model[i]) + mykd[i] = tri + // triMin := d3.MinElem(tri.V[2], d3.MinElem(tri.V[0], tri.V[1])) + // triMax := d3.MaxElem(tri.V[2], d3.MaxElem(tri.V[0], tri.V[1])) + // min = d3.MinElem(triMin, min) + // max = d3.MaxElem(triMax, max) + } + tree := kdtree.New(mykd, true) + // tree.Root.Bounding = &kdtree.Bounding{ + // Min: kdTriangle{V: [3]r3.Vec{min, min, min}}, + // Max: kdTriangle{V: [3]r3.Vec{max, max, max}}, + // } + return kdSDF{ + tree: *tree, + } +} + +type kdSDF struct { + tree kdtree.Tree +} + +func (s kdSDF) Evaluate(v r3.Vec) float64 { + const eps = 1e-3 + // do some ad-hoc math with the triangle normal ???? + triangle := s.Nearest(v) + minDist := math.MaxFloat64 + // Find closest vertex + closest := r3.Vec{} + for i := 0; i < 3; i++ { + vDist := r3.Norm(r3.Sub(v, triangle[i])) + if vDist < minDist { + closest = triangle[i] + minDist = vDist + } + } + if minDist < eps { + return 0 + } + pointDir := r3.Sub(v, closest) + n := triangle.Normal() + alpha := math.Acos(r3.Cos(n, pointDir)) + return math.Copysign(minDist, math.Pi/2-alpha) +} + +// Get nearest triangle to point. +func (s kdSDF) Nearest(v r3.Vec) kdTriangle { + got, _ := s.tree.Nearest(kdTriangle{v, v, v}) + // do some ad-hoc math with the triangle normal ???? + return got.(kdTriangle) +} + +func (s kdSDF) Bounds() r3.Box { + bb := s.tree.Root.Bounding + if bb == nil { + panic("got nil bounding box?") + } + tMin := bb.Min.(kdTriangle) + tMax := bb.Max.(kdTriangle) + return r3.Box{ + Min: d3.MinElem(tMin[2], d3.MinElem(tMin[0], tMin[1])), + Max: d3.MaxElem(tMax[2], d3.MaxElem(tMax[0], tMax[1])), + } +} + +type kdTriangles []kdTriangle + +type kdTriangle Triangle3 + +func (k kdTriangles) Index(i int) kdtree.Comparable { + return k[i] +} + +// Len returns the length of the list. +func (k kdTriangles) Len() int { return len(k) } + +// Pivot partitions the list based on the dimension specified. +func (k kdTriangles) Pivot(d kdtree.Dim) int { + p := kdPlane{dim: int(d), triangles: k} + return kdtree.Partition(p, kdtree.MedianOfMedians(p)) + return 0 +} + +// Slice returns a slice of the list using zero-based half +// open indexing equivalent to built-in slice indexing. +func (k kdTriangles) Slice(start, end int) kdtree.Interface { + return k[start:end] +} + +func (k kdTriangles) Bounds() *kdtree.Bounding { + max := r3.Vec{-math.MaxFloat64, -math.MaxFloat64, -math.MaxFloat64} + min := r3.Vec{math.MaxFloat64, math.MaxFloat64, math.MaxFloat64} + for _, tri := range k { + tbounds := tri.Bounds() + tmin := tbounds.Min.(kdTriangle) + tmax := tbounds.Max.(kdTriangle) + min = d3.MinElem(min, tmin[0]) + max = d3.MaxElem(max, tmax[0]) + } + return &kdtree.Bounding{ + Min: kdTriangle{min, min, min}, + Max: kdTriangle{max, max, max}, + } +} + +// Compare returns the signed distance of a from the plane passing through +// b and perpendicular to the dimension d. +// +// Given c = a.Compare(b, d): +// c = a_d - b_d +func (a kdTriangle) Compare(b kdtree.Comparable, d kdtree.Dim) float64 { + return kdComp(a, b.(kdTriangle), int(d)) +} + +// Dims returns the number of dimensions described in the Comparable. +func (k kdTriangle) Dims() int { + return 3 +} + +// Distance returns the squared Euclidean distance between the receiver and +// the parameter. +func (a kdTriangle) Distance(b kdtree.Comparable) float64 { + return kdDist(a, b.(kdTriangle)) +} + +func (a kdTriangle) Bounds() *kdtree.Bounding { + min := d3.MinElem(a[2], d3.MinElem(a[0], a[1])) + max := d3.MaxElem(a[2], d3.MaxElem(a[0], a[1])) + return &kdtree.Bounding{ + Min: kdTriangle{min, min, min}, + Max: kdTriangle{max, max, max}, + } +} + +func (a kdTriangle) Normal() r3.Vec { + v := Triangle3(a) + return v.Normal() +} + +// c = a.dim - b.dim +func kdComp(a, b kdTriangle, dim int) (c float64) { + switch dim { + case 0: + c = (a[0].X + a[1].X + a[2].X) - (b[0].X + b[1].X + b[2].X) + case 1: + c = (a[0].Y + a[1].Y + a[2].Y) - (b[0].Y + b[1].Y + b[2].Y) + case 2: + c = (a[0].Z + a[1].Z + a[2].Z) - (b[0].Z + b[1].Z + b[2].Z) + } + return c / 3 +} + +// returns euclidean squared norm distance between triangle centroids. +func kdDist(a, b kdTriangle) (c float64) { + ac := kdCentroid(a) + bc := kdCentroid(b) + return r3.Norm2(r3.Sub(ac, bc)) +} + +func kdCentroid(a kdTriangle) r3.Vec { + v := r3.Vec{ + X: a[0].X + a[1].X + a[2].X, + Y: a[0].Y + a[1].Y + a[2].Y, + Z: a[0].Z + a[1].Z + a[2].Z, + } + return r3.Scale(1./3., v) +} + +type kdPlane struct { + dim int + triangles kdTriangles +} + +func (p kdPlane) Less(i, j int) bool { + return kdComp(p.triangles[i], p.triangles[j], p.dim) < 0 +} +func (p kdPlane) Swap(i, j int) { + p.triangles[i], p.triangles[j] = p.triangles[j], p.triangles[i] +} +func (p kdPlane) Len() int { + return len(p.triangles) +} +func (p kdPlane) Slice(start, end int) kdtree.SortSlicer { + p.triangles = p.triangles[start:end] + return p +} diff --git a/render/render_test.go b/render/render_test.go index cab97f4..0ba87e4 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -7,7 +7,7 @@ import ( "github.com/deadsy/sdfx/obj" sdfxrender "github.com/deadsy/sdfx/render" - "github.com/soypat/sdf/form3/obj3" + "github.com/soypat/sdf/form3/obj3/thread" "github.com/soypat/sdf/internal/d3" "github.com/soypat/sdf/render" "gonum.org/v1/gonum/spatial/r3" @@ -38,9 +38,11 @@ func BenchmarkSDFXBolt(b *testing.B) { func BenchmarkBolt(b *testing.B) { const output = "our_bolt.stl" - object, _ := obj3.Bolt(obj3.BoltParms{ - Thread: "npt_1/2", - Style: obj3.CylinderHex, + npt := thread.NPT{} + npt.SetFromNominal(1.0 / 2.0) + object, _ := thread.Bolt(thread.BoltParms{ + Thread: npt, // M16x2 + Style: thread.NutHex, Tolerance: 0.1, TotalLength: 20, ShankLength: 10, @@ -51,7 +53,7 @@ func BenchmarkBolt(b *testing.B) { } } -func TestStressProfile(t *testing.T) { +func testStressProfile(t *testing.T) { const stlName = "stress.stl" startProf(t, "stress.prof") stlStressTest(t, stlName) @@ -66,13 +68,14 @@ func TestStressProfile(t *testing.T) { } func stlStressTest(t testing.TB, filename string) { - object, _ := obj3.Bolt(obj3.BoltParms{ - Thread: "M16x2", - Style: obj3.CylinderHex, + object, _ := thread.Bolt(thread.BoltParms{ + Thread: thread.ISO{D: 16, P: 2}, // M16x2 + Style: thread.NutHex, Tolerance: 0.1, TotalLength: 60.0, ShankLength: 10.0, }) + err := render.CreateSTL(filename, render.NewOctreeRenderer(object, 500)) if err != nil { t.Fatal(err) diff --git a/render/stl_test.go b/render/stl_test.go index 36e668d..2bb4b65 100644 --- a/render/stl_test.go +++ b/render/stl_test.go @@ -19,6 +19,7 @@ func TestSTLCreateWriteRead(t *testing.T) { if err != nil { t.Fatal(err) } + defer os.Remove("box.stl") bfile, err := io.ReadAll(fp) if err != nil { t.Fatal(err) diff --git a/render/testdata/defactoBolt.png b/render/testdata/defactoBolt.png index dd183e8..b90ed17 100644 Binary files a/render/testdata/defactoBolt.png and b/render/testdata/defactoBolt.png differ diff --git a/sdf3.go b/sdf3.go index 85410fd..cce01c3 100644 --- a/sdf3.go +++ b/sdf3.go @@ -2,6 +2,7 @@ package sdf import ( "math" + "strconv" "github.com/soypat/sdf/internal/d2" "github.com/soypat/sdf/internal/d3" @@ -350,6 +351,9 @@ type transform3 struct { // Transform3D applies a transformation matrix to an SDF3. func Transform3D(sdf SDF3, matrix m44) SDF3 { + if sdf == nil { + panic("nil SDF3 argument") + } s := transform3{} s.sdf = sdf s.matrix = matrix @@ -418,9 +422,9 @@ func Union3D(sdf ...SDF3) SDF3Union { s := union3{ sdf: sdf, } - for _, x := range s.sdf { + for i, x := range s.sdf { if x == nil { - panic("nil sdf argument found") + panic("nil sdf argument (" + strconv.Itoa(i) + ") to Union3D") } } // work out the bounding box