Browse Source

Add gcode support to 3D viewer

Toby Chui 5 days ago
parent
commit
3015dff91e

+ 266 - 0
src/mod/filesystem/metadata/gcode.go

@@ -0,0 +1,266 @@
+package metadata
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/base64"
+	"errors"
+	"image"
+	"image/color"
+	"image/draw"
+	"image/jpeg"
+	_ "image/png"
+	"io"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+
+	"github.com/nfnt/resize"
+	"imuslab.com/arozos/mod/filesystem"
+)
+
+/*
+	G-code thumbnail extraction
+
+	Slicers (PrusaSlicer, SuperSlicer, OrcaSlicer, BambuStudio, AnycubicSlicer,
+	Cura with the thumbnail plugin, ...) embed one or more preview images in the
+	comment header of a sliced G-code file, base64 encoded between marker lines:
+
+		; thumbnail begin 230x110 2088
+		; iVBORw0KGgoAAAANSUhEUgAAAOYAAABuCAYAAA...
+		; ...
+		; thumbnail end
+
+	The marker may carry a format suffix (`thumbnail_JPG begin`) and a trailing
+	tag naming an alternate camera angle (`... 512x512 2484 top`). Files can
+	hold several thumbnails at different sizes; the untagged ones are the
+	regular preview a user sees in their slicer, so those are preferred, and
+	the largest is picked from whichever group is used.
+*/
+
+var (
+	gcodeThumbnailBegin = regexp.MustCompile(`(?i)^;\s*thumbnail(?:_[a-z0-9]+)?\s+begin\s+(\d+)\s*[xX]\s*(\d+)\s+(\d+)\s*(\S*)`)
+	gcodeThumbnailEnd   = regexp.MustCompile(`(?i)^;\s*thumbnail(?:_[a-z0-9]+)?\s+end`)
+)
+
+// Thumbnails live in the comment header, so there is no reason to walk through
+// the whole toolpath of what can be a several hundred megabyte file.
+const gcodeThumbnailScanLimit = 4 << 20
+
+// gcodeThumbnail is one embedded preview as found in the file, still encoded.
+type gcodeThumbnail struct {
+	width  int
+	height int
+	tag    string // alternate camera angle, e.g. "top"; empty for the main preview
+	data   []byte
+}
+
+// ExtractGcodeThumbnails returns every embedded preview found in the header of
+// a G-code stream, in the order they appear. Reading stops after
+// gcodeThumbnailScanLimit bytes.
+func extractGcodeThumbnails(r io.Reader) ([]gcodeThumbnail, error) {
+	scanner := bufio.NewScanner(io.LimitReader(r, gcodeThumbnailScanLimit))
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+
+	found := []gcodeThumbnail{}
+	var current *gcodeThumbnail
+	var payload bytes.Buffer
+
+	for scanner.Scan() {
+		line := bytes.TrimSpace(scanner.Bytes())
+		if len(line) == 0 || line[0] != ';' {
+			continue
+		}
+
+		if current == nil {
+			match := gcodeThumbnailBegin.FindSubmatch(line)
+			if match == nil {
+				continue
+			}
+			width, _ := strconv.Atoi(string(match[1]))
+			height, _ := strconv.Atoi(string(match[2]))
+			current = &gcodeThumbnail{width: width, height: height, tag: string(match[4])}
+			payload.Reset()
+			continue
+		}
+
+		if gcodeThumbnailEnd.Match(line) {
+			data, err := base64.StdEncoding.DecodeString(payload.String())
+			if err == nil && len(data) > 0 {
+				current.data = data
+				found = append(found, *current)
+			}
+			current = nil
+			continue
+		}
+
+		// a payload line: everything after the comment marker, whitespace free
+		payload.Write(bytes.TrimSpace(bytes.TrimLeft(line, "; ")))
+	}
+
+	if err := scanner.Err(); err != nil {
+		return found, err
+	}
+	return found, nil
+}
+
+// pickGcodeThumbnails orders the candidates best first: the untagged previews
+// before the alternate angles, and larger before smaller within each group.
+func pickGcodeThumbnails(thumbnails []gcodeThumbnail) []gcodeThumbnail {
+	ordered := make([]gcodeThumbnail, len(thumbnails))
+	copy(ordered, thumbnails)
+	sort.SliceStable(ordered, func(i, j int) bool {
+		if (ordered[i].tag == "") != (ordered[j].tag == "") {
+			return ordered[i].tag == ""
+		}
+		return ordered[i].width*ordered[i].height > ordered[j].width*ordered[j].height
+	})
+	return ordered
+}
+
+// DecodeGcodeThumbnail returns the best embedded preview of a G-code stream as
+// a decoded image. Candidates are tried best first, so a preview in a format
+// this build cannot decode (some slicers can emit QOI) falls through to the
+// next one instead of failing the whole file.
+func decodeGcodeThumbnail(r io.Reader) (image.Image, error) {
+	thumbnails, err := extractGcodeThumbnails(r)
+	if err != nil && len(thumbnails) == 0 {
+		return nil, err
+	}
+	if len(thumbnails) == 0 {
+		return nil, errors.New("no embedded thumbnail in this gcode file")
+	}
+
+	for _, thumbnail := range pickGcodeThumbnails(thumbnails) {
+		img, _, decodeErr := image.Decode(bytes.NewReader(thumbnail.data))
+		if decodeErr == nil {
+			return img, nil
+		}
+	}
+	return nil, errors.New("embedded thumbnail is in an unsupported image format")
+}
+
+// trimTransparent crops src down to the area that actually carries opaque
+// pixels. Slicer previews render the model onto a transparent plate with a
+// generous margin, so without this the model ends up as a small island in the
+// middle of the thumbnail. An image with no transparency is returned unchanged.
+func trimTransparent(src image.Image) image.Image {
+	bounds := src.Bounds()
+	minX, minY := bounds.Max.X, bounds.Max.Y
+	maxX, maxY := bounds.Min.X, bounds.Min.Y
+
+	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+		for x := bounds.Min.X; x < bounds.Max.X; x++ {
+			if _, _, _, alpha := src.At(x, y).RGBA(); alpha > 0x2000 {
+				if x < minX {
+					minX = x
+				}
+				if y < minY {
+					minY = y
+				}
+				if x > maxX {
+					maxX = x
+				}
+				if y > maxY {
+					maxY = y
+				}
+			}
+		}
+	}
+
+	if minX > maxX || minY > maxY {
+		//fully transparent, nothing to trim against
+		return src
+	}
+
+	//keep a small margin so the model does not touch the thumbnail edge
+	margin := (maxX - minX + maxY - minY) / 40
+	content := image.Rect(minX-margin, minY-margin, maxX+1+margin, maxY+1+margin).Intersect(bounds)
+
+	type subImager interface {
+		SubImage(r image.Rectangle) image.Image
+	}
+	if sub, ok := src.(subImager); ok {
+		return sub.SubImage(content)
+	}
+
+	cropped := image.NewRGBA(image.Rect(0, 0, content.Dx(), content.Dy()))
+	draw.Draw(cropped, cropped.Bounds(), src, content.Min, draw.Src)
+	return cropped
+}
+
+// fitOnCanvas scales src to fit inside a size x size square and centers it on
+// an opaque background. Slicer previews are usually transparent and are often
+// far from square, so they are letterboxed rather than center cropped: cropping
+// a 230x110 preview to a square would cut most of the model out of frame.
+func fitOnCanvas(src image.Image, size int, background color.Color) image.Image {
+	bounds := src.Bounds()
+	width := bounds.Dx()
+	height := bounds.Dy()
+	if width <= 0 || height <= 0 {
+		return src
+	}
+
+	if width > height {
+		src = resize.Resize(uint(size), 0, src, resize.Lanczos3)
+	} else {
+		src = resize.Resize(0, uint(size), src, resize.Lanczos3)
+	}
+	bounds = src.Bounds()
+
+	canvas := image.NewRGBA(image.Rect(0, 0, size, size))
+	draw.Draw(canvas, canvas.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src)
+
+	offset := image.Pt((size-bounds.Dx())/2, (size-bounds.Dy())/2)
+	target := image.Rectangle{Min: offset, Max: offset.Add(bounds.Size())}
+	draw.Draw(canvas, target, src, bounds.Min, draw.Over)
+	return canvas
+}
+
+func generateThumbnailForGcode(fsh *filesystem.FileSystemHandler, cacheFolder string, file string, generateOnly bool) (string, error) {
+	if fsh.RequireBuffer {
+		return "", nil
+	}
+	fshAbs := fsh.FileSystemAbstraction
+
+	if !fshAbs.FileExists(file) {
+		//The user removed this file before the thumbnail is finished
+		return "", errors.New("Source not exists")
+	}
+
+	f, err := fshAbs.Open(file)
+	if err != nil {
+		return "", err
+	}
+	img, err := decodeGcodeThumbnail(f)
+	f.Close()
+	if err != nil {
+		return "", err
+	}
+
+	//Slicer previews are transparent with a wide margin; crop to the model and
+	//flatten onto white to match the render used for the other 3D model
+	//thumbnails
+	thumbnail := fitOnCanvas(trimTransparent(img), 480, color.White)
+
+	outputFile := cacheFolder + filepath.Base(file) + ".jpg"
+	outf, err := fshAbs.Create(outputFile)
+	if err != nil {
+		return "", err
+	}
+	err = jpeg.Encode(outf, thumbnail, &jpeg.Options{Quality: 90})
+	outf.Close()
+	if err != nil {
+		return "", err
+	}
+
+	if !generateOnly && fshAbs.FileExists(outputFile) {
+		//return the image as well
+		ctx, err := getImageAsBase64(fsh, outputFile)
+		return ctx, err
+	} else if !fshAbs.FileExists(outputFile) {
+		return "", errors.New("Image generation failed")
+	}
+	return "", nil
+}

+ 282 - 0
src/mod/filesystem/metadata/gcode_test.go

