changeset 5420:851c14d57680 marking-single-beam

Merged default into marking-single-beam branch.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Thu, 08 Jul 2021 00:14:58 +0200
parents 202715173935 (diff) fbad74acd23f (current diff)
children c9da747d4109
files schema/version.sql
diffstat 15 files changed, 422 insertions(+), 127 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/fairway/BottleneckDialogue.vue	Wed Jul 07 20:11:17 2021 +0200
+++ b/client/src/components/fairway/BottleneckDialogue.vue	Thu Jul 08 00:14:58 2021 +0200
@@ -103,6 +103,7 @@
               <select
                 v-model="additionalSurvey"
                 class="form-control form-control-sm small"
+                :disabled="!areDifferecesAllowed"
               >
                 <option :value="null">None</option>
                 <option
@@ -264,7 +265,7 @@
               <button
                 class="btn btn-info btn-sm w-100"
                 @click="toggleCutTool"
-                :disabled="!selectedSurvey"
+                :disabled="!isCutAllowed"
               >
                 <font-awesome-icon :icon="cutToolEnabled ? 'times' : 'plus'" />
                 {{ cutToolEnabled ? "Cancel" : "New" }}
@@ -330,6 +331,7 @@
  *
  * Author(s):
  * Markus Kottländer <markus.kottlaender@intevation.de>
+ * Thomas Junk <thomas.junk@intevation.de>
  */
 import { mapState, mapGetters } from "vuex";
 import Feature from "ol/Feature";
@@ -358,6 +360,15 @@
       "surveys",
       "surveysLoading"
     ]),
