package main import ( "fmt" "image" "math" "math/rand" "strconv" "strings" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" ) // PixelMask stores per-pixel occupancy for placement and collision checks. type PixelMask struct { Width int Height int Data []uint8 } // NewPixelMask creates a mask for width and height and is used for feature occupancy. func NewPixelMask(width, height int) *PixelMask { if width <= 0 || height <= 0 { return &PixelMask{} } return &PixelMask{ Width: width, Height: height, Data: make([]uint8, width*height), } } // index returns the slice index for coordinates and is used by mask helpers. func (m *PixelMask) index(x, y int) int { return y*m.Width + x } // InBounds reports whether x,y are inside the mask and is used to guard access. func (m *PixelMask) InBounds(x, y int) bool { return m != nil && x >= 0 && y >= 0 && x < m.Width && y < m.Height } // GetXY returns occupancy at x,y and is used for collision checks. func (m *PixelMask) GetXY(x, y int) bool { if !m.InBounds(x, y) { return false } return m.Data[m.index(x, y)] != 0 } // SetXY marks x,y as occupied and is used when placing features. func (m *PixelMask) SetXY(x, y int) { if m.InBounds(x, y) { m.Data[m.index(x, y)] = 1 } } // ClearXY clears occupancy at x,y and is used for mask edits. func (m *PixelMask) ClearXY(x, y int) { if m.InBounds(x, y) { m.Data[m.index(x, y)] = 0 } } // GetPoint returns occupancy at a point and is used with image.Point helpers. func (m *PixelMask) GetPoint(p image.Point) bool { return m.GetXY(p.X, p.Y) } // SetPoint marks a point as occupied and is used with image.Point helpers. func (m *PixelMask) SetPoint(p image.Point) { m.SetXY(p.X, p.Y) } // Merge ORs another mask into this one and is used to combine feature masks. func (m *PixelMask) Merge(other *PixelMask) { if m == nil || other == nil || m.Width != other.Width || m.Height != other.Height { return } for i := range m.Data { if other.Data[i] != 0 { m.Data[i] = 1 } } } // AddPoints marks a slice of points as occupied and is used for lake/road masks. func (m *PixelMask) AddPoints(points []image.Point) { for _, p := range points { m.SetPoint(p) } } // ToPoints returns occupied points and is used to extract anchors. func (m *PixelMask) ToPoints() []image.Point { if m == nil { return nil } pts := make([]image.Point, 0) for y := 0; y < m.Height; y++ { row := y * m.Width for x := 0; x < m.Width; x++ { if m.Data[row+x] != 0 { pts = append(pts, image.Point{X: x, Y: y}) } } } return pts } // BuildMaskFromLakes builds a water mask from lake point sets for later merges. func BuildMaskFromLakes(width, height int, lakes [][]image.Point) *PixelMask { mask := NewPixelMask(width, height) for _, lake := range lakes { mask.AddPoints(lake) } return mask } // SeedProvider provides a deterministic stream of seeds for generation steps. type SeedProvider struct { rand *rand.Rand } // NewSeedProvider creates a SeedProvider for splitting a root seed into steps. func NewSeedProvider(seed int64) *SeedProvider { return &SeedProvider{ rand: rand.New(rand.NewSource(seed)), } } // Next returns the next random seed and is used to drive independent generators. func (sp *SeedProvider) Next() int64 { return sp.rand.Int63() } // averageImageDimension returns (width+height)/2 and is used for percent scaling. func averageImageDimension(width, height int) float64 { return (float64(width) + float64(height)) / 2.0 } // clamp clamps v to [lo, hi] and is used for settings normalization. func clamp(v, lo, hi float64) float64 { if v < lo { return lo } if v > hi { return hi } return v } // clamp01 clamps v to [0,1] and is used for normalized weights. func clamp01(v float64) float64 { return clamp(v, 0, 1) } // abs returns the absolute value of an int and is used in raster helpers. func abs(x int) int { if x < 0 { return -x } return x } // cloneMask copies a PixelMask and is used when mutating temporary masks. func cloneMask(src *PixelMask) *PixelMask { if src == nil { return nil } dst := NewPixelMask(src.Width, src.Height) copy(dst.Data, src.Data) return dst } // averagePoint returns the average of points and is used for centroids. func averagePoint(points []image.Point) image.Point { if len(points) == 0 { return image.Point{} } var sx, sy int for _, p := range points { sx += p.X sy += p.Y } return image.Point{X: sx / len(points), Y: sy / len(points)} } // estimateWallTangent approximates the tangent at a wall pixel and is used to align roads and gates. func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) { if wallMask == nil { return 0, 0, false } const r = 4 var pts [][2]float64 for dy := -r; dy <= r; dy++ { y := mid.Y + dy if y < 0 || y >= wallMask.Height { continue } for dx := -r; dx <= r; dx++ { x := mid.X + dx if x < 0 || x >= wallMask.Width { continue } if wallMask.GetXY(x, y) { pts = append(pts, [2]float64{float64(x), float64(y)}) } } } if len(pts) < 3 { return 0, 0, false } var mx, my float64 for _, p := range pts { mx += p[0] my += p[1] } mx /= float64(len(pts)) my /= float64(len(pts)) var sxx, syy, sxy float64 for _, p := range pts { dx := p[0] - mx dy := p[1] - my sxx += dx * dx syy += dy * dy sxy += dx * dy } if sxx+syy < 0.001 { return 0, 0, false } theta := 0.5 * math.Atan2(2*sxy, sxx-syy) return math.Cos(theta), math.Sin(theta), true } type tappableImage struct { widget.BaseWidget image *fyne.Container onTapped func(*fyne.PointEvent) onSecondaryTap func(*fyne.PointEvent) onHover func(fyne.Position) onHoverEnd func() } // newTappableImage wraps a container so taps can trigger image interactions. func newTappableImage(img *fyne.Container, tapped func(*fyne.PointEvent)) *tappableImage { ti := &tappableImage{ image: img, onTapped: tapped, } ti.ExtendBaseWidget(ti) return ti } // CreateRenderer builds the renderer for tappableImage and is used by Fyne. func (t *tappableImage) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(t.image) } // Tapped invokes the handler and is used for click interactions. func (t *tappableImage) Tapped(ev *fyne.PointEvent) { if t.onTapped != nil { t.onTapped(ev) } } // TappedSecondary invokes the secondary handler and is used for right-click interactions. func (t *tappableImage) TappedSecondary(ev *fyne.PointEvent) { if t.onSecondaryTap != nil { t.onSecondaryTap(ev) } } // MouseMoved forwards hover updates and is used for preview positioning. func (t *tappableImage) MouseMoved(ev *desktop.MouseEvent) { if t.onHover != nil { t.onHover(ev.Position) } } // MouseIn is required by desktop.Hoverable. func (t *tappableImage) MouseIn(*desktop.MouseEvent) {} // MouseOut clears hover state and is used when previews leave the canvas. func (t *tappableImage) MouseOut() { if t.onHoverEnd != nil { t.onHoverEnd() } } // numericInputSlider combines a slider and text entry for numeric input. type numericInputSlider struct { widget.BaseWidget value binding.Float min, max float64 step float64 slider *widget.Slider entry *widget.Entry format string errorLabel *widget.Label label *widget.Label } type numericInputSliderRenderer struct { slider *numericInputSlider label *widget.Label entry *widget.Entry sliderWidget *widget.Slider errorLabel *widget.Label layout fyne.Layout objects []fyne.CanvasObject } func (r *numericInputSliderRenderer) MinSize() fyne.Size { return r.layout.MinSize(r.objects) } func (r *numericInputSliderRenderer) Layout(size fyne.Size) { r.layout.Layout(r.objects, size) } func (r *numericInputSliderRenderer) Objects() []fyne.CanvasObject { return r.objects } func (r *numericInputSliderRenderer) Refresh() { r.label.SetText(r.slider.label.Text) } func (r *numericInputSliderRenderer) Destroy() {} // newNumericInputSlider creates a slider+entry pair for numeric settings. func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider { s := &numericInputSlider{ min: min, max: max, format: format, step: 0, } s.ExtendBaseWidget(s) s.label = widget.NewLabel(labelText) s.value = binding.NewFloat() s.value.Set(initialValue) s.slider = widget.NewSlider(min, max) s.slider.Bind(s.value) s.entry = widget.NewEntry() s.value.AddListener(binding.NewDataListener(func() { val, _ := s.value.Get() s.entry.SetText(fmt.Sprintf(s.format, val)) })) s.errorLabel = widget.NewLabel("") s.errorLabel.Hide() return s } // newNumericInputSliderWithStep creates a numeric slider that snaps to increments. func newNumericInputSliderWithStep(min, max, initialValue, step float64, format string, labelText string) *numericInputSlider { s := newNumericInputSlider(min, max, initialValue, format, labelText) if step > 0 { s.step = step s.slider.Step = step rounded := min + math.Round((initialValue-min)/step)*step s.value.Set(rounded) } return s } // validate checks entry input and is used for slider text validation. func (s *numericInputSlider) validate(text string, onError func(bool)) { text = strings.TrimSpace(text) text = strings.TrimSuffix(text, "px") text = strings.TrimSuffix(text, "%") text = strings.TrimSuffix(text, "°") text = strings.TrimSpace(text) val, err := strconv.ParseFloat(text, 64) if err != nil { s.errorLabel.SetText("Not a number") s.errorLabel.Show() onError(true) return } if val < s.min || val > s.max { s.errorLabel.SetText(fmt.Sprintf( "Out of range (%s-%s)", strconv.FormatFloat(s.min, 'f', -1, 64), strconv.FormatFloat(s.max, 'f', -1, 64), )) s.errorLabel.Show() onError(true) return } if s.step > 0 { steps := math.Round((val - s.min) / s.step) snapped := s.min + steps*s.step if math.Abs(val-snapped) > 1e-9 { s.errorLabel.SetText(fmt.Sprintf("Use increments of %s", strconv.FormatFloat(s.step, 'f', -1, 64))) s.errorLabel.Show() onError(true) return } val = snapped } s.errorLabel.Hide() onError(false) s.value.Set(val) } // CreateRenderer builds the widget layout and is used by Fyne. func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer { r := &numericInputSliderRenderer{ slider: s, label: s.label, entry: s.entry, sliderWidget: s.slider, errorLabel: s.errorLabel, } r.layout = layout.NewGridLayout(1) r.objects = []fyne.CanvasObject{ container.NewGridWithColumns(2, r.label, r.entry), r.sliderWidget, r.errorLabel, } return r }