@@ -0,0 +1,282 @@
+package metadata
+
+import (
+	"bytes"
+	"encoding/base64"
+	"image"
+	"image/color"
+	"image/png"
+	"strings"
+	"testing"
+)
+
+// encodePNG builds a tiny solid colour PNG and returns it base64 encoded the
+// way a slicer would embed it.
+func encodePNG(t *testing.T, width, height int, c color.Color) string {
+	t.Helper()
+	img := image.NewRGBA(image.Rect(0, 0, width, height))
+	for y := 0; y < height; y++ {
+		for x := 0; x < width; x++ {
+			img.Set(x, y, c)
+		}
+	}
+	var buf bytes.Buffer
+	if err := png.Encode(&buf, img); err != nil {
+		t.Fatalf("could not encode test png: %v", err)
+	}
+	return base64.StdEncoding.EncodeToString(buf.Bytes())
+}
+
+// gcodeWithThumbnail wraps base64 payload in slicer style comment markers.
+func gcodeWithThumbnail(header string, payload string, footer string) string {
+	var sb strings.Builder
+	sb.WriteString(header)
+	for len(payload) > 0 {
+		chunk := payload
+		if len(chunk) > 78 {
+			chunk = chunk[:78]
+		}
+		payload = payload[len(chunk):]
+		sb.WriteString("; " + chunk + "\n")
+	}
+	sb.WriteString(footer)
+	return sb.String()
+}
+
+func TestExtractGcodeThumbnails(t *testing.T) {
+	small := encodePNG(t, 4, 2, color.RGBA{255, 0, 0, 255})
+	large := encodePNG(t, 8, 8, color.RGBA{0, 255, 0, 255})
+
+	tests := []struct {
+		name      string
+		source    string
+		wantCount int
+		wantFirst string // "WxH" of the first extracted thumbnail
+	}{
+		{
+			name:      "no thumbnail",
+			source:    "G28\nG1 X10 Y10\n; just a comment\n",
+			wantCount: 0,
+		},
+		{
+			name: "single prusa style block",
+			source: gcodeWithThumbnail(
+				"; generated by TestSlicer\n; thumbnail begin 4x2 100\n", small, "; thumbnail end\nG28\n"),
+			wantCount: 1,
+			wantFirst: "4x2",
+		},
+		{
+			name: "format suffix and no space after marker",
+			source: gcodeWithThumbnail(
+				";thumbnail_JPG begin 4x2 100\n", small, ";thumbnail_JPG end\n"),
+			wantCount: 1,
+			wantFirst: "4x2",
+		},
+		{
+			name: "two blocks keeps document order",
+			source: gcodeWithThumbnail("; thumbnail begin 4x2 100\n", small, "; thumbnail end\n") +
+				gcodeWithThumbnail("; thumbnail begin 8x8 200 top\n", large, "; thumbnail end\n"),
+			wantCount: 2,
+			wantFirst: "4x2",
+		},
+		{
+			name:      "unterminated block is discarded",
+			source:    gcodeWithThumbnail("; thumbnail begin 4x2 100\n", small, "G28\n"),
+			wantCount: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := extractGcodeThumbnails(strings.NewReader(tt.source))
+			if err != nil {
+				t.Fatalf("extractGcodeThumbnails returned error: %v", err)
+			}
+			if len(got) != tt.wantCount {
+				t.Fatalf("got %d thumbnails, want %d", len(got), tt.wantCount)
+			}
+			if tt.wantCount == 0 {
+				return
+			}
+			first := itoa(got[0].width) + "x" + itoa(got[0].height)
+			if first != tt.wantFirst {
+				t.Errorf("first thumbnail is %s, want %s", first, tt.wantFirst)
+			}
+			if len(got[0].data) == 0 {
+				t.Error("first thumbnail carries no decoded payload")
+			}
+		})
+	}
+}
+
+func TestPickGcodeThumbnails(t *testing.T) {
+	tests := []struct {
+		name  string
+		input []gcodeThumbnail
+		want  string // "WxH" expected to be ranked first
+	}{
+		{
+			name:  "largest untagged wins",
+			input: []gcodeThumbnail{{width: 16, height: 16}, {width: 64, height: 64}, {width: 32, height: 32}},
+			want:  "64x64",
+		},
+		{
+			name:  "untagged beats a larger tagged preview",
+			input: []gcodeThumbnail{{width: 512, height: 512, tag: "top"}, {width: 230, height: 110}},
+			want:  "230x110",
+		},
+		{
+			name:  "falls back to tagged when nothing is untagged",
+			input: []gcodeThumbnail{{width: 100, height: 100, tag: "top"}, {width: 400, height: 400, tag: "front"}},
+			want:  "400x400",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := pickGcodeThumbnails(tt.input)
+			first := itoa(got[0].width) + "x" + itoa(got[0].height)
+			if first != tt.want {
+				t.Errorf("ranked %s first, want %s", first, tt.want)
+			}
+			if len(got) != len(tt.input) {
+				t.Errorf("got %d candidates back, want %d", len(got), len(tt.input))
+			}
+		})
+	}
+}
+
+func TestDecodeGcodeThumbnail(t *testing.T) {
+	small := encodePNG(t, 4, 2, color.RGBA{255, 0, 0, 255})
+	large := encodePNG(t, 8, 8, color.RGBA{0, 255, 0, 255})
+
+	t.Run("decodes the preferred candidate", func(t *testing.T) {
+		source := gcodeWithThumbnail("; thumbnail begin 8x8 200 top\n", large, "; thumbnail end\n") +
+			gcodeWithThumbnail("; thumbnail begin 4x2 100\n", small, "; thumbnail end\n")
+		img, err := decodeGcodeThumbnail(strings.NewReader(source))
+		if err != nil {
+			t.Fatalf("decodeGcodeThumbnail returned error: %v", err)
+		}
+		// the untagged 4x2 preview should win over the tagged 8x8 one
+		if got := img.Bounds().Dx(); got != 4 {
+			t.Errorf("decoded a %dpx wide image, want the 4px untagged preview", got)
+		}
+	})
+
+	t.Run("skips an undecodable candidate", func(t *testing.T) {
+		junk := base64.StdEncoding.EncodeToString([]byte("qoif not a real image payload"))
+		source := gcodeWithThumbnail("; thumbnail begin 99x99 100\n", junk, "; thumbnail end\n") +
+			gcodeWithThumbnail("; thumbnail begin 8x8 200\n", large, "; thumbnail end\n")
+		img, err := decodeGcodeThumbnail(strings.NewReader(source))
+		if err != nil {
+			t.Fatalf("decodeGcodeThumbnail returned error: %v", err)
+		}
+		if got := img.Bounds().Dx(); got != 8 {
+			t.Errorf("decoded a %dpx wide image, want the 8px fallback", got)
+		}
+	})
+
+	t.Run("reports a file without any thumbnail", func(t *testing.T) {
+		_, err := decodeGcodeThumbnail(strings.NewReader("G28\nG1 X1 Y1 E1\n"))
+		if err == nil {
+			t.Error("expected an error for a gcode file with no embedded thumbnail")
+		}
+	})
+}
+
+func TestTrimTransparent(t *testing.T) {
+	// a 200x100 canvas with an opaque 20x40 block at (90,30)
+	withBlock := func() image.Image {
+		img := image.NewRGBA(image.Rect(0, 0, 200, 100))
+		for y := 30; y < 70; y++ {
+			for x := 90; x < 110; x++ {
+				img.Set(x, y, color.RGBA{200, 200, 40, 255})
+			}
+		}
+		return img
+	}
+
+	tests := []struct {
+		name                  string
+		src                   image.Image
+		wantSmallerThanSource bool
+	}{
+		{name: "crops the transparent margin", src: withBlock(), wantSmallerThanSource: true},
+		{
+			name: "leaves a fully opaque image alone",
+			src: func() image.Image {
+				img := image.NewRGBA(image.Rect(0, 0, 40, 40))
+				for y := 0; y < 40; y++ {
+					for x := 0; x < 40; x++ {
+						img.Set(x, y, color.RGBA{10, 20, 30, 255})
+					}
+				}
+				return img
+			}(),
+			wantSmallerThanSource: false,
+		},
+		{
+			name:                  "leaves a fully transparent image alone",
+			src:                   image.NewRGBA(image.Rect(0, 0, 40, 40)),
+			wantSmallerThanSource: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srcArea := tt.src.Bounds().Dx() * tt.src.Bounds().Dy()
+			got := trimTransparent(tt.src)
+			gotArea := got.Bounds().Dx() * got.Bounds().Dy()
+
+			if tt.wantSmallerThanSource && gotArea >= srcArea {
+				t.Errorf("trimmed area %d is not smaller than the source area %d", gotArea, srcArea)
+			}
+			if !tt.wantSmallerThanSource && gotArea != srcArea {
+				t.Errorf("trimmed area %d, want the source area %d untouched", gotArea, srcArea)
+			}
+			if gotArea == 0 {
+				t.Error("trimming produced an empty image")
+			}
+		})
+	}
+}
+
+func TestFitOnCanvas(t *testing.T) {
+	tests := []struct {
+		name          string
+		width, height int
+	}{
+		{name: "wide preview", width: 230, height: 110},
+		{name: "square preview", width: 512, height: 512},
+		{name: "tall preview", width: 100, height: 400},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			src := image.NewRGBA(image.Rect(0, 0, tt.width, tt.height))
+			got := fitOnCanvas(src, 480, color.White)
+			bounds := got.Bounds()
+			if bounds.Dx() != 480 || bounds.Dy() != 480 {
+				t.Errorf("canvas is %dx%d, want 480x480", bounds.Dx(), bounds.Dy())
+			}
+			// corners must be filled with the background, never left transparent
+			_, _, _, alpha := got.At(0, 0).RGBA()
+			if alpha != 0xffff {
+				t.Errorf("corner alpha is %d, want an opaque background", alpha)
+			}
+		})
+	}
+}
+
+// itoa keeps the table assertions readable without pulling in strconv here.
+func itoa(v int) string {
+	if v == 0 {
+		return "0"
+	}
+	digits := ""
+	for v > 0 {
+		digits = string(rune('0'+v%10)) + digits
+		v /= 10
+	}
+	return digits
+}

+ 8 - 2
src/mod/filesystem/metadata/metadata.go

@@ -192,6 +192,14 @@ func (rh *RenderHandler) generateCache(fsh *filesystem.FileSystemHandler, cacheF
 		return img, err
 	}
 