+    selectedSurveyIsMarking() {
+      return this.selectedSurvey["survey_type"] === "marking";
+    },
+    areDifferecesAllowed() {
+      return !this.selectedSurveyIsMarking;
+    },
+    isCutAllowed() {
+      return !this.selectedSurveyIsMarking && !!this.selectedSurvey;
+    },
     isAllowedToDelete() {
       const userCountryCode = this.userCountries[this.user];
       const bottleneck = this.bottlenecksList.find(
--- a/client/src/components/importconfiguration/types/Soundingresults.vue	Wed Jul 07 20:11:17 2021 +0200
+++ b/client/src/components/importconfiguration/types/Soundingresults.vue	Thu Jul 08 00:14:58 2021 +0200
@@ -254,6 +254,7 @@
       this.token = null;
       this.eMailNotification = false;
       this.messages = [];
+      this.beamType = "";
     },
     fileSelected(e) {
       const files = e.target.files || e.dataTransfer.files;
@@ -296,10 +297,10 @@
         }
       })
         .then(response => {
-          if (response.data.meta) {
+          if (response.data && response.data.meta) {
             const { bottleneck, date, epsg } = response.data.meta;
             const depthReference = response.data.meta["depth-reference"];
-            const singlebeam = response.data.meta["single-beam"];
+            const surveyType = response.data.meta["survey-type"];
             this.negateZ = response.data.meta["negate-z"];
             this.bottleneck = this.bottlenecks.find(
               bn => bn.properties.objnam === bottleneck
@@ -307,9 +308,20 @@
             this.depthReference = depthReference;
             this.importDate = new Date(date).toISOString().split("T")[0];
             this.projection = epsg;
-            this.beamType = singlebeam
-              ? this.$options.BEAMTYPES.SINGLEBEAM
-              : this.$options.BEAMTYPES.MULTIBEAM;
+            switch (surveyType) {
+              case "single":
+                this.beamType = this.$options.BEAMTYPES.SINGLEBEAM;
+                break;
+              case "multi":
+                this.beamType = this.$options.BEAMTYPES.MULTIBEAM;
+                break;
+              case "marking":
+                this.beamType = this.$options.BEAMTYPES.MARKING;
+                break;
+              default:
+                this.beamType = this.$options.BEAMTYPES.MULTIBEAM;
+                break;
+            }
           }
           this.importState = IMPORTSTATE.EDIT;
           this.token = response.data.token;
@@ -337,11 +349,7 @@
       if (this.depthReference)
         formData.append("depth-reference", this.depthReference);
       if (this.projection) formData.append("epsg", this.projection);
-      if (this.beamType)
-        formData.append(
-          "single-beam",
-          this.beamType === this.$options.BEAMTYPES.SINGLEBEAM
-        );
+      if (this.beamType) formData.append("survey-type", this.beamType);
       formData.append("negate-z", this.negateZ == true);
       HTTP.post("/imports/sr", formData, {
         headers: {
@@ -458,8 +466,7 @@
               "depth-reference": this.depthReference,
               bottleneck: this.bottleneck.properties.objnam,
               date: this.importDate,
-              "single-beam":
-                this.beamType === this.$options.BEAMTYPES.SINGLEBEAM,
+              "survey-type": this.beamType,
               epsg: Number(this.projection),
               "negate-z": this.negateZ == true
             })
@@ -487,8 +494,9 @@
     }
   },
   BEAMTYPES: {
-    MULTIBEAM: "multi-beam",
-    SINGLEBEAM: "single-beam"
+    MULTIBEAM: "multi",
+    SINGLEBEAM: "single",
+    MARKING: "marking"
   },
   UPLOADLABEL: "choose a .zip or .txt file",
   on: "on",
--- a/pkg/controllers/diff.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/controllers/diff.go	Thu Jul 08 00:14:58 2021 +0200
@@ -251,7 +251,7 @@
 
 	log.Printf("info: z range: %.3f - %.3f\n", zMin, zMax)
 
-	var heights []float64
+	var heights mesh.ClassBreaks
 
 	heights, err = mesh.LoadClassBreaks(
 		ctx, tx,
@@ -261,7 +261,7 @@
 		err = nil
 		heights = mesh.SampleDiffHeights(zMin, zMax, contourStep)
 	} else {
-		heights = mesh.ExtrapolateClassBreaks(heights, zMin, zMax)
+		heights = heights.ExtrapolateClassBreaks(zMin, zMax)
 	}
 
 	heights = common.DedupFloat64s(heights)
--- a/pkg/controllers/srimports.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/controllers/srimports.go	Thu Jul 08 00:14:58 2021 +0200
@@ -112,17 +112,33 @@
 		sr.NegateZ = &negateZ
 	}
 
+	// Kept this in for compat.
 	if v := req.FormValue("single-beam"); v != "" {
-		var singleBeam bool
+		var surveyType models.SurveyType
 		switch strings.ToLower(v) {
 		case "true", "1", "singlebeam", "single-beam":
-			singleBeam = true
+			surveyType = models.SurveyTypeSingleBeam
 		case "false", "0", "multibeam", "multi-beam":
-			singleBeam = false
+			surveyType = models.SurveyTypeMultiBeam
 		default:
 			return fmt.Errorf("unknown single-beam '%s'", v)
 		}
-		sr.SingleBeam = &singleBeam
+		sr.SurveyType = &surveyType
+	}
+
+	if v := req.FormValue("survey-type"); v != "" {
+		var surveyType models.SurveyType
+		switch strings.ToLower(v) {
+		case "2", "marking":
+			surveyType = models.SurveyTypeMarking
+		case "true", "1", "singlebeam", "single-beam", "single":
+			surveyType = models.SurveyTypeSingleBeam
+		case "false", "0", "multibeam", "multi-beam", "multi":
+			surveyType = models.SurveyTypeMultiBeam
+		default:
+			return fmt.Errorf("unknown survey-type '%s'", v)
+		}
+		sr.SurveyType = &surveyType
 	}
 
 	return nil
--- a/pkg/controllers/surveys.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/controllers/surveys.go	Thu Jul 08 00:14:58 2021 +0200
@@ -33,7 +33,8 @@
   s.date_info::text,
   s.depth_reference,
   COALESCE(g.objname, 'ERROR: MISSING GAUGE') AS gauge_objname,
-  r.value AS waterlevel_value
+  r.value AS waterlevel_value,
+  COALESCE(s.surtyp, 'ERROR: MISSING SURVEY TYPE') AS surtype
 FROM waterway.bottlenecks AS b
   JOIN waterway.sounding_results AS s ON b.bottleneck_id = s.bottleneck_id
   LEFT JOIN waterway.gauges AS g
@@ -63,6 +64,7 @@
 	// (like done in controllers/search.go)
 	for rows.Next() {
 		var survey models.Survey
+		var surType string
 		var level sql.NullInt64
 		if err = rows.Scan(
 			&survey.BottleneckID,
@@ -70,12 +72,14 @@
 			&survey.DepthReference,
 			&survey.ReferenceGauge,
 			&level,
+			&surType,
 		); err != nil {
 			return
 		}
 		if level.Valid {
 			survey.WaterLevelValue = &level.Int64
 		}
+		survey.SurveyType = models.SurveyType(surType)
 		surveys = append(surveys, &survey)
 	}
 
--- a/pkg/imports/isr.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/imports/isr.go	Thu Jul 08 00:14:58 2021 +0200
@@ -157,7 +157,7 @@
 func (isr *IsoRefresh) processBottleneck(
 	ctx context.Context,
 	conn *sql.Conn,
-	heights []float64,
+	heights mesh.ClassBreaks,
 	bn *bottleneckSoundingResults,
 ) error {
 	// Do one transaction per bottleneck.
@@ -179,7 +179,7 @@
 		if err != nil {
 			return err
 		}
-		hs := mesh.ExtrapolateClassBreaks(heights, tree.Min().Z, tree.Max().Z)
+		hs := heights.ExtrapolateClassBreaks(tree.Min().Z, tree.Max().Z)
 		hs = common.DedupFloat64s(hs)
 
 		// Delete the old iso areas.
--- a/pkg/imports/sr.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/imports/sr.go	Thu Jul 08 00:14:58 2021 +0200
@@ -24,6 +24,7 @@
 	"errors"
 	"fmt"
 	"io"
+	"log"
 	"math"
 	"os"
 	"path"
@@ -58,8 +59,10 @@
 	// DepthReference if given overides the DepthReference value
 	// from the meta.json.
 	DepthReference *string `json:"depth-reference,omitempty"`
-	// SingleBeam indicates that the sounding is a single beam scan.
+	// SingleBeam is kept in for compat.
 	SingleBeam *bool `json:"single-beam,omitempty"`
+	// SurveyType indicates that the sounding is a single beam scan.
+	SurveyType *models.SurveyType `json:"survey-type,omitempty"`
 	// NegateZ indicated that the Z values of thy XYZ input should be
 	// multiplied by -1.
 	NegateZ *bool `json:"negate-z,omitempty"`
@@ -104,7 +107,8 @@
 
 func (srJobCreator) Depends() [2][]string {
 	return [2][]string{
-		{"sounding_results", "sounding_results_iso_areas"},
+		{"sounding_results", "sounding_results_iso_areas",
+			"sounding_results_marking_points"},
 		{"bottlenecks"},
 	}
 }
@@ -195,6 +199,19 @@
 FROM waterway.sounding_results sr
 WHERE id = $1
 `
+	insertMarkingPointsSQL = `
+INSERT INTO waterway.sounding_results_marking_points (
+  sounding_result_id,
+  height,
+  points
+)
+SELECT
+  $1,
+  $2,
+  ST_Transform(ST_GeomFromWKB($4, $3::integer), 4326)
+FROM waterway.sounding_results sr
+WHERE id = $1
+`
 
 	selectGaugeLDCSQL = `
 SELECT
@@ -237,18 +254,21 @@
 	if sr.NegateZ != nil && *sr.NegateZ {
 		descs = append(descs, "negateZ")
 	}
+	if sr.surveyType != nil {
+		descs = append(descs, string(*sr.SurveyType))
+	}
 	return strings.Join(descs, "|"), nil
 }
 
-func (sr *SoundingResult) singleBeam() bool {
-	return sr.SingleBeam != nil && *sr.SingleBeam
+func (sr *SoundingResult) surveyType() models.SurveyType {
+	if sr.SurveyType != nil {
+		return *sr.SurveyType
+	}
+	return models.SurveyTypeMultiBeam
 }
 
 func (sr *SoundingResult) surtype() string {
-	if sr.singleBeam() {
-		return "single"
-	}
-	return "multi"
+	return string(sr.surveyType())
 }
 
 func (sr *SoundingResult) negateZ() bool {
@@ -419,24 +439,19 @@
 	zpgException bool,
 ) (interface{}, error) {
 
-	if sr.singleBeam() {
-		feedback.Info("Processing as single beam scan.")
-	} else {
-		feedback.Info("Processing as multi beam scan.")
-	}
+	feedback.Info("Processing as %s beam scan.", sr.surtype())
 
 	feedback.Info("Reproject XYZ data.")
 
 	start := time.Now()
 
-	xyzWKB := xyz.AsWKB()
 	var reproj []byte
 	var epsg uint32
 
 	if err := tx.QueryRowContext(
 		ctx,
 		reprojectPointsSingleBeamSQL,
-		xyzWKB,
+		xyz.AsWKB(),
 		m.EPSG,
 	).Scan(&reproj, &epsg); err != nil {
 		return nil, err
@@ -465,7 +480,6 @@
 		removed                 map[int32]struct{}
 		polygonArea             float64
 		clippingPolygonWKB      []byte
-		tin                     *mesh.Tin
 	)
 
 	if boundary == nil {
@@ -532,7 +546,7 @@
 		removed = str.Clip(&clippingPolygon)
 	}
 
-	if sr.singleBeam() {
+	if sr.surveyType() == models.SurveyTypeSingleBeam {
 
 		origDensity := float64(len(xyz)) / polygonArea
 
@@ -604,27 +618,42 @@
 
 multibeam:
 
-	start = time.Now()
-	tin = tri.Tin()
-	tin.EPSG = epsg
+	final := mesh.STRTree{Entries: 16}
+
+	if sr.surveyType() != models.SurveyTypeMarking {
 
-	var str mesh.STRTree
-	str.Build(tin)
-	feedback.Info("Building clipping index took %v", time.Since(start))
+		start = time.Now()
+		tin := tri.Tin()
+		tin.EPSG = epsg
 
-	start = time.Now()
+		var str mesh.STRTree
+		str.Build(tin)
+		feedback.Info("Building clipping index took %v", time.Since(start))
+
+		start = time.Now()
 
-	clippingPolygonBuffered.Indexify()
-	removed = str.Clip(&clippingPolygonBuffered)
-	feedback.Info("Clipping took %v.", time.Since(start))
-	feedback.Info("Number of triangles to clip %d.", len(removed))
+		clippingPolygonBuffered.Indexify()
+		removed = str.Clip(&clippingPolygonBuffered)
+		feedback.Info("Clipping took %v.", time.Since(start))
+		feedback.Info("Number of triangles to clip %d.", len(removed))
+
+		start = time.Now()
+		final.BuildWithout(tin, removed)
 
-	start = time.Now()
-	final := mesh.STRTree{Entries: 16}
-	final.BuildWithout(tin, removed)
-
-	feedback.Info("Building final mesh took %v.", time.Since(start))
-	feedback.Info("Store mesh.")
+		feedback.Info("Building final mesh took %v.", time.Since(start))
+		feedback.Info("Store mesh.")
+	} else {
+		start = time.Now()
+		clippingPolygonBuffered.Indexify()
+		removed = make(map[int32]struct{})
+		for i, v := range xyz {
+			if clippingPolygonBuffered.IntersectionBox2D(v.Box2D()) == mesh.IntersectionOutSide {
+				removed[int32(i)] = struct{}{}
+			}
+		}
+		feedback.Info("Clipping took %v.", time.Since(start))
+		feedback.Info("Number of points to clip %d.", len(removed))
+	}
 
 	start = time.Now()
 
@@ -663,22 +692,32 @@
 		return nil, err
 	}
 
-	index, err := final.Bytes()
-	if err != nil {
-		return nil, err
-	}
+	if sr.surveyType() != models.SurveyTypeMarking {
+
+		index, err := final.Bytes()
+		if err != nil {
+			return nil, err
+		}
 
-	h := sha1.New()
-	h.Write(index)
-	checksum := hex.EncodeToString(h.Sum(nil))
-	_, err = tx.ExecContext(ctx, insertMeshSQL, id, checksum, index)
-	if err != nil {
-		return nil, err
-	}
-	feedback.Info("Storing mesh index took %s.", time.Since(start))
-	err = generateIsos(ctx, tx, feedback, &final, id)
-	if err != nil {
-		return nil, err
+		h := sha1.New()
+		h.Write(index)
+		checksum := hex.EncodeToString(h.Sum(nil))
+		_, err = tx.ExecContext(ctx, insertMeshSQL, id, checksum, index)
+		if err != nil {
+			return nil, err
+		}
+		feedback.Info("Storing mesh index took %s.", time.Since(start))
+		if err := generateIsos(ctx, tx, feedback, &final, id); err != nil {
+			return nil, err
+		}
+	} else { // SurveyTypeMarking
+		if err := generateMarkingPoints(
+			ctx, tx, feedback,
+			xyz, removed, epsg,
+			id,
+		); err != nil {
+			return nil, err
+		}
 	}
 
 	// Store for potential later removal.
@@ -712,7 +751,7 @@
 	return sr.Bottleneck != nil &&
 		sr.Date != nil &&
 		sr.DepthReference != nil &&
-		sr.SingleBeam != nil &&
+		sr.SurveyType != nil &&
 		sr.NegateZ != nil
 }
 
@@ -729,7 +768,7 @@
 			Bottleneck:     *sr.Bottleneck,
 			EPSG:           epsg,
 			DepthReference: *sr.DepthReference,
-			SingleBeam:     sr.singleBeam(),
+			SurveyType:     sr.surveyType(),
 			NegateZ:        sr.negateZ(),
 		}, nil
 	}
@@ -756,8 +795,18 @@
 	if sr.DepthReference != nil {
 		m.DepthReference = *sr.DepthReference
 	}
+
+	// Kept in for compat
 	if sr.SingleBeam != nil {
-		m.SingleBeam = *sr.SingleBeam
+		if *sr.SingleBeam {
+			m.SurveyType = models.SurveyTypeSingleBeam
+		} else {
+			m.SurveyType = models.SurveyTypeSingleBeam
+		}
+	}
+
+	if sr.SurveyType != nil {
+		m.SurveyType = *sr.SurveyType
 	}
 	if sr.NegateZ != nil {
 		m.NegateZ = *sr.NegateZ
@@ -879,6 +928,85 @@
 	return shapeToPolygon(s)
 }
 
+func defaultClassBreaks(min, max float64) mesh.ClassBreaks {
+	var heights mesh.ClassBreaks
+	h := contourStepWidth * math.Ceil(min/contourStepWidth)
+	for ; h <= max; h += contourStepWidth {
+		heights = append(heights, h)
+	}
+	return heights
+}
+
+func loadClassBreaks(
+	ctx context.Context,
+	tx *sql.Tx,
+	feedback Feedback,
+	minZ, maxZ float64,
+) mesh.ClassBreaks {
+
+	heights, err := mesh.LoadClassBreaks(
+		ctx, tx,
+		"morphology_classbreaks")
+
+	if err != nil {
+		feedback.Warn("Loading class breaks failed: %v", err)
+		feedback.Info("Using default class breaks")
+		heights = defaultClassBreaks(minZ, maxZ)
+	} else {
+		heights = heights.ExtrapolateClassBreaks(minZ, maxZ)
+	}
+
+	return heights.Dedup()
+}
+
+func generateMarkingPoints(
+	ctx context.Context,
+	tx *sql.Tx,
+	feedback Feedback,
+	xyz mesh.MultiPointZ,
+	removed map[int32]struct{},
+	epsg uint32,
+	id int64,
+) error {
+	log.Printf("debug: generateMarkingPoints")
+
+	min, max := mesh.MinMaxVertex(xyz.FilterRemoved(removed))
+
+	log.Printf("debug: min/max %.2f/%.2f\n", min.Z, max.Z)
+
+	heights := loadClassBreaks(ctx, tx, feedback, min.Z, max.Z)
+
+	classes := heights.Classify(xyz.FilterRemoved(removed))
+
+	// Should not happen ... Z values over the top.
+	if n := len(classes) - 1; n > 1 && len(classes[n]) > 0 {
+		// Place the over the top values to the class below.
+		classes[n-1] = append(classes[n-1], classes[n]...)
+		classes[n] = nil
+		classes = classes[:n]
+	}
+
+	stmt, err := tx.PrepareContext(ctx, insertMarkingPointsSQL)
+	if err != nil {
+		return err
+	}
+	defer stmt.Close()
+
+	for i, class := range classes {
+		// Ignore empty classes
+		if len(class) == 0 {
+			continue
+		}
+		log.Printf("debug: class %d: %d\n", i, len(class))
+		_, err := stmt.ExecContext(ctx, id, heights[i], epsg, class.AsWKB())
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func generateIsos(
 	ctx context.Context,
 	tx *sql.Tx,
@@ -887,33 +1015,7 @@
 	id int64,
 ) error {
 
-	heights, err := mesh.LoadClassBreaks(
-		ctx, tx,
-		"morphology_classbreaks",
-	)
-
-	minZ, maxZ := tree.Min().Z, tree.Max().Z
-
-	if err != nil {
-		feedback.Warn("Loading class breaks failed: %v", err)
-		feedback.Info("Using default class breaks")
-		heights = nil
-		h := contourStepWidth * math.Ceil(minZ/contourStepWidth)
-		for ; h <= maxZ; h += contourStepWidth {
-			heights = append(heights, h)
-		}
-	} else {
-		heights = mesh.ExtrapolateClassBreaks(heights, minZ, maxZ)
-	}
-
-	/*
-		for i, v := range heights {
-			fmt.Printf("%d %.2f\n", i, v)
-		}
-		log.Printf("%.2f - %.2f\n", tree.Min.Z, tree.Max.Z)
-	*/
-
-	heights = common.DedupFloat64s(heights)
+	heights := loadClassBreaks(ctx, tx, feedback, tree.Min().Z, tree.Max().Z)
 
 	return generateIsoAreas(ctx, tx, feedback, tree, heights, id)
 }
@@ -923,7 +1025,7 @@
 	tx *sql.Tx,
 	feedback Feedback,
 	tree *mesh.STRTree,
-	heights []float64,
+	heights mesh.ClassBreaks,
 	id int64,
 ) error {
 	feedback.Info("Generate iso areas")
@@ -955,7 +1057,7 @@
 	feedback Feedback,
 	areas []wkb.MultiPolygonGeom,
 	epsg uint32,
-	heights []float64,
+	heights mesh.ClassBreaks,
 	id int64,
 ) error {
 	feedback.Info("Store iso areas.")
--- a/pkg/mesh/classbreaks.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/mesh/classbreaks.go	Thu Jul 08 00:14:58 2021 +0200
@@ -21,6 +21,8 @@
 	"sort"
 	"strconv"
 	"strings"
+
+	"gemma.intevation.de/gemma/pkg/common"
 )
 
 const (
@@ -29,8 +31,10 @@
 WHERE config_key = $1`
 )
 
-func SampleDiffHeights(min, max, step float64) []float64 {
-	var heights []float64
+type ClassBreaks []float64
+
+func SampleDiffHeights(min, max, step float64) ClassBreaks {
+	var heights ClassBreaks
 	switch {
 	case min >= 0: // All values positive.
 		for v := 0.0; v <= max; v += step {
@@ -58,10 +62,10 @@
 	return heights
 }
 
-func ParseClassBreaks(config string) ([]float64, error) {
+func ParseClassBreaks(config string) (ClassBreaks, error) {
 
 	parts := strings.Split(config, ",")
-	classes := make([]float64, 0, len(parts))
+	classes := make(ClassBreaks, 0, len(parts))
 	for _, part := range parts {
 		if idx := strings.IndexRune(part, ':'); idx >= 0 {
 			part = part[:idx]
@@ -80,7 +84,7 @@
 	return classes, nil
 }
 
-func LoadClassBreaks(ctx context.Context, tx *sql.Tx, key string) ([]float64, error) {
+func LoadClassBreaks(ctx context.Context, tx *sql.Tx, key string) (ClassBreaks, error) {
 
 	var config sql.NullString
 
@@ -102,12 +106,12 @@
 	return math.Round(v*10000) / 10000
 }
 
-func ExtrapolateClassBreaks(cbs []float64, min, max float64) []float64 {
+func (cbs ClassBreaks) ExtrapolateClassBreaks(min, max float64) ClassBreaks {
 	if min > max {
 		min, max = max, min
 	}
 
-	n := make([]float64, len(cbs))
+	n := make(ClassBreaks, len(cbs))
 	copy(n, cbs)
 	sort.Float64s(n)
 
@@ -148,3 +152,22 @@
 
 	return n
 }
+
+func (cbs ClassBreaks) Dedup() ClassBreaks {
+	return ClassBreaks(common.DedupFloat64s(cbs))
+}
+
+func (cbs ClassBreaks) Classify(points func() (Vertex, bool)) []MultiPointZ {
+	classes := make([]MultiPointZ, len(cbs)+1)
+	for v, ok := points(); ok; v, ok = points() {
+		idx := len(cbs)
+		for i, cb := range cbs {
+			if v.Z <= cb {
+				idx = i
+				break
+			}
+		}
+		classes[idx] = append(classes[idx], v)
+	}
+	return classes
+}
--- a/pkg/mesh/raster.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/mesh/raster.go	Thu Jul 08 00:14:58 2021 +0200
@@ -195,7 +195,7 @@
 	return min, max, min != math.MaxFloat64
 }
 
-func (r *Raster) Trace(heights []float64) []wkb.MultiPolygonGeom {
+func (r *Raster) Trace(heights ClassBreaks) []wkb.MultiPolygonGeom {
 	start := time.Now()
 
 	tracer := contourmap.FromFloat64s(r.XCells+2, r.YCells+2, r.Cells)
--- a/pkg/mesh/vertex.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/mesh/vertex.go	Thu Jul 08 00:14:58 2021 +0200
@@ -163,6 +163,16 @@
 	return math.Sqrt(v.Dot(v))
 }
 
+// Box2D constructs a Box2D of this vertex.
+func (v Vertex) Box2D() Box2D {
+	return Box2D{
+		X1: v.X,
+		Y1: v.Y,
+		X2: v.X,
+		Y2: v.Y,
+	}
+}
+
 func area(a, b, c Vertex) float64 {
 	return (b.Y-a.Y)*(c.X-b.X) - (b.X-a.X)*(c.Y-b.Y)
 }
@@ -1134,6 +1144,53 @@
 	return out
 }
 
+// All returns all points as an iterator.
+func (mpz MultiPointZ) All() func() (Vertex, bool) {
+	var idx int
+	return func() (v Vertex, ok bool) {
+		if idx >= len(mpz) {
+			ok = false
+			return
+		}
+		v, ok = mpz[idx], true
+		idx++
+		return
+	}
+}
+
+// FilterRemoved returns an iterator that only delivers the vertices
+// which indices are not marked as removed.
+func (mpz MultiPointZ) FilterRemoved(removed map[int32]struct{}) func() (Vertex, bool) {
+	var idx int32
+	return func() (v Vertex, ok bool) {
+		for {
+			if idx >= int32(len(mpz)) {
+				ok = false
+				return
+			}
+			if _, rm := removed[idx]; rm {
+				idx++
+				continue
+			}
+			break
+		}
+		v, ok = mpz[idx], true
+		idx++
+		return
+	}
+}
+
+// MinMaxVertex runs over a point iterator and figures out its boundary.
+func MinMaxVertex(points func() (Vertex, bool)) (Vertex, Vertex) {
+	min := Vertex{math.MaxFloat64, math.MaxFloat64, math.MaxFloat64}
+	max := Vertex{-math.MaxFloat64, -math.MaxFloat64, -math.MaxFloat64}
+	for v, ok := points(); ok; v, ok = points() {
+		min.Minimize(v)
+		max.Maximize(v)
+	}
+	return min, max
+}
+
 // AsWKB returns a WKB representation of the given point cloud.
 func (mpz MultiPointZ) AsWKB() []byte {
 	size := 1 + 4 + 4 + len(mpz)*(1+4+3*8)
--- a/pkg/models/sr.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/models/sr.go	Thu Jul 08 00:14:58 2021 +0200
@@ -25,17 +25,40 @@
 	"gemma.intevation.de/gemma/pkg/common"
 )
 
+type SurveyType string
+
+const (
+	SurveyTypeMultiBeam  = SurveyType("multi")
+	SurveyTypeSingleBeam = SurveyType("single")
+	SurveyTypeMarking    = SurveyType("marking")
+)
+
 type (
 	SoundingResultMeta struct {
-		Date           Date   `json:"date"`
-		Bottleneck     string `json:"bottleneck"`
-		EPSG           uint   `json:"epsg"`
-		DepthReference string `json:"depth-reference"`
-		SingleBeam     bool   `json:"single-beam"`
-		NegateZ        bool   `json:"negate-z,omitempty"`
+		Date           Date       `json:"date"`
+		Bottleneck     string     `json:"bottleneck"`
+		EPSG           uint       `json:"epsg"`
+		DepthReference string     `json:"depth-reference"`
+		SingleBeam     *bool      `json:"single-beam,omitempty"` // kept in for compat!
+		SurveyType     SurveyType `json:"survey-type,omitempty"`
+		NegateZ        bool       `json:"negate-z,omitempty"`
 	}
 )
 
+func (st *SurveyType) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	switch x := SurveyType(s); x {
+	case SurveyTypeMultiBeam, SurveyTypeSingleBeam, SurveyTypeMarking:
+		*st = x
+		return nil
+	default:
+		return fmt.Errorf("unkown survey type '%s'", s)
+	}
+}
+
 const (
 	checkDepthReferenceSQL = `
 SELECT EXISTS(SELECT 1
@@ -58,9 +81,35 @@
 
 func (m *SoundingResultMeta) Decode(r io.Reader) error {
 	err := json.NewDecoder(r).Decode(m)
-	if err == nil && m.EPSG == 0 {
+	if err != nil {
+		return err
+	}
+
+	if m.EPSG == 0 {
 		m.EPSG = WGS84
 	}
+
+	if m.SingleBeam != nil {
+		// Check if single-beam and survey-type match.
+		if m.SurveyType != "" {
+			if (*m.SingleBeam && m.SurveyType != SurveyTypeSingleBeam) ||
+				(!*m.SingleBeam && m.SurveyType != SurveyTypeMultiBeam) {
+				return errors.New("'single-beam' and 'survey-type' mismatch")
+			}
+		} else { // Only single-beam given
+			if *m.SingleBeam {
+				m.SurveyType = SurveyTypeSingleBeam
+			} else {
+				m.SurveyType = SurveyTypeMultiBeam
+			}
+		}
+		// Kill single-beam
+		m.SingleBeam = nil
+	}
+
+	if m.SurveyType == "" { // default to multi-beam
+		m.SurveyType = SurveyTypeMultiBeam
+	}
 	return err
 }
 
--- a/pkg/models/surveys.go	Wed Jul 07 20:11:17 2021 +0200
+++ b/pkg/models/surveys.go	Thu Jul 08 00:14:58 2021 +0200
@@ -15,10 +15,11 @@
 
 type (
 	Survey struct {
-		BottleneckID    string `json:"bottleneck_id"`
-		DateInfo        string `json:"date_info"`
-		DepthReference  string `json:"depth_reference"`
-		ReferenceGauge  string `json:"gauge_objname"`
-		WaterLevelValue *int64 `json:"waterlevel_value,omitempty"`
+		BottleneckID    string     `json:"bottleneck_id"`
+		DateInfo        string     `json:"date_info"`
+		DepthReference  string     `json:"depth_reference"`
+		ReferenceGauge  string     `json:"gauge_objname"`
+		SurveyType      SurveyType `json:"survey_type"`
+		WaterLevelValue *int64     `json:"waterlevel_value,omitempty"`
 	}
 )
--- a/schema/gemma.sql	Wed Jul 07 20:11:17 2021 +0200
+++ b/schema/gemma.sql	Thu Jul 08 00:14:58 2021 +0200
@@ -353,7 +353,7 @@
     survey_type varchar PRIMARY KEY
 );
 
-INSERT INTO survey_types (survey_type) VALUES ('single'), ('multi');
+INSERT INTO survey_types (survey_type) VALUES ('single'), ('multi'), ('marking');
 
 CREATE TABLE coverage_types (
     coverage_type varchar PRIMARY KEY
@@ -828,6 +828,15 @@
             -- CHECK(ST_IsSimple(CAST(areas AS geometry))),
         PRIMARY KEY (sounding_result_id, height)
     )
+
+    CREATE TABLE sounding_results_marking_points (
+        sounding_result_id int NOT NULL REFERENCES sounding_results
+          ON DELETE CASCADE,
+        height numeric NOT NULL,
+        points geometry(MULTIPOINTZ, 4326) NOT NULL,
+        PRIMARY KEY (sounding_result_id, height)
+    )
+
     --
     -- Fairway availability
     --
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1460/01.markings.sql	Thu Jul 08 00:14:58 2021 +0200
@@ -0,0 +1,15 @@
+INSERT INTO survey_types (survey_type) VALUES ('marking');
+
+CREATE TABLE waterway.sounding_results_marking_points (
+   sounding_result_id int NOT NULL REFERENCES waterway.sounding_results
+     ON DELETE CASCADE,
+   height numeric NOT NULL,
+   points geometry(MULTIPOINTZ, 4326) NOT NULL,
+   PRIMARY KEY (sounding_result_id, height)
+);
+
+GRANT INSERT, UPDATE, DELETE ON waterway.sounding_results_marking_points
+  TO waterway_admin;
+
+GRANT SELECT ON waterway.sounding_results_marking_points
+  TO waterway_user;
--- a/schema/version.sql	Wed Jul 07 20:11:17 2021 +0200
+++ b/schema/version.sql	Thu Jul 08 00:14:58 2021 +0200
@@ -1,1 +1,1 @@
-INSERT INTO gemma_schema_version(version) VALUES (1454);
+INSERT INTO gemma_schema_version(version) VALUES (1460);