diff --git a/fortifications.go b/fortifications.go index 0bbb2f1..8bbf969 100644 --- a/fortifications.go +++ b/fortifications.go @@ -12,6 +12,10 @@ const ( minWallWidthPercent = minBuildingSizePercent maxWallWidthPercent = maxBuildingSizePercent wallWidthPercentStep = buildingSizePercentStep + + minTurretSizePercent = 0.2 + maxTurretSizePercent = maxWallWidthPercent + turretSizePercentStep = 0.1 ) func clampWallWidthPercent(v float64) float64 { @@ -56,6 +60,35 @@ func getWallWidthRangePixels(settings *Settings, width, height int) (float64, fl return minPx, maxPx } +func clampTurretSizePercent(v float64) float64 { + if v < minTurretSizePercent { + return minTurretSizePercent + } + if v > maxTurretSizePercent { + return maxTurretSizePercent + } + return v +} + +func snapTurretSizePercent(v float64) float64 { + v = clampTurretSizePercent(v) + steps := math.Round((v - minTurretSizePercent) / turretSizePercentStep) + return clampTurretSizePercent(minTurretSizePercent + steps*turretSizePercentStep) +} + +func getTurretSizePixels(settings *Settings, width, height int) float64 { + sizePercent := snapTurretSizePercent(settings.TurretSize) + avgDim := averageImageDimension(width, height) + if avgDim < 1 { + avgDim = 1 + } + sizePx := (sizePercent / 100.0) * avgDim + if sizePx < 1 { + sizePx = 1 + } + return sizePx +} + type FortificationLayout struct { Mask *PixelMask WallIDByPixel []int @@ -360,3 +393,324 @@ func drawWallMask(img *image.RGBA, wallMask *PixelMask) { } } } + +func GenerateTurrets( + img *image.RGBA, + width, height int, + settings *Settings, + layout *FortificationLayout, + waterMask, roadMask *PixelMask, + roads []*Road, +) *PixelMask { + mask := NewPixelMask(width, height) + if !settings.ShowTurrets || layout == nil || layout.Mask == nil || len(layout.WallIDByPixel) != width*height { + return mask + } + if img == nil { + img = image.NewRGBA(image.Rect(0, 0, width, height)) + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + if roadMask == nil { + roadMask = NewPixelMask(width, height) + } + + sizePx := getTurretSizePixels(settings, width, height) + radius := int(math.Round(sizePx / 2.0)) + if radius < 1 { + radius = 1 + } + shape := settings.TurretShape + if shape != "square" { + shape = "circular" + } + colorRed := color.RGBA{R: 220, G: 25, B: 25, A: 255} + + wallPoints := make(map[int][]image.Point) + waterMeetPoints := make(map[int][]image.Point) + for y := 0; y < height; y++ { + row := y * width + for x := 0; x < width; x++ { + wid := layout.WallIDByPixel[row+x] + if wid <= 0 { + continue + } + if !isBoundaryWallPixel(x, y, layout.Mask) { + continue + } + p := image.Point{X: x, Y: y} + wallPoints[wid] = append(wallPoints[wid], p) + if touchesWater(x, y, waterMask) { + waterMeetPoints[wid] = append(waterMeetPoints[wid], p) + } + } + } + + occupied := make(map[int]bool) + addTurret := func(center image.Point) { + snapped, ok := snapPointToWall(center, layout.Mask, max(3, radius*4)) + if !ok { + return + } + if nearbyTurretExists(mask, snapped, max(2, radius)) { + return + } + key := snapped.Y*width + snapped.X + if occupied[key] { + return + } + occupied[key] = true + drawTurret(img, mask, snapped, radius, shape, colorRed) + } + + // Base spacing turrets along each wall ring. + for wid, pts := range wallPoints { + if len(pts) == 0 { + continue + } + centroid := averagePoint(pts) + sort.Slice(pts, func(i, j int) bool { + ai := math.Atan2(float64(pts[i].Y-centroid.Y), float64(pts[i].X-centroid.X)) + aj := math.Atan2(float64(pts[j].Y-centroid.Y), float64(pts[j].X-centroid.X)) + return ai < aj + }) + // Spacing is "distance along wall as % of wall circumference", independent of turret size. + spacingPct := clamp(settings.TurretSpacing, 0, 100) + step := int(math.Round((spacingPct / 100.0) * float64(len(pts)))) + if step < 1 { + step = 1 + } + if step > len(pts) { + step = len(pts) + } + for i := 0; i < len(pts); i += step { + addTurret(pts[i]) + } + + // Always place turrets where wall meets water. + for _, p := range waterMeetPoints[wid] { + addTurret(p) + } + } + + // Gate turrets: one on each side of each road crossing, spacing = 3x road width. + for _, r := range roads { + if r == nil || len(r.Points) < 2 { + continue + } + gates := roadGateCentersForRoad(r, layout) + if len(gates) == 0 { + continue + } + for _, g := range gates { + tx, ty, ok := fortEstimateWallTangent(g, layout.Mask) + if !ok { + continue + } + offset := 1.5 * float64(max(1, r.Width)) + left := image.Point{ + X: int(math.Round(float64(g.X) + tx*offset)), + Y: int(math.Round(float64(g.Y) + ty*offset)), + } + right := image.Point{ + X: int(math.Round(float64(g.X) - tx*offset)), + Y: int(math.Round(float64(g.Y) - ty*offset)), + } + addTurret(left) + addTurret(right) + } + } + + return mask +} + +func isBoundaryWallPixel(x, y int, wallMask *PixelMask) bool { + if wallMask == nil || !wallMask.GetXY(x, y) { + return false + } + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + nx, ny := x+dx, y+dy + if !wallMask.InBounds(nx, ny) || !wallMask.GetXY(nx, ny) { + return true + } + } + } + return false +} + +func touchesWater(x, y int, waterMask *PixelMask) bool { + if waterMask == nil { + return false + } + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + nx, ny := x+dx, y+dy + if waterMask.GetXY(nx, ny) { + return true + } + } + } + return false +} + +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)} +} + +func drawTurret(img *image.RGBA, mask *PixelMask, center image.Point, radius int, shape string, col color.RGBA) { + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + if shape == "circular" && dx*dx+dy*dy > radius*radius { + continue + } + x, y := center.X+dx, center.Y+dy + if !mask.InBounds(x, y) { + continue + } + mask.SetXY(x, y) + img.Set(x, y, col) + } + } +} + +func nearbyTurretExists(mask *PixelMask, center image.Point, radius int) bool { + if mask == nil { + return false + } + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy > radius*radius { + continue + } + if mask.GetXY(center.X+dx, center.Y+dy) { + return true + } + } + } + return false +} + +func snapPointToWall(center image.Point, wallMask *PixelMask, maxRadius int) (image.Point, bool) { + if wallMask == nil { + return image.Point{}, false + } + if wallMask.GetXY(center.X, center.Y) { + return center, true + } + if maxRadius < 1 { + maxRadius = 1 + } + best := image.Point{} + bestD2 := math.MaxInt + found := false + for r := 1; r <= maxRadius; r++ { + minX := center.X - r + maxX := center.X + r + minY := center.Y - r + maxY := center.Y + r + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + if x != minX && x != maxX && y != minY && y != maxY { + continue + } + if !wallMask.GetXY(x, y) { + continue + } + dx := x - center.X + dy := y - center.Y + d2 := dx*dx + dy*dy + if d2 < bestD2 { + bestD2 = d2 + best = image.Point{X: x, Y: y} + found = true + } + } + } + if found { + return best, true + } + } + return image.Point{}, false +} + +func roadGateCentersForRoad(r *Road, layout *FortificationLayout) []image.Point { + out := make([]image.Point, 0, 2) + if r == nil || layout == nil || layout.Mask == nil || len(r.Points) < 2 { + return out + } + prevID := 0 + if layout.Mask.InBounds(r.Points[0].Point.X, r.Points[0].Point.Y) { + prevID = layout.WallIDByPixel[r.Points[0].Point.Y*layout.Mask.Width+r.Points[0].Point.X] + } + for i := 1; i < len(r.Points); i++ { + p := r.Points[i].Point + currID := 0 + if layout.Mask.InBounds(p.X, p.Y) { + currID = layout.WallIDByPixel[p.Y*layout.Mask.Width+p.X] + } + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + out = append(out, p) + } + prevID = currID + } + return out +} + +func fortEstimateWallTangent(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 +} diff --git a/main.go b/main.go index d20141b..d607148 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,7 @@ func main() { var bridgeMask *PixelMask var exitRoadMask *PixelMask var wallMask *PixelMask + var turretMask *PixelMask // Set up application configuration directory configDir, err := os.UserConfigDir() @@ -282,29 +283,34 @@ func main() { // Step 6: Generating Roads var roadAnchors []image.Point + var roadList []*Road roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() struct { rd *PixelMask br *PixelMask ex *PixelMask anc []image.Point + rl []*Road } { - rd, br, ex, anc := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) + rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) return struct { rd *PixelMask br *PixelMask ex *PixelMask anc []image.Point - }{rd: rd, br: br, ex: ex, anc: anc} + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl drawWallMask(roadBase, wallMask) + turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) finalImage = roadBase } else { log.Println("GenerateRoads timed out after 1 minute; continuing.") roadMask = NewPixelMask(settings.Width, settings.Height) bridgeMask = NewPixelMask(settings.Width, settings.Height) exitRoadMask = NewPixelMask(settings.Width, settings.Height) + turretMask = NewPixelMask(settings.Width, settings.Height) roadAnchors = nil } @@ -731,6 +737,59 @@ func main() { settings.WallCurvyness = val })) + showTurretsCheck := widget.NewCheck("Show Turrets", func(v bool) { + settings.ShowTurrets = v + }) + showTurretsCheck.SetChecked(settings.ShowTurrets) + + turretSizeSlider := newNumericInputSliderWithStep(minTurretSizePercent, maxTurretSizePercent, settings.TurretSize, turretSizePercentStep, "%.1f%%", "Turret Size") + turretSizeSlider.entry.OnChanged = func(s string) { + turretSizeSlider.validate(s, func(hasError bool) { + errorStates["turretSize"] = hasError + updateGenerateBtnState() + }) + } + turretSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := turretSizeSlider.value.Get() + settings.TurretSize = val + })) + + turretShapeLabel := widget.NewLabel("Turret Shape:") + turretShapeSelect := widget.NewSelect([]string{"circular", "square"}, func(s string) { + settings.TurretShape = s + }) + turretShapeSelect.SetSelected(settings.TurretShape) + + turretSpacingSlider := newNumericInputSlider(0, 100, settings.TurretSpacing, "%.0f%%", "Turret Spacing") + turretSpacingSlider.entry.OnChanged = func(s string) { + turretSpacingSlider.validate(s, func(hasError bool) { + errorStates["turretSpacing"] = hasError + updateGenerateBtnState() + }) + } + turretSpacingSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := turretSpacingSlider.value.Get() + settings.TurretSpacing = val + })) + + turretControls := container.NewVBox( + turretSizeSlider, + turretShapeLabel, + turretShapeSelect, + turretSpacingSlider, + ) + if !settings.ShowTurrets { + turretControls.Hide() + } + showTurretsCheck.OnChanged = func(v bool) { + settings.ShowTurrets = v + if v { + turretControls.Show() + } else { + turretControls.Hide() + } + } + // Create UI elements for error display and action buttons errorLabel := widget.NewLabel("") errorLabel.Wrapping = fyne.TextWrapWord @@ -749,7 +808,7 @@ func main() { showSaveDialog(w, bumpmapImg.Image, settings) }) exportMasksBtn := widget.NewButton("Export Masks", func() { - showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, buildingMask) + showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask) }) // Main generation button and logic generateBtn = widget.NewButton("Generate", func() { @@ -905,23 +964,27 @@ func main() { progressBar.SetValue(6.0 / 13.0) }) var roadAnchors []image.Point + var roadList []*Road roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() struct { rd *PixelMask br *PixelMask ex *PixelMask anc []image.Point + rl []*Road } { - rd, br, ex, anc := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) + rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) return struct { rd *PixelMask br *PixelMask ex *PixelMask anc []image.Point - }{rd: rd, br: br, ex: ex, anc: anc} + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl drawWallMask(roadBase, wallMask) + turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) finalImage = roadBase } else { log.Println("GenerateRoads timed out after 1 minute; continuing.") @@ -929,6 +992,7 @@ func main() { roadMask = NewPixelMask(settings.Width, settings.Height) bridgeMask = NewPixelMask(settings.Width, settings.Height) exitRoadMask = NewPixelMask(settings.Width, settings.Height) + turretMask = NewPixelMask(settings.Width, settings.Height) roadAnchors = nil } placementMask := cloneMask(roadMask) @@ -1135,6 +1199,8 @@ func main() { numWallsSlider, cityCoverageSlider, wallCurvynessSlider, + showTurretsCheck, + turretControls, )) numBuildingsSlider := newNumericInputSlider(0, 10000, float64(settings.NumBuildings), "%.0f", "Number of Buildings") numBuildingsSlider.entry.OnChanged = func(s string) { @@ -1498,7 +1564,7 @@ func encodeImageToWriter(w io.Writer, img image.Image, format string) error { } // showMasksSaveDialog displays a dialog for saving the generated masks. -func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, buildingMask *PixelMask) { +func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask *PixelMask) { // Create UI elements for the save dialog fileNameEntry := widget.NewEntry() fileNameEntry.SetPlaceHolder("masks_folder") @@ -1583,6 +1649,7 @@ func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg im {name: "roads_mask." + imgFormat, make: func() image.Image { return maskToGray(roadMask) }}, {name: "bridges_mask." + imgFormat, make: func() image.Image { return maskToGray(bridgeMask) }}, {name: "walls_mask." + imgFormat, make: func() image.Image { return maskToGray(wallMask) }}, + {name: "turrets_mask." + imgFormat, make: func() image.Image { return maskToGray(turretMask) }}, {name: "buildings_mask." + imgFormat, make: func() image.Image { return maskToGray(buildingMask) }}, } diff --git a/roads.go b/roads.go index 527031e..4abe48d 100644 --- a/roads.go +++ b/roads.go @@ -88,7 +88,8 @@ func GenerateRoads( waterMask *PixelMask, seed int64, ) (*PixelMask, *PixelMask, *PixelMask, []image.Point) { - return GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed) + roadMask, bridgeMask, exitRoadMask, roadAnchors, _ := GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed) + return roadMask, bridgeMask, exitRoadMask, roadAnchors } func PrepareRoadNodes(width, height int, settings *Settings, waterMask *PixelMask, seed int64) ([]*PointOfInterest, int, bool) { @@ -122,7 +123,7 @@ func GenerateRoadsWithPOIs( roadTarget int, edgeToEdgeOnly bool, seed int64, -) (*PixelMask, *PixelMask, *PixelMask, []image.Point) { +) (*PixelMask, *PixelMask, *PixelMask, []image.Point, []*Road) { if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } @@ -139,7 +140,7 @@ func GenerateRoadsWithPOIs( internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100))) exitRoads := max(0, settings.RoadExits) if internalRoads == 0 && exitRoads == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } if internalRoads > 0 { roadTarget = internalRoads @@ -159,22 +160,22 @@ func GenerateRoadsWithPOIs( pois = generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) } if len(pois) < 2 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, wallLayout, roadTarget) roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask, wallLayout) } if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) assignRoadWidths(roads, settings, randSrc, width, height, wallLayout) @@ -190,7 +191,7 @@ func GenerateRoadsWithPOIs( } roadAnchors := roadMask.ToPoints() - return roadMask, bridgeMask, exitRoadMask, roadAnchors + return roadMask, bridgeMask, exitRoadMask, roadAnchors, roads } func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMask, settings *Settings, width, height int, randSrc *rand.Rand) { diff --git a/settings.go b/settings.go index 31b57d2..e01ecfc 100644 --- a/settings.go +++ b/settings.go @@ -50,6 +50,10 @@ type Settings struct { NumWalls int `json:"num_walls"` CityCoverage float64 `json:"city_coverage"` WallCurvyness float64 `json:"wall_curvyness"` + ShowTurrets bool `json:"show_turrets"` + TurretSize float64 `json:"turret_size"` + TurretShape string `json:"turret_shape"` + TurretSpacing float64 `json:"turret_spacing"` // Building settings NumBuildings int `json:"num_buildings"` @@ -151,6 +155,10 @@ func LoadSettings() (*Settings, error) { NumWalls: 1, CityCoverage: 70, WallCurvyness: 35, + ShowTurrets: true, + TurretSize: 0.6, + TurretShape: "circular", + TurretSpacing: 55, NumBuildings: 200, MinBuildingSize: 3.5, MaxBuildingSize: 10.0, @@ -230,6 +238,18 @@ func LoadSettings() (*Settings, error) { if _, ok := rawKeys["wall_curvyness"]; !ok { settings.WallCurvyness = 35 } + if _, ok := rawKeys["show_turrets"]; !ok { + settings.ShowTurrets = true + } + if _, ok := rawKeys["turret_size"]; !ok { + settings.TurretSize = 0.6 + } + if _, ok := rawKeys["turret_shape"]; !ok || (settings.TurretShape != "square" && settings.TurretShape != "circular") { + settings.TurretShape = "circular" + } + if _, ok := rawKeys["turret_spacing"]; !ok { + settings.TurretSpacing = 55 + } // Wall widths are percentages of average image dimension. // Migrate older pixel-based values when they exceed the valid percentage range. @@ -250,6 +270,8 @@ func LoadSettings() (*Settings, error) { } settings.CityCoverage = clamp(settings.CityCoverage, 1, 100) settings.WallCurvyness = clamp(settings.WallCurvyness, 0, 100) + settings.TurretSize = snapTurretSizePercent(settings.TurretSize) + settings.TurretSpacing = clamp(settings.TurretSpacing, 0, 100) // Tree sizes are percentages of average image dimension. // Migrate older pixel-based values when they exceed the valid percentage range.