+	//Sliced G-code, which carries a slicer generated preview in its header
+	gcodeFormats := []string{".gcode", ".gco"}
+	if utils.StringInArray(gcodeFormats, strings.ToLower(filepath.Ext(rpath))) {
+		img, err := generateThumbnailForGcode(fsh, cacheFolder, rpath, generateOnly)
+		rh.renderingFiles.Delete(rpath)
+		return img, err
+	}
+
 	//Photoshop file
 	if strings.ToLower(filepath.Ext(rpath)) == ".psd" {
 		img, err := generateThumbnailForPSD(fsh, cacheFolder, rpath, generateOnly)
@@ -397,7 +405,6 @@ func GetCacheFilePath(fsh *filesystem.FileSystemHandler, file string) (string, e
 func RemoveCache(fsh *filesystem.FileSystemHandler, file string) error {
 	if CacheExists(fsh, file) {
 		cachePath, err := GetCacheFilePath(fsh, file)
-		//log.Println("Removing ", cachePath, err)
 		if err != nil {
 			return err
 		}
@@ -406,7 +413,6 @@ func RemoveCache(fsh *filesystem.FileSystemHandler, file string) error {
 		os.Remove(cachePath)
 		return nil
 	} else {
-		//log.Println("Cache not exists: ", file)
 		return errors.New("Thumbnail cache not exists for this file")
 	}
 }

+ 4 - 20
src/module.util.go

@@ -53,31 +53,15 @@ func util_init() {
 	})
 
 	/*
-		3D Model Viewer
+		3D Model Viewer and Gcode Viewer
 
-		Superseded by the "3D Viewer" WebApp in ./web/3D Viewer/, which
+		Both superseded by the "3D Viewer" WebApp in ./web/3D Viewer/, which
 		registers itself from its own init.agi and additionally supports
-		GLB/glTF, PLY, 3MF, FBX, DAE and STEP/IGES/BREP.
+		GLB/glTF, PLY, 3MF, FBX, DAE, STEP/IGES/BREP and sliced G-code.
 	*/
 
 	/*
-		Gcode File Viewer - Plotted from ArOZ Online Beta
-	*/
-	moduleHandler.RegisterModule(module.ModuleInfo{
-		Name:         "Gcode Viewer",
-		Desc:         "Gcode Toolpath Viewer",
-		Group:        "Utilities",
-		IconPath:     "SystemAO/utilities/img/gcodeViewer.png",
-		Version:      "1.0",
-		SupportFW:    false,
-		SupportEmb:   true,
-		LaunchEmb:    "SystemAO/utilities/gcodeViewer.html",
-		InitEmbSize:  []int{720, 500},
-		SupportedExt: []string{".gcode", ".gco"},
-	})
-
-	/*
-		Gcode File Viewer - Plotted from ArOZ Online Beta
+		Image Paste
 	*/
 	moduleHandler.RegisterModule(module.ModuleInfo{
 		Name:         "Image Paste",

+ 87 - 0
src/web/3D Viewer/css/style.css

@@ -577,6 +577,87 @@ body.dark .stage-overlay {
     padding: 7px 13px;
 }
 
+/* ---- layer height cut (G-code only) ---- */
+
+.layerbar {
+    position: absolute;
+    right: 14px;
+    top: 50%;
+    transform: translateY(-50%);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 8px;
+    padding: 12px 8px;
+    background: var(--card);
+    border: 1px solid var(--card-line);
+    border-radius: var(--radius);
+    box-shadow: var(--card-shadow);
+    max-height: calc(100% - 130px);
+}
+
+.layerbar[hidden] {
+    display: none;
+}
+
+.layer-readout {
+    font-size: 11.5px;
+    font-variant-numeric: tabular-nums;
+    color: var(--muted);
+    white-space: nowrap;
+}
+
+.layer-caption {
+    font-size: 11.5px;
+    font-weight: 600;
+    color: var(--muted2);
+}
+
+/* A vertical range input: writing-mode is the modern way to turn a range
+   around, and rtl puts the maximum (the top of the print) at the top. */
+#layerRange {
+    writing-mode: vertical-lr;
+    direction: rtl;
+    width: 20px;
+    /* the shared input[type=range] rule sets flex:1, and its zero basis would
+       beat the height below and collapse the track to its intrinsic minimum */
+    flex: 0 0 auto;
+    height: clamp(150px, 42vh, 340px);
+    margin: 0;
+}
+
+#layerRange::-webkit-slider-runnable-track {
+    width: 4px;
+    height: 100%;
+    border-radius: 2px;
+    background: var(--track);
+}
+
+#layerRange::-moz-range-track {
+    width: 4px;
+    border-radius: 2px;
+    background: var(--track);
+}
+
+#layerRange::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    width: 15px;
+    height: 15px;
+    margin-left: -5.5px;
+    border-radius: 50%;
+    background: var(--card);
+    border: 4px solid var(--accent);
+    box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
+}
+
+#layerRange::-moz-range-thumb {
+    width: 15px;
+    height: 15px;
+    border-radius: 50%;
+    background: var(--card);
+    border: 4px solid var(--accent);
+}
+
 /* ============================== right panel widgets ============================== */
 
 .select-wrap {
@@ -918,6 +999,12 @@ body.zen #workspace {
     .viewbar .tool {
         padding: 6px 10px;
     }
+
+    .layerbar {
+        right: 8px;
+        padding: 9px 6px;
+        max-height: calc(100% - 100px);
+    }
 }
 
 @media (max-width: 560px) {

BIN
src/web/3D Viewer/img/desktop_icon.png


+ 9 - 1
src/web/3D Viewer/index.html

@@ -244,7 +244,7 @@
                     <div class="stage-overlay" id="emptyState">
                         <svg class="ic big"><use href="#ic-view-iso" /></svg>
                         <p>Open a 3D model to begin.</p>
-                        <p class="hint">STL &middot; OBJ &middot; GLB &middot; glTF &middot; PLY &middot; 3MF &middot; FBX &middot; DAE &middot; STEP &middot; IGES &middot; BREP</p>
+                        <p class="hint">STL &middot; OBJ &middot; GLB &middot; glTF &middot; PLY &middot; 3MF &middot; FBX &middot; DAE &middot; STEP &middot; IGES &middot; BREP &middot; G-code</p>
                         <button class="ghost-btn" id="btnOpen">
                             <svg class="ic"><use href="#ic-folder" /></svg><span>Open file</span>
                         </button>
@@ -264,6 +264,14 @@
                         </button>
                     </div>
 
+                    <!-- layer height cut, G-code only -->
+                    <div class="layerbar" id="layerBar" hidden>
+                        <div class="layer-readout" id="layerReadout">&mdash;</div>
+                        <input type="range" id="layerRange" min="0" max="1" step="0.01" value="1"
+                            aria-label="Fade layers above this height">
+                        <div class="layer-caption">Z</div>
+                    </div>
+
                     <!-- view presets -->
                     <div class="viewbar" id="viewPresets">
                         <button class="tool" data-view="top">

+ 4 - 2
src/web/3D Viewer/init.agi

@@ -12,7 +12,7 @@
 
 var moduleLaunchInfo = {
     Name: "3D Viewer",
-    Desc: "View STL, OBJ, GLB, STEP and other 3D model files",
+    Desc: "View STL, OBJ, GLB, STEP, G-code and other 3D model files",
     Group: "Utilities",
     IconPath: "3D Viewer/img/module_icon.svg",
     Version: "1.0",
@@ -36,7 +36,9 @@ var moduleLaunchInfo = {
         ".stp",
         ".iges",
         ".igs",
-        ".brep"
+        ".brep",
+        ".gcode",
+        ".gco"
     ]
 }
 

+ 312 - 12
src/web/3D Viewer/js/app.js

@@ -19,6 +19,16 @@ import { loadModel, extOf, FORMATS, isSupported } from './formats.js';
 
 const DEFAULT_COLOR = 0xd9d332;
 
+// Travel moves in a sliced toolpath: visible enough to read, quiet enough not
+// to bury the printed material under a hairball of lines.
+const TRAVEL_MOVE_COLOR = 0x9aa0a6;
+
+// Fixed studio light the toolpath shading is baked against. Weighted towards
+// one horizontal axis so that two walls meeting at a corner land far apart in
+// brightness instead of both sitting near the middle of the range.
+const TOOLPATH_LIGHT = new THREE.Vector3(-0.82, -0.26, 0.51).normalize();
+const TOOLPATH_AMBIENT = 0.3;
+
 const PALETTE = [
     0xd9d332, 0xe8a33d, 0xd9534f, 0xc45bb0, 0x7a5cd1, 0x3f7fd6,
     0x35a7b5, 0x4caf72, 0x8bc34a, 0xb5651d, 0xe6e6e6, 0x8e9299,
@@ -65,6 +75,9 @@ const dom = {
     leftPanel: $id('leftPanel'),
     scrim: $id('scrim'),
     viewPresets: $id('viewPresets'),
+    layerBar: $id('layerBar'),
+    layerRange: $id('layerRange'),
+    layerReadout: $id('layerReadout'),
     emptyState: $id('emptyState'),
     loadingState: $id('loadingState'),
     loadingText: $id('loadingText'),
@@ -90,6 +103,9 @@ const state = {
     model: null,          // THREE.Object3D currently in the scene
     modelInfo: null,      // { format, ext, bytes }
     geometryOnly: false,  // model carries no materials of its own
+    toolpath: false,      // model is a sliced G-code toolpath, not a surface
+    toolpathZ: { min: 0, max: 1 },  // printed height range, drives the layer cut
+    layerCut: Infinity,   // fade everything printed above this height
     viewMode: 'solid',
     modelColor: DEFAULT_COLOR,
     navMode: 'rotate',
@@ -313,6 +329,207 @@ function clearModel() {
     Place a freshly parsed object into the Z-up world: convert its up axis,
     drop its origin onto the world origin and remember its real world size.
 */
+/*
+    Give a sliced toolpath some form.
+
+    G-code is unlit line geometry, so a dense print renders as one flat
+    silhouette with no way to tell a face from an edge. There is nothing to cast
+    a real shadow with - a line has no surface - so the shading is baked into a
+    vertex color attribute instead, which LineBasicMaterial multiplies against
+    the model color at no extra draw cost. Two cues are combined:
+
+      facet shading   an extrusion runs along the surface it is printing, so the
+                      wall it belongs to faces perpendicular to the path.
+                      cross(tangent, up) recovers that facing, and lighting it
+                      from a fixed direction makes the two walls that meet at a
+                      corner land at clearly different brightness.
+      height gradient a gentle top-brighter ramp, the "lit from above" cue that
+                      separates the top of the print from its base.
+
+    The light is fixed in model space rather than following the camera, so the
+    print keeps a consistent solid appearance while it is orbited.
+*/
+function bakeToolpathShading(root) {
+    const targets = [];
+    let minZ = Infinity;
+    let maxZ = -Infinity;
+
+    root.traverse(function (child) {
+        if (!child.isLineSegments || !child.material || child.material.name !== 'extruded') return;
+        const position = child.geometry.getAttribute('position');
+        if (!position) return;
+        targets.push(child);
+        for (let i = 0; i < position.count; i++) {
+            const z = position.getZ(i);
+            if (z < minZ) minZ = z;
+            if (z > maxZ) maxZ = z;
+        }
+    });
+    if (targets.length === 0) return;
+
+    //the layer cut slider spans the same range as the printed material
+    state.toolpathZ = { min: minZ, max: maxZ };
+
+    const span = Math.max(maxZ - minZ, 1e-6);
+    const tangent = new THREE.Vector3();
+    const normal = new THREE.Vector3();
+    const up = new THREE.Vector3(0, 0, 1);
+
+    for (let t = 0; t < targets.length; t++) {
+        const geometry = targets[t].geometry;
+        const position = geometry.getAttribute('position');
+        const shade = new Float32Array(position.count * 3);
+
+        for (let i = 0; i + 1 < position.count; i += 2) {
+            tangent.set(
+                position.getX(i + 1) - position.getX(i),
+                position.getY(i + 1) - position.getY(i),
+                position.getZ(i + 1) - position.getZ(i)
+            );
+
+            normal.crossVectors(tangent, up);
+            if (normal.lengthSq() < 1e-10) {
+                //a purely vertical move has no wall to face; treat it as facing
+                //up so seams and z hops stay bright instead of flickering black
+                normal.copy(up);
+            } else {
+                normal.normalize();
+            }
+
+            //two sided: a wall should read the same whichever way the head ran
+            const facet = Math.abs(normal.dot(TOOLPATH_LIGHT));
+            const height = ((position.getZ(i) + position.getZ(i + 1)) * 0.5 - minZ) / span;
+            const value = Math.min(
+                (TOOLPATH_AMBIENT + (1 - TOOLPATH_AMBIENT) * facet) * (0.8 + 0.3 * height),
+                1
+            );
+
+            shade[i * 3] = shade[i * 3 + 1] = shade[i * 3 + 2] = value;
+            shade[i * 3 + 3] = shade[i * 3 + 4] = shade[i * 3 + 5] = value;
+        }
+
+        geometry.setAttribute('color', new THREE.BufferAttribute(shade, 3));
+        targets[t].material.vertexColors = true;
+        targets[t].material.needsUpdate = true;
+    }
+}
+
+/*
+    Layer height cut.
+
+    Everything at or below the cut height draws normally; everything above it
+    draws as a translucent ghost, so the inside of a print can be inspected
+    without losing the context of what sits on top of it.
+
+    This needs two passes rather than one blended material: a single pass would
+    have to write depth for the faded layers as well, and those are the layers
+    nearest the camera when looking down into a print - they would hide exactly
+    the interior the cut is meant to reveal. So the geometry is drawn twice,
+    sharing one buffer, with each pass discarding the half it does not own. The
+    solid pass keeps normal depth behaviour, and the ghost pass blends without
+    writing depth.
+*/
+function installToolpathCutoff(material, isGhost) {
+    material.userData.cutoff = { value: Infinity };
+    material.onBeforeCompile = function (shader) {
+        shader.uniforms.uCutoff = material.userData.cutoff;
+        shader.vertexShader = 'varying float vLayerZ;\n' + shader.vertexShader.replace(
+            '#include <begin_vertex>',
+            '#include <begin_vertex>\n\tvLayerZ = position.z;'
+        );
+        shader.fragmentShader = 'uniform float uCutoff;\nvarying float vLayerZ;\n' + shader.fragmentShader.replace(
+            '#include <color_fragment>',
+            '#include <color_fragment>\n\tif (' + (isGhost ? 'vLayerZ <= uCutoff' : 'vLayerZ > uCutoff') + ') discard;'
+        );
+    };
+    // the two variants compile to different programs from otherwise identical
+    // material settings, so they need distinct cache keys
+    material.customProgramCacheKey = function () {
+        return isGhost ? 'toolpath-ghost' : 'toolpath-solid';
+    };
+    material.needsUpdate = true;
+}
+
+function buildToolpathCutPasses(root) {
+    const ghosts = [];
+
+    root.traverse(function (child) {
+        if (!child.isLineSegments || !child.material || child.userData.toolpathGhost) return;
+
+        installToolpathCutoff(child.material, false);
+
+        const ghostMaterial = child.material.clone();
+        ghostMaterial.name = child.material.name;
+        ghostMaterial.transparent = true;
+        ghostMaterial.depthWrite = false;
+        installToolpathCutoff(ghostMaterial, true);
+
+        const ghost = new THREE.LineSegments(child.geometry, ghostMaterial);
+        ghost.userData.toolpathGhost = true;
+        ghost.renderOrder = 1;
+        ghost.visible = false;
+        ghosts.push({ parent: child.parent, ghost: ghost });
+    });
+
+    for (let i = 0; i < ghosts.length; i++) ghosts[i].parent.add(ghosts[i].ghost);
+}
+
+function setLayerCut(height) {
+    if (!state.model) return;
+    state.layerCut = height;
+    const cutting = height < state.toolpathZ.max;
+
+    state.model.traverse(function (child) {
+        if (!child.isLineSegments || !child.material) return;
+        if (child.material.userData.cutoff) child.material.userData.cutoff.value = height;
+        if (child.userData.toolpathGhost) {
+            //nothing to ghost while the cut sits at the top of the print
+            child.visible = cutting && child.material.name !== 'path';
+        }
+    });
+
+    dom.layerReadout.textContent = height.toFixed(1) + ' mm';
+    needsRender = true;
+}
+
+function setupLayerBar() {
+    if (!state.toolpath) {
+        dom.layerBar.hidden = true;
+        return;
+    }
+    const min = state.toolpathZ.min;
+    const max = state.toolpathZ.max;
+    dom.layerRange.min = min;
+    dom.layerRange.max = max;
+    dom.layerRange.step = Math.max((max - min) / 500, 0.001);
+    dom.layerRange.value = max;
+    dom.layerBar.hidden = false;
+    setLayerCut(max);
+}
+
+/*
+    Bounding box of what the model actually is. For a sliced toolpath the
+    travel moves sweep the whole bed, so measuring and framing against them
+    would report the printer's bed size instead of the part and leave the print
+    tiny on screen; only the extruded material counts.
+*/
+function modelBounds(root) {
+    if (!state.toolpath) return new THREE.Box3().setFromObject(root);
+
+    const box = new THREE.Box3();
+    root.traverse(function (child) {
+        if (!child.isLineSegments || !child.material || child.material.name !== 'extruded') return;
+        const position = child.geometry.getAttribute('position');
+        if (!position) return;
+        child.updateWorldMatrix(true, false);
+        const segment = new THREE.Box3().setFromBufferAttribute(position);
+        segment.applyMatrix4(child.matrixWorld);
+        box.union(segment);
+    });
+
+    return box.isEmpty() ? new THREE.Box3().setFromObject(root) : box;
+}
+
 function placeModel(object, format) {
     clearModel();
 
@@ -328,7 +545,7 @@ function placeModel(object, format) {
     root.add(pivot);
     root.updateMatrixWorld(true);
 
-    const box = new THREE.Box3().setFromObject(root);
+    const box = modelBounds(root);
     const size = box.getSize(new THREE.Vector3());
     const center = box.getCenter(new THREE.Vector3());
 
@@ -341,11 +558,21 @@ function placeModel(object, format) {
             child.castShadow = true;
             child.receiveShadow = true;
         }
+        // Tone down the loader's stock bright red travel moves; they are
+        // context, not the print itself.
+        if (state.toolpath && child.material && child.material.name === 'path') {
+            child.material.color.setHex(TRAVEL_MOVE_COLOR);
+        }
     });
 
+    if (state.toolpath) {
+        bakeToolpathShading(root);
+        buildToolpathCutPasses(root);
+    }
+
     scene.add(root);
     state.model = root;
-    state.radius = Math.max(new THREE.Box3().setFromObject(root).getBoundingSphere(new THREE.Sphere()).radius, 0.0001);
+    state.radius = Math.max(modelBounds(root).getBoundingSphere(new THREE.Sphere()).radius, 0.0001);
 
     // clip planes and control limits scale with the model
     camera.near = state.radius / 400;
@@ -362,6 +589,15 @@ function placeModel(object, format) {
     return size;
 }
 
+/*
+    The ground shadow only makes sense under an opaque surface model: a
+    wireframe or see-through body casting a solid shadow reads as a bug, and a
+    G-code toolpath has no surfaces to cast one at all.
+*/
+function shadowPlaneShouldShow() {
+    return state.shadows && state.model !== null && state.viewMode === 'solid' && !state.toolpath;
+}
+
 function layoutLights() {
     const r = Math.max(state.radius, 0.0001);
     const height = r * 0.9;
@@ -385,25 +621,75 @@ function layoutLights() {
 
     shadowPlane.scale.set(r * 14, r * 14, 1);
     shadowPlane.position.set(0, 0, -r * 0.002);
-    shadowPlane.visible = state.shadows && state.model !== null && state.viewMode === 'solid';
+    shadowPlane.visible = shadowPlaneShouldShow();
 
     applyLighting();
 }
 
 /* ---- materials ---- */
 
+/*
+    Walk every material the loaded model owns. Line geometry counts too - a
+    G-code toolpath is nothing but lines - while the viewer's own x-ray edge
+    overlay is skipped so it keeps its highlight color.
+*/
 function eachMaterial(callback) {
     if (!state.model) return;
     state.model.traverse(function (child) {
-        if (!child.isMesh || !child.material) return;
+        if (!child.material || child.userData.xrayEdges) return;
         const list = Array.isArray(child.material) ? child.material : [child.material];
         for (let i = 0; i < list.length; i++) callback(list[i], child);
     });
 }
 
+/*
+    A sliced toolpath is line geometry, so the three view modes are mapped onto
+    what is meaningful for it rather than onto surface shading:
+
+        Solid     - printed material only, which is what the part will look like
+        Wireframe - also draws the travel moves the head makes between them
+        X-Ray     - the same, drawn translucent so inner perimeters show through
+*/
+/*
+    A sliced toolpath is line geometry, so the three view modes are mapped onto
+    what is meaningful for it rather than onto surface shading:
+
+        Solid     - printed material only, which is what the part will look like
+        Wireframe - also draws the travel moves the head makes between them
+        X-Ray     - the same, drawn translucent so inner perimeters show through
+*/
+function applyToolpathViewMode(mode) {
+    state.model.traverse(function (child) {
+        if (!child.isLineSegments || !child.material) return;
+        const isTravel = (child.material.name === 'path');
+        child.visible = isTravel ? (mode !== 'solid') : true;
+
+        if (child.userData.toolpathGhost) {
+            //the ghost pass owns its own blending; only its strength follows
+            //the view mode, and setLayerCut decides whether it draws at all
+            child.material.opacity = (mode === 'xray') ? 0.05 : 0.13;
+            child.material.needsUpdate = true;
+            return;
+        }
+        child.material.transparent = (mode === 'xray');
+        //thousands of overlapping extrusions accumulate back to opaque, so the
+        //x-ray opacity has to go a lot lower here than it does for a surface
+        child.material.opacity = (mode === 'xray') ? (isTravel ? 0.12 : 0.1) : 1;
+        child.material.depthWrite = (mode !== 'xray');
+        child.material.needsUpdate = true;
+    });
+    shadowPlane.visible = false;
+    needsRender = true;
+}
+
 function applyViewMode() {
     if (!state.model) return;
 
+    if (state.toolpath) {
+        applyToolpathViewMode(state.viewMode);
+        return;
+    }
+
     // remove any x-ray edge overlay from a previous mode
     const stale = [];
     state.model.traverse(function (child) { if (child.userData.xrayEdges) stale.push(child); });
@@ -420,7 +706,7 @@ function applyViewMode() {
     state.model.traverse(function (child) {
         if (child.isMesh) child.castShadow = (mode === 'solid');
     });
-    shadowPlane.visible = state.shadows && mode === 'solid';
+    shadowPlane.visible = shadowPlaneShouldShow();
 
     eachMaterial(function (material) {
         material.wireframe = (mode === 'wireframe');
@@ -449,17 +735,20 @@ function applyViewMode() {
 }
 
 /*
-    Apply the model color. Geometry-only formats (STL, PLY) always take it;
-    formats that ship their own materials only take it once the user has picked
-    a color explicitly, so a textured GLB opens looking the way it was authored.
+    Apply the model color. Geometry-only formats (STL, PLY, G-code) always take
+    it; formats that ship their own materials only take it once the user has
+    picked a color explicitly, so a textured GLB opens looking the way it was
+    authored.
 */
 function applyModelColor(userPicked) {
     if (!state.model) return;
     if (!state.geometryOnly && !userPicked) return;
 
     eachMaterial(function (material) {
+        // travel moves are not printed material, so they keep their own color
+        if (state.toolpath && material.name === 'path') return;
         if (material.color) material.color.setHex(state.modelColor);
-        if (state.geometryOnly) {
+        if (state.geometryOnly && material.roughness !== undefined) {
             material.roughness = 0.42;
             material.metalness = 0.05;
         }
@@ -491,7 +780,7 @@ function applyBackground() {
 function applyShadows() {
     renderer.shadowMap.enabled = state.shadows;
     keyLight.castShadow = state.shadows;
-    shadowPlane.visible = state.shadows && state.model !== null && state.viewMode === 'solid';
+    shadowPlane.visible = shadowPlaneShouldShow();
     eachMaterial(function (material) { material.needsUpdate = true; });
     needsRender = true;
 }
@@ -502,7 +791,7 @@ function applyShadows() {
 
 function frameModel(direction) {
     if (!state.model) return;
-    const box = new THREE.Box3().setFromObject(state.model);
+    const box = modelBounds(state.model);
     const sphere = box.getBoundingSphere(new THREE.Sphere());
 
     // Fit against whichever field of view is tighter, so a wide model still
@@ -527,7 +816,7 @@ function frameModel(direction) {
 
 function centerModel() {
     if (!state.model) return;
-    const box = new THREE.Box3().setFromObject(state.model);
+    const box = modelBounds(state.model);
     const center = box.getCenter(new THREE.Vector3());
     const offset = camera.position.clone().sub(controls.target);
     controls.target.copy(center);
@@ -663,9 +952,11 @@ async function openSource(source) {
         });
 
         state.geometryOnly = !result.ownMaterials;
+        state.toolpath = result.format.toolpath === true;
         state.modelInfo = result;
 
         const size = placeModel(result.object, result.format).multiplyScalar(result.format.unitToMM || 1);
+        setupLayerBar();
         dom.fileDim.textContent = formatDimension(size);
         dom.fileSize.textContent = formatBytes(result.bytes);
         if (source.filepath) fetchStoredFileSize(source.filepath);
@@ -674,6 +965,7 @@ async function openSource(source) {
     } catch (err) {
         console.error('[3D Viewer] load failed', err);
         clearModel();
+        dom.layerBar.hidden = true;
         showOverlay('error', (err && err.message) || 'Could not load this model.');
     }
 }
@@ -813,6 +1105,10 @@ function initUI() {
         modeButtons[i].addEventListener('click', function () { setNavMode(this.dataset.nav); });
     }
 
+    dom.layerRange.addEventListener('input', function () {
+        setLayerCut(Number(this.value));
+    });
+
     dom.viewPresets.addEventListener('click', function (e) {
         const button = e.target.closest('.tool');
         if (!button) return;
@@ -820,6 +1116,10 @@ function initUI() {
     });
 
     dom.btnReset.addEventListener('click', function () {
+        if (state.toolpath) {
+            dom.layerRange.value = state.toolpathZ.max;
+            setLayerCut(state.toolpathZ.max);
+        }
         setNavMode('rotate');
         setView('iso');
         closeDrawers();

+ 17 - 1
src/web/3D Viewer/js/formats.js

@@ -19,6 +19,7 @@ import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
 import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
 import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
 import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
+import { GCodeLoader } from 'three/addons/loaders/GCodeLoader.js';
 
 /*
     Supported extensions.
@@ -48,7 +49,12 @@ export const FORMATS = {
     stp: { label: 'STEP', up: 'Z', ownMaterials: true, unitToMM: 1 },
     iges: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
     igs: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
-    brep: { label: 'BREP', up: 'Z', ownMaterials: true, unitToMM: 1 }
+    brep: { label: 'BREP', up: 'Z', ownMaterials: true, unitToMM: 1 },
+    // Sliced toolpaths. GCodeLoader rotates its own root a quarter turn to hand
+    // back a Y-up object, so declaring "Y" here makes the viewer's pivot cancel
+    // that back out and the print stands up the way it was sliced.
+    gcode: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true },
+    gco: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true }
 };
 
 export function extOf(filename) {
@@ -302,6 +308,11 @@ export async function loadModel(source, options) {
         ? 'Tessellating CAD geometry...'
         : 'Parsing model...');
 
+    // Parsing runs on the main thread and a big model (or a long toolpath) can
+    // hold it for a while, so yield once and let the progress overlay paint
+    // before the browser goes quiet.
+    await new Promise(function (resolve) { setTimeout(resolve, 0); });
+
     const manager = makeManager(source.resolveSibling);
     let object;
     // Most formats always carry their own materials; the CAD path decides per
@@ -347,6 +358,11 @@ export async function loadModel(source, options) {
             object = new ColladaLoader(manager).parse(decodeText(buffer), '').scene;
             break;
 
+        case 'gcode':
+        case 'gco':
+            object = new GCodeLoader(manager).parse(decodeText(buffer));
+            break;
+
         case 'step':
         case 'stp':
         case 'iges':

+ 318 - 0
src/web/3D Viewer/lib/three/addons/loaders/GCodeLoader.js

@@ -0,0 +1,318 @@
+import {
+	BufferGeometry,
+	FileLoader,
+	Float32BufferAttribute,
+	Group,
+	LineBasicMaterial,
+	LineSegments,
+	Loader
+} from 'three';
+
+/**
+ * A loader for the GCode format.
+ *
+ * GCode files are usually used for 3D printing or CNC applications.
+ *
+ * ```js
+ * const loader = new GCodeLoader();
+ * const object = await loader.loadAsync( 'models/gcode/benchy.gcode' );
+ * scene.add( object );
+ * ```
+ *
+ * @augments Loader
+ * @three_import import { GCodeLoader } from 'three/addons/loaders/GCodeLoader.js';
+ */
+class GCodeLoader extends Loader {
+
+	/**
+	 * Constructs a new GCode loader.
+	 *
+	 * @param {LoadingManager} [manager] - The loading manager.
+	 */
+	constructor( manager ) {
+
+		super( manager );
+
+		/**
+		 * Whether to split layers or not.
+		 *
+		 * @type {boolean}
+		 * @default false
+		 */
+		this.splitLayer = false;
+
+	}
+
+	/**
+	 * Starts loading from the given URL and passes the loaded GCode asset
+	 * to the `onLoad()` callback.
+	 *
+	 * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
+	 * @param {function(Group)} onLoad - Executed when the loading process has been finished.
+	 * @param {onProgressCallback} onProgress - Executed while the loading is in progress.
+	 * @param {onErrorCallback} onError - Executed when errors occur.
+	 */
+	load( url, onLoad, onProgress, onError ) {
+
+		const scope = this;
+
+		const loader = new FileLoader( scope.manager );
+		loader.setPath( scope.path );
+		loader.setRequestHeader( scope.requestHeader );
+		loader.setWithCredentials( scope.withCredentials );
+		loader.load( url, function ( text ) {
+
+			try {
+
+				onLoad( scope.parse( text ) );
+
+			} catch ( e ) {
+
+				if ( onError ) {
+
+					onError( e );
+
+				} else {
+
+					console.error( e );
+
+				}
+
+				scope.manager.itemError( url );
+
+			}
+
+		}, onProgress, onError );
+
+	}
+
+	/**
+	 * Parses the given GCode data and returns a group with lines.
+	 *
+	 * @param {string} data - The raw Gcode data as a string.
+	 * @return {Group} The parsed GCode asset.
+	 */
+	parse( data ) {
+
+		let state = { x: 0, y: 0, z: 0, e: 0, f: 0, extruding: false, relative: false, extrusionOverride: false, extrusionRelative: false };
+		const layers = [];
+
+		let currentLayer = undefined;
+
+		const pathMaterial = new LineBasicMaterial( { color: 0xFF0000 } );
+		pathMaterial.name = 'path';
+
+		const extrudingMaterial = new LineBasicMaterial( { color: 0x00FF00 } );
+		extrudingMaterial.name = 'extruded';
+
+		function newLayer( line ) {
+
+			currentLayer = { vertex: [], pathVertex: [], z: line.z };
+			layers.push( currentLayer );
+
+		}
+
+		//Create lie segment between p1 and p2
+		function addSegment( p1, p2 ) {
+
+			if ( currentLayer === undefined ) {
+
+				newLayer( p1 );
+
+			}
+
+			if ( state.extruding ) {
+
+				currentLayer.vertex.push( p1.x, p1.y, p1.z );
+				currentLayer.vertex.push( p2.x, p2.y, p2.z );
+
+			} else {
+
+				currentLayer.pathVertex.push( p1.x, p1.y, p1.z );
+				currentLayer.pathVertex.push( p2.x, p2.y, p2.z );
+
+			}
+
+		}
+
+		function delta( v1, v2 ) {
+
+			return state.relative ? v2 : v2 - v1;
+
+		}
+
+		function absolute( v1, v2 ) {
+
+			return state.relative ? v1 + v2 : v2;
+
+		}
+
+		function absoluteExtrusion( v1, v2 ) {
+
+			const relative = state.extrusionOverride ? state.extrusionRelative : state.relative;
+
+			return relative ? v1 + v2 : v2;
+
+		}
+
+		const lines = data.replace( /;.+/g, '' ).split( '\n' );
+
+		for ( let i = 0; i < lines.length; i ++ ) {
+
+			const tokens = lines[ i ].split( ' ' );
+			const cmd = tokens[ 0 ].toUpperCase();
+
+			//Arguments
+			const args = {};
+			tokens.splice( 1 ).forEach( function ( token ) {
+
+				if ( token[ 0 ] !== undefined ) {
+
+					const key = token[ 0 ].toLowerCase();
+					const value = parseFloat( token.substring( 1 ) );
+					args[ key ] = value;
+
+				}
+
+			} );
+
+			//Process commands
+			//G0/G1 – Linear Movement
+			if ( cmd === 'G0' || cmd === 'G1' ) {
+
+				const line = Object.assign( {}, state ); // clone state
+
+				if ( args.x !== undefined ) line.x = absolute( state.x, args.x );
+				if ( args.y !== undefined ) line.y = absolute( state.y, args.y );
+				if ( args.z !== undefined ) line.z = absolute( state.z, args.z );
+				if ( args.e !== undefined ) line.e = absoluteExtrusion( state.e, args.e );
+				if ( args.f !== undefined ) line.f = absolute( state.f, args.f );
+
+				//Layer change detection is or made by watching Z, it's made by watching when we extrude at a new Z position
+				if ( delta( state.e, line.e ) > 0 ) {
+
+					state.extruding = delta( state.e, line.e ) > 0;
+
+					if ( currentLayer == undefined || line.z != currentLayer.z ) {
+
+						newLayer( line );
+
+					}
+
+				}
+
+				addSegment( state, line );
+				state = line;
+
+			} else if ( cmd === 'G2' || cmd === 'G3' ) {
+
+				//G2/G3 - Arc Movement ( G2 clock wise and G3 counter clock wise )
+				//console.warn( 'THREE.GCodeLoader: Arc command not supported' );
+
+			} else if ( cmd === 'G90' ) {
+
+				//G90: Set to Absolute Positioning
+				state.relative = false;
+
+				// reset M82/M83 extrusion override
+				state.extrusionOverride = false;
+
+			} else if ( cmd === 'G91' ) {
+
+				//G91: Set to state.relative Positioning
+				state.relative = true;
+
+				// reset M82/M83 extrusion override
+				state.extrusionOverride = false;
+
+			} else if ( cmd === 'M82' ) {
+
+				//M82: Override G91 and put the E axis into absolute mode independent of the other axes
+				state.extrusionOverride = true;
+				state.extrusionRelative = false;
+
+			} else if ( cmd === 'M83' ) {
+
+				//M83: Overrides G90 and put the E axis into relative mode independent of the other axes
+				state.extrusionOverride = true;
+				state.extrusionRelative = true;
+
+			} else if ( cmd === 'G92' ) {
+
+				//G92: Set Position
+				const line = state;
+				line.x = args.x !== undefined ? args.x : line.x;
+				line.y = args.y !== undefined ? args.y : line.y;
+				line.z = args.z !== undefined ? args.z : line.z;
+				line.e = args.e !== undefined ? args.e : line.e;
+
+			} else {
+
+				//console.warn( 'THREE.GCodeLoader: Command not supported:' + cmd );
+
+			}
+
+		}
+
+		function addObject( vertex, extruding, i ) {
+
+			const geometry = new BufferGeometry();
+			geometry.setAttribute( 'position', new Float32BufferAttribute( vertex, 3 ) );
+			const segments = new LineSegments( geometry, extruding ? extrudingMaterial : pathMaterial );
+			segments.name = 'layer' + i;
+			object.add( segments );
+
+		}
+
+		const object = new Group();
+		object.name = 'gcode';
+
+		if ( this.splitLayer ) {
+
+			for ( let i = 0; i < layers.length; i ++ ) {
+
+				const layer = layers[ i ];
+				addObject( layer.vertex, true, i );
+				addObject( layer.pathVertex, false, i );
+
+			}
+
+		} else {
+
+			const vertex = [],
+				pathVertex = [];
+
+			for ( let i = 0; i < layers.length; i ++ ) {
+
+				const layer = layers[ i ];
+				const layerVertex = layer.vertex;
+				const layerPathVertex = layer.pathVertex;
+
+				for ( let j = 0; j < layerVertex.length; j ++ ) {
+
+					vertex.push( layerVertex[ j ] );
+
+				}
+
+				for ( let j = 0; j < layerPathVertex.length; j ++ ) {
+
+					pathVertex.push( layerPathVertex[ j ] );
+
+				}
+
+			}
+
+			addObject( vertex, true, layers.length );
+			addObject( pathVertex, false, layers.length );
+
+		}
+
+		object.rotation.set( - Math.PI / 2, 0, 0 );
+
+		return object;
+
+	}
+
+}
+
+export { GCodeLoader };

+ 0 - 125
src/web/SystemAO/utilities/gcodeViewer.html

@@ -1,125 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-	<head>
-		<title>Gcode Viewer</title>
-		<meta charset="utf-8">
-		<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
-		<!-- Modified from THREE.JS Gcode loader example-->
-		<style>
-			body {
-				font-family: Monospace;
-				background-color: #FFFFFF;
-				margin: 0px;
-				overflow: hidden;
-			}
-			#infotab{
-				position:fixed;
-				z-index:999;
-				right:10px;
-				bottom:0px;
-				max-width:480px;
-				word-break: break-all;
-				color:white;
-			}
-		</style>
-	</head>
-	<body>
-		<script src="script/Threejs/build/three.js"></script>
-		<script src="script/Threejs/OrbitControls.js"></script>
-		<script src="script/Threejs/GCodeLoader.js"></script>
-		<script src="../../script/jquery.min.js"></script>
-		<script src="../../script/ao_module.js"></script>
-		<div id="infotab">
-			<p id="filename"></p>
-			<p id="filepath" style="display:none;"></p>
-			<p id="displayfilepath"></p>
-			<p id="filesize"></p>
-		</div>
-		<script>
-			//Get file information from the hash info
-			var files = ao_module_loadInputFiles();
-			var file = "";
-			if (files.length > 0){
-				file = files[0];
-				$("#filename").text(file.filename);
-				$("#filepath").text("../../media?file=" + file.filepath);
-				$("#displayfilepath").text(file.filepath);
-				//Get filesize info
-				$.ajax({
-					url: "../../system/file_system/getProperties", 
-					data: {path: file.filepath},
-					success: function(data){
-						var filesize = ao_module_utils.formatBytes(data.Filesize, 2);
-						$("#filesize").text(filesize);
-					}
-				});
-				
-			}
-
-			//ao module initiation
-			ao_module_setWindowTitle("GCODEviewer - " + $("#filename").text().trim());
-
-			var container;
-			var camera, scene, renderer;
-
-			init();
-			animate();
-
-			function init() {
-
-				container = document.createElement( 'div' );
-				document.body.appendChild( container );
-				camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 10000 );
-				camera.position.set(0,50,100);
-				
-
-				var controls = new THREE.OrbitControls( camera );
-				controls.target = new THREE.Vector3(0, 20, 0);
-				controls.update();
-				scene = new THREE.Scene();
-				
-				//Setup the background color and the platform
-				var backgroundcolor = new THREE.Color("#212121");
-				scene.background = backgroundcolor;
-				
-				var loader = new THREE.GCodeLoader();
-				loader.load( $("#filepath").text(), function ( object ) {
-				    var box = new THREE.Box3().setFromObject( object );
-                    const center = new THREE.Vector3();
-                    box.getSize(center)
-                    console.log(center)
-					object.position.set(0,0,0);
-					camera.position.set(center.x,50,center.z);
-					controls.update();
-					scene.add( object );
-					
-				} );
-				
-				renderer = new THREE.WebGLRenderer();
-				renderer.setPixelRatio( window.devicePixelRatio );
-				renderer.setSize( window.innerWidth, window.innerHeight );
-				container.appendChild( renderer.domElement );
-				window.addEventListener( 'resize', resize, false );
-			}
-			
-
-			function resize() {
-
-				camera.aspect = window.innerWidth / window.innerHeight;
-				camera.updateProjectionMatrix();
-
-				renderer.setSize( window.innerWidth, window.innerHeight );
-
-			}
-
-			function animate() {
-
-				renderer.render( scene, camera );
-
-				requestAnimationFrame( animate );
-
-			}
-		</script>
-
-	</body>
-</html>

BIN
src/web/SystemAO/utilities/img/gcodeViewer.png


+ 0 - 225
src/web/SystemAO/utilities/script/Threejs/GCodeLoader.js

@@ -1,225 +0,0 @@
-'use strict';
-
-/**
- * THREE.GCodeLoader is used to load gcode files usually used for 3D printing or CNC applications.
- *
- * Gcode files are composed by commands used by machines to create objects.
- *
- * @class THREE.GCodeLoader
- * @param {Manager} manager Loading manager.
- * @author tentone
- * @author joewalnes
- */
-THREE.GCodeLoader = function ( manager ) {
-
-	this.manager = ( manager !== undefined ) ? manager : THREE.DefaultLoadingManager;
-
-	this.splitLayer = false;
-
-};
-
-THREE.GCodeLoader.prototype.load = function ( url, onLoad, onProgress, onError ) {
-
-	var self = this;
-
-	var loader = new THREE.FileLoader( self.manager );
-	loader.setPath( self.path );
-	loader.load( url, function ( text ) {
-
-		onLoad( self.parse( text ) );
-
-	}, onProgress, onError );
-
-};
-
-THREE.GCodeLoader.prototype.setPath = function ( value ) {
-
-	this.path = value;
-	return this;
-
-};
-
-THREE.GCodeLoader.prototype.parse = function ( data ) {
-
-	var state = { x: 0, y: 0, z: 0, e: 0, f: 0, extruding: false, relative: false };
-	var layers = [];
-
-	var currentLayer = undefined;
-
-	var pathMaterial = new THREE.LineBasicMaterial( { color: 0x0d5ed8,linewidth: 3.0 } );
-	pathMaterial.name = 'path';
-
-	var extrudingMaterial = new THREE.LineBasicMaterial( { color: 0xd8c70d,linewidth: 3.0} );
-	extrudingMaterial.name = 'extruded';
-
-	function newLayer( line ) {
-
-		currentLayer = { vertex: [], pathVertex: [], z: line.z };
-		layers.push( currentLayer );
-
-	}
-
-	//Create lie segment between p1 and p2
-	function addSegment( p1, p2 ) {
-
-		if ( currentLayer === undefined ) {
-
-			newLayer( p1 );
-
-		}
-
-		if ( line.extruding ) {
-
-			currentLayer.vertex.push( p1.x, p1.y, p1.z );
-			currentLayer.vertex.push( p2.x, p2.y, p2.z );
-
-		} else {
-
-			currentLayer.pathVertex.push( p1.x, p1.y, p1.z );
-			currentLayer.pathVertex.push( p2.x, p2.y, p2.z );
-
-		}
-
-	}
-
-	function delta( v1, v2 ) {
-
-		return state.relative ? v2 : v2 - v1;
-
-	}
-
-	function absolute( v1, v2 ) {
-
-		return state.relative ? v1 + v2 : v2;
-
-	}
-
-	var lines = data.replace( /;.+/g, '' ).split( '\n' );
-
-	for ( var i = 0; i < lines.length; i ++ ) {
-
-		var tokens = lines[ i ].split( ' ' );
-		var cmd = tokens[ 0 ].toUpperCase();
-
-		//Argumments
-		var args = {};
-		tokens.splice( 1 ).forEach( function ( token ) {
-
-			if ( token[ 0 ] !== undefined ) {
-
-				var key = token[ 0 ].toLowerCase();
-				var value = parseFloat( token.substring( 1 ) );
-				args[ key ] = value;
-
-			}
-
-		} );
-
-		//Process commands
-		//G0/G1 – Linear Movement
-		if ( cmd === 'G0' || cmd === 'G1' ) {
-
-			var line = {
-				x: args.x !== undefined ? absolute( state.x, args.x ) : state.x,
-				y: args.y !== undefined ? absolute( state.y, args.y ) : state.y,
-				z: args.z !== undefined ? absolute( state.z, args.z ) : state.z,
-				e: args.e !== undefined ? absolute( state.e, args.e ) : state.e,
-				f: args.f !== undefined ? absolute( state.f, args.f ) : state.f,
-			};
-
-			//Layer change detection is or made by watching Z, it's made by watching when we extrude at a new Z position
-			if ( delta( state.e, line.e ) > 0 ) {
-
-				line.extruding = delta( state.e, line.e ) > 0;
-
-				if ( currentLayer == undefined || line.z != currentLayer.z ) {
-
-					newLayer( line );
-
-				}
-
-			}
-
-			addSegment( state, line );
-			state = line;
-
-		} else if ( cmd === 'G2' || cmd === 'G3' ) {
-
-			//G2/G3 - Arc Movement ( G2 clock wise and G3 counter clock wise )
-			console.warn( 'THREE.GCodeLoader: Arc command not supported' );
-
-		} else if ( cmd === 'G90' ) {
-
-			//G90: Set to Absolute Positioning
-			state.relative = false;
-
-		} else if ( cmd === 'G91' ) {
-
-			//G91: Set to state.relative Positioning
-			state.relative = true;
-
-		} else if ( cmd === 'G92' ) {
-
-			//G92: Set Position
-			var line = state;
-			line.x = args.x !== undefined ? args.x : line.x;
-			line.y = args.y !== undefined ? args.y : line.y;
-			line.z = args.z !== undefined ? args.z : line.z;
-			line.e = args.e !== undefined ? args.e : line.e;
-			state = line;
-
-		} else {
-
-			console.warn( 'THREE.GCodeLoader: Command not supported:' + cmd );
-
-		}
-
-	}
-
-	function addObject( vertex, extruding ) {
-
-		var geometry = new THREE.BufferGeometry();
-		geometry.addAttribute( 'position', new THREE.Float32BufferAttribute( vertex, 3 ) );
-
-		var segments = new THREE.LineSegments( geometry, extruding ? extrudingMaterial : pathMaterial );
-		segments.name = 'layer' + i;
-		object.add( segments );
-
-	}
-
-	var object = new THREE.Group();
-	object.name = 'gcode';
-
-	if ( this.splitLayer ) {
-
-		for ( var i = 0; i < layers.length; i ++ ) {
-
-			var layer = layers[ i ];
-			addObject( layer.vertex, true );
-			addObject( layer.pathVertex, false );
-
-		}
-
-	} else {
-
-		var vertex = [], pathVertex = [];
-
-		for ( var i = 0; i < layers.length; i ++ ) {
-
-			var layer = layers[ i ];
-
-			vertex = vertex.concat( layer.vertex );
-			pathVertex = pathVertex.concat( layer.pathVertex );
-
-		}
-
-		addObject( vertex, true );
-		addObject( pathVertex, false );
-
-	}
-
-	object.quaternion.setFromEuler( new THREE.Euler( - Math.PI / 2, 0, 0 ) );
-
-	return object;
-
-};

+ 0 - 1062
src/web/SystemAO/utilities/script/Threejs/OrbitControls.js

@@ -1,1062 +0,0 @@
-/**
- * @author qiao / https://github.com/qiao
- * @author mrdoob / http://mrdoob.com
- * @author alteredq / http://alteredqualia.com/
- * @author WestLangley / http://github.com/WestLangley
- * @author erich666 / http://erichaines.com
- */
-
-// This set of controls performs orbiting, dollying (zooming), and panning.
-// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
-//
-//    Orbit - left mouse / touch: one-finger move
-//    Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
-//    Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
-
-THREE.OrbitControls = function ( object, domElement ) {
-
-	this.object = object;
-
-	this.domElement = ( domElement !== undefined ) ? domElement : document;
-
-	// Set to false to disable this control
-	this.enabled = true;
-
-	// "target" sets the location of focus, where the object orbits around
-	this.target = new THREE.Vector3();
-
-	// How far you can dolly in and out ( PerspectiveCamera only )
-	this.minDistance = 0;
-	this.maxDistance = Infinity;
-
-	// How far you can zoom in and out ( OrthographicCamera only )
-	this.minZoom = 0;
-	this.maxZoom = Infinity;
-
-	// How far you can orbit vertically, upper and lower limits.
-	// Range is 0 to Math.PI radians.
-	this.minPolarAngle = 0; // radians
-	this.maxPolarAngle = Math.PI; // radians
-
-	// How far you can orbit horizontally, upper and lower limits.
-	// If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ].
-	this.minAzimuthAngle = - Infinity; // radians
-	this.maxAzimuthAngle = Infinity; // radians
-
-	// Set to true to enable damping (inertia)
-	// If damping is enabled, you must call controls.update() in your animation loop
-	this.enableDamping = false;
-	this.dampingFactor = 0.25;
-
-	// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
-	// Set to false to disable zooming
-	this.enableZoom = true;
-	this.zoomSpeed = 1.0;
-
-	// Set to false to disable rotating
-	this.enableRotate = true;
-	this.rotateSpeed = 1.0;
-
-	// Set to false to disable panning
-	this.enablePan = true;
-	this.panSpeed = 1.0;
-	this.screenSpacePanning = false; // if true, pan in screen-space
-	this.keyPanSpeed = 7.0;	// pixels moved per arrow key push
-
-	// Set to true to automatically rotate around the target
-	// If auto-rotate is enabled, you must call controls.update() in your animation loop
-	this.autoRotate = false;
-	this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60
-
-	// Set to false to disable use of the keys
-	this.enableKeys = true;
-
-	// The four arrow keys
-	this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 };
-
-	// Mouse buttons
-	this.mouseButtons = { LEFT: THREE.MOUSE.LEFT, MIDDLE: THREE.MOUSE.MIDDLE, RIGHT: THREE.MOUSE.RIGHT };
-
-	// for reset
-	this.target0 = this.target.clone();
-	this.position0 = this.object.position.clone();
-	this.zoom0 = this.object.zoom;
-
-	//
-	// public methods
-	//
-
-	this.getPolarAngle = function () {
-
-		return spherical.phi;
-
-	};
-
-	this.getAzimuthalAngle = function () {
-
-		return spherical.theta;
-
-	};
-
-	this.saveState = function () {
-
-		scope.target0.copy( scope.target );
-		scope.position0.copy( scope.object.position );
-		scope.zoom0 = scope.object.zoom;
-
-	};
-
-	this.reset = function () {
-
-		scope.target.copy( scope.target0 );
-		scope.object.position.copy( scope.position0 );
-		scope.object.zoom = scope.zoom0;
-
-		scope.object.updateProjectionMatrix();
-		scope.dispatchEvent( changeEvent );
-
-		scope.update();
-
-		state = STATE.NONE;
-
-	};
-
-	// this method is exposed, but perhaps it would be better if we can make it private...
-	this.update = function () {
-
-		var offset = new THREE.Vector3();
-
-		// so camera.up is the orbit axis
-		var quat = new THREE.Quaternion().setFromUnitVectors( object.up, new THREE.Vector3( 0, 1, 0 ) );
-		var quatInverse = quat.clone().inverse();
-
-		var lastPosition = new THREE.Vector3();
-		var lastQuaternion = new THREE.Quaternion();
-
-		return function update() {
-
-			var position = scope.object.position;
-
-			offset.copy( position ).sub( scope.target );
-
-			// rotate offset to "y-axis-is-up" space
-			offset.applyQuaternion( quat );
-
-			// angle from z-axis around y-axis
-			spherical.setFromVector3( offset );
-
-			if ( scope.autoRotate && state === STATE.NONE ) {
-
-				rotateLeft( getAutoRotationAngle() );
-
-			}
-
-			spherical.theta += sphericalDelta.theta;
-			spherical.phi += sphericalDelta.phi;
-
-			// restrict theta to be between desired limits
-			spherical.theta = Math.max( scope.minAzimuthAngle, Math.min( scope.maxAzimuthAngle, spherical.theta ) );
-
-			// restrict phi to be between desired limits
-			spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
-
-			spherical.makeSafe();
-
-
-			spherical.radius *= scale;
-
-			// restrict radius to be between desired limits
-			spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) );
-
-			// move target to panned location
-			scope.target.add( panOffset );
-
-			offset.setFromSpherical( spherical );
-
-			// rotate offset back to "camera-up-vector-is-up" space
-			offset.applyQuaternion( quatInverse );
-
-			position.copy( scope.target ).add( offset );
-
-			scope.object.lookAt( scope.target );
-
-			if ( scope.enableDamping === true ) {
-
-				sphericalDelta.theta *= ( 1 - scope.dampingFactor );
-				sphericalDelta.phi *= ( 1 - scope.dampingFactor );
-
-				panOffset.multiplyScalar( 1 - scope.dampingFactor );
-
-			} else {
-
-				sphericalDelta.set( 0, 0, 0 );
-
-				panOffset.set( 0, 0, 0 );
-
-			}
-
-			scale = 1;
-
-			// update condition is:
-			// min(camera displacement, camera rotation in radians)^2 > EPS
-			// using small-angle approximation cos(x/2) = 1 - x^2 / 8
-
-			if ( zoomChanged ||
-				lastPosition.distanceToSquared( scope.object.position ) > EPS ||
-				8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) {
-
-				scope.dispatchEvent( changeEvent );
-
-				lastPosition.copy( scope.object.position );
-				lastQuaternion.copy( scope.object.quaternion );
-				zoomChanged = false;
-
-				return true;
-
-			}
-
-			return false;
-
-		};
-
-	}();
-
-	this.dispose = function () {
-
-		scope.domElement.removeEventListener( 'contextmenu', onContextMenu, false );
-		scope.domElement.removeEventListener( 'mousedown', onMouseDown, false );
-		scope.domElement.removeEventListener( 'wheel', onMouseWheel, false );
-
-		scope.domElement.removeEventListener( 'touchstart', onTouchStart, false );
-		scope.domElement.removeEventListener( 'touchend', onTouchEnd, false );
-		scope.domElement.removeEventListener( 'touchmove', onTouchMove, false );
-
-		document.removeEventListener( 'mousemove', onMouseMove, false );
-		document.removeEventListener( 'mouseup', onMouseUp, false );
-
-		window.removeEventListener( 'keydown', onKeyDown, false );
-
-		//scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
-
-	};
-
-	//
-	// internals
-	//
-
-	var scope = this;
-
-	var changeEvent = { type: 'change' };
-	var startEvent = { type: 'start' };
-	var endEvent = { type: 'end' };
-
-	var STATE = { NONE: - 1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY_PAN: 4 };
-
-	var state = STATE.NONE;
-
-	var EPS = 0.000001;
-
-	// current position in spherical coordinates
-	var spherical = new THREE.Spherical();
-	var sphericalDelta = new THREE.Spherical();
-
-	var scale = 1;
-	var panOffset = new THREE.Vector3();
-	var zoomChanged = false;
-
-	var rotateStart = new THREE.Vector2();
-	var rotateEnd = new THREE.Vector2();
-	var rotateDelta = new THREE.Vector2();
-
-	var panStart = new THREE.Vector2();
-	var panEnd = new THREE.Vector2();
-	var panDelta = new THREE.Vector2();
-
-	var dollyStart = new THREE.Vector2();
-	var dollyEnd = new THREE.Vector2();
-	var dollyDelta = new THREE.Vector2();
-
-	function getAutoRotationAngle() {
-
-		return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
-
-	}
-
-	function getZoomScale() {
-
-		return Math.pow( 0.95, scope.zoomSpeed );
-
-	}
-
-	function rotateLeft( angle ) {
-
-		sphericalDelta.theta -= angle;
-
-	}
-
-	function rotateUp( angle ) {
-
-		sphericalDelta.phi -= angle;
-
-	}
-
-	var panLeft = function () {
-
-		var v = new THREE.Vector3();
-
-		return function panLeft( distance, objectMatrix ) {
-
-			v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
-			v.multiplyScalar( - distance );
-
-			panOffset.add( v );
-
-		};
-
-	}();
-
-	var panUp = function () {
-
-		var v = new THREE.Vector3();
-
-		return function panUp( distance, objectMatrix ) {
-
-			if ( scope.screenSpacePanning === true ) {
-
-				v.setFromMatrixColumn( objectMatrix, 1 );
-
-			} else {
-
-				v.setFromMatrixColumn( objectMatrix, 0 );
-				v.crossVectors( scope.object.up, v );
-
-			}
-
-			v.multiplyScalar( distance );
-
-			panOffset.add( v );
-
-		};
-
-	}();
-
-	// deltaX and deltaY are in pixels; right and down are positive
-	var pan = function () {
-
-		var offset = new THREE.Vector3();
-
-		return function pan( deltaX, deltaY ) {
-
-			var element = scope.domElement === document ? scope.domElement.body : scope.domElement;
-
-			if ( scope.object.isPerspectiveCamera ) {
-
-				// perspective
-				var position = scope.object.position;
-				offset.copy( position ).sub( scope.target );
-				var targetDistance = offset.length();
-
-				// half of the fov is center to top of screen
-				targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 );
-
-				// we use only clientHeight here so aspect ratio does not distort speed
-				panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
-				panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );
-
-			} else if ( scope.object.isOrthographicCamera ) {
-
-				// orthographic
-				panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
-				panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
-
-			} else {
-
-				// camera neither orthographic nor perspective
-				console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
-				scope.enablePan = false;
-
-			}
-
-		};
-
-	}();
-
-	function dollyIn( dollyScale ) {
-
-		if ( scope.object.isPerspectiveCamera ) {
-
-			scale /= dollyScale;
-
-		} else if ( scope.object.isOrthographicCamera ) {
-
-			scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
-			scope.object.updateProjectionMatrix();
-			zoomChanged = true;
-
-		} else {
-
-			console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
-			scope.enableZoom = false;
-
-		}
-
-	}
-
-	function dollyOut( dollyScale ) {
-
-		if ( scope.object.isPerspectiveCamera ) {
-
-			scale *= dollyScale;
-
-		} else if ( scope.object.isOrthographicCamera ) {
-
-			scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
-			scope.object.updateProjectionMatrix();
-			zoomChanged = true;
-
-		} else {
-
-			console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
-			scope.enableZoom = false;
-
-		}
-
-	}
-
-	//
-	// event callbacks - update the object state
-	//
-
-	function handleMouseDownRotate( event ) {
-
-		//console.log( 'handleMouseDownRotate' );
-
-		rotateStart.set( event.clientX, event.clientY );
-
-	}
-
-	function handleMouseDownDolly( event ) {
-
-		//console.log( 'handleMouseDownDolly' );
-
-		dollyStart.set( event.clientX, event.clientY );
-
-	}
-
-	function handleMouseDownPan( event ) {
-
-		//console.log( 'handleMouseDownPan' );
-
-		panStart.set( event.clientX, event.clientY );
-
-	}
-
-	function handleMouseMoveRotate( event ) {
-
-		//console.log( 'handleMouseMoveRotate' );
-
-		rotateEnd.set( event.clientX, event.clientY );
-
-		rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
-
-		var element = scope.domElement === document ? scope.domElement.body : scope.domElement;
-
-		rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
-
-		rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
-
-		rotateStart.copy( rotateEnd );
-
-		scope.update();
-
-	}
-
-	function handleMouseMoveDolly( event ) {
-
-		//console.log( 'handleMouseMoveDolly' );
-
-		dollyEnd.set( event.clientX, event.clientY );
-
-		dollyDelta.subVectors( dollyEnd, dollyStart );
-
-		if ( dollyDelta.y > 0 ) {
-
-			dollyIn( getZoomScale() );
-
-		} else if ( dollyDelta.y < 0 ) {
-
-			dollyOut( getZoomScale() );
-
-		}
-
-		dollyStart.copy( dollyEnd );
-
-		scope.update();
-
-	}
-
-	function handleMouseMovePan( event ) {
-
-		//console.log( 'handleMouseMovePan' );
-
-		panEnd.set( event.clientX, event.clientY );
-
-		panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
-
-		pan( panDelta.x, panDelta.y );
-
-		panStart.copy( panEnd );
-
-		scope.update();
-
-	}
-
-	function handleMouseUp( event ) {
-
-		// console.log( 'handleMouseUp' );
-
-	}
-
-	function handleMouseWheel( event ) {
-
-		// console.log( 'handleMouseWheel' );
-
-		if ( event.deltaY < 0 ) {
-
-			dollyOut( getZoomScale() );
-
-		} else if ( event.deltaY > 0 ) {
-
-			dollyIn( getZoomScale() );
-
-		}
-
-		scope.update();
-
-	}
-
-	function handleKeyDown( event ) {
-
-		//console.log( 'handleKeyDown' );
-
-		// prevent the browser from scrolling on cursor up/down
-
-		event.preventDefault();
-
-		switch ( event.keyCode ) {
-
-			case scope.keys.UP:
-				pan( 0, scope.keyPanSpeed );
-				scope.update();
-				break;
-
-			case scope.keys.BOTTOM:
-				pan( 0, - scope.keyPanSpeed );
-				scope.update();
-				break;
-
-			case scope.keys.LEFT:
-				pan( scope.keyPanSpeed, 0 );
-				scope.update();
-				break;
-
-			case scope.keys.RIGHT:
-				pan( - scope.keyPanSpeed, 0 );
-				scope.update();
-				break;
-
-		}
-
-	}
-
-	function handleTouchStartRotate( event ) {
-
-		//console.log( 'handleTouchStartRotate' );
-
-		rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
-
-	}
-
-	function handleTouchStartDollyPan( event ) {
-
-		//console.log( 'handleTouchStartDollyPan' );
-
-		if ( scope.enableZoom ) {
-
-			var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
-			var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
-
-			var distance = Math.sqrt( dx * dx + dy * dy );
-
-			dollyStart.set( 0, distance );
-
-		}
-
-		if ( scope.enablePan ) {
-
-			var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
-			var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
-
-			panStart.set( x, y );
-
-		}
-
-	}
-
-	function handleTouchMoveRotate( event ) {
-
-		//console.log( 'handleTouchMoveRotate' );
-
-		rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
-
-		rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
-
-		var element = scope.domElement === document ? scope.domElement.body : scope.domElement;
-
-		rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
-
-		rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
-
-		rotateStart.copy( rotateEnd );
-
-		scope.update();
-
-	}
-
-	function handleTouchMoveDollyPan( event ) {
-
-		//console.log( 'handleTouchMoveDollyPan' );
-
-		if ( scope.enableZoom ) {
-
-			var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
-			var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
-
-			var distance = Math.sqrt( dx * dx + dy * dy );
-
-			dollyEnd.set( 0, distance );
-
-			dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) );
-
-			dollyIn( dollyDelta.y );
-
-			dollyStart.copy( dollyEnd );
-
-		}
-
-		if ( scope.enablePan ) {
-
-			var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
-			var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
-
-			panEnd.set( x, y );
-
-			panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
-
-			pan( panDelta.x, panDelta.y );
-
-			panStart.copy( panEnd );
-
-		}
-
-		scope.update();
-
-	}
-
-	function handleTouchEnd( event ) {
-
-		//console.log( 'handleTouchEnd' );
-
-	}
-
-	//
-	// event handlers - FSM: listen for events and reset state
-	//
-
-	function onMouseDown( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		// Prevent the browser from scrolling.
-
-		event.preventDefault();
-
-		// Manually set the focus since calling preventDefault above
-		// prevents the browser from setting it automatically.
-
-		scope.domElement.focus ? scope.domElement.focus() : window.focus();
-
-		switch ( event.button ) {
-
-			case scope.mouseButtons.LEFT:
-
-				if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
-
-					if ( scope.enablePan === false ) return;
-
-					handleMouseDownPan( event );
-
-					state = STATE.PAN;
-
-				} else {
-
-					if ( scope.enableRotate === false ) return;
-
-					handleMouseDownRotate( event );
-
-					state = STATE.ROTATE;
-
-				}
-
-				break;
-
-			case scope.mouseButtons.MIDDLE:
-
-				if ( scope.enableZoom === false ) return;
-
-				handleMouseDownDolly( event );
-
-				state = STATE.DOLLY;
-
-				break;
-
-			case scope.mouseButtons.RIGHT:
-
-				if ( scope.enablePan === false ) return;
-
-				handleMouseDownPan( event );
-
-				state = STATE.PAN;
-
-				break;
-
-		}
-
-		if ( state !== STATE.NONE ) {
-
-			document.addEventListener( 'mousemove', onMouseMove, false );
-			document.addEventListener( 'mouseup', onMouseUp, false );
-
-			scope.dispatchEvent( startEvent );
-
-		}
-
-	}
-
-	function onMouseMove( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		event.preventDefault();
-
-		switch ( state ) {
-
-			case STATE.ROTATE:
-
-				if ( scope.enableRotate === false ) return;
-
-				handleMouseMoveRotate( event );
-
-				break;
-
-			case STATE.DOLLY:
-
-				if ( scope.enableZoom === false ) return;
-
-				handleMouseMoveDolly( event );
-
-				break;
-
-			case STATE.PAN:
-
-				if ( scope.enablePan === false ) return;
-
-				handleMouseMovePan( event );
-
-				break;
-
-		}
-
-	}
-
-	function onMouseUp( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		handleMouseUp( event );
-
-		document.removeEventListener( 'mousemove', onMouseMove, false );
-		document.removeEventListener( 'mouseup', onMouseUp, false );
-
-		scope.dispatchEvent( endEvent );
-
-		state = STATE.NONE;
-
-	}
-
-	function onMouseWheel( event ) {
-
-		if ( scope.enabled === false || scope.enableZoom === false || ( state !== STATE.NONE && state !== STATE.ROTATE ) ) return;
-
-		event.preventDefault();
-		event.stopPropagation();
-
-		scope.dispatchEvent( startEvent );
-
-		handleMouseWheel( event );
-
-		scope.dispatchEvent( endEvent );
-
-	}
-
-	function onKeyDown( event ) {
-
-		if ( scope.enabled === false || scope.enableKeys === false || scope.enablePan === false ) return;
-
-		handleKeyDown( event );
-
-	}
-
-	function onTouchStart( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		event.preventDefault();
-
-		switch ( event.touches.length ) {
-
-			case 1:	// one-fingered touch: rotate
-
-				if ( scope.enableRotate === false ) return;
-
-				handleTouchStartRotate( event );
-
-				state = STATE.TOUCH_ROTATE;
-
-				break;
-
-			case 2:	// two-fingered touch: dolly-pan
-
-				if ( scope.enableZoom === false && scope.enablePan === false ) return;
-
-				handleTouchStartDollyPan( event );
-
-				state = STATE.TOUCH_DOLLY_PAN;
-
-				break;
-
-			default:
-
-				state = STATE.NONE;
-
-		}
-
-		if ( state !== STATE.NONE ) {
-
-			scope.dispatchEvent( startEvent );
-
-		}
-
-	}
-
-	function onTouchMove( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		event.preventDefault();
-		event.stopPropagation();
-
-		switch ( event.touches.length ) {
-
-			case 1: // one-fingered touch: rotate
-
-				if ( scope.enableRotate === false ) return;
-				if ( state !== STATE.TOUCH_ROTATE ) return; // is this needed?
-
-				handleTouchMoveRotate( event );
-
-				break;
-
-			case 2: // two-fingered touch: dolly-pan
-
-				if ( scope.enableZoom === false && scope.enablePan === false ) return;
-				if ( state !== STATE.TOUCH_DOLLY_PAN ) return; // is this needed?
-
-				handleTouchMoveDollyPan( event );
-
-				break;
-
-			default:
-
-				state = STATE.NONE;
-
-		}
-
-	}
-
-	function onTouchEnd( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		handleTouchEnd( event );
-
-		scope.dispatchEvent( endEvent );
-
-		state = STATE.NONE;
-
-	}
-
-	function onContextMenu( event ) {
-
-		if ( scope.enabled === false ) return;
-
-		event.preventDefault();
-
-	}
-
-	//
-
-	scope.domElement.addEventListener( 'contextmenu', onContextMenu, false );
-
-	scope.domElement.addEventListener( 'mousedown', onMouseDown, false );
-	scope.domElement.addEventListener( 'wheel', onMouseWheel, false );
-
-	scope.domElement.addEventListener( 'touchstart', onTouchStart, false );
-	scope.domElement.addEventListener( 'touchend', onTouchEnd, false );
-	scope.domElement.addEventListener( 'touchmove', onTouchMove, false );
-
-	window.addEventListener( 'keydown', onKeyDown, false );
-
-	// force an update at start
-
-	this.update();
-
-};
-
-THREE.OrbitControls.prototype = Object.create( THREE.EventDispatcher.prototype );
-THREE.OrbitControls.prototype.constructor = THREE.OrbitControls;
-
-Object.defineProperties( THREE.OrbitControls.prototype, {
-
-	center: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .center has been renamed to .target' );
-			return this.target;
-
-		}
-
-	},
-
-	// backward compatibility
-
-	noZoom: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.' );
-			return ! this.enableZoom;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.' );
-			this.enableZoom = ! value;
-
-		}
-
-	},
-
-	noRotate: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.' );
-			return ! this.enableRotate;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.' );
-			this.enableRotate = ! value;
-
-		}
-
-	},
-
-	noPan: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .noPan has been deprecated. Use .enablePan instead.' );
-			return ! this.enablePan;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .noPan has been deprecated. Use .enablePan instead.' );
-			this.enablePan = ! value;
-
-		}
-
-	},
-
-	noKeys: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.' );
-			return ! this.enableKeys;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.' );
-			this.enableKeys = ! value;
-
-		}
-
-	},
-
-	staticMoving: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.' );
-			return ! this.enableDamping;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.' );
-			this.enableDamping = ! value;
-
-		}
-
-	},
-
-	dynamicDampingFactor: {
-
-		get: function () {
-
-			console.warn( 'THREE.OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.' );
-			return this.dampingFactor;
-
-		},
-
-		set: function ( value ) {
-
-			console.warn( 'THREE.OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.' );
-			this.dampingFactor = value;
-
-		}
-
-	}
-
-} );

File diff suppressed because it is too large
+ 0 - 6045
src/web/SystemAO/utilities/script/Threejs/build/three.js


File diff suppressed because it is too large
+ 0 - 419
src/web/SystemAO/utilities/script/Threejs/build/three.min.js


File diff suppressed because it is too large
+ 0 - 6039
src/web/SystemAO/utilities/script/Threejs/build/three.module.js


Some files were not shown because too many files changed in this diff