Repository: ezrec/uv3dp
Pull request #44 (2020-05-17 22:00:33)

Issue 43 cws support


Patch:
diff --git a/README.md b/README.md
index 999938a..fc6630d 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,9 @@ The command line tool is designed to be used in a 'pipeline' style, for example:
     
       -e, --encryption-seed uint32   Specify a specific encryption seed
     
+    Options for '.cws':
+    
+    
     Options for '.photon':
     
       -a, --anti-alias int   Override antialias level (1,2,4,8) (default 1)
@@ -140,6 +143,7 @@ The command line tool is designed to be used in a 'pipeline' style, for example:
         EPAX-X156            2160x3840, 194x345 mm
         EPAX-X9              1600x2560, 120x192 mm
         Elogoo-Mars          1440x2560, 68x121 mm
+        Nova3D-Elfin         1410x2550, 73x132 mm
         Phrozen-Sonic-Mini   1080x1920, 68x121 mm
         Zortrax-Inkspire     1440x2560, 72x128 mm
     
diff --git a/cmd/uv3dp/machine.go b/cmd/uv3dp/machine.go
index 2267641..879d0e4 100644
--- a/cmd/uv3dp/machine.go
+++ b/cmd/uv3dp/machine.go
@@ -29,6 +29,7 @@ var (
 		"EPAX-X10":           {Size: MachineSize{1600, 2560, 135.0, 216.0}},
 		"EPAX-X133":          {Size: MachineSize{2160, 3840, 165.0, 293.0}},
 		"EPAX-X156":          {Size: MachineSize{2160, 3840, 194.0, 345.0}},
+		"Nova3D-Elfin":       {Size: MachineSize{1410, 2550, 73.0, 132.0}},
 		"Phrozen-Sonic-Mini": {Size: MachineSize{1080, 1920, 68.04, 120.96}},
 		"Zortrax-Inkspire":   {Size: MachineSize{1440, 2560, 72.0, 128.0}},
 	}
