diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a7e7e01 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,111 @@ +# Copilot Instructions for draw2d + +## Project Overview + +draw2d is a Go 2D vector graphics library with multiple backends: +- `draw2d` — Core package: interfaces (`GraphicContext`, `PathBuilder`), types (`Matrix`, `Path`), and font management +- `draw2dbase` — Base implementations shared across backends (`StackGraphicContext`, flattener, stroker, dasher) +- `draw2dimg` — Raster image backend (using freetype-go) +- `draw2dpdf` — PDF backend (using gofpdf) +- `draw2dsvg` — SVG backend +- `draw2dgl` — OpenGL backend +- `draw2dkit` — Drawing helpers (`Rectangle`, `Circle`, `Ellipse`, `RoundedRectangle`) +- `samples/` — Example drawings used as integration tests + +## Language and Conventions + +- All code, comments, commit messages, and documentation must be written in **English**. +- The project uses **Go 1.20+** (see `go.mod`). + +## Code Style + +### File Headers + +Source files include a copyright header and creation date: +```go +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 21/11/2010 by Laurent Le Goff +``` + +New files should follow the same pattern with the current date and author name. + +### Comments + +- Exported types, functions, and methods must have GoDoc comments. +- Comments should start with the name of the thing being documented: + ```go + // Rectangle draws a rectangle using a path between (x1,y1) and (x2,y2) + func Rectangle(path draw2d.PathBuilder, x1, y1, x2, y2 float64) { + ``` +- Package comments go in the main source file or a `doc.go` file. + +### Naming + +- Follow standard Go naming conventions (camelCase for unexported, PascalCase for exported). +- Backend packages are named `draw2d` (e.g., `draw2dimg`, `draw2dpdf`). +- The `GraphicContext` struct in each backend embeds `*draw2dbase.StackGraphicContext`. + +### Error Handling + +- Functions that can fail return `error` as the last return value. +- Do not silently ignore errors — log or return them. + +## Testing + +### Structure + +- **Unit tests** go alongside the source file they test (e.g., `matrix_test.go` tests `matrix.go`). +- **Integration/sample tests** live in `samples_test.go`, `draw2dpdf/samples_test.go`, etc. +- Test output files go in the `output/` directory (generated, not committed). + +### Writing Tests + +- Use the standard `testing` package only — no external test frameworks. +- Use table-driven tests where multiple inputs share the same logic: + ```go + tests := []struct { + name string + // ... + }{ + {"case1", ...}, + {"case2", ...}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { ... }) + } + ``` +- Tests must not depend on external resources (fonts, network) unless testing that specific integration. +- For image-based tests, use `image.NewRGBA(image.Rect(0, 0, w, h))` as the canvas. +- Use `t.TempDir()` for any file output in tests. +- Reference GitHub issue numbers in regression test comments: + ```go + // Test related to issue #95: DashVertexConverter state preservation + ``` + +### Running Tests + +```bash +go test ./... +go test -cover ./... | grep -v "no test" +``` + +### Test Coverage Goals + +- Every exported function and method should have at least one unit test. +- Core types (`Matrix`, `Path`, `StackGraphicContext`) should have thorough coverage. +- Backend-specific operations (`Stroke`, `Fill`, `FillStroke`, `Clear`) should verify pixel output where possible. +- Known bugs in the issue tracker should have corresponding regression tests. + +## Documentation + +- When adding or changing public API, update the GoDoc comments accordingly. +- When fixing a bug, add a comment referencing the issue number. +- If a change affects behavior described in `README.md` or package READMEs, update them. +- The `samples/` directory serves as living documentation — keep samples working after changes. + +## Architecture Notes + +- All backends implement the `draw2d.GraphicContext` interface defined in `gc.go`. +- `draw2dbase.StackGraphicContext` provides the common state management (colors, transforms, font, path). Backends embed it and override rendering methods (`Stroke`, `Fill`, `FillStroke`, string drawing, etc.). +- The `draw2dkit` helpers operate on `draw2d.PathBuilder`, not `GraphicContext`, making them backend-agnostic. +- `Matrix` is a `[6]float64` affine transformation matrix. Coordinate system follows the HTML Canvas 2D Context conventions. diff --git a/draw2dbase/dasher_test.go b/draw2dbase/dasher_test.go new file mode 100644 index 0000000..8242d63 --- /dev/null +++ b/draw2dbase/dasher_test.go @@ -0,0 +1,127 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dbase + +import ( + "testing" +) + +// Test related to issue #95: DashVertexConverter state preservation +func TestDashVertexConverter_StatePreservation(t *testing.T) { + segPath := &SegmentedPath{} + dash := []float64{5, 5} + dasher := NewDashConverter(dash, 0, segPath) + + dasher.MoveTo(0, 0) + initialLen := len(segPath.Points) + + dasher.LineTo(10, 0) + afterFirstLen := len(segPath.Points) + + dasher.LineTo(20, 0) + afterSecondLen := len(segPath.Points) + + // Second LineTo should add more points + if afterSecondLen <= afterFirstLen { + t.Error("Second LineTo should add more points, state may not be preserved") + } + if initialLen >= afterFirstLen { + t.Error("First LineTo should add points") + } +} + +func TestDashVertexConverter_SingleDash(t *testing.T) { + segPath := &SegmentedPath{} + dash := []float64{10} + dasher := NewDashConverter(dash, 0, segPath) + + dasher.MoveTo(0, 0) + dasher.LineTo(50, 0) + + // Should produce output + if len(segPath.Points) == 0 { + t.Error("Single-element dash array should produce output") + } +} + +func TestDashVertexConverter_DashOffset(t *testing.T) { + segPath1 := &SegmentedPath{} + segPath2 := &SegmentedPath{} + dash := []float64{5, 5} + + dasher1 := NewDashConverter(dash, 0, segPath1) + dasher1.MoveTo(0, 0) + dasher1.LineTo(50, 0) + + dasher2 := NewDashConverter(dash, 2.5, segPath2) + dasher2.MoveTo(0, 0) + dasher2.LineTo(50, 0) + + // Different offsets should produce different output + if len(segPath1.Points) == len(segPath2.Points) { + // Check if points are actually different + allSame := true + minLen := len(segPath1.Points) + if len(segPath2.Points) < minLen { + minLen = len(segPath2.Points) + } + for i := 0; i < minLen; i++ { + if segPath1.Points[i] != segPath2.Points[i] { + allSame = false + break + } + } + if allSame && len(segPath1.Points) > 0 { + t.Error("Different dash offsets should produce different output") + } + } +} + +func TestDashVertexConverter_Close(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Close panicked: %v", r) + } + }() + segPath := &SegmentedPath{} + dash := []float64{5, 5} + dasher := NewDashConverter(dash, 0, segPath) + dasher.MoveTo(0, 0) + dasher.LineTo(10, 10) + dasher.Close() +} + +func TestDashVertexConverter_End(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("End panicked: %v", r) + } + }() + segPath := &SegmentedPath{} + dash := []float64{5, 5} + dasher := NewDashConverter(dash, 0, segPath) + dasher.MoveTo(0, 0) + dasher.LineTo(10, 10) + dasher.End() +} + +func TestDashVertexConverter_MoveTo(t *testing.T) { + segPath := &SegmentedPath{} + dash := []float64{5, 5} + dasher := NewDashConverter(dash, 0, segPath) + + dasher.MoveTo(10, 20) + // Check that position is set correctly + if dasher.x != 10 || dasher.y != 20 { + t.Errorf("MoveTo should set position to (10, 20), got (%f, %f)", dasher.x, dasher.y) + } + // Check that distance is reset to dashOffset + if dasher.distance != dasher.dashOffset { + t.Error("MoveTo should reset distance to dashOffset") + } + // Check that currentDash is reset + if dasher.currentDash != 0 { + t.Error("MoveTo should reset currentDash to 0") + } +} diff --git a/draw2dbase/flattener_test.go b/draw2dbase/flattener_test.go new file mode 100644 index 0000000..ca14f87 --- /dev/null +++ b/draw2dbase/flattener_test.go @@ -0,0 +1,134 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dbase + +import ( + "testing" + + "github.com/llgcode/draw2d" +) + +func TestFlatten_EmptyPath(t *testing.T) { + p := new(draw2d.Path) + segPath := &SegmentedPath{} + Flatten(p, segPath, 1.0) + if len(segPath.Points) != 0 { + t.Error("Empty path should produce no points") + } +} + +func TestFlatten_MoveTo(t *testing.T) { + p := new(draw2d.Path) + p.MoveTo(10, 20) + segPath := &SegmentedPath{} + Flatten(p, segPath, 1.0) + if len(segPath.Points) < 2 { + t.Error("MoveTo should add points to segmented path") + } + if segPath.Points[0] != 10 || segPath.Points[1] != 20 { + t.Errorf("MoveTo point = (%f, %f), want (10, 20)", segPath.Points[0], segPath.Points[1]) + } +} + +func TestFlatten_LineSegments(t *testing.T) { + p := new(draw2d.Path) + p.MoveTo(0, 0) + p.LineTo(10, 10) + segPath := &SegmentedPath{} + Flatten(p, segPath, 1.0) + // Should have at least 4 points (MoveTo + LineTo) + if len(segPath.Points) < 4 { + t.Errorf("MoveTo + LineTo should have at least 4 points, got %d", len(segPath.Points)) + } +} + +func TestFlatten_WithClose(t *testing.T) { + p := new(draw2d.Path) + p.MoveTo(0, 0) + p.LineTo(10, 0) + p.LineTo(10, 10) + p.Close() + segPath := &SegmentedPath{} + Flatten(p, segPath, 1.0) + // Close should add a line back to start + lastIdx := len(segPath.Points) - 2 + if lastIdx >= 0 { + lastX, lastY := segPath.Points[lastIdx], segPath.Points[lastIdx+1] + // Should be back at start (0, 0) + if lastX != 0 || lastY != 0 { + t.Errorf("After Close, last point should be (0, 0), got (%f, %f)", lastX, lastY) + } + } +} + +func TestTransformer_Identity(t *testing.T) { + segPath := &SegmentedPath{} + tr := Transformer{ + Tr: draw2d.NewIdentityMatrix(), + Flattener: segPath, + } + tr.MoveTo(10, 20) + tr.LineTo(30, 40) + // Identity transform should pass through + if segPath.Points[0] != 10 || segPath.Points[1] != 20 { + t.Error("Identity transform should pass through points") + } + if segPath.Points[2] != 30 || segPath.Points[3] != 40 { + t.Error("Identity transform should pass through points") + } +} + +func TestTransformer_Translation(t *testing.T) { + segPath := &SegmentedPath{} + tr := Transformer{ + Tr: draw2d.NewTranslationMatrix(5, 10), + Flattener: segPath, + } + tr.MoveTo(10, 20) + // Should be translated to (15, 30) + if segPath.Points[0] != 15 || segPath.Points[1] != 30 { + t.Errorf("Translation transform: point = (%f, %f), want (15, 30)", segPath.Points[0], segPath.Points[1]) + } +} + +func TestSegmentedPath_MoveTo(t *testing.T) { + segPath := &SegmentedPath{} + segPath.MoveTo(10, 20) + if len(segPath.Points) != 2 { + t.Error("MoveTo should append 2 points") + } + if segPath.Points[0] != 10 || segPath.Points[1] != 20 { + t.Error("MoveTo should append correct coordinates") + } +} + +func TestSegmentedPath_LineTo(t *testing.T) { + segPath := &SegmentedPath{} + segPath.MoveTo(0, 0) + segPath.LineTo(10, 10) + if len(segPath.Points) != 4 { + t.Error("MoveTo + LineTo should have 4 points") + } + if segPath.Points[2] != 10 || segPath.Points[3] != 10 { + t.Error("LineTo should append correct coordinates") + } +} + +func TestDemuxFlattener(t *testing.T) { + segPath1 := &SegmentedPath{} + segPath2 := &SegmentedPath{} + demux := DemuxFlattener{ + Flatteners: []Flattener{segPath1, segPath2}, + } + demux.MoveTo(10, 20) + demux.LineTo(30, 40) + + // Both flatteners should receive the calls + if len(segPath1.Points) != 4 || len(segPath2.Points) != 4 { + t.Error("DemuxFlattener should dispatch to all flatteners") + } + if segPath1.Points[0] != 10 || segPath2.Points[0] != 10 { + t.Error("DemuxFlattener should dispatch correct values") + } +} diff --git a/draw2dbase/stack_gc_test.go b/draw2dbase/stack_gc_test.go new file mode 100644 index 0000000..731de46 --- /dev/null +++ b/draw2dbase/stack_gc_test.go @@ -0,0 +1,360 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dbase + +import ( + "image" + "testing" + + "github.com/llgcode/draw2d" +) + +func TestNewStackGraphicContext_Defaults(t *testing.T) { + gc := NewStackGraphicContext() + if gc.Current.LineWidth != 1.0 { + t.Errorf("Default LineWidth = %f, want 1.0", gc.Current.LineWidth) + } + if gc.Current.Cap != draw2d.RoundCap { + t.Errorf("Default Cap = %v, want RoundCap", gc.Current.Cap) + } + if gc.Current.Join != draw2d.RoundJoin { + t.Errorf("Default Join = %v, want RoundJoin", gc.Current.Join) + } + if gc.Current.FillRule != draw2d.FillRuleEvenOdd { + t.Errorf("Default FillRule = %v, want EvenOdd", gc.Current.FillRule) + } + if gc.Current.FontSize != 10 { + t.Errorf("Default FontSize = %f, want 10", gc.Current.FontSize) + } + if gc.Current.FontData.Name != "luxi" { + t.Errorf("Default FontData.Name = %s, want 'luxi'", gc.Current.FontData.Name) + } + if !gc.Current.Tr.IsIdentity() { + t.Error("Default matrix should be identity") + } + if gc.Current.StrokeColor != image.Black { + t.Error("Default StrokeColor should be Black") + } + if gc.Current.FillColor != image.White { + t.Error("Default FillColor should be White") + } + if gc.Current.Path == nil { + t.Error("Default Path should not be nil") + } +} + +func TestStackGraphicContext_SetStrokeColor(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetStrokeColor(image.White) + if gc.Current.StrokeColor != image.White { + t.Error("SetStrokeColor failed") + } +} + +func TestStackGraphicContext_SetFillColor(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetFillColor(image.Black) + if gc.Current.FillColor != image.Black { + t.Error("SetFillColor failed") + } +} + +func TestStackGraphicContext_SetLineWidth(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetLineWidth(5.0) + if gc.Current.LineWidth != 5.0 { + t.Errorf("SetLineWidth = %f, want 5.0", gc.Current.LineWidth) + } +} + +func TestStackGraphicContext_SetLineCap(t *testing.T) { + gc := NewStackGraphicContext() + caps := []draw2d.LineCap{draw2d.RoundCap, draw2d.ButtCap, draw2d.SquareCap} + for _, cap := range caps { + gc.SetLineCap(cap) + if gc.Current.Cap != cap { + t.Errorf("SetLineCap(%v) failed", cap) + } + } +} + +func TestStackGraphicContext_SetLineJoin(t *testing.T) { + gc := NewStackGraphicContext() + joins := []draw2d.LineJoin{draw2d.RoundJoin, draw2d.BevelJoin, draw2d.MiterJoin} + for _, join := range joins { + gc.SetLineJoin(join) + if gc.Current.Join != join { + t.Errorf("SetLineJoin(%v) failed", join) + } + } +} + +func TestStackGraphicContext_SetLineDash(t *testing.T) { + gc := NewStackGraphicContext() + dash := []float64{5, 5} + offset := 2.5 + gc.SetLineDash(dash, offset) + if len(gc.Current.Dash) != 2 || gc.Current.Dash[0] != 5 || gc.Current.Dash[1] != 5 { + t.Error("SetLineDash: dash array not set correctly") + } + if gc.Current.DashOffset != offset { + t.Errorf("SetLineDash: offset = %f, want %f", gc.Current.DashOffset, offset) + } +} + +func TestStackGraphicContext_SetFillRule(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetFillRule(draw2d.FillRuleWinding) + if gc.Current.FillRule != draw2d.FillRuleWinding { + t.Error("SetFillRule failed") + } + gc.SetFillRule(draw2d.FillRuleEvenOdd) + if gc.Current.FillRule != draw2d.FillRuleEvenOdd { + t.Error("SetFillRule failed") + } +} + +func TestStackGraphicContext_FontSize(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetFontSize(12.0) + if gc.GetFontSize() != 12.0 { + t.Errorf("FontSize = %f, want 12.0", gc.GetFontSize()) + } +} + +func TestStackGraphicContext_FontData(t *testing.T) { + gc := NewStackGraphicContext() + fontData := draw2d.FontData{Name: "test", Family: draw2d.FontFamilySerif, Style: draw2d.FontStyleBold} + gc.SetFontData(fontData) + result := gc.GetFontData() + if result.Name != "test" || result.Family != draw2d.FontFamilySerif || result.Style != draw2d.FontStyleBold { + t.Error("SetFontData/GetFontData failed") + } +} + +func TestStackGraphicContext_GetFontName(t *testing.T) { + gc := NewStackGraphicContext() + name := gc.GetFontName() + if name == "" { + t.Error("GetFontName should return non-empty string") + } +} + +func TestStackGraphicContext_Translate(t *testing.T) { + gc := NewStackGraphicContext() + gc.Translate(5, 10) + m := gc.GetMatrixTransform() + x, y := m.GetTranslation() + if x != 5 || y != 10 { + t.Errorf("Translate: translation = (%f, %f), want (5, 10)", x, y) + } +} + +func TestStackGraphicContext_Scale(t *testing.T) { + gc := NewStackGraphicContext() + gc.Scale(2, 3) + m := gc.GetMatrixTransform() + sx, sy := m.GetScaling() + if sx != 2 || sy != 3 { + t.Errorf("Scale: scaling = (%f, %f), want (2, 3)", sx, sy) + } +} + +func TestStackGraphicContext_Rotate(t *testing.T) { + gc := NewStackGraphicContext() + gc.Rotate(1.57) // ~π/2 + m := gc.GetMatrixTransform() + if m.IsIdentity() { + t.Error("After Rotate, matrix should not be identity") + } +} + +func TestStackGraphicContext_SetGetMatrixTransform(t *testing.T) { + gc := NewStackGraphicContext() + tr := draw2d.NewTranslationMatrix(10, 20) + gc.SetMatrixTransform(tr) + result := gc.GetMatrixTransform() + if !result.Equals(tr) { + t.Error("SetMatrixTransform/GetMatrixTransform round trip failed") + } +} + +func TestStackGraphicContext_ComposeMatrixTransform(t *testing.T) { + gc := NewStackGraphicContext() + gc.Translate(5, 10) + scale := draw2d.NewScaleMatrix(2, 3) + gc.ComposeMatrixTransform(scale) + m := gc.GetMatrixTransform() + // Should be composed transformation + if m.IsIdentity() { + t.Error("After ComposeMatrixTransform, matrix should not be identity") + } +} + +func TestStackGraphicContext_SaveRestore(t *testing.T) { + gc := NewStackGraphicContext() + // Set some values + gc.SetLineWidth(5.0) + gc.SetStrokeColor(image.White) + gc.SetFillColor(image.Black) + gc.SetFontSize(20.0) + + // Save + gc.Save() + + // Change values + gc.SetLineWidth(10.0) + gc.SetStrokeColor(image.Black) + gc.SetFillColor(image.White) + gc.SetFontSize(30.0) + + // Restore + gc.Restore() + + // Check restored values + if gc.Current.LineWidth != 5.0 { + t.Errorf("After Restore, LineWidth = %f, want 5.0", gc.Current.LineWidth) + } + if gc.Current.StrokeColor != image.White { + t.Error("After Restore, StrokeColor should be White") + } + if gc.Current.FillColor != image.Black { + t.Error("After Restore, FillColor should be Black") + } + if gc.Current.FontSize != 20.0 { + t.Errorf("After Restore, FontSize = %f, want 20.0", gc.Current.FontSize) + } +} + +func TestStackGraphicContext_SaveRestore_MatrixIndependence(t *testing.T) { + gc := NewStackGraphicContext() + // Save + gc.Save() + // Translate + gc.Translate(10, 20) + // Restore + gc.Restore() + // Should be back to identity + m := gc.GetMatrixTransform() + if !m.IsIdentity() { + t.Error("After Save/Translate/Restore, matrix should be identity") + } +} + +func TestStackGraphicContext_RestoreWithoutSave(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetLineWidth(5.0) + // Restore without Save should not crash + gc.Restore() + // Values should be unchanged + if gc.Current.LineWidth != 5.0 { + t.Error("Restore without Save should not change values") + } +} + +func TestStackGraphicContext_MultipleSaveRestore(t *testing.T) { + gc := NewStackGraphicContext() + gc.SetLineWidth(1.0) + + gc.Save() + gc.SetLineWidth(2.0) + + gc.Save() + gc.SetLineWidth(3.0) + + gc.Save() + gc.SetLineWidth(4.0) + + gc.Restore() + if gc.Current.LineWidth != 3.0 { + t.Errorf("After 1st Restore, LineWidth = %f, want 3.0", gc.Current.LineWidth) + } + + gc.Restore() + if gc.Current.LineWidth != 2.0 { + t.Errorf("After 2nd Restore, LineWidth = %f, want 2.0", gc.Current.LineWidth) + } + + gc.Restore() + if gc.Current.LineWidth != 1.0 { + t.Errorf("After 3rd Restore, LineWidth = %f, want 1.0", gc.Current.LineWidth) + } +} + +func TestStackGraphicContext_BeginPath(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(10, 20) + gc.BeginPath() + if !gc.IsEmpty() { + t.Error("BeginPath should clear the path") + } +} + +func TestStackGraphicContext_PathOperations(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(10, 20) + x, y := gc.LastPoint() + if x != 10 || y != 20 { + t.Errorf("LastPoint = (%f, %f), want (10, 20)", x, y) + } + gc.LineTo(30, 40) + x, y = gc.LastPoint() + if x != 30 || y != 40 { + t.Errorf("LastPoint after LineTo = (%f, %f), want (30, 40)", x, y) + } +} + +func TestStackGraphicContext_GetPath_ReturnsCopy(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(10, 20) + p := gc.GetPath() + // Modify gc's path + gc.LineTo(30, 40) + // Returned copy should be unchanged + if len(p.Components) != 1 { + t.Error("GetPath should return a copy, not a reference") + } +} + +func TestStackGraphicContext_QuadCurveTo(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(0, 0) + gc.QuadCurveTo(10, 10, 20, 0) + x, y := gc.LastPoint() + if x != 20 || y != 0 { + t.Errorf("LastPoint after QuadCurveTo = (%f, %f), want (20, 0)", x, y) + } +} + +func TestStackGraphicContext_CubicCurveTo(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(0, 0) + gc.CubicCurveTo(10, 0, 20, 20, 30, 20) + x, y := gc.LastPoint() + if x != 30 || y != 20 { + t.Errorf("LastPoint after CubicCurveTo = (%f, %f), want (30, 20)", x, y) + } +} + +func TestStackGraphicContext_ArcTo(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(0, 0) + gc.ArcTo(100, 100, 50, 50, 0, 3.14) + // Should not crash and should update last point + x, y := gc.LastPoint() + if x == 0 && y == 0 { + t.Error("ArcTo should update last point") + } +} + +func TestStackGraphicContext_Close(t *testing.T) { + gc := NewStackGraphicContext() + gc.MoveTo(0, 0) + gc.LineTo(10, 0) + gc.Close() + p := gc.GetPath() + if len(p.Components) == 0 || p.Components[len(p.Components)-1] != draw2d.CloseCmp { + t.Error("Close should add CloseCmp") + } +} diff --git a/draw2dbase/stroker_test.go b/draw2dbase/stroker_test.go new file mode 100644 index 0000000..2e406ce --- /dev/null +++ b/draw2dbase/stroker_test.go @@ -0,0 +1,103 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dbase + +import ( + "math" + "testing" + + "github.com/llgcode/draw2d" +) + +func TestLineStroker_BasicLine(t *testing.T) { + segPath := &SegmentedPath{} + stroker := NewLineStroker(draw2d.RoundCap, draw2d.RoundJoin, segPath) + stroker.HalfLineWidth = 1.0 + + stroker.MoveTo(0, 0) + stroker.LineTo(10, 0) + stroker.End() + + // Should produce output in the inner flattener + if len(segPath.Points) == 0 { + t.Error("LineStroker should produce output") + } +} + +func TestLineStroker_HalfLineWidth(t *testing.T) { + segPath1 := &SegmentedPath{} + stroker1 := NewLineStroker(draw2d.RoundCap, draw2d.RoundJoin, segPath1) + stroker1.HalfLineWidth = 1.0 + stroker1.MoveTo(0, 0) + stroker1.LineTo(10, 0) + stroker1.End() + + segPath2 := &SegmentedPath{} + stroker2 := NewLineStroker(draw2d.RoundCap, draw2d.RoundJoin, segPath2) + stroker2.HalfLineWidth = 2.0 + stroker2.MoveTo(0, 0) + stroker2.LineTo(10, 0) + stroker2.End() + + // Different line widths should produce different output + if len(segPath1.Points) == len(segPath2.Points) { + // Check if points are actually different + allSame := true + for i := range segPath1.Points { + if segPath1.Points[i] != segPath2.Points[i] { + allSame = false + break + } + } + if allSame && len(segPath1.Points) > 0 { + t.Error("Different HalfLineWidth should produce different output") + } + } +} + +func TestLineStroker_End(t *testing.T) { + segPath := &SegmentedPath{} + stroker := NewLineStroker(draw2d.RoundCap, draw2d.RoundJoin, segPath) + stroker.HalfLineWidth = 1.0 + + stroker.MoveTo(0, 0) + stroker.LineTo(10, 0) + stroker.LineTo(10, 10) + + initialLen := len(segPath.Points) + stroker.End() + afterEndLen := len(segPath.Points) + + // End should flush output + if afterEndLen <= initialLen { + t.Error("End should flush and add points to output") + } + + // After End, internal state should be reset + if len(stroker.vertices) != 0 || len(stroker.rewind) != 0 { + t.Error("End should reset internal vertices and rewind") + } +} + +func TestVectorDistance(t *testing.T) { + tests := []struct { + name string + dx, dy float64 + expected float64 + }{ + {"horizontal", 3, 0, 3}, + {"vertical", 0, 4, 4}, + {"diagonal", 3, 4, 5}, + {"zero", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := vectorDistance(tt.dx, tt.dy) + if math.Abs(result-tt.expected) > 1e-6 { + t.Errorf("vectorDistance(%f, %f) = %f, want %f", tt.dx, tt.dy, result, tt.expected) + } + }) + } +} diff --git a/draw2dimg/gc_test.go b/draw2dimg/gc_test.go new file mode 100644 index 0000000..7efe539 --- /dev/null +++ b/draw2dimg/gc_test.go @@ -0,0 +1,233 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dimg + +import ( + "image" + "image/color" + "os" + "testing" + + "github.com/llgcode/draw2d/draw2dkit" +) + +func TestNewGraphicContext_RGBA(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + if gc == nil { + t.Error("NewGraphicContext should not return nil for RGBA image") + } +} + +func TestNewGraphicContext_UnsupportedImageType(t *testing.T) { + // Test related to issue #143: Unsupported image types should panic + defer func() { + if r := recover(); r == nil { + t.Error("NewGraphicContext should panic for unsupported image type") + } + }() + img := image.NewPaletted(image.Rect(0, 0, 100, 100), nil) + NewGraphicContext(img) +} + +func TestGraphicContext_Clear(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.NRGBA{255, 0, 0, 255}) + gc.Clear() + // Check that a pixel has the fill color + r, g, b, a := img.At(50, 50).RGBA() + if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 || a>>8 != 255 { + t.Errorf("Clear should fill with fill color, got RGBA(%d, %d, %d, %d)", r>>8, g>>8, b>>8, a>>8) + } +} + +func TestGraphicContext_ClearRect(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + // Clear entire image with white + gc.SetFillColor(color.White) + gc.Clear() + // Clear a rect with red + gc.SetFillColor(color.NRGBA{255, 0, 0, 255}) + gc.ClearRect(10, 10, 20, 20) + // Check inside the rect + r, g, b, _ := img.At(15, 15).RGBA() + if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 { + t.Error("ClearRect should fill rect area with fill color") + } + // Check outside the rect + r2, g2, b2, _ := img.At(50, 50).RGBA() + if r2>>8 != 255 || g2>>8 != 255 || b2>>8 != 255 { + t.Error("ClearRect should not affect area outside rect") + } +} + +func TestGraphicContext_GetDPI(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + if gc.GetDPI() != 92 { + t.Errorf("Default DPI = %d, want 92", gc.GetDPI()) + } +} + +func TestGraphicContext_SetDPI(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetDPI(150) + if gc.GetDPI() != 150 { + t.Errorf("SetDPI(150): DPI = %d, want 150", gc.GetDPI()) + } +} + +func TestGraphicContext_StrokeRectangle(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.White) + gc.Clear() + gc.SetStrokeColor(color.NRGBA{255, 0, 0, 255}) + gc.SetLineWidth(2) + draw2dkit.Rectangle(gc, 10, 10, 90, 90) + gc.Stroke() + // Check that an edge pixel is colored + r, g, b, _ := img.At(10, 10).RGBA() + if r>>8 == 255 && g>>8 == 255 && b>>8 == 255 { + t.Error("StrokeRectangle should draw on edges") + } +} + +func TestGraphicContext_FillRectangle(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.White) + gc.Clear() + gc.SetFillColor(color.NRGBA{0, 255, 0, 255}) + draw2dkit.Rectangle(gc, 20, 20, 80, 80) + gc.Fill() + // Check that an inside pixel is colored + r, g, b, _ := img.At(50, 50).RGBA() + if r>>8 != 0 || g>>8 != 255 || b>>8 != 0 { + t.Errorf("FillRectangle should fill interior, got RGB(%d, %d, %d)", r>>8, g>>8, b>>8) + } +} + +func TestGraphicContext_FillStroke(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.White) + gc.Clear() + gc.SetFillColor(color.NRGBA{0, 255, 0, 255}) + gc.SetStrokeColor(color.NRGBA{255, 0, 0, 255}) + gc.SetLineWidth(2) + draw2dkit.Rectangle(gc, 20, 20, 80, 80) + gc.FillStroke() + // Check that interior is filled + r, g, b, _ := img.At(50, 50).RGBA() + if r>>8 != 0 || g>>8 != 255 || b>>8 != 0 { + t.Error("FillStroke should fill interior") + } +} + +func TestGraphicContext_SaveRestoreColors(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetStrokeColor(color.NRGBA{255, 0, 0, 255}) + gc.SetFillColor(color.NRGBA{0, 255, 0, 255}) + gc.Save() + gc.SetStrokeColor(color.NRGBA{0, 0, 255, 255}) + gc.SetFillColor(color.NRGBA{255, 255, 0, 255}) + gc.Restore() + // Check that colors are restored + r1, g1, b1, _ := gc.Current.StrokeColor.RGBA() + if r1>>8 != 255 || g1>>8 != 0 || b1>>8 != 0 { + t.Error("Restore should restore StrokeColor") + } + r2, g2, b2, _ := gc.Current.FillColor.RGBA() + if r2>>8 != 0 || g2>>8 != 255 || b2>>8 != 0 { + t.Error("Restore should restore FillColor") + } +} + +func TestGraphicContext_SetLineCap(t *testing.T) { + // Test related to issue #155: LineCap should be stored correctly + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetLineCap(0) // ButtCap + if gc.Current.Cap != 0 { + t.Errorf("SetLineCap(ButtCap): got %v, want ButtCap", gc.Current.Cap) + } + gc.SetLineCap(1) // RoundCap + if gc.Current.Cap != 1 { + t.Errorf("SetLineCap(RoundCap): got %v, want RoundCap", gc.Current.Cap) + } + gc.SetLineCap(2) // SquareCap + if gc.Current.Cap != 2 { + t.Errorf("SetLineCap(SquareCap): got %v, want SquareCap", gc.Current.Cap) + } +} + +func TestGraphicContext_DrawImage(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("DrawImage panicked: %v", r) + } + }() + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + srcImg := image.NewRGBA(image.Rect(0, 0, 50, 50)) + gc.DrawImage(srcImg) +} + +func TestSaveToPngFile(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + tmpFile := t.TempDir() + "/test.png" + err := SaveToPngFile(tmpFile, img) + if err != nil { + t.Errorf("SaveToPngFile failed: %v", err) + } + // Verify file exists + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + t.Error("SaveToPngFile did not create file") + } +} + +func TestGraphicContext_TransformAffectsDrawing(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.White) + gc.Clear() + gc.Translate(10, 10) + gc.SetFillColor(color.NRGBA{255, 0, 0, 255}) + draw2dkit.Rectangle(gc, 0, 0, 10, 10) + gc.Fill() + // Rectangle should be at (10,10) due to translation + r, g, b, _ := img.At(15, 15).RGBA() + if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 { + t.Error("Transform should affect drawing position") + } +} + +func TestGraphicContext_LineWidth(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetLineWidth(5.0) + if gc.Current.LineWidth != 5.0 { + t.Errorf("SetLineWidth(5.0): got %f, want 5.0", gc.Current.LineWidth) + } +} + +func TestGraphicContext_Circle_Fill(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + gc := NewGraphicContext(img) + gc.SetFillColor(color.White) + gc.Clear() + gc.SetFillColor(color.NRGBA{0, 0, 255, 255}) + draw2dkit.Circle(gc, 50, 50, 20) + gc.Fill() + // Check that center pixel is colored + r, g, b, _ := img.At(50, 50).RGBA() + if r>>8 != 0 || g>>8 != 0 || b>>8 != 255 { + t.Errorf("Circle Fill should color center pixel, got RGB(%d, %d, %d)", r>>8, g>>8, b>>8) + } +} diff --git a/draw2dkit/draw2dkit_extended_test.go b/draw2dkit/draw2dkit_extended_test.go new file mode 100644 index 0000000..5f19588 --- /dev/null +++ b/draw2dkit/draw2dkit_extended_test.go @@ -0,0 +1,195 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2dkit + +import ( + "image" + "image/color" + "math" + "testing" + + "github.com/llgcode/draw2d" + "github.com/llgcode/draw2d/draw2dimg" +) + +func newTestGC(t *testing.T) *draw2dimg.GraphicContext { + img := image.NewRGBA(image.Rect(0, 0, 200, 200)) + return draw2dimg.NewGraphicContext(img) +} + +func TestRectangle(t *testing.T) { + p := new(draw2d.Path) + Rectangle(p, 10, 10, 100, 100) + if len(p.Components) != 5 { + t.Errorf("Rectangle should have 5 components, got %d", len(p.Components)) + } + // Should be: MoveTo + 3 LineTo + Close + if p.Components[0] != draw2d.MoveToCmp { + t.Error("First component should be MoveTo") + } + if p.Components[len(p.Components)-1] != draw2d.CloseCmp { + t.Error("Last component should be Close") + } +} + +func TestRoundedRectangle(t *testing.T) { + p := new(draw2d.Path) + RoundedRectangle(p, 10, 10, 100, 100, 10, 10) + if p.IsEmpty() { + t.Error("RoundedRectangle should not be empty") + } + // Should contain QuadCurveToCmp + hasQuadCurve := false + for _, cmp := range p.Components { + if cmp == draw2d.QuadCurveToCmp { + hasQuadCurve = true + break + } + } + if !hasQuadCurve { + t.Error("RoundedRectangle should contain QuadCurveToCmp") + } +} + +func TestEllipse(t *testing.T) { + p := new(draw2d.Path) + Ellipse(p, 100, 100, 50, 30) + if p.IsEmpty() { + t.Error("Ellipse should not be empty") + } + // Should contain ArcToCmp + hasArc := false + for _, cmp := range p.Components { + if cmp == draw2d.ArcToCmp { + hasArc = true + break + } + } + if !hasArc { + t.Error("Ellipse should contain ArcToCmp") + } +} + +func TestCircle_PathComponents(t *testing.T) { + p := new(draw2d.Path) + Circle(p, 100, 100, 50) + // Should contain ArcTo and Close + hasArc := false + hasClose := false + for _, cmp := range p.Components { + if cmp == draw2d.ArcToCmp { + hasArc = true + } + if cmp == draw2d.CloseCmp { + hasClose = true + } + } + if !hasArc { + t.Error("Circle should contain ArcToCmp") + } + if !hasClose { + t.Error("Circle should contain CloseCmp") + } +} + +func TestCircle_StrokeDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Circle Stroke panicked: %v", r) + } + }() + gc := newTestGC(t) + gc.SetStrokeColor(color.NRGBA{255, 0, 0, 255}) + gc.SetLineWidth(1) + Circle(gc, 100, 100, 50) + gc.Stroke() +} + +func TestCircle_FillDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Circle Fill panicked: %v", r) + } + }() + gc := newTestGC(t) + gc.SetFillColor(color.NRGBA{0, 0, 255, 255}) + Circle(gc, 100, 100, 50) + gc.Fill() +} + +func TestRectangle_FillStrokeDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Rectangle FillStroke panicked: %v", r) + } + }() + gc := newTestGC(t) + gc.SetStrokeColor(color.NRGBA{255, 0, 0, 255}) + gc.SetFillColor(color.NRGBA{0, 255, 0, 255}) + gc.SetLineWidth(2) + Rectangle(gc, 20, 20, 180, 180) + gc.FillStroke() +} + +func TestEllipse_DifferentRadii(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Ellipse with different radii panicked: %v", r) + } + }() + gc := newTestGC(t) + gc.SetStrokeColor(color.NRGBA{255, 0, 255, 255}) + gc.SetLineWidth(1) + Ellipse(gc, 100, 100, 80, 40) + gc.Stroke() +} + +func TestRoundedRectangle_FillStrokeDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("RoundedRectangle FillStroke panicked: %v", r) + } + }() + gc := newTestGC(t) + gc.SetStrokeColor(color.NRGBA{0, 0, 0, 255}) + gc.SetFillColor(color.NRGBA{255, 255, 0, 255}) + gc.SetLineWidth(2) + RoundedRectangle(gc, 20, 20, 180, 180, 20, 20) + gc.FillStroke() +} + +func TestCircle_FullCircle(t *testing.T) { + p := new(draw2d.Path) + Circle(p, 100, 100, 50) + // Arc should be close to 2π + foundArc := false + for i, cmp := range p.Components { + if cmp == draw2d.ArcToCmp { + foundArc = true + // ArcTo has 6 parameters, angle is the last one + pointIdx := 0 + for j := 0; j < i; j++ { + switch p.Components[j] { + case draw2d.MoveToCmp, draw2d.LineToCmp: + pointIdx += 2 + case draw2d.QuadCurveToCmp: + pointIdx += 4 + case draw2d.CubicCurveToCmp, draw2d.ArcToCmp: + pointIdx += 6 + } + } + if pointIdx+5 < len(p.Points) { + angle := p.Points[pointIdx+5] + // Should be close to -2π + if math.Abs(angle+2*math.Pi) > 0.01 { + t.Errorf("Circle angle = %f, want ~-2π", angle) + } + } + break + } + } + if !foundArc { + t.Error("Circle should contain an arc") + } +} diff --git a/matrix_test.go b/matrix_test.go new file mode 100644 index 0000000..91a1164 --- /dev/null +++ b/matrix_test.go @@ -0,0 +1,277 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2d + +import ( + "math" + "testing" +) + +func TestNewIdentityMatrix(t *testing.T) { + m := NewIdentityMatrix() + if !m.IsIdentity() { + t.Error("NewIdentityMatrix should create an identity matrix") + } + expected := Matrix{1, 0, 0, 1, 0, 0} + if !m.Equals(expected) { + t.Errorf("NewIdentityMatrix = %v, want %v", m, expected) + } +} + +func TestNewTranslationMatrix(t *testing.T) { + m := NewTranslationMatrix(5, 10) + x, y := m.GetTranslation() + if !fequals(x, 5) || !fequals(y, 10) { + t.Errorf("GetTranslation() = (%f, %f), want (5, 10)", x, y) + } + if !m.IsTranslation() { + t.Error("Translation matrix should return true for IsTranslation()") + } +} + +func TestNewScaleMatrix(t *testing.T) { + m := NewScaleMatrix(2, 3) + sx, sy := m.GetScaling() + if !fequals(sx, 2) || !fequals(sy, 3) { + t.Errorf("GetScaling() = (%f, %f), want (2, 3)", sx, sy) + } +} + +func TestNewRotationMatrix(t *testing.T) { + m := NewRotationMatrix(math.Pi / 2) + // Rotating (1,0) by π/2 should give (0,1) + x, y := m.TransformPoint(1, 0) + if !fequals(x, 0) || !fequals(y, 1) { + t.Errorf("Rotating (1,0) by π/2 = (%f, %f), want (0, 1)", x, y) + } +} + +func TestMatrixDeterminant(t *testing.T) { + tests := []struct { + name string + m Matrix + want float64 + }{ + {"identity", NewIdentityMatrix(), 1}, + {"scale(2,3)", NewScaleMatrix(2, 3), 6}, + {"translation", NewTranslationMatrix(5, 10), 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.m.Determinant() + if !fequals(got, tt.want) { + t.Errorf("Determinant() = %f, want %f", got, tt.want) + } + }) + } +} + +func TestMatrixTransformPoint(t *testing.T) { + m := NewTranslationMatrix(5, 10) + x, y := m.TransformPoint(1, 2) + if !fequals(x, 6) || !fequals(y, 12) { + t.Errorf("TransformPoint(1, 2) = (%f, %f), want (6, 12)", x, y) + } +} + +func TestMatrixTransform(t *testing.T) { + m := NewTranslationMatrix(5, 10) + points := []float64{1, 2, 3, 4} + m.Transform(points) + expected := []float64{6, 12, 8, 14} + for i := range points { + if !fequals(points[i], expected[i]) { + t.Errorf("Transform() point[%d] = %f, want %f", i, points[i], expected[i]) + } + } +} + +func TestMatrixInverseTransformPoint(t *testing.T) { + m := NewTranslationMatrix(5, 10) + x, y := m.InverseTransformPoint(6, 12) + if !fequals(x, 1) || !fequals(y, 2) { + t.Errorf("InverseTransformPoint(6, 12) = (%f, %f), want (1, 2)", x, y) + } +} + +func TestMatrixInverseTransform(t *testing.T) { + m := NewTranslationMatrix(5, 10) + points := []float64{6, 12, 8, 14} + m.InverseTransform(points) + expected := []float64{1, 2, 3, 4} + for i := range points { + if !fequals(points[i], expected[i]) { + t.Errorf("InverseTransform() point[%d] = %f, want %f", i, points[i], expected[i]) + } + } +} + +func TestMatrixInverse(t *testing.T) { + m := NewTranslationMatrix(5, 10) + m.Inverse() + x, y := m.GetTranslation() + if !fequals(x, -5) || !fequals(y, -10) { + t.Errorf("Inverse translation = (%f, %f), want (-5, -10)", x, y) + } +} + +func TestMatrixInverse_RoundTrip(t *testing.T) { + m := NewTranslationMatrix(5, 10) + origX, origY := 3.0, 7.0 + // Transform + x, y := m.TransformPoint(origX, origY) + // Inverse transform + x2, y2 := m.InverseTransformPoint(x, y) + if !fequals(x2, origX) || !fequals(y2, origY) { + t.Errorf("Round trip: got (%f, %f), want (%f, %f)", x2, y2, origX, origY) + } +} + +func TestMatrixCompose(t *testing.T) { + m := NewTranslationMatrix(5, 10) + scale := NewScaleMatrix(2, 3) + m.Compose(scale) + // Composed matrix should scale then translate + x, y := m.TransformPoint(1, 1) + expected_x := 1.0*2 + 5 + expected_y := 1.0*3 + 10 + if !fequals(x, expected_x) || !fequals(y, expected_y) { + t.Errorf("Compose transform = (%f, %f), want (%f, %f)", x, y, expected_x, expected_y) + } +} + +func TestMatrixCopy(t *testing.T) { + m1 := NewTranslationMatrix(5, 10) + m2 := m1.Copy() + // Modify m2 + m2.Translate(1, 1) + // m1 should be unchanged + x, y := m1.GetTranslation() + if !fequals(x, 5) || !fequals(y, 10) { + t.Error("Copy should be independent of original") + } +} + +func TestMatrixTransformRectangle(t *testing.T) { + m := NewIdentityMatrix() + x0, y0, x2, y2 := m.TransformRectangle(10, 10, 20, 20) + if !fequals(x0, 10) || !fequals(y0, 10) || !fequals(x2, 20) || !fequals(y2, 20) { + t.Errorf("Identity transform rectangle = (%f, %f, %f, %f), want (10, 10, 20, 20)", x0, y0, x2, y2) + } +} + +func TestMatrixTransformRectangle_WithScale(t *testing.T) { + m := NewScaleMatrix(2, 3) + x0, y0, x2, y2 := m.TransformRectangle(10, 10, 20, 20) + if !fequals(x0, 20) || !fequals(y0, 30) || !fequals(x2, 40) || !fequals(y2, 60) { + t.Errorf("Scale transform rectangle = (%f, %f, %f, %f), want (20, 30, 40, 60)", x0, y0, x2, y2) + } +} + +func TestMatrixVectorTransform(t *testing.T) { + m := NewTranslationMatrix(5, 10) + points := []float64{1, 2} + m.VectorTransform(points) + // Translation should be ignored in VectorTransform + if !fequals(points[0], 1) || !fequals(points[1], 2) { + t.Errorf("VectorTransform should ignore translation: got (%f, %f), want (1, 2)", points[0], points[1]) + } +} + +func TestMatrixEquals(t *testing.T) { + m1 := NewIdentityMatrix() + m2 := NewIdentityMatrix() + if !m1.Equals(m2) { + t.Error("Two identity matrices should be equal") + } + m3 := NewTranslationMatrix(1, 1) + if m1.Equals(m3) { + t.Error("Identity and translation matrices should not be equal") + } +} + +func TestMatrixIsIdentity(t *testing.T) { + m := NewIdentityMatrix() + if !m.IsIdentity() { + t.Error("Identity matrix should return true for IsIdentity()") + } + m.Translate(1, 1) + if m.IsIdentity() { + t.Error("Translated matrix should not be identity") + } +} + +func TestMatrixIsTranslation(t *testing.T) { + m := NewTranslationMatrix(5, 10) + if !m.IsTranslation() { + t.Error("Translation matrix should return true for IsTranslation()") + } + m2 := NewScaleMatrix(2, 2) + if m2.IsTranslation() { + t.Error("Scale matrix should not be a translation") + } +} + +func TestMatrixScale(t *testing.T) { + m := NewIdentityMatrix() + m.Scale(2, 3) + sx, sy := m.GetScaling() + if !fequals(sx, 2) || !fequals(sy, 3) { + t.Errorf("Scale() result GetScaling() = (%f, %f), want (2, 3)", sx, sy) + } +} + +func TestMatrixTranslate(t *testing.T) { + m := NewIdentityMatrix() + m.Translate(5, 10) + x, y := m.GetTranslation() + if !fequals(x, 5) || !fequals(y, 10) { + t.Errorf("Translate() result GetTranslation() = (%f, %f), want (5, 10)", x, y) + } +} + +func TestMatrixRotate(t *testing.T) { + m := NewIdentityMatrix() + m.Rotate(math.Pi) + // Rotating (1,0) by π should give (-1,0) + x, y := m.TransformPoint(1, 0) + if !fequals(x, -1) || !fequals(y, 0) { + t.Errorf("Rotate(π) on (1,0) = (%f, %f), want (-1, 0)", x, y) + } +} + +func TestNewMatrixFromRects(t *testing.T) { + rect1 := [4]float64{0, 0, 10, 10} + rect2 := [4]float64{0, 0, 20, 20} + m := NewMatrixFromRects(rect1, rect2) + // Midpoint (5,5) of rect1 should map to midpoint (10,10) of rect2 + x, y := m.TransformPoint(5, 5) + if !fequals(x, 10) || !fequals(y, 10) { + t.Errorf("NewMatrixFromRects transform midpoint = (%f, %f), want (10, 10)", x, y) + } +} + +func TestMatrixGetScale(t *testing.T) { + m := NewIdentityMatrix() + scale := m.GetScale() + if !fequals(scale, 1.0) { + t.Errorf("GetScale() on identity = %f, want ~1.0", scale) + } +} + +func TestMatrixGetTranslation(t *testing.T) { + m := NewTranslationMatrix(3, 7) + x, y := m.GetTranslation() + if !fequals(x, 3) || !fequals(y, 7) { + t.Errorf("GetTranslation() = (%f, %f), want (3, 7)", x, y) + } +} + +func TestMatrixGetScaling(t *testing.T) { + m := NewScaleMatrix(4, 5) + sx, sy := m.GetScaling() + if !fequals(sx, 4) || !fequals(sy, 5) { + t.Errorf("GetScaling() = (%f, %f), want (4, 5)", sx, sy) + } +} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..6109915 --- /dev/null +++ b/path_test.go @@ -0,0 +1,251 @@ +// Copyright 2010 The draw2d Authors. All rights reserved. +// created: 07/02/2026 by draw2d contributors + +package draw2d + +import ( + "math" + "strings" + "testing" +) + +func TestPathMoveTo(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + x, y := p.LastPoint() + if x != 10 || y != 20 { + t.Errorf("LastPoint() = (%f, %f), want (10, 20)", x, y) + } + if len(p.Components) != 1 || p.Components[0] != MoveToCmp { + t.Error("MoveTo should add a MoveToCmp component") + } + if len(p.Points) != 2 || p.Points[0] != 10 || p.Points[1] != 20 { + t.Error("MoveTo should add correct points") + } +} + +func TestPathLineTo(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + p.LineTo(30, 40) + if len(p.Components) != 2 || p.Components[1] != LineToCmp { + t.Error("LineTo should add a LineToCmp component") + } + x, y := p.LastPoint() + if x != 30 || y != 40 { + t.Errorf("LastPoint after LineTo = (%f, %f), want (30, 40)", x, y) + } +} + +func TestPathLineToWithoutMoveTo(t *testing.T) { + p := new(Path) + p.LineTo(10, 20) + // Should auto-create MoveTo + if len(p.Components) != 1 || p.Components[0] != MoveToCmp { + t.Error("LineTo without MoveTo should auto-create MoveTo") + } +} + +func TestPathQuadCurveTo(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.QuadCurveTo(10, 10, 20, 0) + if len(p.Components) != 2 || p.Components[1] != QuadCurveToCmp { + t.Error("QuadCurveTo should add QuadCurveToCmp component") + } + // MoveTo adds 2 points, QuadCurveTo adds 4 points + if len(p.Points) != 6 { + t.Errorf("Points count = %d, want 6", len(p.Points)) + } +} + +func TestPathQuadCurveToWithoutMoveTo(t *testing.T) { + p := new(Path) + p.QuadCurveTo(10, 10, 20, 0) + // Should auto-create MoveTo + if len(p.Components) != 1 || p.Components[0] != MoveToCmp { + t.Error("QuadCurveTo without MoveTo should auto-create MoveTo") + } +} + +func TestPathCubicCurveTo(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.CubicCurveTo(10, 0, 20, 20, 30, 20) + if len(p.Components) != 2 || p.Components[1] != CubicCurveToCmp { + t.Error("CubicCurveTo should add CubicCurveToCmp component") + } + // MoveTo adds 2 points, CubicCurveTo adds 6 points + if len(p.Points) != 8 { + t.Errorf("Points count = %d, want 8", len(p.Points)) + } +} + +func TestPathCubicCurveToWithoutMoveTo(t *testing.T) { + p := new(Path) + p.CubicCurveTo(10, 0, 20, 20, 30, 20) + // Should auto-create MoveTo + if len(p.Components) != 1 || p.Components[0] != MoveToCmp { + t.Error("CubicCurveTo without MoveTo should auto-create MoveTo") + } +} + +func TestPathArcTo(t *testing.T) { + p := new(Path) + p.MoveTo(100, 100) + // Full circle + p.ArcTo(100, 100, 50, 50, 0, 2*math.Pi) + x, y := p.LastPoint() + // After full circle, should return near start point + if math.Abs(x-150) > epsilon || math.Abs(y-100) > epsilon { + t.Errorf("ArcTo full circle end point = (%f, %f), want (~150, ~100)", x, y) + } +} + +func TestPathArcTo_EmptyPath(t *testing.T) { + p := new(Path) + p.ArcTo(100, 100, 50, 50, 0, math.Pi) + // Should start with MoveTo + if len(p.Components) == 0 || p.Components[0] != MoveToCmp { + t.Error("ArcTo on empty path should start with MoveTo") + } +} + +func TestPathArcTo_ExistingPath(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.ArcTo(100, 100, 50, 50, 0, math.Pi) + // Should have MoveTo, LineTo, ArcTo + if len(p.Components) < 3 { + t.Errorf("ArcTo on existing path should add LineTo and ArcTo, got %d components", len(p.Components)) + } + foundArc := false + for _, cmp := range p.Components { + if cmp == ArcToCmp { + foundArc = true + } + } + if !foundArc { + t.Error("ArcTo should add ArcToCmp component") + } +} + +func TestPathClose(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.LineTo(10, 0) + p.Close() + if len(p.Components) == 0 || p.Components[len(p.Components)-1] != CloseCmp { + t.Error("Close should add CloseCmp component") + } +} + +func TestPathCopy(t *testing.T) { + p1 := new(Path) + p1.MoveTo(10, 20) + p1.LineTo(30, 40) + p2 := p1.Copy() + // Modify p2 + p2.LineTo(50, 60) + // p1 should be unchanged + if len(p1.Components) != 2 { + t.Error("Copy should be independent of original") + } +} + +func TestPathClear(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + p.LineTo(30, 40) + p.Clear() + if !p.IsEmpty() { + t.Error("Clear should make path empty") + } + if len(p.Components) != 0 || len(p.Points) != 0 { + t.Error("Clear should remove all components and points") + } +} + +func TestPathIsEmpty(t *testing.T) { + p := new(Path) + if !p.IsEmpty() { + t.Error("New path should be empty") + } + p.MoveTo(10, 20) + if p.IsEmpty() { + t.Error("Path with MoveTo should not be empty") + } +} + +func TestPathString(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + p.LineTo(30, 40) + p.Close() + s := p.String() + if !strings.Contains(s, "MoveTo") { + t.Error("String should contain 'MoveTo'") + } + if !strings.Contains(s, "LineTo") { + t.Error("String should contain 'LineTo'") + } + if !strings.Contains(s, "Close") { + t.Error("String should contain 'Close'") + } +} + +func TestPathString_AllComponents(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.LineTo(10, 10) + p.QuadCurveTo(20, 20, 30, 10) + p.CubicCurveTo(40, 0, 50, 0, 60, 10) + p.ArcTo(70, 10, 5, 5, 0, math.Pi) + p.Close() + s := p.String() + keywords := []string{"MoveTo", "LineTo", "QuadCurveTo", "CubicCurveTo", "ArcTo", "Close"} + for _, kw := range keywords { + if !strings.Contains(s, kw) { + t.Errorf("String should contain '%s'", kw) + } + } +} + +func TestPathVerticalFlip(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + p.LineTo(30, 40) + p2 := p.VerticalFlip() + // Y coordinates should be negated + if p2.Points[1] != -20 || p2.Points[3] != -40 { + t.Error("VerticalFlip should negate Y coordinates") + } + // Original should be unchanged + if p.Points[1] != 20 || p.Points[3] != 40 { + t.Error("VerticalFlip should not modify original") + } +} + +func TestPathVerticalFlip_LastPoint(t *testing.T) { + p := new(Path) + p.MoveTo(10, 20) + p2 := p.VerticalFlip() + _, y := p2.LastPoint() + if y != -20 { + t.Errorf("Flipped LastPoint Y = %f, want -20", y) + } +} + +func TestPathMultipleSubpaths(t *testing.T) { + p := new(Path) + p.MoveTo(0, 0) + p.LineTo(10, 10) + p.Close() + p.MoveTo(20, 20) + p.LineTo(30, 30) + p.Close() + // Should have 2 MoveTo + 2 LineTo + 2 Close = 6 components + if len(p.Components) != 6 { + t.Errorf("Multiple subpaths: got %d components, want 6", len(p.Components)) + } +}