Repository: ezrec/uv3dp
Pull request #51 (2020-05-22 14:18:06)

Add 'UVJ' generic format (Zip file with a JSON config and slice images)

ezrec: @X3msnake - ya gonna approve this change or what?

Patch:
diff --git a/.gitignore b/.gitignore
index 5003bbd..9e249eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,6 @@
 *.png
 *.sl1
 *.swp
+*.uvj
 *.zip
 config.ini
diff --git a/README.md b/README.md
index 5d00186..9d43120 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,13 @@ Printers known to work with this tool:
 
 | Printer          | File Formats | Issues                                            |
 | ---------------- | ------------ | --------------------------------------------------|
+| -                | uvj          | Zip file with JSON and image slices               |
 | EPAX X1/X10      | cbddlp       | None                                              |
 | EPAX X1-N        | ctb          | None                                              |
 | Anycubic Photon  | photon       | None                                              |
 | Prusa SL1        | sl1          | None                                              |
+| NOVA3D Elfin     | cws          | Untested                                          |
+| Phrozen Sonic    | phz          | None                                              |
 | Zortrax Inkspire | zcodex       | Read-only (for format conversion)                 |
 
 ## Installation
@@ -128,6 +131,9 @@ The command line tool is designed to be used in a 'pipeline' style, for example:
     
       -m, --material-name string   config.init entry 'materialName' (default "3DM-ABS @")
     
+    Options for '.uvj':
+    
+    
     Options for '.zcodex':
     
     
