diff --git a/internal/geo/polyline.go b/internal/geo/polyline.go new file mode 100644 index 0000000..3a9a1db --- /dev/null +++ b/internal/geo/polyline.go @@ -0,0 +1,38 @@ +package geo + +import ( + "encoding/json" + "fmt" + + geom "github.com/peterstace/simplefeatures/geom" +) + +// ParsePolyline parses a JSON array of coordinates into a geom.LineString. +// Input format: "[[x1,y1],[x2,y2],...]" +func ParsePolyline(input string) (geom.LineString, error) { + var coords [][]float64 + if err := json.Unmarshal([]byte(input), &coords); err != nil { + return geom.LineString{}, fmt.Errorf("failed to parse polyline JSON: %w", err) + } + + if len(coords) < 2 { + return geom.LineString{}, fmt.Errorf("polyline must have at least 2 points, got %d", len(coords)) + } + + // Build coordinate sequence for LineString + flatCoords := make([]float64, 0, len(coords)*2) + for i, coord := range coords { + if len(coord) < 2 { + return geom.LineString{}, fmt.Errorf("coordinate %d has insufficient values", i) + } + flatCoords = append(flatCoords, coord[0], coord[1]) + } + + seq := geom.NewSequence(flatCoords, geom.DimXY) + ls, err := geom.NewLineString(seq) + if err != nil { + return geom.LineString{}, fmt.Errorf("failed to create LineString: %w", err) + } + + return ls, nil +} diff --git a/internal/geo/polyline_test.go b/internal/geo/polyline_test.go new file mode 100644 index 0000000..7ba635f --- /dev/null +++ b/internal/geo/polyline_test.go @@ -0,0 +1,85 @@ +package geo + +import ( + "testing" +) + +func TestParsePolyline_Valid(t *testing.T) { + input := "[[100.5,200.25],[300.75,400.5],[500,600]]" + + ls, err := ParsePolyline(input) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + seq := ls.Coordinates() + if seq.Length() != 3 { + t.Fatalf("expected 3 points, got %d", seq.Length()) + } + + expected := [][2]float64{ + {100.5, 200.25}, + {300.75, 400.5}, + {500, 600}, + } + for i := 0; i < seq.Length(); i++ { + pt := seq.GetXY(i) + if pt.X != expected[i][0] || pt.Y != expected[i][1] { + t.Errorf("point %d: expected (%f,%f), got (%f,%f)", i, expected[i][0], expected[i][1], pt.X, pt.Y) + } + } +} + +func TestParsePolyline_TwoPoints(t *testing.T) { + input := "[[0,0],[100,100]]" + + ls, err := ParsePolyline(input) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ls.Coordinates().Length() != 2 { + t.Fatalf("expected 2 points, got %d", ls.Coordinates().Length()) + } +} + +func TestParsePolyline_InvalidJSON(t *testing.T) { + input := "not valid json" + + _, err := ParsePolyline(input) + + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParsePolyline_TooFewPoints(t *testing.T) { + input := "[[100,200]]" + + _, err := ParsePolyline(input) + + if err == nil { + t.Fatal("expected error for single point") + } +} + +func TestParsePolyline_EmptyArray(t *testing.T) { + input := "[]" + + _, err := ParsePolyline(input) + + if err == nil { + t.Fatal("expected error for empty array") + } +} + +func TestParsePolyline_InsufficientCoordinates(t *testing.T) { + input := "[[100],[200,300]]" + + _, err := ParsePolyline(input) + + if err == nil { + t.Fatal("expected error for coordinate with single value") + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 242a501..7c5a7e0 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1391,19 +1391,28 @@ func (s *Service) LogMarkerCreate(data []string) (model.Marker, error) { // side marker.Side = data[9] - // position - parse from arma string "[x,y,z]" + // shape - read first to determine position format + marker.Shape = data[11] + + // position - parse based on shape pos := data[10] - pos = strings.TrimPrefix(pos, "[") - pos = strings.TrimSuffix(pos, "]") - point, _, err := geo.Coord3857FromString(pos) - if err != nil { - s.writeLog(functionName, fmt.Sprintf("Error parsing position: %v", err), "ERROR") - return marker, err + if marker.Shape == "POLYLINE" { + polyline, err := geo.ParsePolyline(pos) + if err != nil { + s.writeLog(functionName, fmt.Sprintf("Error parsing polyline: %v", err), "ERROR") + return marker, err + } + marker.Polyline = polyline + } else { + pos = strings.TrimPrefix(pos, "[") + pos = strings.TrimSuffix(pos, "]") + point, _, err := geo.Coord3857FromString(pos) + if err != nil { + s.writeLog(functionName, fmt.Sprintf("Error parsing position: %v", err), "ERROR") + return marker, err + } + marker.Position = point } - marker.Position = point - - // shape - marker.Shape = data[11] // alpha alpha, err := strconv.ParseFloat(data[12], 32) diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index 56e6c8f..fcdf5db 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -18,6 +18,20 @@ func pointToPosition3D(p geom.Point) core.Position3D { return core.Position3D{X: coord.XY.X, Y: coord.XY.Y, Z: coord.Z} } +// lineStringToPolyline converts a geom.LineString to a core.Polyline +func lineStringToPolyline(ls geom.LineString) core.Polyline { + seq := ls.Coordinates() + if seq.Length() == 0 { + return nil + } + polyline := make(core.Polyline, seq.Length()) + for i := 0; i < seq.Length(); i++ { + pt := seq.GetXY(i) + polyline[i] = core.Position2D{X: pt.X, Y: pt.Y} + } + return polyline +} + // SoldierToCore converts a GORM Soldier to a core.Soldier. // GORM Soldier.ObjectID maps to core Soldier.ID. func SoldierToCore(s model.Soldier) core.Soldier { @@ -335,6 +349,7 @@ func MarkerToCore(m model.Marker) core.Marker { Size: m.Size, Side: m.Side, Position: pointToPosition3D(m.Position), + Polyline: lineStringToPolyline(m.Polyline), Shape: m.Shape, Alpha: m.Alpha, Brush: m.Brush, diff --git a/internal/model/core/marker.go b/internal/model/core/marker.go index 6711766..4f2343c 100644 --- a/internal/model/core/marker.go +++ b/internal/model/core/marker.go @@ -18,6 +18,7 @@ type Marker struct { Size string Side string Position Position3D + Polyline Polyline Shape string Alpha float32 Brush string diff --git a/internal/model/core/types.go b/internal/model/core/types.go index 21f15d2..8caf0b8 100644 --- a/internal/model/core/types.go +++ b/internal/model/core/types.go @@ -8,6 +8,15 @@ type Position3D struct { Z float64 `json:"z"` // elevation ASL } +// Position2D represents a 2D map coordinate +type Position2D struct { + X float64 `json:"x"` // easting + Y float64 `json:"y"` // northing +} + +// Polyline represents a sequence of 2D positions for line markers +type Polyline []Position2D + // SoldierScores stores Arma 3 player scores type SoldierScores struct { InfantryKills uint8 `json:"infantryKills"` diff --git a/internal/model/model.go b/internal/model/model.go index 4cc2fe7..fa4d054 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -699,8 +699,9 @@ type Marker struct { Color string `json:"color" gorm:"size:32"` Size string `json:"size" gorm:"size:32"` // stored as "[w,h]" Side string `json:"side" gorm:"size:16"` - Position geom.Point `json:"position"` - Shape string `json:"shape" gorm:"size:32"` + Position geom.Point `json:"position"` + Polyline geom.LineString `json:"polyline"` + Shape string `json:"shape" gorm:"size:32"` Alpha float32 `json:"alpha"` Brush string `json:"brush" gorm:"size:32"` IsDeleted bool `json:"isDeleted" gorm:"default:false"` diff --git a/internal/storage/memory/export.go b/internal/storage/memory/export.go index bda078e..2dc569a 100644 --- a/internal/storage/memory/export.go +++ b/internal/storage/memory/export.go @@ -332,26 +332,42 @@ func (b *Backend) buildExport() OcapExport { // Convert markers // Format: [type, text, startFrame, endFrame, playerId, color, sideIndex, positions, size, shape, brush] - // Where positions is: [[frameNum, [x, y], direction, alpha], ...] + // positions is always: [[frameNum, pos, direction, alpha], ...] + // For POLYLINE: pos is [[x1,y1],[x2,y2],...] (array of coordinates) + // For other shapes: pos is [x, y] (single coordinate) for _, record := range b.markers { - positions := make([][]any, 0) - - // Initial position: [frameNum, [x, y], direction, alpha] - positions = append(positions, []any{ - record.Marker.CaptureFrame, - []float64{record.Marker.Position.X, record.Marker.Position.Y}, - record.Marker.Direction, - record.Marker.Alpha, - }) + posArray := make([][]any, 0) - // State changes - for _, state := range record.States { - positions = append(positions, []any{ - state.CaptureFrame, - []float64{state.Position.X, state.Position.Y}, - state.Direction, - state.Alpha, + if record.Marker.Shape == "POLYLINE" { + // For polylines: pos contains the coordinate array + coords := make([][]float64, len(record.Marker.Polyline)) + for i, pt := range record.Marker.Polyline { + coords[i] = []float64{pt.X, pt.Y} + } + posArray = append(posArray, []any{ + record.Marker.CaptureFrame, + coords, // [[x1,y1], [x2,y2], ...] + record.Marker.Direction, + record.Marker.Alpha, }) + } else { + // For other shapes: pos is a single coordinate + posArray = append(posArray, []any{ + record.Marker.CaptureFrame, + []float64{record.Marker.Position.X, record.Marker.Position.Y}, + record.Marker.Direction, + record.Marker.Alpha, + }) + + // State changes + for _, state := range record.States { + posArray = append(posArray, []any{ + state.CaptureFrame, + []float64{state.Position.X, state.Position.Y}, + state.Direction, + state.Alpha, + }) + } } // Strip "#" prefix from hex colors (e.g., "#800000" -> "800000") for URL compatibility @@ -367,7 +383,7 @@ func (b *Backend) buildExport() OcapExport { record.Marker.OwnerID, // [4] playerId (entity ID of creating player, -1 for system markers) markerColor, // [5] color (# prefix stripped for URL compatibility) sideToIndex(record.Marker.Side), // [6] sideIndex - positions, // [7] positions + posArray, // [7] positions parseMarkerSize(record.Marker.Size), // [8] size record.Marker.Shape, // [9] shape record.Marker.Brush, // [10] brush diff --git a/internal/storage/memory/export_test.go b/internal/storage/memory/export_test.go index 814b446..f2e66e4 100644 --- a/internal/storage/memory/export_test.go +++ b/internal/storage/memory/export_test.go @@ -1074,3 +1074,51 @@ func TestTimesExportJSON(t *testing.T) { assert.Equal(t, float64(1.0), timeEntry["timeMultiplier"]) assert.Equal(t, float64(0), timeEntry["time"]) } + +func TestPolylineMarkerExport(t *testing.T) { + b := New(config.MemoryConfig{}) + + require.NoError(t, b.StartMission(&core.Mission{MissionName: "Test", StartTime: time.Now()}, &core.World{WorldName: "Test"})) + + // Add a polyline marker + require.NoError(t, b.AddMarker(&core.Marker{ + MarkerName: "polyline_1", Text: "", MarkerType: "mil_dot", Color: "000000", + Side: "GLOBAL", Shape: "POLYLINE", OwnerID: 0, CaptureFrame: 71, + Polyline: core.Polyline{ + {X: 8261.73, Y: 18543.5}, + {X: 8160.17, Y: 18527.4}, + {X: 8051.69, Y: 18497.4}, + }, + Direction: 0, Alpha: 1.0, Brush: "Solid", + })) + + export := b.buildExport() + + require.Len(t, export.Markers, 1) + marker := export.Markers[0] + + // Verify shape at index 9 + assert.Equal(t, "POLYLINE", marker[9]) + + // Verify positions at index 7 is frame-based: [[frameNum, [[x1,y1], [x2,y2], ...], direction, alpha]] + positions, ok := marker[7].([][]any) + require.True(t, ok, "positions should be [][]any, got %T", marker[7]) + require.Len(t, positions, 1) // Single frame entry for polylines + + frameEntry := positions[0] + assert.EqualValues(t, 71, frameEntry[0]) // frameNum + assert.EqualValues(t, 0, frameEntry[2]) // direction + assert.EqualValues(t, 1.0, frameEntry[3]) // alpha + + // frameEntry[1] contains the coordinate array + coords, ok := frameEntry[1].([][]float64) + require.True(t, ok, "polyline coords should be [][]float64, got %T", frameEntry[1]) + require.Len(t, coords, 3) + + assert.Equal(t, 8261.73, coords[0][0]) + assert.Equal(t, 18543.5, coords[0][1]) + assert.Equal(t, 8160.17, coords[1][0]) + assert.Equal(t, 18527.4, coords[1][1]) + assert.Equal(t, 8051.69, coords[2][0]) + assert.Equal(t, 18497.4, coords[2][1]) +}