From c076347d704d768a6f990fd0c646b5e57caa450e Mon Sep 17 00:00:00 2001 From: IliaDenisov Date: Sat, 7 Mar 2026 19:28:22 +0200 Subject: [PATCH] feat: hit on primitives --- client/canvas.go | 16 +- client/editor.go | 39 +++- client/go.mod | 1 - client/go.sum | 2 - client/world/hit.go | 221 ++++++++++++++++++++ client/world/hit_geom.go | 74 +++++++ client/world/hit_primitives.go | 247 +++++++++++++++++++++++ client/world/hit_test.go | 152 ++++++++++++++ client/world/indexing_test.go | 87 ++++---- client/world/options.go | 18 ++ client/world/primitive.go | 34 +++- client/world/primitive_test.go | 8 +- client/world/renderer_circles_test.go | 98 +++++++++ client/world/renderer_draw.go | 20 +- client/world/renderer_draw_primitives.go | 19 +- client/world/renderer_query.go | 4 +- client/world/renderer_test.go | 3 +- client/world/style.go | 6 + client/world/world.go | 214 +++++++++++++++----- client/world/world_id_allocator_test.go | 37 ++++ client/world/world_test.go | 32 ++- 21 files changed, 1167 insertions(+), 165 deletions(-) create mode 100644 client/world/hit.go create mode 100644 client/world/hit_geom.go create mode 100644 client/world/hit_primitives.go create mode 100644 client/world/hit_test.go create mode 100644 client/world/world_id_allocator_test.go diff --git a/client/canvas.go b/client/canvas.go index 9cfdb51..cc67351 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -18,7 +18,7 @@ type interactiveRaster struct { onScrolled func(*fyne.ScrollEvent) onDragged func(*fyne.DragEvent) onDragEnd func() - onTapped *fyne.PointEvent + onTapped func(*fyne.PointEvent) } func (r *interactiveRaster) SetMinSize(size fyne.Size) { @@ -40,17 +40,22 @@ func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer { // Tapped is a left-click event func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) { - x, y := int(ev.Position.X), int(ev.Position.Y) - size := r.raster.Size() - if x >= int(size.Width) || y >= int(size.Height) { + if r.onTapped == nil { return } + r.onTapped(ev) } // TappedSecondary is a right-click event func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} -func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyne.Size), onScrolled func(*fyne.ScrollEvent), onDragged func(*fyne.DragEvent), onDragEnd func()) *interactiveRaster { +func newInteractiveRaster(edit *editor, raster *canvas.Raster, + onLayout func(fyne.Size), + onScrolled func(*fyne.ScrollEvent), + onDragged func(*fyne.DragEvent), + onDragEnd func(), + onTapped func(*fyne.PointEvent), +) *interactiveRaster { r := &interactiveRaster{ // raster: canvas.NewRaster(edit.draw), raster: raster, @@ -59,6 +64,7 @@ func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyn onScrolled: onScrolled, onDragged: onDragged, onDragEnd: onDragEnd, + onTapped: onTapped, } r.ExtendBaseWidget(r) return r diff --git a/client/editor.go b/client/editor.go index e90a0bf..6162195 100644 --- a/client/editor.go +++ b/client/editor.go @@ -1,6 +1,7 @@ package client import ( + "fmt" "image" "image/color" "math" @@ -47,6 +48,8 @@ type editor struct { viewportImg *image.RGBA viewportW int viewportH int + + hits []world.Hit } func (e *editor) CanvasScale() float32 { return e.canvasScale } @@ -171,7 +174,24 @@ func (e *editor) onDradEnd() { e.pan.DragEnd() } -func (e *editor) wheelZoom(stepDelta int) {} +func (e *editor) onTapped(ev *fyne.PointEvent) { + hits, err := e.world.HitTest(e.hits, e.wp, int(ev.Position.X*e.canvasScale), int(ev.Position.Y*e.canvasScale)) + if err != nil { + panic(err) + } + m := func(v int) float64 { + return float64(v) / float64(world.SCALE) + } + var coord string + for _, hit := range hits { + if hit.Kind == world.KindLine { + coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) + } else { + coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) + } + fmt.Println("hit:", hit.ID, "Coord:", coord) + } +} func (e *editor) InitImage() { s := fyne.NewSize(292, 292) @@ -203,6 +223,7 @@ func NewEditor() *editor { Options: &world.RenderOptions{DisableWrapScroll: false}, }, canvasScale: 1.0, + hits: make([]world.Hit, 5), } // Create a drawer with some initial context; real size will be adjusted on first draw. @@ -213,7 +234,7 @@ func NewEditor() *editor { return e.draw(wPx, hPx) }) - e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd) + e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) e.pan = NewPanController(e) // Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call. @@ -241,18 +262,24 @@ func testWorldInit(w *world.World) { StrokeDashes: new([]float64{10.}), }) - circleStyle := w.AddStyleCircle(world.StyleOverride{ + discStyle := w.AddStyleCircle(world.StyleOverride{ FillColor: color.RGBA{R: 255, G: 255, B: 0, A: 255}, }) - if _, err := w.AddCircle(150, 150, 50, world.CircleWithStyleID(circleStyle)); err != nil { + circleStyle := w.AddStyleCircle(world.StyleOverride{ + FillColor: world.TransparentFill(), + StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + StrokeWidthPx: new(4.0), + }) + + if _, err := w.AddCircle(150, 150, 50, world.CircleWithStyleID(discStyle)); err != nil { panic(err) } - if _, err := w.AddCircle(150, 299, 30); err != nil { + if _, err := w.AddCircle(150, 299, 30, world.CircleWithStyleID(circleStyle)); err != nil { panic(err) } - if _, err := w.AddCircle(299, 150, 30); err != nil { + if _, err := w.AddCircle(299, 150, 30, world.CircleWithStyleID(circleStyle)); err != nil { panic(err) } diff --git a/client/go.mod b/client/go.mod index 4b67898..e0a1adc 100644 --- a/client/go.mod +++ b/client/go.mod @@ -5,7 +5,6 @@ go 1.26.0 require ( fyne.io/fyne/v2 v2.7.3 github.com/fogleman/gg v1.3.0 - github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 ) diff --git a/client/go.sum b/client/go.sum index 1b47dcb..e8b9a6b 100644 --- a/client/go.sum +++ b/client/go.sum @@ -39,8 +39,6 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= diff --git a/client/world/hit.go b/client/world/hit.go new file mode 100644 index 0000000..bb0ca0f --- /dev/null +++ b/client/world/hit.go @@ -0,0 +1,221 @@ +package world + +import ( + "sort" +) + +// PrimitiveKind identifies primitive types in hit-test results. +type PrimitiveKind uint8 + +const ( + KindLine PrimitiveKind = iota + KindCircle + KindPoint +) + +// Hit describes one primitive that matches a hit-test query. +type Hit struct { + ID PrimitiveID + Kind PrimitiveKind + Priority int + StyleID StyleID + + // DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort). + // Used for tie-breaking (smaller is better). + DistanceSq u128 + + // Primitive world coordinates: + // - Point: X,Y set + // - Circle: X,Y,Radius set + // - Line: X1,Y1,X2,Y2 set + X, Y int + Radius int + X1, Y1 int + X2, Y2 int +} + +// Default hit slop (in pixels) per primitive type. +const ( + DefaultHitSlopLinePx = 6 + DefaultHitSlopCirclePx = 6 + DefaultHitSlopPointPx = 8 + + // If a circle's screen radius is below this threshold, treat it as point-like for hit testing. + CirclePointLikeMinRadiusPx = 3 +) + +// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop. +// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations). +// +// If cap(out) is too small, it returns only the best hits by ranking: +// +// Priority desc, Distance asc, Kind asc, ID asc. +// +// Notes: +// - cursorXPx/cursorYPx are relative to viewport top-left. +// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll). +func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) { + if err := params.Validate(); err != nil { + return nil, err + } + if w.grid == nil || w.rows == 0 || w.cols == 0 { + return nil, errGridNotBuilt + } + + zoomFp, err := params.CameraZoomFp() + if err != nil { + return nil, err + } + + allowWrap := true + if params.Options != nil && params.Options.DisableWrapScroll { + allowWrap = false + } + + // Use clamped camera in no-wrap mode for consistency. + camX := params.CameraXWorldFp + camY := params.CameraYWorldFp + if !allowWrap { + camX, camY = ClampCameraNoWrapViewport( + camX, camY, + params.ViewportWidthPx, params.ViewportHeightPx, + zoomFp, + w.W, w.H, + ) + } + + // Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera). + worldPerPx := PixelSpanToWorldFixed(1, zoomFp) + offXPx := cursorXPx - params.ViewportWidthPx/2 + offYPx := cursorYPx - params.ViewportHeightPx/2 + + cursorX := camX + offXPx*worldPerPx + cursorY := camY + offYPx*worldPerPx + + if allowWrap { + cursorX = wrap(cursorX, w.W) + cursorY = wrap(cursorY, w.H) + } else { + // Clamp cursor into world bounds to avoid weird negative coords in margins. + cursorX = clamp(cursorX, 0, w.W-1) + cursorY = clamp(cursorY, 0, w.H-1) + } + + // Compute a conservative search bbox around cursor using max possible slop (px->world). + // We use the maximum of default slops; per-object overrides are handled later. + maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx)) + maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp) + + minX := cursorX - maxSlopWorld + maxX := cursorX + maxSlopWorld + 1 + minY := cursorY - maxSlopWorld + maxY := cursorY + maxSlopWorld + 1 + + var rects []Rect + if allowWrap { + rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY) + } else { + // Clamp to world. + minX = clamp(minX, 0, w.W) + maxX = clamp(maxX, 0, w.W) + minY = clamp(minY, 0, w.H) + maxY = clamp(maxY, 0, w.H) + if maxX <= minX || maxY <= minY { + return out[:0], nil + } + rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}} + } + + // Gather candidates from grid cells, dedupe by ID. + cand := make(map[PrimitiveID]MapItem, 32) + for _, r := range rects { + colStart := w.worldToCellX(r.minX) + colEnd := w.worldToCellX(r.maxX - 1) + rowStart := w.worldToCellY(r.minY) + rowEnd := w.worldToCellY(r.maxY - 1) + + for row := rowStart; row <= rowEnd; row++ { + for col := colStart; col <= colEnd; col++ { + cell := w.grid[row][col] + for _, it := range cell { + cand[it.ID()] = it + } + } + } + } + + // Use caller buffer as backing store; keep only best cap(out) hits. + out = out[:0] + limit := cap(out) + + for _, it := range cand { + h, ok := w.hitOne(it, cursorX, cursorY, zoomFp, allowWrap) + if !ok { + continue + } + + if limit == 0 { + // Caller provided zero-cap buffer; cannot store anything. + continue + } + + if len(out) < limit { + out = append(out, h) + continue + } + + // Replace the worst hit if the new one is better. + worstIdx := 0 + for i := 1; i < len(out); i++ { + if hitLess(out[worstIdx], out[i]) { + worstIdx = i // out[i] is worse than out[worstIdx] + } + } + if hitLess(h, out[worstIdx]) { + out[worstIdx] = h + } + } + + // Sort final hits by best-first order. + sort.Slice(out, func(i, j int) bool { + return hitLess(out[i], out[j]) + }) + + return out, nil +} + +// hitLess orders hits by: +// Priority desc, DistanceSq asc, Kind asc, ID asc. +func hitLess(a, b Hit) bool { + if a.Priority != b.Priority { + return a.Priority > b.Priority + } + if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 { + return c < 0 + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.ID < b.ID +} + +func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) { + switch v := it.(type) { + case Point: + return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H) + + case Circle: + style, ok := w.styles.Get(v.StyleID) + if !ok { + // Unknown style should not happen; treat as no-hit rather than panic. + return Hit{}, false + } + return hitCircle(v, style, cx, cy, zoomFp, allowWrap, w.W, w.H) + + case Line: + return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H) + + default: + panic("HitTest: unknown map item type") + } +} diff --git a/client/world/hit_geom.go b/client/world/hit_geom.go new file mode 100644 index 0000000..0a41634 --- /dev/null +++ b/client/world/hit_geom.go @@ -0,0 +1,74 @@ +package world + +import "math/bits" + +// u128 is an unsigned 128-bit integer for safe squared comparisons. +type u128 struct{ hi, lo uint64 } + +func u128FromMul64(a, b uint64) u128 { + hi, lo := bits.Mul64(a, b) + return u128{hi: hi, lo: lo} +} + +func u128Add(a, b u128) u128 { + lo := a.lo + b.lo + hi := a.hi + b.hi + if lo < a.lo { + hi++ + } + return u128{hi: hi, lo: lo} +} + +func u128Cmp(a, b u128) int { + if a.hi < b.hi { + return -1 + } + if a.hi > b.hi { + return 1 + } + if a.lo < b.lo { + return -1 + } + if a.lo > b.lo { + return 1 + } + return 0 +} + +func abs64(x int64) int64 { + if x < 0 { + return -x + } + return x +} + +func sqU128Int64(x int64) u128 { + u := uint64(abs64(x)) + return u128FromMul64(u, u) +} + +func distSqU128(dx, dy int64) u128 { + return u128Add(sqU128Int64(dx), sqU128Int64(dy)) +} + +// shortestTorusDelta returns the shortest signed delta from a->b on a torus axis of size. +// It is deterministic in tie cases (size even, exactly half): chooses negative direction. +func shortestTorusDelta(a, b, size int) int64 { + d := int64(b - a) + s := int64(size) + half := s / 2 + + // Normalize d into (-s, s). + d = d % s + if d <= -half { + d += s + } else if d > half { + d -= s + } + + // Tie case when size even and d == +half: choose -half. + if s%2 == 0 && d == half { + d = -half + } + return d +} diff --git a/client/world/hit_primitives.go b/client/world/hit_primitives.go new file mode 100644 index 0000000..f38294d --- /dev/null +++ b/client/world/hit_primitives.go @@ -0,0 +1,247 @@ +package world + +import ( + "image/color" + "math/bits" +) + +func effectiveHitSlopPx(hitSlopPx int, def int) int { + if hitSlopPx > 0 { + return hitSlopPx + } + return def +} + +func alphaNonZero(c color.Color) bool { + if c == nil { + return false + } + _, _, _, a := c.RGBA() + return a != 0 +} + +func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { + slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx) + slopW := PixelSpanToWorldFixed(slopPx, zoomFp) + + var dx, dy int64 + if allowWrap { + dx = shortestTorusDelta(p.X, cx, worldW) + dy = shortestTorusDelta(p.Y, cy, worldH) + } else { + dx = int64(cx - p.X) + dy = int64(cy - p.Y) + } + + // Point is treated as a small disc: dist <= slop. + ds := distSqU128(dx, dy) + rs := sqU128Int64(int64(slopW)) + + if u128Cmp(ds, rs) <= 0 { + return Hit{ + ID: p.Id, + Kind: KindPoint, + Priority: p.Priority, + StyleID: p.StyleID, + DistanceSq: ds, + X: p.X, + Y: p.Y, + }, true + } + return Hit{}, false +} + +func hitCircle(c Circle, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { + slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx) + slopW := PixelSpanToWorldFixed(slopPx, zoomFp) + + fillVisible := alphaNonZero(style.FillColor) + + // Determine if circle is point-like at current zoom. + // IMPORTANT: point-like disc behavior applies only for filled circles. + rPx := worldSpanFixedToCanvasPx(c.Radius, zoomFp) + pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx + + var dx, dy int64 + if allowWrap { + dx = shortestTorusDelta(c.X, cx, worldW) + dy = shortestTorusDelta(c.Y, cy, worldH) + } else { + dx = int64(cx - c.X) + dy = int64(cy - c.Y) + } + + ds := distSqU128(dx, dy) + + // Filled + point-like: treat as a disc with minimum visible radius + slop. + if pointLike { + // Treat as a disc with minimum visible radius in px. + minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) + effR := minRW + if c.Radius > effR { + effR = c.Radius + } + r := effR + slopW + if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 { + return Hit{ + ID: c.Id, + Kind: KindCircle, + Priority: c.Priority, + StyleID: c.StyleID, + DistanceSq: ds, + X: c.X, + Y: c.Y, + Radius: c.Radius, + }, true + } + return Hit{}, false + } + + // Filled circle: hit-test by disc (surface). + if fillVisible { + r := c.Radius + slopW + if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 { + return Hit{ + ID: c.Id, + Kind: KindCircle, + Priority: c.Priority, + StyleID: c.StyleID, + DistanceSq: ds, + X: c.X, + Y: c.Y, + Radius: c.Radius, + }, true + } + return Hit{}, false + } + + // Stroke-only circle: ring hit, but NEVER at exact center. + // For very small circles, expand the effective radius to a minimum visible size + // so that ring selection remains practical, while still excluding center. + effR := c.Radius + if rPx < CirclePointLikeMinRadiusPx { + minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp) + if minRW > effR { + effR = minRW + } + } + + low := effR - slopW + // IMPORTANT: center must not hit for stroke-only circles. + if low < 1 { + low = 1 + } + high := effR + slopW + + lowSq := sqU128Int64(int64(low)) + highSq := sqU128Int64(int64(high)) + + if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 { + return Hit{ + ID: c.Id, + Kind: KindCircle, + Priority: c.Priority, + StyleID: c.StyleID, + DistanceSq: ds, + X: c.X, + Y: c.Y, + Radius: c.Radius, + }, true + } + return Hit{}, false +} + +func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) { + slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx) + slopW := PixelSpanToWorldFixed(slopPx, zoomFp) + + // For wrap: compare against torus-shortest representation (same as rendering). + // We test all segments produced by torusShortestLineSegments and take the best (min distance). + segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}} + if allowWrap { + segs = torusShortestLineSegments(l, worldW, worldH) + } + + best := Hit{} + found := false + + for _, s := range segs { + ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2)) + + // Check ds <= slopW^2 + if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 { + h := Hit{ + ID: l.Id, + Kind: KindLine, + Priority: l.Priority, + StyleID: l.StyleID, + DistanceSq: ds, + X1: l.X1, + Y1: l.Y1, + X2: l.X2, + Y2: l.Y2, + } + if !found || hitLess(h, best) { + best = h + found = true + } + } + } + + return best, found +} + +// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons. +func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 { + abx := bx - ax + aby := by - ay + apx := px - ax + apy := py - ay + + // Degenerate segment => distance to point A. + if abx == 0 && aby == 0 { + return distSqU128(apx, apy) + } + + dot := apx*abx + apy*aby + if dot <= 0 { + return distSqU128(apx, apy) + } + + abLen2 := abx*abx + aby*aby + if dot >= abLen2 { + bpx := px - bx + bpy := py - by + return distSqU128(bpx, bpy) + } + + // Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers. + // Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain + // would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use + // dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64. + // + // cross = AP x AB + cross := apx*aby - apy*abx + + // cross^2 fits in u128, abLen2 fits in int64. + c2 := sqU128Int64(cross) + return u128DivByU64(c2, uint64(abLen2)) +} + +// u128DivByU64 returns floor(a / d) where d>0, producing u128 result. +// Here we only need it for tie-breaking (monotonic). +func u128DivByU64(a u128, d uint64) u128 { + if d == 0 { + panic("u128DivByU64: divide by zero") + } + // Simple long division for 128/64 -> 128 quotient (but high part will be small here). + // We compute using two-step: divide high then combine. + qHi := a.hi / d + rHi := a.hi % d + + // Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d. + // Use bits.Div64 for (hi, lo)/d. + qLo, _ := bits.Div64(rHi, a.lo, d) + + return u128{hi: qHi, lo: qLo} +} diff --git a/client/world/hit_test.go b/client/world/hit_test.go new file mode 100644 index 0000000..7b33c0c --- /dev/null +++ b/client/world/hit_test.go @@ -0,0 +1,152 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + // Build index once renderer state is initialized. + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) + + // Add overlapping objects near center. + idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) + require.NoError(t, err) + + idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) + require.NoError(t, err) + + idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200)) + require.NoError(t, err) + + // Force index rebuild from last state (Add already does it, but keep explicit). + w.Reindex() + + buf := make([]Hit, 0, 8) + hits, err := w.HitTest(buf, ¶ms, 50, 50) // center of viewport + require.NoError(t, err) + + // Should find all three, best first (priority desc). + require.Len(t, hits, 3) + require.Equal(t, idCircle, hits[0].ID) + require.Equal(t, idPoint, hits[1].ID) + require.Equal(t, idLine, hits[2].ID) +} + +func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) + + _, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100)) + idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300)) + _, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200)) + w.Reindex() + + // Only room for 1 hit => must keep the best (highest priority). + buf := make([]Hit, 0, 1) + hits, err := w.HitTest(buf, ¶ms, 50, 50) + require.NoError(t, err) + require.Len(t, hits, 1) + require.Equal(t, idCircle, hits[0].ID) +} + +func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 25, + MarginYPx: 25, + CameraXWorldFp: -100000, // invalid camera, should be clamped + CameraYWorldFp: -100000, + CameraZoom: 1.0, + Options: &RenderOptions{DisableWrapScroll: true}, + } + w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) + + _, err := w.AddPoint(0.0, 0.0, PointWithPriority(100)) + require.NoError(t, err) + w.Reindex() + + // Tap near top-left of viewport should still map to world and find the point. + buf := make([]Hit, 0, 8) + hits, err := w.HitTest(buf, ¶ms, 0, 0) + require.NoError(t, err) + require.NotEmpty(t, hits) +} + +func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 100, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom) + + // Stroke-only circle: FillColor alpha=0 => ring mode. + ov := StyleOverride{ + FillColor: color.RGBA{A: 0}, + StrokeColor: color.RGBA{A: 255}, + } + strokeStyle := w.AddStyleCircle(ov) + + _, err := w.AddCircle(5.0, 5.0, 2.0, + CircleWithStyleID(strokeStyle), + CircleWithPriority(100), + ) + require.NoError(t, err) + + w.Reindex() + + buf := make([]Hit, 0, 8) + + // Center must NOT hit. + hits, err := w.HitTest(buf, ¶ms, 50, 50) + require.NoError(t, err) + require.Empty(t, hits) + + // Near ring should hit. For small circles we use a minimum visible ring radius (3px). + // So tapping at +3px from center should be within ring+slop. + hits, err = w.HitTest(buf, ¶ms, 50+3, 50) + require.NoError(t, err) + require.NotEmpty(t, hits) + require.Equal(t, KindCircle, hits[0].Kind) +} diff --git a/client/world/indexing_test.go b/client/world/indexing_test.go index 0cf7ad0..5dd7365 100644 --- a/client/world/indexing_test.go +++ b/client/world/indexing_test.go @@ -4,7 +4,6 @@ import ( "fmt" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -17,7 +16,7 @@ func newTestWorld(wReal, hReal int) *World { return NewWorld(wReal, hReal) } -func countObjectInGrid(g *World, id uuid.UUID) int { +func countObjectInGrid(g *World, id PrimitiveID) int { count := 0 for row := range g.grid { for col := range g.grid[row] { @@ -31,7 +30,7 @@ func countObjectInGrid(g *World, id uuid.UUID) int { return count } -func hasObjectInCell(g *World, row, col int, id uuid.UUID) bool { +func hasObjectInCell(g *World, row, col int, id PrimitiveID) bool { for _, item := range g.grid[row][col] { if item.ID() == id { return true @@ -283,7 +282,7 @@ func TestIndexPoint(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) p := Point{ Id: id, X: 150 * SCALE, @@ -300,7 +299,7 @@ func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) p := Point{ Id: id, X: -1, @@ -317,7 +316,7 @@ func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) c := Circle{ Id: id, X: 50 * SCALE, @@ -344,7 +343,7 @@ func TestIndexCircle_NoWrap(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) c := Circle{ Id: id, X: 300 * SCALE, @@ -364,7 +363,7 @@ func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) c := Circle{ Id: id, X: 300 * SCALE, @@ -385,7 +384,7 @@ func TestIndexLine_HorizontalWrap(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 590 * SCALE, @@ -405,7 +404,7 @@ func TestIndexLine_VerticalWrap(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 200 * SCALE, @@ -424,7 +423,7 @@ func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 590 * SCALE, @@ -443,7 +442,7 @@ func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 100 * SCALE, @@ -465,7 +464,7 @@ func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 200 * SCALE, @@ -487,7 +486,7 @@ func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 250 * SCALE, @@ -506,7 +505,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) l := Line{ Id: id, X1: 200 * SCALE, @@ -523,7 +522,7 @@ func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { require.False(t, hasObjectInCell(g, 1, 4, id)) } -func collectOccupiedCells(g *World, id uuid.UUID) []gridCell { +func collectOccupiedCells(g *World, id PrimitiveID) []gridCell { var cells []gridCell for row := range g.grid { for col := range g.grid[row] { @@ -548,7 +547,7 @@ func allGridCells(rows, cols int) []gridCell { return cells } -func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []gridCell) { +func requireIndexedExactlyInCells(t *testing.T, g *World, id PrimitiveID, want []gridCell) { t.Helper() got := collectOccupiedCells(g, id) @@ -557,8 +556,8 @@ func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []g t, want, got, - "unexpected indexed cells for object %s", - id.String(), + "unexpected indexed cells for object %d", + id, ) } @@ -577,7 +576,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Point{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 150 * SCALE, Y: 250 * SCALE, }, @@ -591,7 +590,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Point{ - Id: uuid.New(), + Id: PrimitiveID(1), X: -1, Y: -1, }, @@ -605,7 +604,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Point{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 600 * SCALE, Y: 600 * SCALE, }, @@ -619,7 +618,7 @@ func TestIndexObject_Point_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Point{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 200 * SCALE, Y: 300 * SCALE, }, @@ -656,7 +655,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 300 * SCALE, Y: 300 * SCALE, Radius: 50 * SCALE, @@ -674,7 +673,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 50 * SCALE, Y: 50 * SCALE, Radius: 75 * SCALE, @@ -697,7 +696,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 575 * SCALE, Y: 300 * SCALE, Radius: 50 * SCALE, @@ -715,7 +714,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 300 * SCALE, Y: 575 * SCALE, Radius: 50 * SCALE, @@ -733,7 +732,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 300 * SCALE, Y: 300 * SCALE, Radius: 400 * SCALE, @@ -746,7 +745,7 @@ func TestIndexObject_Circle_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Circle{ - Id: uuid.New(), + Id: PrimitiveID(1), X: 300 * SCALE, Y: 300 * SCALE, Radius: 100 * SCALE, // bbox [200, 400) x [200, 400) @@ -787,7 +786,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 100 * SCALE, Y1: 200 * SCALE, X2: 300 * SCALE, @@ -805,7 +804,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 200 * SCALE, Y1: 100 * SCALE, X2: 200 * SCALE, @@ -823,7 +822,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 590 * SCALE, Y1: 200 * SCALE, X2: 10 * SCALE, @@ -840,7 +839,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 200 * SCALE, Y1: 590 * SCALE, X2: 200 * SCALE, @@ -857,7 +856,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 590 * SCALE, Y1: 590 * SCALE, X2: 10 * SCALE, @@ -876,7 +875,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 250 * SCALE, Y1: 350 * SCALE, X2: 250 * SCALE, @@ -892,7 +891,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 200 * SCALE, Y1: 100 * SCALE, X2: 400 * SCALE, @@ -910,7 +909,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 100 * SCALE, Y1: 100 * SCALE, X2: 300 * SCALE, @@ -931,7 +930,7 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { worldH: 600, cellSize: 100 * SCALE, item: Line{ - Id: uuid.New(), + Id: PrimitiveID(1), X1: 600 * SCALE, Y1: 100 * SCALE, X2: 0, @@ -960,9 +959,9 @@ func TestIndexObject_Line_TableDriven(t *testing.T) { func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) { g := newTestWorld(600, 400) - pID := uuid.New() - cID := uuid.New() - lID := uuid.New() + pID := PrimitiveID(1) + cID := PrimitiveID(2) + lID := PrimitiveID(3) g.objects[pID] = Point{ Id: pID, @@ -1007,7 +1006,7 @@ func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) { g := newTestWorld(600, 600) - id := uuid.New() + id := PrimitiveID(1) g.objects[id] = Circle{ Id: id, X: 300 * SCALE, @@ -1041,7 +1040,7 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { g := newTestWorld(600, 600) g.resetGrid(100 * SCALE) - id := uuid.New() + id := PrimitiveID(1) p := Point{ Id: id, X: 100 * SCALE, @@ -1051,5 +1050,5 @@ func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { g.indexObject(p) got := collectOccupiedCells(g, id) - require.NotEmpty(t, got, fmt.Sprintf("object %s should occupy at least one cell", id.String())) + require.NotEmpty(t, got, fmt.Sprintf("object %d should occupy at least one cell", id)) } diff --git a/client/world/options.go b/client/world/options.go index ea031a2..c130fe9 100644 --- a/client/world/options.go +++ b/client/world/options.go @@ -10,6 +10,8 @@ type PointOptions struct { StyleID StyleID Override StyleOverride + HitSlopPx int + hasStyleID bool } @@ -44,6 +46,10 @@ func PointWithStyleOverride(ov StyleOverride) PointOpt { } } +func PointWithHitSlopPx(px int) PointOpt { + return func(o *PointOptions) { o.HitSlopPx = px } +} + type CircleOpt func(*CircleOptions) type CircleOptions struct { @@ -51,6 +57,8 @@ type CircleOptions struct { StyleID StyleID Override StyleOverride + HitSlopPx int + hasStyleID bool } @@ -81,6 +89,10 @@ func CircleWithStyleOverride(ov StyleOverride) CircleOpt { } } +func CircleWithHitSlopPx(px int) CircleOpt { + return func(o *CircleOptions) { o.HitSlopPx = px } +} + type LineOpt func(*LineOptions) type LineOptions struct { @@ -88,6 +100,8 @@ type LineOptions struct { StyleID StyleID Override StyleOverride + HitSlopPx int + hasStyleID bool } @@ -117,3 +131,7 @@ func LineWithStyleOverride(ov StyleOverride) LineOpt { o.Override = ov } } + +func LineWithHitSlopPx(px int) LineOpt { + return func(o *LineOptions) { o.HitSlopPx = px } +} diff --git a/client/world/primitive.go b/client/world/primitive.go index 1bca9e3..df70cba 100644 --- a/client/world/primitive.go +++ b/client/world/primitive.go @@ -1,28 +1,32 @@ package world -import ( - "github.com/google/uuid" -) +// PrimitiveID is a compact stable identifier for primitives stored in the World. +// It is allocated by the World and may be reused after deletion (free-list). +type PrimitiveID uint32 // MapItem is the common interface implemented by all world primitives. type MapItem interface { - ID() uuid.UUID + ID() PrimitiveID } // Point is a point primitive in fixed-point world coordinates. type Point struct { - Id uuid.UUID + Id PrimitiveID X, Y int // Priority controls per-object draw ordering. Smaller draws earlier. Priority int // StyleID references a resolved style in the world's style table. StyleID StyleID + + // HitSlopPx expands hit-test radius in screen pixels (per-object override). + // 0 means "use primitive default". + HitSlopPx int } // Line is a line segment primitive in fixed-point world coordinates. type Line struct { - Id uuid.UUID + Id PrimitiveID X1, Y1 int X2, Y2 int @@ -30,28 +34,36 @@ type Line struct { Priority int // StyleID references a resolved style in the world's style table. StyleID StyleID + + // HitSlopPx expands hit-test radius in screen pixels (per-object override). + // 0 means "use primitive default". + HitSlopPx int } // Circle is a circle primitive in fixed-point world coordinates. type Circle struct { - Id uuid.UUID + Id PrimitiveID X, Y int Radius int + // Priority controls per-object draw ordering. Smaller draws earlier. Priority int - // StyleID references a resolved style in the world's style table. StyleID StyleID + + // HitSlopPx expands hit-test radius in screen pixels (per-object override). + // 0 means "use primitive default". + HitSlopPx int } // ID returns the point identifier. -func (p Point) ID() uuid.UUID { return p.Id } +func (p Point) ID() PrimitiveID { return p.Id } // ID returns the line identifier. -func (l Line) ID() uuid.UUID { return l.Id } +func (l Line) ID() PrimitiveID { return l.Id } // ID returns the circle identifier. -func (c Circle) ID() uuid.UUID { return c.Id } +func (c Circle) ID() PrimitiveID { return c.Id } // MinX returns the minimum X endpoint coordinate of the line. func (l Line) MinX() int { return min(l.X1, l.X2) } diff --git a/client/world/primitive_test.go b/client/world/primitive_test.go index 1054760..86a11df 100644 --- a/client/world/primitive_test.go +++ b/client/world/primitive_test.go @@ -2,16 +2,14 @@ package world import ( "testing" - - "github.com/google/uuid" ) func TestPrimitiveIDs(t *testing.T) { t.Parallel() - id1 := uuid.New() - id2 := uuid.New() - id3 := uuid.New() + id1 := PrimitiveID(1) + id2 := PrimitiveID(2) + id3 := PrimitiveID(3) p := Point{Id: id1} l := Line{Id: id2} diff --git a/client/world/renderer_circles_test.go b/client/world/renderer_circles_test.go index cee4d93..fe7fb98 100644 --- a/client/world/renderer_circles_test.go +++ b/client/world/renderer_circles_test.go @@ -1,6 +1,7 @@ package world import ( + "image/color" "testing" "github.com/stretchr/testify/require" @@ -197,3 +198,100 @@ func TestCircles_NoWrap_DoesNotDuplicateAcrossEdges(t *testing.T) { // Center must be at (9,9) only, no (-1,*) or (*,-1). require.Equal(t, []float64{9, 9, 2}, cmds[0].Args) } + +func TestRender_CircleTransparentFill_UsesStrokeNotFill(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + sw := 4.0 + circleStyle := w.AddStyleCircle(StyleOverride{ + FillColor: color.RGBA{A: 0}, // explicitly transparent + StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + StrokeWidthPx: &sw, + }) + + _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(circleStyle), CircleWithPriority(100)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + + iAdd := indexOfFirstName(cmds, "AddCircle") + require.NotEqual(t, -1, iAdd) + + // After AddCircle we must see Stroke (not Fill). + iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+6, len(cmds))) + iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+6, len(cmds))) + + require.Equal(t, -1, iFill, "transparent fill must not trigger Fill()") + require.NotEqual(t, -1, iStroke, "transparent fill must trigger Stroke() when stroke is visible") +} + +func TestRender_CircleFillAndStroke_DrawsFillThenStroke(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + sw := 2.0 + styleID := w.AddStyleCircle(StyleOverride{ + FillColor: color.RGBA{R: 10, G: 20, B: 30, A: 255}, + StrokeColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + StrokeWidthPx: &sw, + }) + + _, err := w.AddCircle(5, 5, 2, CircleWithStyleID(styleID), CircleWithPriority(100)) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + BackgroundColor: color.RGBA{A: 255}, + }, + } + + d := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d, params)) + + cmds := d.Commands() + iAdd := indexOfFirstName(cmds, "AddCircle") + require.NotEqual(t, -1, iAdd) + + iFill := indexOfFirstNameInRange(cmds, "Fill", iAdd+1, min(iAdd+10, len(cmds))) + iStroke := indexOfFirstNameInRange(cmds, "Stroke", iAdd+1, min(iAdd+10, len(cmds))) + + require.NotEqual(t, -1, iFill, "expected Fill() for visible fill") + require.NotEqual(t, -1, iStroke, "expected Stroke() for visible stroke") + require.Less(t, iFill, iStroke, "Stroke must be last when both are visible") +} diff --git a/client/world/renderer_draw.go b/client/world/renderer_draw.go index 0d1fa11..5e61d97 100644 --- a/client/world/renderer_draw.go +++ b/client/world/renderer_draw.go @@ -2,8 +2,6 @@ package world import ( "sort" - - "github.com/google/uuid" ) // drawKind is used only for stable tie-breaking when priorities are equal. @@ -18,7 +16,7 @@ const ( type drawItem struct { kind drawKind priority int - id uuid.UUID + id PrimitiveID styleID StyleID // Exactly one of these is set. @@ -122,7 +120,7 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo if a.kind != b.kind { return a.kind < b.kind } - return uuidLess(a.id, b.id) + return a.id < b.id }) drawer.Save() @@ -149,17 +147,3 @@ func (w *World) drawPlanSinglePass(drawer PrimitiveDrawer, plan RenderPlan, allo drawer.Restore() } } - -func uuidLess(a, b uuid.UUID) bool { - aa := a[:] - bb := b[:] - for i := 0; i < len(aa); i++ { - if aa[i] < bb[i] { - return true - } - if aa[i] > bb[i] { - return false - } - } - return false -} diff --git a/client/world/renderer_draw_primitives.go b/client/world/renderer_draw_primitives.go index e585622..7cd3660 100644 --- a/client/world/renderer_draw_primitives.go +++ b/client/world/renderer_draw_primitives.go @@ -29,10 +29,14 @@ func (w *World) drawPointInTile(drawer PrimitiveDrawer, plan RenderPlan, td Tile drawer.AddPoint(float64(px), float64(py), rPx) - // For points we use Fill if fill is configured, otherwise Stroke if stroke is configured. - if lastStyle.FillColor != nil { + fill := alphaNonZero(lastStyle.FillColor) + stroke := alphaNonZero(lastStyle.StrokeColor) + + if fill { drawer.Fill() - } else if lastStyle.StrokeColor != nil { + } + if stroke { + // Stroke must be last when both are present. drawer.Stroke() } } @@ -58,9 +62,14 @@ func (w *World) drawCircleInTile(drawer PrimitiveDrawer, plan RenderPlan, td Til drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) - if lastStyle.FillColor != nil { + fill := alphaNonZero(lastStyle.FillColor) + stroke := alphaNonZero(lastStyle.StrokeColor) + + if fill { drawer.Fill() - } else if lastStyle.StrokeColor != nil { + } + if stroke { + // Stroke must be last when both are present. drawer.Stroke() } } diff --git a/client/world/renderer_query.go b/client/world/renderer_query.go index fdd2a68..887ec4a 100644 --- a/client/world/renderer_query.go +++ b/client/world/renderer_query.go @@ -2,8 +2,6 @@ package world import ( "errors" - - "github.com/google/uuid" ) var ( @@ -59,7 +57,7 @@ func (w *World) collectCandidatesForTile(r Rect) []MapItem { rowStart := w.worldToCellY(r.minY) rowEnd := w.worldToCellY(r.maxY - 1) - seen := make(map[uuid.UUID]struct{}) + seen := make(map[PrimitiveID]struct{}) result := make([]MapItem, 0) for row := rowStart; row <= rowEnd; row++ { diff --git a/client/world/renderer_test.go b/client/world/renderer_test.go index 0d52412..650cbdd 100644 --- a/client/world/renderer_test.go +++ b/client/world/renderer_test.go @@ -4,7 +4,6 @@ import ( "sort" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -664,7 +663,7 @@ func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { if len(td.Candidates) == 0 { continue } - seen := map[uuid.UUID]struct{}{} + seen := map[PrimitiveID]struct{}{} for _, it := range td.Candidates { _, ok := seen[it.ID()] require.False(t, ok, "candidate duplicated within a tile") diff --git a/client/world/style.go b/client/world/style.go index 7e5580c..cb7c7f2 100644 --- a/client/world/style.go +++ b/client/world/style.go @@ -25,6 +25,12 @@ const ( DefaultPriorityPoint = 300 ) +var ( + transparentColor color.Color = &color.RGBA{A: 0} +) + +func TransparentFill() color.Color { return transparentColor } + // Style is a fully resolved style used by the renderer. // All fields are concrete values; no "optional" markers here. // Optionality is handled by StyleOverride during style creation. diff --git a/client/world/world.go b/client/world/world.go index 0d16159..5aff9bb 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -3,25 +3,41 @@ package world import ( "errors" "fmt" - - "github.com/google/uuid" ) var ( errBadCoordinate = errors.New("invalid coordinates") errBadRadius = errors.New("invalid radius") + errNoSuchObject = errors.New("no such object") + errIDExhausted = errors.New("primitive id exhausted") ) +type indexState struct { + initialized bool + viewportW int + viewportH int + zoomFp int +} + // World stores torus world dimensions, all registered objects, // and the grid-based spatial index built for the current viewport settings. type World struct { - W, H int // Fixed-point world size. - grid [][][]MapItem - cellSize int - rows, cols int - objects map[uuid.UUID]MapItem + W, H int // Fixed-point world size. + grid [][][]MapItem + cellSize int + rows, cols int + objects map[PrimitiveID]MapItem + styles *StyleTable + + // PrimitiveID allocator state. + nextID PrimitiveID + freeIDs []PrimitiveID + + // Index dirty flag for add/remove updates. + indexDirty bool + index indexState + renderState rendererIncrementalState - styles *StyleTable } // NewWorld constructs a new world with the given real dimensions. @@ -34,11 +50,36 @@ func NewWorld(width, height int) *World { W: width * SCALE, H: height * SCALE, cellSize: 1, - objects: make(map[uuid.UUID]MapItem), + objects: make(map[PrimitiveID]MapItem), styles: NewStyleTable(), + nextID: 1, // 0 is reserved as "invalid" } } +// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter. +// It returns an error if the ID space is exhausted. +func (g *World) allocID() (PrimitiveID, error) { + if n := len(g.freeIDs); n > 0 { + id := g.freeIDs[n-1] + g.freeIDs = g.freeIDs[:n-1] + return id, nil + } + if g.nextID == PrimitiveID(^uint32(0)) { + return 0, errIDExhausted + } + id := g.nextID + g.nextID++ + return id, nil +} + +// freeID returns an id back to the pool. It is safe to call only after the object is removed. +func (g *World) freeID(id PrimitiveID) { + if id == 0 { + return + } + g.freeIDs = append(g.freeIDs, id) +} + // checkCoordinate reports whether the fixed-point coordinate (xf, yf) // lies inside the world bounds: [0, W) x [0, H). func (g *World) checkCoordinate(xf, yf int) bool { @@ -63,14 +104,52 @@ func (g *World) AddStylePoint(override StyleOverride) StyleID { return g.styles.AddDerived(StyleIDDefaultPoint, override) } +// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown. +// It marks the spatial index dirty and triggers an autonomous rebuild if possible. +func (g *World) Remove(id PrimitiveID) error { + if _, ok := g.objects[id]; !ok { + return errNoSuchObject + } + delete(g.objects, id) + g.freeID(id) + + g.indexDirty = true + g.rebuildIndexFromLastState() + return nil +} + +// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state +// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true. +func (g *World) Reindex() { + g.indexDirty = true + g.rebuildIndexFromLastState() +} + +// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp +// from renderer state. If that state is not initialized, it does nothing. +func (g *World) rebuildIndexFromLastState() { + if !g.indexDirty { + return + } + if !g.index.initialized { + return + } + if g.index.viewportW <= 0 || g.index.viewportH <= 0 || g.index.zoomFp <= 0 { + return + } + + g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp) + g.indexDirty = false +} + // AddPoint validates and stores a point primitive in the world. // The input coordinates are given in real world units and are converted // to fixed-point before validation. -func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) { +func (g *World) AddPoint(x, y float64, opts ...PointOpt) (PrimitiveID, error) { xf := fixedPoint(x) yf := fixedPoint(y) if ok := g.checkCoordinate(xf, yf); !ok { - return uuid.Nil, errBadCoordinate + return 0, errBadCoordinate } o := defaultPointOptions() @@ -81,29 +160,38 @@ func (g *World) AddPoint(x, y float64, opts ...PointOpt) (uuid.UUID, error) { } styleID := g.resolvePointStyleID(o) - id := uuid.New() - g.objects[id] = Point{ - Id: id, - X: xf, - Y: yf, - Priority: o.Priority, - StyleID: styleID, + id, err := g.allocID() + if err != nil { + return 0, err } + + g.objects[id] = Point{ + Id: id, + X: xf, + Y: yf, + Priority: o.Priority, + StyleID: styleID, + HitSlopPx: o.HitSlopPx, + } + + g.indexDirty = true + g.rebuildIndexFromLastState() + return id, nil } // AddCircle validates and stores a circle primitive in the world. // The center and radius are given in real world units and are converted // to fixed-point before validation. A zero radius is allowed. -func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error) { +func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (PrimitiveID, error) { xf := fixedPoint(x) yf := fixedPoint(y) if ok := g.checkCoordinate(xf, yf); !ok { - return uuid.Nil, errBadCoordinate + return 0, errBadCoordinate } if r < 0 { - return uuid.Nil, errBadRadius + return 0, errBadRadius } o := defaultCircleOptions() @@ -114,32 +202,41 @@ func (g *World) AddCircle(x, y, r float64, opts ...CircleOpt) (uuid.UUID, error) } styleID := g.resolveCircleStyleID(o) - id := uuid.New() - g.objects[id] = Circle{ - Id: id, - X: xf, - Y: yf, - Radius: fixedPoint(r), - Priority: o.Priority, - StyleID: styleID, + id, err := g.allocID() + if err != nil { + return 0, err } + + g.objects[id] = Circle{ + Id: id, + X: xf, + Y: yf, + Radius: fixedPoint(r), + Priority: o.Priority, + StyleID: styleID, + HitSlopPx: o.HitSlopPx, + } + + g.indexDirty = true + g.rebuildIndexFromLastState() + return id, nil } // AddLine validates and stores a line primitive in the world. // The endpoints are given in real world units and are converted // to fixed-point before validation. -func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, error) { +func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, error) { x1f := fixedPoint(x1) y1f := fixedPoint(y1) x2f := fixedPoint(x2) y2f := fixedPoint(y2) if ok := g.checkCoordinate(x1f, y1f); !ok { - return uuid.Nil, errBadCoordinate + return 0, errBadCoordinate } if ok := g.checkCoordinate(x2f, y2f); !ok { - return uuid.Nil, errBadCoordinate + return 0, errBadCoordinate } o := defaultLineOptions() @@ -150,16 +247,25 @@ func (g *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (uuid.UUID, err } styleID := g.resolveLineStyleID(o) - id := uuid.New() - g.objects[id] = Line{ - Id: id, - X1: x1f, - Y1: y1f, - X2: x2f, - Y2: y2f, - Priority: o.Priority, - StyleID: styleID, + id, err := g.allocID() + if err != nil { + return 0, err } + + g.objects[id] = Line{ + Id: id, + X1: x1f, + Y1: y1f, + X2: x2f, + Y2: y2f, + Priority: o.Priority, + StyleID: styleID, + HitSlopPx: o.HitSlopPx, + } + + g.indexDirty = true + g.rebuildIndexFromLastState() + return id, nil } @@ -206,6 +312,10 @@ func (g *World) worldToCellY(y int) int { // resetGrid recreates the spatial grid with the given cell size // and clears all previous indexing state. func (g *World) resetGrid(cellSize int) { + if cellSize <= 0 { + panic("resetGrid: invalid cell size") + } + g.cellSize = cellSize g.cols = ceilDiv(g.W, g.cellSize) g.rows = ceilDiv(g.H, g.cellSize) @@ -277,12 +387,24 @@ func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { } } -// IndexOnViewportChange rebuilds the grid for a new viewport size and zoom. -// The zoom is provided by the UI as a real multiplier and is converted -// to fixed-point inside the function. +// IndexOnViewportChange is called when UI window sizes are changed. +// cameraZoom is float64, converted inside world to fixed-point. func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) { - cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom) - worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp) + zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code + + // Remember params for autonomous reindex after Add/Remove. + g.index.initialized = true + g.index.viewportW = viewportWidthPx + g.index.viewportH = viewportHeightPx + g.index.zoomFp = zoomFp + + g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp) + g.indexDirty = false +} + +// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom. +func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) { + worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp) cellsAcrossMin := 8 visibleMin := min(worldWidth, worldHeight) diff --git a/client/world/world_id_allocator_test.go b/client/world/world_id_allocator_test.go new file mode 100644 index 0000000..a2e7ef1 --- /dev/null +++ b/client/world/world_id_allocator_test.go @@ -0,0 +1,37 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorldPrimitiveID_ReusesFreedIDs(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id1, err := w.AddPoint(1, 1) + require.NoError(t, err) + + id2, err := w.AddPoint(2, 2) + require.NoError(t, err) + + require.NotEqual(t, id1, id2) + + require.NoError(t, w.Remove(id1)) + + id3, err := w.AddPoint(3, 3) + require.NoError(t, err) + + // LIFO free-list: id1 should be reused. + require.Equal(t, id1, id3) +} + +func TestWorldRemove_UnknownID(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + err := w.Remove(12345) + require.ErrorIs(t, err, errNoSuchObject) +} diff --git a/client/world/world_test.go b/client/world/world_test.go index cf4c327..cd1ff59 100644 --- a/client/world/world_test.go +++ b/client/world/world_test.go @@ -3,8 +3,6 @@ package world import ( "errors" "testing" - - "github.com/google/uuid" ) func newIndexedTestWorld() *World { @@ -13,7 +11,7 @@ func newIndexedTestWorld() *World { return w } -func cellHasOnlyID(t *testing.T, w *World, row, col int, want uuid.UUID) { +func cellHasOnlyID(t *testing.T, w *World, row, col int, want PrimitiveID) { t.Helper() cell := w.grid[row][col] @@ -33,7 +31,7 @@ func cellIsEmpty(t *testing.T, w *World, row, col int) { } } -func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} { +func occupiedCellsByID(w *World, id PrimitiveID) map[[2]int]struct{} { result := make(map[[2]int]struct{}) for row := range w.grid { @@ -49,7 +47,7 @@ func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} { return result } -func assertOccupiedCells(t *testing.T, w *World, id uuid.UUID, want ...[2]int) { +func assertOccupiedCells(t *testing.T, w *World, id PrimitiveID, want ...[2]int) { t.Helper() got := occupiedCellsByID(w, id) @@ -375,7 +373,7 @@ func TestIndexObjectPoint(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) p := Point{Id: id, X: 2500, Y: 4500} w.indexObject(p) @@ -389,7 +387,7 @@ func TestIndexObjectCircleWithoutWrap(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900} w.indexObject(c) @@ -404,7 +402,7 @@ func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) c := Circle{Id: id, X: 500, Y: 500, Radius: 900} w.indexObject(c) @@ -421,7 +419,7 @@ func TestIndexObjectCircleCoversWholeWorld(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000} w.indexObject(c) @@ -440,7 +438,7 @@ func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000} w.indexObject(l) @@ -456,7 +454,7 @@ func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000} w.indexObject(l) @@ -472,7 +470,7 @@ func TestIndexObjectLineWrapsAcrossX(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000} w.indexObject(l) @@ -487,7 +485,7 @@ func TestIndexObjectLineWrapsAcrossY(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000} w.indexObject(l) @@ -502,7 +500,7 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) { t.Parallel() w := newIndexedTestWorld() - id := uuid.New() + id := PrimitiveID(1) l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000} w.indexObject(l) @@ -515,10 +513,10 @@ func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) { } type unknown struct { - id uuid.UUID + id PrimitiveID } -func (u unknown) ID() uuid.UUID { +func (u unknown) ID() PrimitiveID { return u.id } @@ -533,5 +531,5 @@ func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) { } }() - w.indexObject(unknown{id: uuid.New()}) + w.indexObject(unknown{id: PrimitiveID(1)}) }