diff --git a/cbddlp/format.go b/cbddlp/format.go
index 315892d..6507dd3 100644
--- a/cbddlp/format.go
+++ b/cbddlp/format.go
@@ -8,9 +8,7 @@ import (
 	"fmt"
 	"image"
 	"io/ioutil"
-	"math"
 	"sort"
-	"time"
 
 	"encoding/binary"
 
@@ -110,14 +108,6 @@ func align4(in uint32) (out uint32) {
 	return
 }
 
-func float32ToDuration(time_s float32) time.Duration {
-	return time.Duration(math.Round(float64(time_s) * float64(time.Second)))
-}
-
-func durationToFloat32(time_ns time.Duration) float32 {
-	return float32(float64(time_ns) / float64(time.Second))
-}
-
 type CbddlpFormatter struct {
 	*pflag.FlagSet
 
@@ -255,7 +245,7 @@ func (cf *CbddlpFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err e
 
 	type layerInfo struct {
 		Z        float32
-		Exposure *uv3dp.Exposure
+		Exposure uv3dp.Exposure
 		Rle      []byte
 		Hash     uint64
 		BitsOn   uint
@@ -292,8 +282,8 @@ func (cf *CbddlpFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err e
 
 			layerDef[n+bit*size.Layers] = cbddlpLayerDef{
 				LayerHeight:   info.Z,
-				LayerExposure: durationToFloat32(info.Exposure.LightOnTime),
-				LayerOffTime:  durationToFloat32(info.Exposure.LightOffTime),
+				LayerExposure: info.Exposure.LightOnTime,
+				LayerOffTime:  info.Exposure.LightOffTime,
 				ImageOffset:   rleHash[info.Hash].offset,
 				ImageLength:   uint32(len(info.Rle)),
 			}
@@ -307,9 +297,9 @@ func (cf *CbddlpFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err e
 	header.BedSizeMM[1] = size.Millimeter.Y
 	header.BedSizeMM[2] = forceBedSizeMM_3
 	header.LayerHeight = size.LayerHeight
-	header.LayerExposure = durationToFloat32(exp.LightOnTime)
-	header.BottomExposure = durationToFloat32(bot.Exposure.LightOnTime)
-	header.LayerOffTime = durationToFloat32(exp.LightOffTime)
+	header.LayerExposure = exp.LightOnTime
+	header.BottomExposure = bot.Exposure.LightOnTime
+	header.LayerOffTime = exp.LightOffTime
 	header.BottomCount = uint32(bot.Count)
 	header.ResolutionX = uint32(size.X)
 	header.ResolutionY = uint32(size.Y)
@@ -317,7 +307,7 @@ func (cf *CbddlpFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err e
 	header.LayerDefs = layerDefBase
 	header.LayerCount = uint32(size.Layers)
 	header.PreviewLow = previewTinyBase
-	header.PrintTime = uint32(properties.Duration() / time.Second)
+	header.PrintTime = uint32(properties.Duration())
 	header.Projector = 1 // LCD_X_MIRROR
 
 	if header.Version >= 2 {
@@ -360,8 +350,8 @@ func (cf *CbddlpFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err e
 	pixelVolume := float64(header.LayerHeight) * bedArea / float64(bedPixels)
 	param.VolumeMilliliters = float32(float64(totalOn) * pixelVolume / 1000.0)
 
-	param.BottomLightOffTime = durationToFloat32(bot.Exposure.LightOffTime)
-	param.LightOffTime = durationToFloat32(exp.LightOffTime)
+	param.BottomLightOffTime = bot.Exposure.LightOffTime
+	param.LightOffTime = exp.LightOffTime
 	param.BottomLayerCount = header.BottomCount
 
 	// Collect file data
@@ -517,13 +507,13 @@ func (cf *CbddlpFormatter) Decode(file uv3dp.Reader, filesize int64) (printable
 	size.LayerHeight = header.LayerHeight
 
 	exp := &prop.Exposure
-	exp.LightOnTime = float32ToDuration(header.LayerExposure)
-	exp.LightOffTime = float32ToDuration(header.LayerOffTime)
+	exp.LightOnTime = header.LayerExposure
+	exp.LightOffTime = header.LayerOffTime
 
 	bot := &prop.Bottom
 	bot.Count = int(header.BottomCount)
-	bot.Exposure.LightOnTime = float32ToDuration(header.BottomExposure)
-	bot.Exposure.LightOffTime = float32ToDuration(header.LayerOffTime)
+	bot.Exposure.LightOnTime = header.BottomExposure
+	bot.Exposure.LightOffTime = header.LayerOffTime
 
 	if header.Version > 1 && header.ParamSize > 0 && header.ParamOffset > 0 {
 		var param cbddlpParam
@@ -537,13 +527,13 @@ func (cf *CbddlpFormatter) Decode(file uv3dp.Reader, filesize int64) (printable
 		bot.Count = int(param.BottomLayerCount)
 		bot.Exposure.LiftHeight = param.BottomLiftHeight
 		bot.Exposure.LiftSpeed = param.BottomLiftSpeed
-		bot.Exposure.LightOffTime = float32ToDuration(param.BottomLightOffTime)
+		bot.Exposure.LightOffTime = param.BottomLightOffTime
 		bot.Exposure.RetractSpeed = param.RetractSpeed
 		bot.Exposure.RetractHeight = defaultRetractHeight
 
 		exp.LiftHeight = param.LiftHeight
 		exp.LiftSpeed = param.LiftSpeed
-		exp.LightOffTime = float32ToDuration(param.LightOffTime)
+		exp.LightOffTime = param.LightOffTime
 		exp.RetractSpeed = param.RetractSpeed
 		exp.RetractHeight = defaultRetractHeight
 	} else {
@@ -602,17 +592,17 @@ func (cbd *CbdDlp) Layer(index int) (layer uv3dp.Layer) {
 	}
 
 	if layerDef.LayerExposure > 0.0 {
-		exposure.LightOnTime = float32ToDuration(layerDef.LayerExposure)
+		exposure.LightOnTime = layerDef.LayerExposure
 	}
 
 	if layerDef.LayerOffTime > 0.0 {
-		exposure.LightOffTime = float32ToDuration(layerDef.LayerOffTime)
+		exposure.LightOffTime = layerDef.LayerOffTime
 	}
 
 	layer = uv3dp.Layer{
 		Z:        layerDef.LayerHeight,
 		Image:    layerImage,
-		Exposure: &exposure,
+		Exposure: exposure,
 	}
 
 	return
diff --git a/check.sh b/check.sh
index d0f4cf8..9547ccc 100755
--- a/check.sh
+++ b/check.sh
@@ -21,10 +21,13 @@ Printers known to work with this tool:
 
 | Printer          | File Formats | Issues                                            |
 | ---------------- | ------------ | --------------------------------------------------|
+| -                | uvj          | Zip file with JSON and image slices               |
 | EPAX X1/X10      | cbddlp       | None                                              |
 | EPAX X1-N        | ctb          | None                                              |
 | Anycubic Photon  | photon       | None                                              |
 | Prusa SL1        | sl1          | None                                              |
+| NOVA3D Elfin     | cws          | Untested                                          |
+| Phrozen Sonic    | phz          | None                                              |
 | Zortrax Inkspire | zcodex       | Read-only (for format conversion)                 |
 
 ## Installation
diff --git a/cmd/uv3dp/bottom.go b/cmd/uv3dp/bottom.go
index 4c10ef7..6e3f29a 100644
--- a/cmd/uv3dp/bottom.go
+++ b/cmd/uv3dp/bottom.go
@@ -6,7 +6,6 @@ package main
 
 import (
 	"fmt"
-	"time"
 
 	"github.com/spf13/pflag"
 
@@ -59,7 +58,7 @@ func (mod *bottomModifier) Layer(index int) (layer uv3dp.Layer) {
 	layer = mod.Printable.Layer(index)
 
 	if index < mod.Bottom.Count {
-		layer.Exposure = &mod.Bottom.Exposure
+		layer.Exposure = mod.Bottom.Exposure
 	}
 
 	return
@@ -90,12 +89,12 @@ func (cmd *BottomCommand) Filter(input uv3dp.Printable) (output uv3dp.Printable,
 
 	if cmd.Changed("light-on") {
 		TraceVerbosef(VerbosityNotice, "  Setting default bottom time to %v", cmd.LightOnTime)
-		bot.Exposure.LightOnTime = time.Duration(cmd.LightOnTime * float32(time.Second))
+		bot.Exposure.LightOnTime = cmd.LightOnTime
 	}
 
 	if cmd.Changed("light-off") {
 		TraceVerbosef(VerbosityNotice, "  Setting default bottom off time to %v", cmd.LightOffTime)
-		bot.Exposure.LightOffTime = time.Duration(cmd.LightOffTime * float32(time.Second))
+		bot.Exposure.LightOffTime = cmd.LightOffTime
 	}
 
 	if cmd.Changed("lift-height") {
diff --git a/cmd/uv3dp/exposure.go b/cmd/uv3dp/exposure.go
index 5fe9e32..ab7b2a1 100644
--- a/cmd/uv3dp/exposure.go
+++ b/cmd/uv3dp/exposure.go
@@ -5,8 +5,6 @@
 package main
 
 import (
-	"time"
-
 	"github.com/spf13/pflag"
 
 	"github.com/ezrec/uv3dp"
@@ -52,7 +50,7 @@ func (mod *exposureModifier) Layer(index int) (layer uv3dp.Layer) {
 	layer = mod.Printable.Layer(index)
 
 	if index >= mod.BottomCount {
-		layer.Exposure = &mod.Exposure
+		layer.Exposure = mod.Exposure
 	}
 
 	return
@@ -65,12 +63,12 @@ func (cmd *ExposureCommand) Filter(input uv3dp.Printable) (mod uv3dp.Printable,
 
 	if cmd.Changed("light-on") {
 		TraceVerbosef(VerbosityNotice, "  Setting default exposure time to %v", cmd.LightOnTime)
-		exp.LightOnTime = time.Duration(cmd.LightOnTime * float32(time.Second))
+		exp.LightOnTime = cmd.LightOnTime
 	}
 
 	if cmd.Changed("light-off") {
 		TraceVerbosef(VerbosityNotice, "  Setting default light off time to %v", cmd.LightOffTime)
-		exp.LightOffTime = time.Duration(cmd.LightOffTime * float32(time.Second))
+		exp.LightOffTime = cmd.LightOffTime
 	}
 
 	mod = &exposureModifier{
diff --git a/cmd/uv3dp/info.go b/cmd/uv3dp/info.go
index 97a81c1..9a20e6e 100644
--- a/cmd/uv3dp/info.go
+++ b/cmd/uv3dp/info.go
@@ -6,7 +6,6 @@ package main
 
 import (
 	"fmt"
-	"time"
 
 	"github.com/spf13/pflag"
 
@@ -60,11 +59,11 @@ func (info *InfoCommand) Filter(input uv3dp.Printable) (output uv3dp.Printable,
 		}
 
 		fmt.Printf("Exposure: %.2gs on, %.2gs off\n",
-			float32(exp.LightOnTime)/float32(time.Second),
-			float32(exp.LightOffTime)/float32(time.Second))
+			exp.LightOnTime,
+			exp.LightOffTime)
 		fmt.Printf("Bottom: %.2gs on, %.2gs off (%v %v layers)\n",
-			float32(bot.Exposure.LightOnTime)/float32(time.Second),
-			float32(bot.Exposure.LightOffTime)/float32(time.Second),
+			bot.Exposure.LightOnTime,
+			bot.Exposure.LightOffTime,
 			bot.Count, bottomStyle)
 		fmt.Printf("Lift: %v mm, %v mm/min\n",
 			exp.LiftHeight, exp.LiftSpeed)
@@ -75,7 +74,7 @@ func (info *InfoCommand) Filter(input uv3dp.Printable) (output uv3dp.Printable,
 	if info.LayerDetail {
 		for n := 0; n < prop.Size.Layers; n++ {
 			layer := input.Layer(n)
-			fmt.Printf("%d: @%.2f %+v\n", n, layer.Z, *layer.Exposure)
+			fmt.Printf("%d: @%.2f %+v\n", n, layer.Z, layer.Exposure)
 		}
 	}
 
diff --git a/cmd/uv3dp/lift.go b/cmd/uv3dp/lift.go
index 3de7868..c82f6f1 100644
--- a/cmd/uv3dp/lift.go
+++ b/cmd/uv3dp/lift.go
@@ -49,7 +49,7 @@ func (mod *liftModifier) Layer(index int) (layer uv3dp.Layer) {
 	layer = mod.Printable.Layer(index)
 
 	if index >= mod.BottomCount {
-		layer.Exposure = &mod.Exposure
+		layer.Exposure = mod.Exposure
 	}
 
 	return
diff --git a/cmd/uv3dp/main.go b/cmd/uv3dp/main.go
index 8fffef0..28edd37 100644
--- a/cmd/uv3dp/main.go
+++ b/cmd/uv3dp/main.go
@@ -15,6 +15,7 @@ import (
 	_ "github.com/ezrec/uv3dp/cws"
 	_ "github.com/ezrec/uv3dp/phz"
 	_ "github.com/ezrec/uv3dp/sl1"
+	_ "github.com/ezrec/uv3dp/uvj"
 	_ "github.com/ezrec/uv3dp/zcodex"
 
 	"github.com/spf13/pflag"
diff --git a/cmd/uv3dp/resin.go b/cmd/uv3dp/resin.go
index e7f432d..74dc427 100644
--- a/cmd/uv3dp/resin.go
+++ b/cmd/uv3dp/resin.go
@@ -48,8 +48,8 @@ func (mod *resinModifier) Properties() (prop uv3dp.Properties) {
 func (mod *resinModifier) Layer(index int) (layer uv3dp.Layer) {
 	layer = mod.Printable.Layer(index)
 
-	exp := &mod.Resin.Exposure
-	bot := &mod.Resin.Bottom.Exposure
+	exp := mod.Resin.Exposure
+	bot := mod.Resin.Bottom.Exposure
 	bottomCount := mod.Resin.Bottom.Count
 
 	if index < bottomCount {
diff --git a/cmd/uv3dp/resin_map.go b/cmd/uv3dp/resin_map.go
index f4ef4c4..11c5283 100644
--- a/cmd/uv3dp/resin_map.go
+++ b/cmd/uv3dp/resin_map.go
@@ -12,7 +12,6 @@ import (
 	"sort"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/ezrec/uv3dp"
 )
@@ -114,25 +113,27 @@ func init() {
 				}
 			}
 
-			iVal, _ := strconv.ParseInt(val, 10, 32)
-			fVal, _ := strconv.ParseFloat(val, 32)
-			tVal := time.Duration(fVal * float64(time.Second))
+			iVal64, _ := strconv.ParseInt(val, 10, 32)
+			fVal64, _ := strconv.ParseFloat(val, 32)
+
+			iVal := int(iVal64)
+			fVal := float32(fVal64)
 
 			switch attr {
 			case "bottomLayCount":
-				resin.Bottom.Count = int(iVal)
+				resin.Bottom.Count = iVal
 			case "bottomLayerCount":
-				resin.Bottom.Count = int(iVal)
+				resin.Bottom.Count = iVal
 			case "bottomLayerLiftSpeed":
-				resin.Bottom.Exposure.LiftSpeed = float32(fVal)
+				resin.Bottom.Exposure.LiftSpeed = fVal
 			case "bottomLayExposureTime":
-				resin.Bottom.Exposure.LightOnTime = tVal
+				resin.Bottom.Exposure.LightOnTime = fVal
 			case "bottomLightOffTime":
-				resin.Bottom.Exposure.LightOffTime = tVal
+				resin.Bottom.Exposure.LightOffTime = fVal
 			case "normalExposureTime":
-				resin.Exposure.LightOnTime = tVal
+				resin.Exposure.LightOnTime = fVal
 			case "normalLayerLiftSpeed":
-				resin.Exposure.LiftSpeed = float32(fVal)
+				resin.Exposure.LiftSpeed = fVal
 			default:
 				// Ignored
 			}
diff --git a/cmd/uv3dp/retract.go b/cmd/uv3dp/retract.go
index ac59d31..ebfcad0 100644
--- a/cmd/uv3dp/retract.go
+++ b/cmd/uv3dp/retract.go
@@ -49,7 +49,7 @@ func (mod *retractModifier) Layer(index int) (layer uv3dp.Layer) {
 	layer = mod.Printable.Layer(index)
 
 	if index >= mod.BottomCount {
-		layer.Exposure = &mod.Exposure
+		layer.Exposure = mod.Exposure
 	}
 
 	return
diff --git a/ctb/format.go b/ctb/format.go
index 064cb7e..60a6aae 100644
--- a/ctb/format.go
+++ b/ctb/format.go
@@ -8,10 +8,8 @@ import (
 	"fmt"
 	"image"
 	"io/ioutil"
-	"math"
 	"math/rand"
 	"sort"
-	"time"
 
 	"encoding/binary"
 
@@ -133,14 +131,6 @@ func align4(in uint32) (out uint32) {
 	return
 }
 
-func float32ToDuration(time_s float32) time.Duration {
-	return time.Duration(math.Round(float64(time_s) * float64(time.Second)))
-}
-
-func durationToFloat32(time_ns time.Duration) float32 {
-	return float32(float64(time_ns) / float64(time.Second))
-}
-
 type CtbFormatter struct {
 	*pflag.FlagSet
 
@@ -254,7 +244,7 @@ func (cf *CtbFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 
 	type layerInfo struct {
 		Z        float32
-		Exposure *uv3dp.Exposure
+		Exposure uv3dp.Exposure
 		Rle      []byte
 		Hash     uint64
 		BitsOn   uint
@@ -292,8 +282,8 @@ func (cf *CtbFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 
 		layerDef[n] = ctbLayerDef{
 			LayerHeight:   info.Z,
-			LayerExposure: durationToFloat32(info.Exposure.LightOnTime),
-			LayerOffTime:  durationToFloat32(info.Exposure.LightOffTime),
+			LayerExposure: info.Exposure.LightOnTime,
+			LayerOffTime:  info.Exposure.LightOffTime,
 			ImageOffset:   rleHash[info.Hash].offset,
 			ImageLength:   uint32(len(info.Rle)),
 		}
@@ -307,9 +297,9 @@ func (cf *CtbFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	header.BedSizeMM[2] = forceBedSizeMM_3
 	header.HeightMM = size.LayerHeight * float32(size.Layers)
 	header.LayerHeight = size.LayerHeight
-	header.LayerExposure = durationToFloat32(exp.LightOnTime)
-	header.BottomExposure = durationToFloat32(bot.Exposure.LightOnTime)
-	header.LayerOffTime = durationToFloat32(exp.LightOffTime)
+	header.LayerExposure = exp.LightOnTime
+	header.BottomExposure = bot.Exposure.LightOnTime
+	header.LayerOffTime = exp.LightOffTime
 	header.BottomCount = uint32(bot.Count)
 	header.ResolutionX = uint32(size.X)
 	header.ResolutionY = uint32(size.Y)
@@ -317,7 +307,7 @@ func (cf *CtbFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	header.LayerDefs = layerDefBase
 	header.LayerCount = uint32(size.Layers)
 	header.PreviewLow = previewTinyBase
-	header.PrintTime = uint32(properties.Duration() / time.Second)
+	header.PrintTime = uint32(properties.Duration())
 	header.Projector = 1 // LCD_X_MIRROR
 
 	header.ParamOffset = paramBase
@@ -371,8 +361,8 @@ func (cf *CtbFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	pixelVolume := float64(header.LayerHeight) * bedArea / float64(bedPixels)
 	param.VolumeMilliliters = float32(float64(totalOn) * pixelVolume / 1000.0)
 
-	param.BottomLightOffTime = durationToFloat32(bot.Exposure.LightOffTime)
-	param.LightOffTime = durationToFloat32(exp.LightOffTime)
+	param.BottomLightOffTime = bot.Exposure.LightOffTime
+	param.LightOffTime = exp.LightOffTime
 	param.BottomLayerCount = header.BottomCount
 
 	// Collect file data
@@ -526,13 +516,13 @@ func (cf *CtbFormatter) Decode(file uv3dp.Reader, filesize int64) (printable uv3
 	size.LayerHeight = header.LayerHeight
 
 	exp := &prop.Exposure
-	exp.LightOnTime = float32ToDuration(header.LayerExposure)
-	exp.LightOffTime = float32ToDuration(header.LayerOffTime)
+	exp.LightOnTime = header.LayerExposure
+	exp.LightOffTime = header.LayerOffTime
 
 	bot := &prop.Bottom
 	bot.Count = int(header.BottomCount)
-	bot.Exposure.LightOnTime = float32ToDuration(header.BottomExposure)
-	bot.Exposure.LightOffTime = float32ToDuration(header.LayerOffTime)
+	bot.Exposure.LightOnTime = header.BottomExposure
+	bot.Exposure.LightOffTime = header.LayerOffTime
 
 	if header.ParamSize > 0 && header.ParamOffset > 0 {
 		var param ctbParam
@@ -546,13 +536,13 @@ func (cf *CtbFormatter) Decode(file uv3dp.Reader, filesize int64) (printable uv3
 		bot.Count = int(param.BottomLayerCount)
 		bot.Exposure.LiftHeight = param.BottomLiftHeight
 		bot.Exposure.LiftSpeed = param.BottomLiftSpeed
-		bot.Exposure.LightOffTime = float32ToDuration(param.BottomLightOffTime)
+		bot.Exposure.LightOffTime = param.BottomLightOffTime
 		bot.Exposure.RetractSpeed = param.RetractSpeed
 		bot.Exposure.RetractHeight = defaultRetractHeight
 
 		exp.LiftHeight = param.LiftHeight
 		exp.LiftSpeed = param.LiftSpeed
-		exp.LightOffTime = float32ToDuration(param.LightOffTime)
+		exp.LightOffTime = param.LightOffTime
 		exp.RetractSpeed = param.RetractSpeed
 		exp.RetractHeight = defaultRetractHeight
 	} else {
@@ -611,17 +601,17 @@ func (cbd *Ctb) Layer(index int) (layer uv3dp.Layer) {
 	}
 
 	if layerDef.LayerExposure > 0.0 {
-		exposure.LightOnTime = float32ToDuration(layerDef.LayerExposure)
+		exposure.LightOnTime = layerDef.LayerExposure
 	}
 
 	if layerDef.LayerOffTime > 0.0 {
-		exposure.LightOffTime = float32ToDuration(layerDef.LayerOffTime)
+		exposure.LightOffTime = layerDef.LayerOffTime
 	}
 
 	layer = uv3dp.Layer{
 		Z:        layerDef.LayerHeight,
 		Image:    layerImage,
-		Exposure: &exposure,
+		Exposure: exposure,
 	}
 
 	return
diff --git a/cws/format.go b/cws/format.go
index d5becb4..789c76b 100644
--- a/cws/format.go
+++ b/cws/format.go
@@ -311,11 +311,11 @@ func (sf *CWSFormat) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (err
 		XResolution:         size.X,
 		YResolution:         size.Y,
 		LayerThickness:      size.LayerHeight,
-		LayerTime:           int(exp.LightOnTime / time.Millisecond),
+		LayerTime:           int(exp.LightOnTime * 1000.0),
 		OutlineWidthInset:   2,
-		BottomLayersTime:    int(bot.LightOnTime / time.Millisecond),
+		BottomLayersTime:    int(bot.LightOnTime * 1000.0),
 		BottomLayers:        prop.Bottom.Count,
-		BlankingLayerTime:   int(exp.LightOffTime / time.Millisecond),
+		BlankingLayerTime:   int(exp.LightOffTime * 1000.0),
 		BuildDirection:      "Bottom_Up",
 		LiftDistance:        int(exp.LiftHeight),
 		AntiAliasing:        true,
@@ -363,7 +363,7 @@ M106 S0
 
 		// Create all the layers
 		fmt.Fprintf(gcode, "\n;<Slice> %v\n", n)
-		fmt.Fprintf(gcode, "M106 S255\n;<Delay> %v\n", int(layer.Exposure.LightOnTime/time.Millisecond))
+		fmt.Fprintf(gcode, "M106 S255\n;<Delay> %v\n", int(layer.Exposure.LightOnTime*1000.0))
 		fmt.Fprintf(gcode, "M106 S0\n;<Slice> Blank\n")
 		fmt.Fprintf(gcode, "G1 Z%1.3f F%v\n", layer.Exposure.LiftHeight, int(layer.Exposure.LiftSpeed))
 		priorZ = layer.Z
@@ -478,13 +478,13 @@ func (sf *CWSFormat) Decode(reader uv3dp.Reader, filesize int64) (printable uv3d
 	size.LayerHeight = config.LayerThickness
 
 	bot := &prop.Bottom
-	bot.Exposure.LightOnTime = time.Duration(config.BottomLayersTime) * time.Millisecond
+	bot.Exposure.LightOnTime = float32(config.BottomLayersTime) / 1000.0
 
 	bot.Count = config.BottomLayers
 
 	exp := &prop.Exposure
-	exp.LightOnTime = time.Duration(config.LayerTime) * time.Millisecond
-	exp.LightOffTime = time.Duration(config.BlankingLayerTime) * time.Millisecond
+	exp.LightOnTime = float32(config.LayerTime) / 1000.0
+	exp.LightOffTime = float32(config.BlankingLayerTime) / 1000.0
 	exp.LiftHeight = float32(config.LiftDistance)
 	exp.LiftSpeed = config.ZLiftFeedRate
 	exp.RetractSpeed = config.ZLiftRetractRate
@@ -519,8 +519,7 @@ func (cws *CWS) Layer(index int) (layer uv3dp.Layer) {
 
 	layer.Z = float32(index) * cws.properties.Size.LayerHeight
 	layer.Image = pngImage.(*image.Gray)
-	exposure := cws.properties.LayerExposure(index)
-	layer.Exposure = &exposure
+	layer.Exposure = cws.properties.LayerExposure(index)
 
 	return
 }
diff --git a/empty.go b/empty.go
index ba1df2c..4ddba58 100644
--- a/empty.go
+++ b/empty.go
@@ -33,9 +33,9 @@ func (empty *EmptyPrintable) Layer(index int) (layer Layer) {
 	layer.Image = image.NewGray(prop.Bounds())
 
 	if index < prop.Bottom.Count {
-		layer.Exposure = &prop.Bottom.Exposure
+		layer.Exposure = prop.Bottom.Exposure
 	} else {
-		layer.Exposure = &prop.Exposure
+		layer.Exposure = prop.Exposure
 	}
 
 	return
diff --git a/phz/format.go b/phz/format.go
index 9c12014..488d090 100644
--- a/phz/format.go
+++ b/phz/format.go
@@ -8,9 +8,7 @@ import (
 	"fmt"
 	"image"
 	"io/ioutil"
-	"math"
 	"sort"
-	"time"
 
 	"encoding/binary"
 
@@ -112,14 +110,6 @@ func align4(in uint32) (out uint32) {
 	return
 }
 
-func float32ToDuration(time_s float32) time.Duration {
-	return time.Duration(math.Round(float64(time_s) * float64(time.Second)))
-}
-
-func durationToFloat32(time_ns time.Duration) float32 {
-	return float32(float64(time_ns) / float64(time.Second))
-}
-
 type PhzFormatter struct {
 	*pflag.FlagSet
 
@@ -222,7 +212,7 @@ func (pf *PhzFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 
 	type layerInfo struct {
 		Z        float32
-		Exposure *uv3dp.Exposure
+		Exposure uv3dp.Exposure
 		Rle      []byte
 		Hash     uint64
 		BitsOn   uint
@@ -260,8 +250,8 @@ func (pf *PhzFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 
 		layerDef[n] = phzLayerDef{
 			LayerHeight:   info.Z,
-			LayerExposure: durationToFloat32(info.Exposure.LightOnTime),
-			LayerOffTime:  durationToFloat32(info.Exposure.LightOffTime),
+			LayerExposure: info.Exposure.LightOnTime,
+			LayerOffTime:  info.Exposure.LightOffTime,
 			ImageOffset:   rleHash[info.Hash].offset,
 			ImageLength:   uint32(len(info.Rle)),
 		}
@@ -275,9 +265,9 @@ func (pf *PhzFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	header.BedSizeMM[2] = forceBedSizeMM_3
 	header.HeightMM = size.LayerHeight * float32(size.Layers)
 	header.LayerHeight = size.LayerHeight
-	header.LayerExposure = durationToFloat32(exp.LightOnTime)
-	header.BottomExposure = durationToFloat32(bot.Exposure.LightOnTime)
-	header.LayerOffTime = durationToFloat32(exp.LightOffTime)
+	header.LayerExposure = exp.LightOnTime
+	header.BottomExposure = bot.Exposure.LightOnTime
+	header.LayerOffTime = exp.LightOffTime
 	header.BottomCount = uint32(bot.Count)
 	header.ResolutionX = uint32(size.X)
 	header.ResolutionY = uint32(size.Y)
@@ -285,7 +275,7 @@ func (pf *PhzFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	header.LayerDefs = layerDefBase
 	header.LayerCount = uint32(size.Layers)
 	header.PreviewLow = previewTinyBase
-	header.PrintTime = uint32(properties.Duration() / time.Second)
+	header.PrintTime = uint32(properties.Duration())
 	header.Projector = 1 // LCD_X_MIRROR
 
 	header.AntiAliasLevel = 1
@@ -329,7 +319,7 @@ func (pf *PhzFormatter) Encode(writer uv3dp.Writer, p uv3dp.Printable) (err erro
 	pixelVolume := float64(header.LayerHeight) * bedArea / float64(bedPixels)
 	header.VolumeMilliliters = float32(float64(totalOn) * pixelVolume / 1000.0)
 
-	header.BottomLightOffTime = durationToFloat32(bot.Exposure.LightOffTime)
+	header.BottomLightOffTime = bot.Exposure.LightOffTime
 	header.BottomLayerCount = header.BottomCount
 
 	// Collect file data
@@ -479,13 +469,13 @@ func (pf *PhzFormatter) Decode(file uv3dp.Reader, filesize int64) (printable uv3
 	size.LayerHeight = header.LayerHeight
 
 	exp := &prop.Exposure
-	exp.LightOnTime = float32ToDuration(header.LayerExposure)
-	exp.LightOffTime = float32ToDuration(header.LayerOffTime)
+	exp.LightOnTime = header.LayerExposure
+	exp.LightOffTime = header.LayerOffTime
 
 	bot := &prop.Bottom
 	bot.Count = int(header.BottomCount)
-	bot.Exposure.LightOnTime = float32ToDuration(header.BottomExposure)
-	bot.Exposure.LightOffTime = float32ToDuration(header.LayerOffTime)
+	bot.Exposure.LightOnTime = header.BottomExposure
+	bot.Exposure.LightOffTime = header.LayerOffTime
 
 	bot.Count = int(header.BottomLayerCount)
 	bot.Exposure.LiftHeight = header.BottomLiftHeight
@@ -541,17 +531,17 @@ func (phz *Phz) Layer(index int) (layer uv3dp.Layer) {
 	}
 
 	if layerDef.LayerExposure > 0.0 {
-		exposure.LightOnTime = float32ToDuration(layerDef.LayerExposure)
+		exposure.LightOnTime = layerDef.LayerExposure
 	}
 
 	if layerDef.LayerOffTime > 0.0 {
-		exposure.LightOffTime = float32ToDuration(layerDef.LayerOffTime)
+		exposure.LightOffTime = layerDef.LayerOffTime
 	}
 
 	layer = uv3dp.Layer{
 		Z:        layerDef.LayerHeight,
 		Image:    layerImage,
-		Exposure: &exposure,
+		Exposure: exposure,
 	}
 
 	return
diff --git a/printable.go b/printable.go
index 54aa634..95eebb3 100644
--- a/printable.go
+++ b/printable.go
@@ -12,8 +12,8 @@ import (
 // Everything needed to print a single layer
 type Layer struct {
 	Z        float32     // Z height in mm
-	Exposure *Exposure   // Layer exposure settings
-	Image    *image.Gray // Image mask
+	Exposure Exposure    // Layer exposure settings
+	Image    *image.Gray `json:",omitempty"` // Image mask
 }
 
 type Printable interface {
diff --git a/properties.go b/properties.go
index 359b96c..7ba5c9c 100644
--- a/properties.go
+++ b/properties.go
@@ -7,7 +7,6 @@ package uv3dp
 import (
 	"fmt"
 	"image"
-	"time"
 )
 
 type SizeMillimeter struct {
@@ -23,28 +22,28 @@ type Size struct {
 
 // Per-layer exposure
 type Exposure struct {
-	LightOnTime   time.Duration // Exposure time
-	LightOffTime  time.Duration // Cool down time
-	LiftHeight    float32       // mm
-	LiftSpeed     float32       // mm/min
-	RetractHeight float32       // mm
-	RetractSpeed  float32       // mm/min
+	LightOnTime   float32 // Exposure time
+	LightOffTime  float32 // Cool down time
+	LiftHeight    float32 // mm
+	LiftSpeed     float32 // mm/min
+	RetractHeight float32 // mm
+	RetractSpeed  float32 // mm/min
 }
 
 // Total duration of an exposure
-func (exp *Exposure) Duration() (total time.Duration) {
+func (exp *Exposure) Duration() (total float32) {
 	total = exp.LightOnTime + exp.LightOffTime
 
 	// Motion is lift; then retract -> move back to start at retract speed
-	total += time.Duration(exp.LiftHeight / exp.LiftSpeed * float32(time.Second))
-	total += time.Duration((exp.LiftHeight + exp.RetractHeight*2) / exp.RetractSpeed * float32(time.Second))
+	total += exp.LiftHeight / exp.LiftSpeed
+	total += (exp.LiftHeight + exp.RetractHeight*2) / exp.RetractSpeed
 	return
 }
 
 // Interpolate scales settings between this and another Exposure
 func (exp *Exposure) Interpolate(target Exposure, scale float32) (result Exposure) {
-	result.LightOnTime = exp.LightOnTime + time.Duration(float64(target.LightOnTime-exp.LightOnTime)*float64(scale))
-	result.LightOffTime = exp.LightOffTime + time.Duration(float64(target.LightOffTime-exp.LightOffTime)*float64(scale))
+	result.LightOnTime = exp.LightOnTime + float32(float64(target.LightOnTime-exp.LightOnTime)*float64(scale))
+	result.LightOffTime = exp.LightOffTime + float32(float64(target.LightOffTime-exp.LightOffTime)*float64(scale))
 	result.LiftHeight = exp.LiftHeight + (target.LiftHeight-exp.LiftHeight)*scale
 	result.LiftSpeed = exp.LiftSpeed + (target.LiftSpeed-exp.LiftSpeed)*scale
 	result.RetractHeight = exp.RetractHeight + (target.RetractHeight-exp.RetractHeight)*scale
@@ -90,8 +89,8 @@ type Properties struct {
 	Size     Size
 	Exposure Exposure
 	Bottom   Bottom
-	Preview  map[PreviewType]image.Image
-	Metadata map[string](interface{})
+	Preview  map[PreviewType]image.Image `json:",omitempty"`
+	Metadata map[string](interface{})    `json:",omitempty"`
 }
 
 // Get image bounds
@@ -100,13 +99,13 @@ func (prop *Properties) Bounds() image.Rectangle {
 }
 
 // Duration returns total printing time
-func (prop *Properties) Duration() (duration time.Duration) {
+func (prop *Properties) Duration() (duration float32) {
 	size := &prop.Size
 	bot := &prop.Bottom.Exposure
 	botCount := prop.Bottom.Count
 	exp := &prop.Exposure
-	botTime := bot.Duration() * time.Duration(botCount)
-	expTime := exp.Duration() * time.Duration(size.Layers-int(botCount))
+	botTime := bot.Duration() * float32(botCount)
+	expTime := exp.Duration() * float32(size.Layers-int(botCount))
 
 	duration = botTime + expTime
 
diff --git a/sl1/format.go b/sl1/format.go
index 115d3f4..ddb3783 100644
--- a/sl1/format.go
+++ b/sl1/format.go
@@ -187,8 +187,8 @@ func (sf *Sl1Format) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (err
 	config_ini := map[string]string{
 		"action":                "print",
 		"jobDir":                "uv3dp",
-		"expTime":               fmt.Sprintf("%.3g", float64(exp.LightOnTime)/float64(time.Second)),
-		"expTimeFirst":          fmt.Sprintf("%.3g", float64(bot.LightOnTime)/float64(time.Second)),
+		"expTime":               fmt.Sprintf("%.3g", exp.LightOnTime),
+		"expTimeFirst":          fmt.Sprintf("%.3g", bot.LightOnTime),
 		"fileCreationTimestamp": sl1Timestamp(),
 		"layerHeight":           layerHeight,
 		"materialName":          materialName,
@@ -196,7 +196,7 @@ func (sf *Sl1Format) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (err
 		"numFast":               fmt.Sprintf("%v", size.Layers),
 		"numSlow":               fmt.Sprintf("%v", numSlow),
 		"printProfile":          layerHeight + " Normal",
-		"printTime":             fmt.Sprintf("%.3f", float64(prop.Duration())/float64(time.Second)),
+		"printTime":             fmt.Sprintf("%.3f", prop.Duration()),
 		"printerModel":          "SL1",
 		"printerProfile":        "Original Prusa SL1",
 		"prusaSlicerVersion":    "uv3dp",
@@ -359,7 +359,7 @@ func (sf *Sl1Format) Decode(reader uv3dp.Reader, filesize int64) (printable uv3d
 
 	bot := &prop.Bottom
 	bot.Exposure = defaultBottomExposure
-	bot.Exposure.LightOnTime = time.Duration(config.expTimeFirst*1000) * time.Millisecond
+	bot.Exposure.LightOnTime = config.expTimeFirst
 
 	if config.numFade > 0 {
 		bot.Count = int(config.numFade)
@@ -371,7 +371,7 @@ func (sf *Sl1Format) Decode(reader uv3dp.Reader, filesize int64) (printable uv3d
 
 	exp := &prop.Exposure
 	*exp = defaultExposure
-	exp.LightOnTime = time.Duration(config.expTime*1000) * time.Millisecond
+	exp.LightOnTime = config.expTime
 
 	// Calculate layer off time based off of total print time
 	bottomExposure := config.expTimeFirst * float32(config.numFade)
@@ -379,7 +379,7 @@ func (sf *Sl1Format) Decode(reader uv3dp.Reader, filesize int64) (printable uv3d
 	totalOffTime := config.printTime - bottomExposure - restExposure
 	layerOffTime := totalOffTime / float32(config.numFast)
 
-	exp.LightOffTime = time.Duration(layerOffTime) * time.Millisecond
+	exp.LightOffTime = float32(layerOffTime) / 1000.0
 	bot.LightOffTime = exp.LightOffTime
 
 	prop.Preview = thumbImage
@@ -410,8 +410,7 @@ func (sl1 *Sl1) Layer(index int) (layer uv3dp.Layer) {
 
 	layer.Z = float32(index) * sl1.properties.Size.LayerHeight
 	layer.Image = pngImage.(*image.Gray)
-	exposure := sl1.properties.LayerExposure(index)
-	layer.Exposure = &exposure
+	layer.Exposure = sl1.properties.LayerExposure(index)
 
 	return
 }
diff --git a/uvj/format.go b/uvj/format.go
new file mode 100644
index 0000000..3247732
--- /dev/null
+++ b/uvj/format.go
@@ -0,0 +1,271 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+package uvj
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"image"
+	"image/png"
+	"io"
+	"io/ioutil"
+	"time"
+
+	"github.com/ezrec/uv3dp"
+	"github.com/spf13/pflag"
+)
+
+var (
+	time_Now = time.Now
+)
+
+const (
+	mmPerPixel       = 0.0472500
+	defaultPixelsX   = 1440
+	defaultPixelsY   = 2560
+	defaultCacheSize = 16
+)
+
+type ErrConfigMissing string
+
+func (e ErrConfigMissing) Error() string {
+	return fmt.Sprintf("config.ini: Parameter '%s' missing", string(e))
+}
+
+type ErrConfigInvalid string
+
+func (e ErrConfigInvalid) Error() string {
+	return fmt.Sprintf("config.ini: Parameter '%s' invalid", string(e))
+}
+
+type UVJConfig struct {
+	Properties uv3dp.Properties
+	Layers     []uv3dp.Layer
+}
+
+type UVJ struct {
+	Config   UVJConfig
+	layerPng []([]byte)
+}
+
+type UVJFormat struct {
+	*pflag.FlagSet
+}
+
+func NewUVJFormatter(suffix string) (sf *UVJFormat) {
+	flagSet := pflag.NewFlagSet(suffix, pflag.ContinueOnError)
+
+	sf = &UVJFormat{
+		FlagSet: flagSet,
+	}
+
+	sf.SetInterspersed(false)
+
+	return
+}
+
+func (sf *UVJFormat) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (err error) {
+	archive := zip.NewWriter(writer)
+	defer archive.Close()
+
+	prop := printable.Properties()
+
+	// Don't encode the preview images into the config file
+	preview := prop.Preview
+	prop.Preview = nil
+
+	config := UVJConfig{
+		Properties: prop,
+		Layers:     make([]uv3dp.Layer, prop.Size.Layers),
+	}
+
+	// Create all the layers
+	for n := 0; n < prop.Size.Layers; n++ {
+		layer := printable.Layer(n)
+
+		filename := fmt.Sprintf("slice/%08d.png", n)
+
+		var writer io.Writer
+		writer, err = archive.Create(filename)
+		if err != nil {
+			return
+		}
+
+		err = png.Encode(writer, layer.Image)
+		if err != nil {
+			return
+		}
+
+		layer.Image = nil
+		config.Layers[n] = layer
+	}
+
+	// Create the config file
+	fileConfig, err := archive.Create("config.json")
+	if err != nil {
+		return
+	}
+
+	data, err := json.MarshalIndent(config, "", "  ")
+	if err != nil {
+		return
+	}
+
+	fileConfig.Write(data)
+	fileConfig.Write([]byte("\n"))
+
+	// Save the thumbnails
+	for _, image := range preview {
+		imageSize := image.Bounds().Size()
+		filename := fmt.Sprintf("preview/%dx%d.png", imageSize.X, imageSize.Y)
+
+		var writer io.Writer
+		writer, err = archive.Create(filename)
+		if err != nil {
+			return
+		}
+
+		err = png.Encode(writer, image)
+		if err != nil {
+			return
+		}
+	}
+
+	return
+}
+
+func (sf *UVJFormat) Decode(reader uv3dp.Reader, filesize int64) (printable uv3dp.Printable, err error) {
+	archive, err := zip.NewReader(reader, filesize)
+	if err != nil {
+		return
+	}
+
+	fileMap := make(map[string](*zip.File))
+
+	for _, file := range archive.File {
+		fileMap[file.Name] = file
+	}
+
+	cfg, found := fileMap["config.json"]
+	if !found {
+		err = errors.New("config.json not found in archive")
+		return
+	}
+
+	cfg_reader, err := cfg.Open()
+	if err != nil {
+		return
+	}
+	defer func() { cfg_reader.Close() }()
+
+	// Load the config file
+	data, err := ioutil.ReadAll(cfg_reader)
+	if err != nil {
+		return
+	}
+
+	var config UVJConfig
+
+	err = json.Unmarshal(data, &config)
+	if err != nil {
+		return
+	}
+
+	if len(config.Layers) > 0 && len(config.Layers) != config.Properties.Size.Layers {
+		err = fmt.Errorf("config.json: expected %v layers, found %v layers", config.Properties.Size.Layers, config.Layers)
+		return
+	}
+
+	// Collect the layer files
+	layerPng := make([]([]byte), config.Properties.Size.Layers)
+	for n := 0; n < cap(layerPng); n++ {
+		name := fmt.Sprintf("slice/%08d.png", n)
+		file, ok := fileMap[name]
+		if !ok {
+			err = errors.New(fmt.Sprintf("%s: Missing from archive", name))
+			return
+		}
+		var reader io.ReadCloser
+		reader, err = file.Open()
+		if err != nil {
+			return
+		}
+		defer reader.Close()
+
+		layerPng[n], err = ioutil.ReadAll(reader)
+		if err != nil {
+			return
+		}
+	}
+
+	// Collect the thumbnails
+	thumbs := map[uv3dp.PreviewType]string{
+		uv3dp.PreviewTypeTiny: "preview/400x400.png",
+		uv3dp.PreviewTypeHuge: "preview/800x480.png",
+	}
+
+	thumbImage := make(map[uv3dp.PreviewType]image.Image)
+	for pt, pn := range thumbs {
+		file, ok := fileMap[pn]
+		if !ok {
+			continue
+		}
+
+		var reader io.ReadCloser
+		reader, err = file.Open()
+		if err != nil {
+			return
+		}
+		defer func() { reader.Close() }()
+
+		var thumb image.Image
+		thumb, err = png.Decode(reader)
+		if err != nil {
+			return
+		}
+		thumbImage[pt] = thumb
+	}
+
+	config.Properties.Preview = thumbImage
+
+	uvj := &UVJ{
+		Config:   config,
+		layerPng: layerPng,
+	}
+
+	printable = uvj
+
+	return
+}
+
+func (uvj *UVJ) Close() {
+}
+
+func (uvj *UVJ) Properties() (prop uv3dp.Properties) {
+	prop = uvj.Config.Properties
+	return
+}
+
+func (uvj *UVJ) Layer(index int) (layer uv3dp.Layer) {
+	pngImage, err := png.Decode(bytes.NewReader(uvj.layerPng[index]))
+	if err != nil {
+		panic(err)
+	}
+
+	if len(uvj.Config.Layers) == 0 {
+		layer = uv3dp.Layer{
+			Z:        float32(index) * uvj.Config.Properties.Size.LayerHeight,
+			Exposure: uvj.Config.Properties.LayerExposure(index),
+		}
+	} else {
+		layer = uvj.Config.Layers[index]
+	}
+	layer.Image = pngImage.(*image.Gray)
+
+	return
+}
diff --git a/uvj/init.go b/uvj/init.go
new file mode 100644
index 0000000..ca7cb7a
--- /dev/null
+++ b/uvj/init.go
@@ -0,0 +1,16 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+// Package uvj handles input and output of UV3DP 'generic' zip files (JSON slice description and images)
+package uvj
+
+import (
+	"github.com/ezrec/uv3dp"
+)
+
+func init() {
+	newFormatter := func(suffix string) uv3dp.Formatter { return NewUVJFormatter(suffix) }
+
+	uv3dp.RegisterFormatter(".uvj", newFormatter)
+}
diff --git a/zcodex/format.go b/zcodex/format.go
index b4f734f..e240cc2 100644
--- a/zcodex/format.go
+++ b/zcodex/format.go
@@ -128,11 +128,11 @@ func (sf *ZcodexFormat) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (
 	}
 
 	rm.LayerThickness = prop.Size.LayerHeight
-	rm.LayerTime = int(prop.Exposure.LightOnTime / time.Millisecond)
-	rm.BottomLayersTime = int(prop.Bottom.Exposure.LightOnTime / time.Millisecond)
+	rm.LayerTime = int(prop.Exposure.LightOnTime * 1000.0)
+	rm.BottomLayersTime = int(prop.Bottom.Exposure.LightOnTime * 1000.0)
 	rm.TotalLayersCount = prop.Size.Layers
 	rm.BottomLayersNumber = prop.Bottom.Count
-	rm.BlankingLayerTime = int(prop.Exposure.LightOffTime / time.Millisecond)
+	rm.BlankingLayerTime = int(prop.Exposure.LightOffTime * 1000.0)
 
 	var us UserSettingsData
 	anon, ok = prop.Metadata["zcodex/UserSettingsData"]
@@ -145,9 +145,9 @@ func (sf *ZcodexFormat) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (
 
 	us.MaxLayer = prop.Size.Layers
 	us.LayerThickness = fmt.Sprintf("%.2g mm", prop.Size.LayerHeight)
-	us.LayerExposureTime = int(prop.Exposure.LightOnTime / time.Millisecond)
-	us.ExposureOffTime = int(prop.Exposure.LightOffTime / time.Millisecond)
-	us.BottomLayerExposureTime = int(prop.Bottom.Exposure.LightOnTime / time.Millisecond)
+	us.LayerExposureTime = int(prop.Exposure.LightOnTime * 1000.0)
+	us.ExposureOffTime = int(prop.Exposure.LightOffTime * 1000.0)
+	us.BottomLayerExposureTime = int(prop.Bottom.Exposure.LightOnTime * 1000.0)
 	us.BottomLayersCount = prop.Bottom.Count
 	us.ZLiftDistance = prop.Exposure.LiftHeight
 	us.ZLiftRetractRate = prop.Exposure.RetractSpeed
@@ -354,8 +354,8 @@ func (sf *ZcodexFormat) Decode(reader uv3dp.Reader, filesize int64) (printable u
 	size.LayerHeight = rm.LayerThickness
 
 	exp := &prop.Exposure
-	exp.LightOnTime = time.Duration(rm.LayerTime) * time.Millisecond
-	exp.LightOffTime = time.Duration(rm.BlankingLayerTime) * time.Millisecond
+	exp.LightOnTime = float32(rm.LayerTime) / 1000.0
+	exp.LightOffTime = float32(rm.BlankingLayerTime) / 1000.0
 
 	exp.LiftHeight = us.ZLiftDistance
 	exp.RetractSpeed = us.ZLiftRetractRate
@@ -364,7 +364,7 @@ func (sf *ZcodexFormat) Decode(reader uv3dp.Reader, filesize int64) (printable u
 	bot := &prop.Bottom
 	bot.Exposure = *exp
 
-	bot.Exposure.LightOnTime = time.Duration(rm.BottomLayersTime) * time.Millisecond
+	bot.Exposure.LightOnTime = float32(rm.BottomLayersTime) / 1000.0
 
 	bot.Count = rm.BottomLayersNumber
 	bot.Style = uv3dp.BottomStyleSlow
@@ -419,8 +419,7 @@ func (zcodex *Zcodex) Layer(index int) (layer uv3dp.Layer) {
 
 	layer.Z = float32(index) * zcodex.properties.Size.LayerHeight
 	layer.Image = grayImage
-	exposure := zcodex.properties.LayerExposure(index)
-	layer.Exposure = &exposure
+	layer.Exposure = zcodex.properties.LayerExposure(index)
 
 	return
 }


Test patch:
diff --git a/cbddlp/format_alias_test.go b/cbddlp/format_alias_test.go
index 18f5b1d..bd34783 100644
--- a/cbddlp/format_alias_test.go
+++ b/cbddlp/format_alias_test.go
@@ -9,7 +9,6 @@ import (
 	"bytes"
 	"encoding/binary"
 	"image"
-	"time"
 
 	"testing"
 
@@ -51,8 +50,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Nanosecond,
-			LightOffTime:  time.Nanosecond,
+			LightOnTime:   0.000000001,
+			LightOffTime:  0.000000001,
 			LiftHeight:    1.0,
 			LiftSpeed:     1.0,
 			RetractHeight: 1.0,
@@ -61,8 +60,8 @@ var (
 		Bottom: uv3dp.Bottom{
 			Count: 1,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Nanosecond,
-				LightOffTime:  time.Nanosecond,
+				LightOnTime:   0.000000001,
+				LightOffTime:  0.000000001,
 				LiftHeight:    1.0,
 				LiftSpeed:     1.0,
 				RetractHeight: 1.0,
diff --git a/cbddlp/format_empty_test.go b/cbddlp/format_empty_test.go
index 186d3aa..334ff16 100644
--- a/cbddlp/format_empty_test.go
+++ b/cbddlp/format_empty_test.go
@@ -10,7 +10,6 @@ import (
 	"encoding/binary"
 	"image"
 	"io"
-	"time"
 
 	"testing"
 
@@ -32,8 +31,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: defaultRetractHeight + 1.0,
@@ -42,8 +41,8 @@ var (
 		Bottom: uv3dp.Bottom{
 			Count: 2,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 16500,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: defaultRetractHeight + 2.0,
diff --git a/ctb/format_empty_test.go b/ctb/format_empty_test.go
index aed7300..e7e325e 100644
--- a/ctb/format_empty_test.go
+++ b/ctb/format_empty_test.go
@@ -9,7 +9,6 @@ import (
 	"encoding/binary"
 	"image"
 	"io"
-	"time"
 
 	"testing"
 
@@ -31,8 +30,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: defaultRetractHeight, // field cannot be saved by CTB format
@@ -41,8 +40,8 @@ var (
 		Bottom: uv3dp.Bottom{
 			Count: 2,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 16500,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: defaultRetractHeight, // field cannot be saved by CTB format
diff --git a/cws/format_test.go b/cws/format_test.go
index 3cbf6b4..5e484cd 100644
--- a/cws/format_test.go
+++ b/cws/format_test.go
@@ -47,8 +47,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: 3.3,
@@ -58,8 +58,8 @@ var (
 			Count: 2,
 			Style: uv3dp.BottomStyleFade,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 16500,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: 3.3,
diff --git a/phz/format_empty_test.go b/phz/format_empty_test.go
index 0b8735f..7e69161 100644
--- a/phz/format_empty_test.go
+++ b/phz/format_empty_test.go
@@ -9,7 +9,6 @@ import (
 	"encoding/binary"
 	"image"
 	"io"
-	"time"
 
 	"testing"
 
@@ -31,8 +30,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: defaultRetractHeight, // field cannot be saved by CTB format
@@ -41,8 +40,8 @@ var (
 		Bottom: uv3dp.Bottom{
 			Count: 2,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 16500,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: defaultRetractHeight, // field cannot be saved by CTB format
diff --git a/sl1/format_test.go b/sl1/format_test.go
index 4d9795b..5592c4d 100644
--- a/sl1/format_test.go
+++ b/sl1/format_test.go
@@ -47,8 +47,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: 3.3,
@@ -58,8 +58,8 @@ var (
 			Count: 2,
 			Style: uv3dp.BottomStyleFade,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 16500,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: 3.3,
diff --git a/uvj/format_test.go b/uvj/format_test.go
new file mode 100644
index 0000000..bba85f8
--- /dev/null
+++ b/uvj/format_test.go
@@ -0,0 +1,209 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+// Package uvj is a set of tools for data exchange in the Prusa SL1 format
+package uvj
+
+import (
+	"archive/zip"
+	"bytes"
+	"image"
+	"image/png"
+	"io/ioutil"
+	"strings"
+	"time"
+
+	"testing"
+
+	"github.com/ezrec/uv3dp"
+)
+
+type bufferReader struct {
+	data []byte
+}
+
+func (br *bufferReader) ReadAt(p []byte, off int64) (n int, err error) {
+	copy(p, br.data[off:])
+	n = len(p)
+
+	return
+}
+
+func (br *bufferReader) Len() int64 {
+	return int64(len(br.data))
+}
+
+var (
+	testProperties = uv3dp.Properties{
+		Size: uv3dp.Size{
+			X: 10,
+			Y: 20,
+			Millimeter: uv3dp.SizeMillimeter{
+				X: 20.0,
+				Y: 40.0,
+			},
+			Layers:      4, // 2 bottom, 2 normal
+			LayerHeight: 0.05,
+		},
+		Exposure: uv3dp.Exposure{
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
+			LiftHeight:    5.5,
+			LiftSpeed:     120.0,
+			RetractHeight: 3.3,
+			RetractSpeed:  200.0,
+		},
+		Bottom: uv3dp.Bottom{
+			Count: 2,
+			Style: uv3dp.BottomStyleFade,
+			Exposure: uv3dp.Exposure{
+				LightOnTime:   16.500,
+				LightOffTime:  2.250,
+				LiftHeight:    5.5,
+				LiftSpeed:     120.0,
+				RetractHeight: 3.3,
+				RetractSpeed:  200.0,
+			},
+		},
+		Preview: map[uv3dp.PreviewType]image.Image{
+			uv3dp.PreviewTypeTiny: image.NewRGBA(image.Rect(0, 0, 10, 10)),
+			uv3dp.PreviewTypeHuge: image.NewCMYK(image.Rect(0, 0, 20, 12)),
+		},
+	}
+)
+
+const (
+	testConfigJson = `{
+  "Properties": {
+    "Size": {
+      "X": 10,
+      "Y": 20,
+      "Millimeter": {
+        "X": 20,
+        "Y": 40
+      },
+      "Layers": 4,
+      "LayerHeight": 0.05
+    },
+    "Exposure": {
+      "LightOnTime": 16.5,
+      "LightOffTime": 2.25,
+      "LiftHeight": 5.5,
+      "LiftSpeed": 120,
+      "RetractHeight": 3.3,
+      "RetractSpeed": 200
+    },
+    "Bottom": {
+      "LightOnTime": 16.5,
+      "LightOffTime": 2.25,
+      "LiftHeight": 5.5,
+      "LiftSpeed": 120,
+      "RetractHeight": 3.3,
+      "RetractSpeed": 200,
+      "Count": 2,
+      "Style": 1
+    }
+  },
+  "Layers": [
+    {
+      "Z": 0,
+      "Exposure": {
+        "LightOnTime": 16.5,
+        "LightOffTime": 2.25,
+        "LiftHeight": 5.5,
+        "LiftSpeed": 120,
+        "RetractHeight": 3.3,
+        "RetractSpeed": 200
+      }
+    },
+    {
+      "Z": 0.05,
+      "Exposure": {
+        "LightOnTime": 16.5,
+        "LightOffTime": 2.25,
+        "LiftHeight": 5.5,
+        "LiftSpeed": 120,
+        "RetractHeight": 3.3,
+        "RetractSpeed": 200
+      }
+    },
+    {
+      "Z": 0.1,
+      "Exposure": {
+        "LightOnTime": 16.5,
+        "LightOffTime": 2.25,
+        "LiftHeight": 5.5,
+        "LiftSpeed": 120,
+        "RetractHeight": 3.3,
+        "RetractSpeed": 200
+      }
+    },
+    {
+      "Z": 0.15,
+      "Exposure": {
+        "LightOnTime": 16.5,
+        "LightOffTime": 2.25,
+        "LiftHeight": 5.5,
+        "LiftSpeed": 120,
+        "RetractHeight": 3.3,
+        "RetractSpeed": 200
+      }
+    }
+  ]
+}
+`
+)
+
+func TestEncodeEmptyUVJ(t *testing.T) {
+	// Collect an empty printable
+	time_Now = func() (now time.Time) { return }
+
+	buffPng := &bytes.Buffer{}
+	png.Encode(buffPng, image.NewGray(testProperties.Bounds()))
+	png_empty := buffPng.Bytes()
+
+	expected_zip := map[string]([]byte){
+		"config.json":        []byte(testConfigJson),
+		"slice/00000000.png": png_empty,
+		"slice/00000001.png": png_empty,
+		"slice/00000002.png": png_empty,
+		"slice/00000003.png": png_empty,
+	}
+
+	empty := uv3dp.NewEmptyPrintable(testProperties)
+
+	formatter := NewUVJFormatter(".uvj")
+
+	buffWriter := &bytes.Buffer{}
+	formatter.Encode(buffWriter, empty)
+
+	buffReader := &bufferReader{buffWriter.Bytes()}
+
+	archive, _ := zip.NewReader(buffReader, buffReader.Len())
+
+	fileMap := map[string](*zip.File){}
+	for _, file := range archive.File {
+		fileMap[file.Name] = file
+	}
+
+	for name, expected := range expected_zip {
+		file, found := fileMap[name]
+		if !found {
+			t.Errorf("%v: Not found in encoded archive", name)
+			continue
+		}
+
+		rc, _ := file.Open()
+		defer rc.Close()
+		got, _ := ioutil.ReadAll(rc)
+
+		if !bytes.Equal(expected, got) {
+			if strings.HasSuffix(name, ".json_") {
+				t.Errorf("%s: expected:\n%v\n  got:\n%v", name, string(expected), string(got))
+			} else {
+				t.Errorf("%s: expected %d bytes, got %d bytes", name, expected, got)
+			}
+		}
+	}
+}
diff --git a/zcodex/format_test.go b/zcodex/format_test.go
index 52c7222..4f3f5b4 100644
--- a/zcodex/format_test.go
+++ b/zcodex/format_test.go
@@ -47,8 +47,8 @@ var (
 			LayerHeight: 0.05,
 		},
 		Exposure: uv3dp.Exposure{
-			LightOnTime:   time.Millisecond * 16500,
-			LightOffTime:  time.Millisecond * 2250,
+			LightOnTime:   16.500,
+			LightOffTime:  2.250,
 			LiftHeight:    5.5,
 			LiftSpeed:     120.0,
 			RetractHeight: 3.3,
@@ -58,8 +58,8 @@ var (
 			Count: 2,
 			Style: uv3dp.BottomStyleFade,
 			Exposure: uv3dp.Exposure{
-				LightOnTime:   time.Millisecond * 80000,
-				LightOffTime:  time.Millisecond * 2250,
+				LightOnTime:   80.000,
+				LightOffTime:  2.250,
 				LiftHeight:    5.5,
 				LiftSpeed:     120.0,
 				RetractHeight: 3.3,