Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions internal/geo/polyline.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions internal/geo/polyline_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
31 changes: 20 additions & 11 deletions internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions internal/model/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/model/core/marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Marker struct {
Size string
Side string
Position Position3D
Polyline Polyline
Shape string
Alpha float32
Brush string
Expand Down
9 changes: 9 additions & 0 deletions internal/model/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
5 changes: 3 additions & 2 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
52 changes: 34 additions & 18 deletions internal/storage/memory/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions internal/storage/memory/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Loading