diff --git a/cmd/uv3dp/main.go b/cmd/uv3dp/main.go
index 40cf436..3c2764e 100644
--- a/cmd/uv3dp/main.go
+++ b/cmd/uv3dp/main.go
@@ -12,6 +12,7 @@ import (
 	"github.com/ezrec/uv3dp"
 	_ "github.com/ezrec/uv3dp/cbddlp"
 	_ "github.com/ezrec/uv3dp/ctb"
+	_ "github.com/ezrec/uv3dp/cws"
 	_ "github.com/ezrec/uv3dp/phz"
 	_ "github.com/ezrec/uv3dp/sl1"
 	_ "github.com/ezrec/uv3dp/zcodex"
diff --git a/cmd/uv3dp/select.go b/cmd/uv3dp/select.go
index 7ae7213..941559b 100644
--- a/cmd/uv3dp/select.go
+++ b/cmd/uv3dp/select.go
@@ -59,16 +59,21 @@ func (cmd *SelectCommand) Filter(input uv3dp.Printable) (output uv3dp.Printable,
 	first := cmd.First
 	count := cmd.Count
 
-	if first >= layers {
-		first = layers - 1
-	}
-
-	if count < 0 {
-		count = layers - first
-	}
-
-	if first+count > layers {
-		count = layers - first
+	if layers == 0 {
+		first = 0
+		count = 0
+	} else {
+		if first >= layers {
+			first = layers - 1
+		}
+
+		if count < 0 {
+			count = layers - first
+		}
+
+		if first+count > layers {
+			count = layers - first
+		}
 	}
 
 	sp := &SelectPrintable{
diff --git a/cws/format.go b/cws/format.go
new file mode 100644
index 0000000..e17a992
--- /dev/null
+++ b/cws/format.go
@@ -0,0 +1,526 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+package cws
+
+import (
+	"archive/zip"
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"image"
+	"image/png"
+	"io"
+	"io/ioutil"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/ezrec/uv3dp"
+	"github.com/spf13/pflag"
+)
+
+var (
+	time_Now = time.Now
+)
+
+const (
+	defaultName = "uv3dp"
+)
+
+type cwsHeader struct {
+	Vendor        string
+	SlicerName    string
+	SlicerVersion string
+	SlicerArch    string
+	Timestamp     time.Time
+}
+
+func (ch *cwsHeader) String() string {
+	now := ch.Timestamp
+	return fmt.Sprintf("%s %s %s %s %d-%02d-%02d %02d:%02d:%02d",
+		ch.Vendor,
+		ch.SlicerName,
+		ch.SlicerVersion,
+		ch.SlicerArch,
+		now.Year(), int(now.Month()), now.Day(), now.Hour(), now.Minute(), now.Second())
+}
+
+type cwsConfig struct {
+	Header              cwsHeader
+	Xppm                float32 `name:"Pix per mm X"`
+	Yppm                float32 `name:"Pix per mm Y"`
+	XResolution         int
+	YResolution         int
+	LayerThickness      float32 `units:"mm"`
+	LayerTime           int     `units:"ms"`
+	RenderOutlines      bool
+	OutlineWidthInset   int
+	OutlineWidthOutset  int // Nominally 0
+	BottomLayersTime    int `units:"ms"`
+	BottomLayers        int `name:"Number of Bottom Layers"`
+	BlankingLayerTime   int `units:"ms"`
+	BuildDirection      string
+	LiftDistance        int  `units:"mm"`
+	SlideTileValue      int  `name:"Slide/Tilt Value"`
+	UseMainliftGCodeTab bool `name:"Use Mainlift GCode Tab"`
+	AntiAliasing        bool
+	AntiAliasingValue   float32
+	ZLiftFeedRate       float32 `units:"mm/s"`
+	ZBottomLiftFeedRate float32 `units:"mm/s"`
+	ZLiftRetractRate    float32 `units:"mm/s"`
+	FlipX               bool
+	FlipY               bool
+	Layers              int `name:"Number of Slices"`
+}
+
+func (cc *cwsConfig) FieldAttr(n int) (value reflect.Value, name string, units string) {
+	v := reflect.ValueOf(cc).Elem()
+	t := reflect.TypeOf(*cc)
+
+	value = v.Field(n)
+	field := t.Field(n)
+
+	var ok bool
+	name, ok = field.Tag.Lookup("name")
+	if !ok {
+		name = ""
+		for n, c := range field.Name {
+			if n != 0 && unicode.IsUpper(c) {
+				name += " "
+			}
+			name += string([]rune{c})
+		}
+	}
+	units, ok = field.Tag.Lookup("units")
+	if ok {
+		units = " " + units
+	}
+
+	return
+}
+
+func (cc *cwsConfig) Save(gcode io.Writer) (err error) {
+	_, err = fmt.Fprintf(gcode, "; %v\n", cc.Header.String())
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(****Build and Slicing Parameters****)\n")
+	if err != nil {
+		return
+	}
+
+	// Start at 1, skip the header field
+	t := reflect.TypeOf(*cc)
+
+	for n := 1; n < t.NumField(); n++ {
+		value, name, units := cc.FieldAttr(n)
+
+		var repr string
+		switch t.Field(n).Type.String() {
+		case "int":
+			repr = fmt.Sprintf("%v", value.Int())
+		case "float32":
+			repr = fmt.Sprintf("%1.3f", value.Float())
+		case "string":
+			repr = value.String()
+		case "bool":
+			if value.Bool() {
+				repr = "True"
+			} else {
+				repr = "False"
+			}
+		default:
+			panic(fmt.Sprintf("unknown type '%v' for field '%v'", t.Field(n).Type.String(), t.Field(n).Name))
+		}
+
+		_, err = fmt.Fprintf(gcode, ";(%-24v= %v%v )\n", name, repr, units)
+		if err != nil {
+			return
+		}
+	}
+
+	_, err = fmt.Fprintf(gcode, "(****Machine Configuration ******)\n")
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %1.2fmm )\n", "Platform X Size", float32(cc.XResolution)/cc.Xppm)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %1.2fmm )\n", "Platform Y Size", float32(cc.YResolution)/cc.Yppm)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %1.2fmm )\n", "Platform Z Size", float32(cc.Layers)*cc.LayerThickness)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %vmm/s )\n", "Max X Feedrate", 200)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %vmm/s )\n", "Max Y Feedrate", 200)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %vmm/s )\n", "Max Z Feedrate", 200)
+	if err != nil {
+		return
+	}
+	_, err = fmt.Fprintf(gcode, ";(%-24v= %s )\n", "Machine Type", "UV_LCD")
+	if err != nil {
+		return
+	}
+
+	return
+}
+
+func (cc *cwsConfig) ValueByName(name string) (value reflect.Value, ok bool) {
+	t := reflect.TypeOf(*cc)
+
+	for n := 1; n < t.NumField(); n++ {
+		fieldV, fieldN, _ := cc.FieldAttr(n)
+		if fieldN == name {
+			value = fieldV
+			ok = true
+			return
+		}
+	}
+
+	return
+}
+
+func (cc *cwsConfig) Load(gcodeFile io.Reader) (err error) {
+	scanner := bufio.NewScanner(gcodeFile)
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		if len(line) > 2 {
+			if line[0] == ';' && strings.Contains(line, " = ") {
+				fields := strings.SplitN(line[1:], " = ", 2)
+				if len(fields) == 2 {
+					name := strings.Trim(fields[0], " (")
+					value := strings.Trim(strings.Split(fields[1], " ")[0], " )")
+					ccValue, ok := cc.ValueByName(name)
+					if ok {
+						switch ccValue.Type().String() {
+						case "int":
+							var ivalue int64
+							ivalue, err = strconv.ParseInt(value, 10, 64)
+							if err != nil {
+								return
+							}
+							ccValue.SetInt(ivalue)
+						case "float32":
+							var fvalue float64
+							fvalue, err = strconv.ParseFloat(value, 64)
+							if err != nil {
+								return
+							}
+							ccValue.SetFloat(fvalue)
+						case "string":
+							ccValue.SetString(value)
+						case "bool":
+							var bvalue bool
+							bvalue, err = strconv.ParseBool(value)
+							ccValue.SetBool(bvalue)
+						default:
+							panic(fmt.Sprintf("unknown data type '%s'", ccValue.Type().String()))
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return
+}
+
+type ReadSeekCloser interface {
+	io.Reader
+	io.Seeker
+	io.Closer
+}
+
+type CWS struct {
+	properties uv3dp.Properties
+	config     cwsConfig
+	layerPng   []([]byte)
+}
+
+type CWSFormat struct {
+	*pflag.FlagSet
+}
+
+func NewCWSFormatter(suffix string) (sf *CWSFormat) {
+	flagSet := pflag.NewFlagSet(suffix, pflag.ContinueOnError)
+
+	sf = &CWSFormat{
+		FlagSet: flagSet,
+	}
+
+	sf.SetInterspersed(false)
+
+	return
+}
+
+func (sf *CWSFormat) Encode(writer uv3dp.Writer, printable uv3dp.Printable) (err error) {
+	jobName := defaultName
+
+	archive := zip.NewWriter(writer)
+	defer archive.Close()
+
+	prop := printable.Properties()
+
+	size := &prop.Size
+	exp := &prop.Exposure
+	bot := &prop.Bottom.Exposure
+
+	for n := 0; n < size.Layers; n++ {
+		layer := printable.Layer(n)
+
+		filename := fmt.Sprintf("%s%04d.png", jobName, n)
+
+		var writer io.Writer
+		writer, err = archive.Create(filename)
+		if err != nil {
+			return
+		}
+
+		err = png.Encode(writer, layer.Image)
+		if err != nil {
+			return
+		}
+	}
+
+	config := cwsConfig{
+		Header: cwsHeader{
+			Vendor:        "github.com/ezrec/uv3dp",
+			SlicerName:    "uv3dp",
+			SlicerVersion: "v0.0.0",
+			SlicerArch:    "64-bits",
+			Timestamp:     time_Now(),
+		},
+		Xppm:                size.Millimeter.X / float32(size.X),
+		Yppm:                size.Millimeter.Y / float32(size.Y),
+		XResolution:         size.X,
+		YResolution:         size.Y,
+		LayerThickness:      size.LayerHeight,
+		LayerTime:           int(exp.LightOnTime / time.Millisecond),
+		OutlineWidthInset:   2,
+		BottomLayersTime:    int(bot.LightOnTime / time.Millisecond),
+		BottomLayers:        prop.Bottom.Count,
+		BlankingLayerTime:   int(exp.LightOffTime / time.Millisecond),
+		BuildDirection:      "Bottom_Up",
+		LiftDistance:        int(exp.LiftHeight),
+		AntiAliasing:        true,
+		AntiAliasingValue:   2.0,
+		ZLiftFeedRate:       exp.LiftSpeed,
+		ZBottomLiftFeedRate: bot.LiftSpeed,
+		ZLiftRetractRate:    exp.RetractSpeed,
+		FlipX:               true,
+		FlipY:               true,
+		Layers:              size.Layers,
+	}
+
+	// Create the gcode file
+	gcode, err := archive.Create(jobName + ".gcode")
+	if err != nil {
+		return
+	}
+
+	// Save the config
+	err = config.Save(gcode)
+	if err != nil {
+		return
+	}
+
+	// Emit the GCode header
+	fmt.Fprintf(gcode, `
+G28
+G21 ;Set units to be mm
+G91 ;Relative Positioning
+M17 ;Enable motors
+<Slice> Blank
+M106 S0
+`)
+
+	// Create all the layer movement gcode
+	priorZ := float32(0.0)
+	for n := 0; n < size.Layers; n++ {
+		layer := printable.Layer(n)
+		if n > 0 {
+			thickness := layer.Z - priorZ
+			fmt.Fprintf(gcode, "G1 Z%1.3f F%v\n", -(layer.Exposure.LiftHeight - thickness), int(layer.Exposure.LiftSpeed))
+			// This is just a guess here
+			fmt.Fprintf(gcode, ";<Delay> %v\n", 720000/int(layer.Exposure.LiftSpeed))
+		}
+
+		// 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 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
+	}
+
+	// Emit the GCode trailer
+	fmt.Fprintf(gcode, "\n")
+	fmt.Fprintf(gcode, "M18 ;Disable Motors\n")
+	fmt.Fprintf(gcode, "M106 S0\n")
+	fmt.Fprintf(gcode, "G1 Z80\n")
+	fmt.Fprintf(gcode, ";<Completed>\n")
+
+	return
+}
+
+func (sf *CWSFormat) 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))
+
+	jobName := defaultName
+	for _, file := range archive.File {
+		fileMap[file.Name] = file
+		if strings.HasSuffix(file.Name, ".gcode") {
+			jobName = file.Name[:len(file.Name)-len(".gcode")]
+		}
+	}
+
+	filename := jobName + ".gcode"
+	gcodeFile, found := fileMap[filename]
+	if !found {
+		err = errors.New(filename + " not found in archive")
+		return
+	}
+
+	gcodeReader, err := gcodeFile.Open()
+	if err != nil {
+		return
+	}
+	defer func() { gcodeReader.Close() }()
+
+	// Load the config file
+	var config cwsConfig
+
+	err = config.Load(gcodeReader)
+	if err != nil {
+		return
+	}
+
+	// Collect the layer files
+	layerPng := make([]([]byte), config.Layers)
+	for n := 0; n < cap(layerPng); n++ {
+		name := fmt.Sprintf("%s%04d.png", jobName, 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: "thumbnail/thumbnail400x400.png",
+		uv3dp.PreviewTypeHuge: "thumbnail/thumbnail800x480.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
+	}
+
+	prop := uv3dp.Properties{}
+
+	size := &prop.Size
+	size.X = config.XResolution
+	size.Y = config.YResolution
+	size.Layers = config.Layers
+
+	size.Millimeter.X = float32(size.X) * config.Xppm
+	size.Millimeter.Y = float32(size.Y) * config.Yppm
+	size.LayerHeight = config.LayerThickness
+
+	bot := &prop.Bottom
+	bot.Exposure.LightOnTime = time.Duration(config.BottomLayersTime) * time.Millisecond
+
+	bot.Count = config.BottomLayers
+
+	exp := &prop.Exposure
+	exp.LightOnTime = time.Duration(config.LayerTime) * time.Millisecond
+	exp.LightOffTime = time.Duration(config.BlankingLayerTime) * time.Millisecond
+	exp.LiftHeight = float32(config.LiftDistance)
+	exp.LiftSpeed = config.ZLiftFeedRate
+	exp.RetractSpeed = config.ZLiftRetractRate
+
+	bot.LightOffTime = exp.LightOffTime
+	bot.LiftSpeed = exp.LiftSpeed
+	bot.RetractSpeed = exp.RetractSpeed
+
+	cws := &CWS{
+		properties: prop,
+		layerPng:   layerPng,
+	}
+
+	printable = cws
+
+	return
+}
+
+func (cws *CWS) Close() {
+}
+
+func (cws *CWS) Properties() (prop uv3dp.Properties) {
+	prop = cws.properties
+	return
+}
+
+func (cws *CWS) Layer(index int) (layer uv3dp.Layer) {
+	pngImage, err := png.Decode(bytes.NewReader(cws.layerPng[index]))
+	if err != nil {
+		panic(err)
+	}
+
+	layer.Z = float32(index) * cws.properties.Size.LayerHeight
+	layer.Image = pngImage.(*image.Gray)
+	exposure := cws.properties.LayerExposure(index)
+	layer.Exposure = &exposure
+
+	return
+}
diff --git a/cws/init.go b/cws/init.go
new file mode 100644
index 0000000..4ea5009
--- /dev/null
+++ b/cws/init.go
@@ -0,0 +1,16 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+// Package cws handles input and output of NOVA32 CWS printables
+package cws
+
+import (
+	"github.com/ezrec/uv3dp"
+)
+
+func init() {
+	newFormatter := func(suffix string) uv3dp.Formatter { return NewCWSFormatter(suffix) }
+
+	uv3dp.RegisterFormatter(".cws", newFormatter)
+}
diff --git a/sl1/format.go b/sl1/format.go
index f052826..115d3f4 100644
--- a/sl1/format.go
+++ b/sl1/format.go
@@ -34,6 +34,22 @@ const (
 	defaultCacheSize = 16
 )
 
+var (
+	defaultExposure = uv3dp.Exposure{
+		LiftHeight:    4,
+		LiftSpeed:     60,
+		RetractHeight: 4,
+		RetractSpeed:  60,
+	}
+
+	defaultBottomExposure = uv3dp.Exposure{
+		LiftHeight:    4,
+		LiftSpeed:     60,
+		RetractHeight: 4,
+		RetractSpeed:  60,
+	}
+)
+
 type sl1Config struct {
 	jobDir       string
 	expTime      float32
@@ -342,6 +358,7 @@ func (sf *Sl1Format) Decode(reader uv3dp.Reader, filesize int64) (printable uv3d
 	size.LayerHeight = config.layerHeight
 
 	bot := &prop.Bottom
+	bot.Exposure = defaultBottomExposure
 	bot.Exposure.LightOnTime = time.Duration(config.expTimeFirst*1000) * time.Millisecond
 
 	if config.numFade > 0 {
@@ -353,6 +370,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
 
 	// Calculate layer off time based off of total print time
@@ -361,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*1000) * time.Millisecond
+	exp.LightOffTime = time.Duration(layerOffTime) * time.Millisecond
 	bot.LightOffTime = exp.LightOffTime
 
 	prop.Preview = thumbImage


Test patch:
diff --git a/cws/format_test.go b/cws/format_test.go
new file mode 100644
index 0000000..d579506
--- /dev/null
+++ b/cws/format_test.go
@@ -0,0 +1,211 @@
+//
+// Copyright (c) 2020 Jason S. McMullan <jason.mcmullan@gmail.com>
+//
+
+// Package cws is a set of tools for data exchange in the Prusa CWS format
+package cws
+
+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:   time.Millisecond * 16500,
+			LightOffTime:  time.Millisecond * 2250,
+			LiftHeight:    5.5,
+			LiftSpeed:     120.0,
+			RetractHeight: 3.3,
+			RetractSpeed:  200.0,
+		},
+		Bottom: uv3dp.Bottom{
+			Count: 2,
+			Style: uv3dp.BottomStyleFade,
+			Exposure: uv3dp.Exposure{
+				LightOnTime:   time.Millisecond * 16500,
+				LightOffTime:  time.Millisecond * 2250,
+				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 (
+	testConfigIni = `; github.com/ezrec/uv3dp uv3dp v0.0.0 64-bits 1-01-01 00:00:00
+;(****Build and Slicing Parameters****)
+;(Pix per mm X            = 2.000 )
+;(Pix per mm Y            = 2.000 )
+;(X Resolution            = 10 )
+;(Y Resolution            = 20 )
+;(Layer Thickness         = 0.050 mm )
+;(Layer Time              = 16500 ms )
+;(Render Outlines         = False )
+;(Outline Width Inset     = 2 )
+;(Outline Width Outset    = 0 )
+;(Bottom Layers Time      = 16500 ms )
+;(Number of Bottom Layers = 2 )
+;(Blanking Layer Time     = 2250 ms )
+;(Build Direction         = Bottom_Up )
+;(Lift Distance           = 5 mm )
+;(Slide/Tilt Value        = 0 )
+;(Use Mainlift GCode Tab  = False )
+;(Anti Aliasing           = True )
+;(Anti Aliasing Value     = 2.000 )
+;(Z Lift Feed Rate        = 120.000 mm/s )
+;(Z Bottom Lift Feed Rate = 120.000 mm/s )
+;(Z Lift Retract Rate     = 200.000 mm/s )
+;(Flip X                  = True )
+;(Flip Y                  = True )
+;(Number of Slices        = 4 )
+(****Machine Configuration ******)
+;(Platform X Size         = 5.00mm )
+;(Platform Y Size         = 10.00mm )
+;(Platform Z Size         = 0.20mm )
+;(Max X Feedrate          = 200mm/s )
+;(Max Y Feedrate          = 200mm/s )
+;(Max Z Feedrate          = 200mm/s )
+;(Machine Type            = UV_LCD )
+
+G28
+G21 ;Set units to be mm
+G91 ;Relative Positioning
+M17 ;Enable motors
+<Slice> Blank
+M106 S0
+
+;<Slice> 0
+M106 S255
+;<Delay> 16500
+M106 S0
+;<Slice> Blank
+G1 Z5.500 F120
+G1 Z-5.450 F120
+;<Delay> 6000
+
+;<Slice> 1
+M106 S255
+;<Delay> 16500
+M106 S0
+;<Slice> Blank
+G1 Z5.500 F120
+G1 Z-5.450 F120
+;<Delay> 6000
+
+;<Slice> 2
+M106 S255
+;<Delay> 16500
+M106 S0
+;<Slice> Blank
+G1 Z5.500 F120
+G1 Z-5.450 F120
+;<Delay> 6000
+
+;<Slice> 3
+M106 S255
+;<Delay> 16500
+M106 S0
+;<Slice> Blank
+G1 Z5.500 F120
+
+M18 ;Disable Motors
+M106 S0
+G1 Z80
+;<Completed>
+`
+)
+
+func TestEncodeEmptyCWS(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){
+		"uv3dp.gcode":   []byte(testConfigIni),
+		"uv3dp0000.png": png_empty,
+		"uv3dp0001.png": png_empty,
+		"uv3dp0002.png": png_empty,
+		"uv3dp0003.png": png_empty,
+	}
+
+	empty := uv3dp.NewEmptyPrintable(testProperties)
+
+	formatter := NewCWSFormatter(".cws")
+
+	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, ".gcode") {
+				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)
+			}
+		}
+	}
+}