From efc724f89f8469043e287de920ed7a84bfe83543 Mon Sep 17 00:00:00 2001 From: Grimsace Date: Mon, 2 Mar 2026 10:40:14 -0600 Subject: [PATCH] added basic wall generation --- fortifications.go | 347 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 278 +++++++++++++++++++++++++++++++------ roads.go | 320 +++++++++++++++++++++++++++++++++++------- settings.go | 47 +++++++ 4 files changed, 900 insertions(+), 92 deletions(-) create mode 100644 fortifications.go diff --git a/fortifications.go b/fortifications.go new file mode 100644 index 0000000..0796e06 --- /dev/null +++ b/fortifications.go @@ -0,0 +1,347 @@ +package main + +import ( + "image" + "image/color" + "math" + "math/rand" + "sort" +) + +const ( + minWallWidthPercent = minBuildingSizePercent + maxWallWidthPercent = maxBuildingSizePercent + wallWidthPercentStep = buildingSizePercentStep +) + +func clampWallWidthPercent(v float64) float64 { + if v < minWallWidthPercent { + return minWallWidthPercent + } + if v > maxWallWidthPercent { + return maxWallWidthPercent + } + return v +} + +func snapWallWidthPercent(v float64) float64 { + v = clampWallWidthPercent(v) + steps := math.Round((v - minWallWidthPercent) / wallWidthPercentStep) + return clampWallWidthPercent(minWallWidthPercent + steps*wallWidthPercentStep) +} + +func normalizeWallWidthPercentRange(minPercent, maxPercent float64) (float64, float64) { + minPercent = snapWallWidthPercent(minPercent) + maxPercent = snapWallWidthPercent(maxPercent) + if minPercent > maxPercent { + minPercent, maxPercent = maxPercent, minPercent + } + return minPercent, maxPercent +} + +func getWallWidthRangePixels(settings *Settings, width, height int) (float64, float64) { + minPercent, maxPercent := normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) + avgDim := averageImageDimension(width, height) + if avgDim < 1 { + avgDim = 1 + } + minPx := (minPercent / 100.0) * avgDim + maxPx := (maxPercent / 100.0) * avgDim + if minPx < 1 { + minPx = 1 + } + if maxPx < 1 { + maxPx = 1 + } + return minPx, maxPx +} + +type FortificationLayout struct { + Mask *PixelMask + WallIDByPixel []int + Coverages []float64 +} + +func GenerateFortifications( + img *image.RGBA, + width, height int, + settings *Settings, + waterMask *PixelMask, + roadNodes []*PointOfInterest, + seed int64, +) (*FortificationLayout, [][]image.Point) { + layout := &FortificationLayout{ + Mask: NewPixelMask(width, height), + WallIDByPixel: make([]int, width*height), + } + if settings.NumWalls <= 0 || settings.CityCoverage <= 0 { + return layout, nil + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + if img == nil { + img = image.NewRGBA(image.Rect(0, 0, width, height)) + } + + randSrc := rand.New(rand.NewSource(seed)) + minWidthPx, maxWidthPx := getWallWidthRangePixels(settings, width, height) + wallColor := color.RGBA{R: 0, G: 0, B: 0, A: 255} + + walls := make([][]image.Point, 0, settings.NumWalls) + outerCoverage := clamp(settings.CityCoverage, 1, 100) + totalWalls := max(1, settings.NumWalls) + prevCoverage := 101.0 + layout.Coverages = make([]float64, 0, totalWalls) + + for i := 0; i < totalWalls; i++ { + baseCoverage := outerCoverage * float64(totalWalls-i) / float64(totalWalls) + coverage := baseCoverage + if i > 0 { + coverage += randSrc.Float64()*10.0 - 5.0 + } + coverage = clamp(coverage, 1, 100) + if coverage >= prevCoverage { + coverage = prevCoverage - 1 + if coverage < 1 { + coverage = 1 + } + } + prevCoverage = coverage + layout.Coverages = append(layout.Coverages, coverage) + + nodes := estimateWallNodeCount(coverage) + wallPath := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes) + if len(wallPath) < 3 { + continue + } + + wallWidthPx := minWidthPx + if maxWidthPx > minWidthPx { + wallWidthPx = minWidthPx + randSrc.Float64()*(maxWidthPx-minWidthPx) + } + wallWidth := int(math.Round(wallWidthPx)) + if wallWidth < 1 { + wallWidth = 1 + } + + pixels := drawWallLoopWithWaterGaps(img, wallPath, wallColor, wallWidth, layout.Mask, waterMask, layout.WallIDByPixel, i+1) + if len(pixels) > 0 { + walls = append(walls, pixels) + } + } + + return layout, walls +} + +func estimateWallNodeCount(coverage float64) int { + n := int(math.Round(20 + coverage*0.7)) + if n < 20 { + n = 20 + } + if n > 96 { + n = 96 + } + return n +} + +func generateWallLoop(width, height int, coverage, curvyness float64, nodes int, randSrc *rand.Rand, roadNodes []*PointOfInterest) []image.Point { + if width <= 0 || height <= 0 || nodes < 3 { + return nil + } + + centerX, centerY, baseRadiusX, baseRadiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes) + curveScale := clamp(curvyness, 0, 100) / 100.0 + warpAmp := 0.20 * curveScale + phaseA := randSrc.Float64() * 2 * math.Pi + phaseB := randSrc.Float64() * 2 * math.Pi + + out := make([]image.Point, 0, nodes+1) + for i := 0; i < nodes; i++ { + t := (2 * math.Pi * float64(i)) / float64(nodes) + warp := 1.0 + warpAmp*(0.6*math.Sin(3*t+phaseA)+0.4*math.Sin(5*t+phaseB)) + if warp < 0.7 { + warp = 0.7 + } + rx := baseRadiusX * warp + ry := baseRadiusY * warp + x := int(math.Round(centerX + rx*math.Cos(t))) + y := int(math.Round(centerY + ry*math.Sin(t))) + if x < 0 { + x = 0 + } + if x >= width { + x = width - 1 + } + if y < 0 { + y = 0 + } + if y >= height { + y = height - 1 + } + out = append(out, image.Point{X: x, Y: y}) + } + if len(out) > 0 { + out = append(out, out[0]) + } + return out +} + +func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []*PointOfInterest) (centerX, centerY, radiusX, radiusY float64) { + centerX = float64(width-1) * 0.5 + centerY = float64(height-1) * 0.5 + coverageRadius := math.Sqrt(clamp(coverage, 1, 100) / 100.0) + radiusX = centerX * coverageRadius + radiusY = centerY * coverageRadius + + if len(roadNodes) == 0 { + return centerX, centerY, radiusX, radiusY + } + + sumX, sumY := 0.0, 0.0 + for _, n := range roadNodes { + sumX += float64(n.X) + sumY += float64(n.Y) + } + centerX = sumX / float64(len(roadNodes)) + centerY = sumY / float64(len(roadNodes)) + + dists := make([]float64, 0, len(roadNodes)) + var sx, sy float64 + for _, n := range roadNodes { + dx := float64(n.X) - centerX + dy := float64(n.Y) - centerY + dists = append(dists, math.Hypot(dx, dy)) + sx += dx * dx + sy += dy * dy + } + sort.Float64s(dists) + q := clamp(coverage, 1, 100) / 100.0 + idx := int(math.Ceil(q*float64(len(dists)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(dists) { + idx = len(dists) - 1 + } + baseRadius := dists[idx] + if baseRadius < 10 { + baseRadius = 10 + } + + stdX := math.Sqrt(sx / float64(len(roadNodes))) + stdY := math.Sqrt(sy / float64(len(roadNodes))) + aspect := 1.0 + if stdY > 0.001 { + aspect = stdX / stdY + } + aspect = clamp(aspect, 0.65, 1.55) + radiusX = baseRadius * aspect + radiusY = baseRadius / aspect + + maxRadiusX := math.Max(5, math.Min(centerX, float64(width-1)-centerX)) + maxRadiusY := math.Max(5, math.Min(centerY, float64(height-1)-centerY)) + radiusX = clamp(radiusX, 5, maxRadiusX) + radiusY = clamp(radiusY, 5, maxRadiusY) + + return centerX, centerY, radiusX, radiusY +} + +func drawWallLoopWithWaterGaps( + img *image.RGBA, + loop []image.Point, + col color.RGBA, + width int, + wallMask *PixelMask, + waterMask *PixelMask, + wallIDByPixel []int, + wallID int, +) []image.Point { + if len(loop) < 2 || wallMask == nil { + return nil + } + seen := make(map[int]bool) + pixels := make([]image.Point, 0, len(loop)*8) + radius := max(1, width/2) + + for i := 0; i < len(loop)-1; i++ { + a := loop[i] + b := loop[i+1] + drawSegmentSelective(a.X, a.Y, b.X, b.Y, func(x, y int) { + if !wallMask.InBounds(x, y) { + return + } + if waterMask != nil && waterMask.GetXY(x, y) { + return + } + for dy := -radius; dy <= radius; dy++ { + yy := y + dy + if yy < 0 || yy >= wallMask.Height { + continue + } + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy > radius*radius { + continue + } + xx := x + dx + if xx < 0 || xx >= wallMask.Width { + continue + } + if waterMask != nil && waterMask.GetXY(xx, yy) { + continue + } + wallMask.SetXY(xx, yy) + if len(wallIDByPixel) == wallMask.Width*wallMask.Height { + wallIDByPixel[yy*wallMask.Width+xx] = wallID + } + img.Set(xx, yy, col) + idx := yy*wallMask.Width + xx + if !seen[idx] { + seen[idx] = true + pixels = append(pixels, image.Point{X: xx, Y: yy}) + } + } + } + }) + } + + return pixels +} + +func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) { + dx := abs(x1 - x0) + dy := abs(y1 - y0) + sx := -1 + if x0 < x1 { + sx = 1 + } + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx - dy + for { + plot(x0, y0) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x0 += sx + } + if e2 < dx { + err += dx + y0 += sy + } + } +} + +func cloneMask(src *PixelMask) *PixelMask { + if src == nil { + return nil + } + dst := NewPixelMask(src.Width, src.Height) + copy(dst.Data, src.Data) + return dst +} diff --git a/main.go b/main.go index d3ab2db..fbaacdd 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,7 @@ func main() { var roadMask *PixelMask var bridgeMask *PixelMask var exitRoadMask *PixelMask + var wallMask *PixelMask // Set up application configuration directory configDir, err := os.UserConfigDir() @@ -231,7 +232,55 @@ func main() { if riverMask != nil { waterMask.Merge(riverMask) } - // Step 4: Generating Roads + // Step 4: Preparing Road Nodes + var roadNodes []*PointOfInterest + var roadTarget int + var edgeToEdgeOnly bool + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + pois []*PointOfInterest + target int + edgeToEdge bool + } { + pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + return struct { + pois []*PointOfInterest + target int + edgeToEdge bool + }{pois: pois, target: target, edgeToEdge: edgeToEdge} + }); ok { + roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge + } else { + log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") + roadNodes = nil + roadTarget = 0 + edgeToEdgeOnly = false + } + + // Step 5: Generating Fortifications + var wallLayout *FortificationLayout + fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + layout *FortificationLayout + } { + layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) + return struct { + layout *FortificationLayout + }{layout: layout} + }); ok { + wallLayout = out.layout + if wallLayout != nil { + wallMask = wallLayout.Mask + } else { + wallMask = NewPixelMask(settings.Width, settings.Height) + } + finalImage = fortBase + } else { + log.Println("GenerateFortifications timed out after 1 minute; continuing.") + wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} + wallMask = wallLayout.Mask + } + + // Step 6: Generating Roads var roadAnchors []image.Point roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() struct { @@ -240,7 +289,7 @@ func main() { ex *PixelMask anc []image.Point } { - rd, br, ex, anc := GenerateRoads(roadBase, settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + rd, br, ex, anc := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) return struct { rd *PixelMask br *PixelMask @@ -258,13 +307,21 @@ func main() { roadAnchors = nil } - // Step 5: Generating Buildings + placementMask := cloneMask(roadMask) + if placementMask == nil { + placementMask = NewPixelMask(settings.Width, settings.Height) + } + if wallMask != nil { + placementMask.Merge(wallMask) + } + + // Step 7: Generating Buildings buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() struct { blds [][]image.Point bmsk *PixelMask } { - blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, roadMask, exitRoadMask, seedProvider.Next()) + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) return struct { blds [][]image.Point bmsk *PixelMask @@ -278,10 +335,10 @@ func main() { buildingMask = NewPixelMask(settings.Width, settings.Height) } - // Step 6: Generating Trees + // Step 8: Generating Trees treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { - return GenerateTrees(treeBase, waterMask, roadMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out finalImage = treeBase @@ -296,8 +353,8 @@ func main() { // Step 8: Flattening Building Areas flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) - // Step 9: Flattening Road Areas - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, roadMask) + // Step 9: Flattening Road and Wall Areas + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) // Step 10: Applying Roughness compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) @@ -613,6 +670,66 @@ func main() { settings.MinRoadAngle = val })) + minWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MinWallWidth, wallWidthPercentStep, "%.1f%%", "Min Wall Width") + minWallWidthSlider.entry.OnChanged = func(s string) { + minWallWidthSlider.validate(s, func(hasError bool) { + errorStates["minWallWidth"] = hasError + updateGenerateBtnState() + }) + } + minWallWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minWallWidthSlider.value.Get() + settings.MinWallWidth = val + })) + + maxWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MaxWallWidth, wallWidthPercentStep, "%.1f%%", "Max Wall Width") + maxWallWidthSlider.entry.OnChanged = func(s string) { + maxWallWidthSlider.validate(s, func(hasError bool) { + errorStates["maxWallWidth"] = hasError + updateGenerateBtnState() + }) + } + maxWallWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxWallWidthSlider.value.Get() + settings.MaxWallWidth = val + })) + + numWallsSlider := newNumericInputSlider(1, 5, float64(settings.NumWalls), "%.0f", "Number of Walls") + numWallsSlider.entry.OnChanged = func(s string) { + numWallsSlider.validate(s, func(hasError bool) { + errorStates["numWalls"] = hasError + updateGenerateBtnState() + }) + } + numWallsSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := numWallsSlider.value.Get() + settings.NumWalls = int(val) + })) + + cityCoverageSlider := newNumericInputSlider(1, 100, settings.CityCoverage, "%.0f%%", "City Coverage") + cityCoverageSlider.entry.OnChanged = func(s string) { + cityCoverageSlider.validate(s, func(hasError bool) { + errorStates["cityCoverage"] = hasError + updateGenerateBtnState() + }) + } + cityCoverageSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := cityCoverageSlider.value.Get() + settings.CityCoverage = val + })) + + wallCurvynessSlider := newNumericInputSlider(0, 100, settings.WallCurvyness, "%.0f%%", "Wall Curvyness") + wallCurvynessSlider.entry.OnChanged = func(s string) { + wallCurvynessSlider.validate(s, func(hasError bool) { + errorStates["wallCurvyness"] = hasError + updateGenerateBtnState() + }) + } + wallCurvynessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := wallCurvynessSlider.value.Get() + settings.WallCurvyness = val + })) + // Create UI elements for error display and action buttons errorLabel := widget.NewLabel("") errorLabel.Wrapping = fyne.TextWrapWord @@ -631,7 +748,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, buildingMask) + showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, buildingMask) }) // Main generation button and logic generateBtn = widget.NewButton("Generate", func() { @@ -660,16 +777,16 @@ func main() { } // Step 1: Generating Heightmap fyne.Do(func() { - progressLabel.SetText("Step 1 of 11: Generating Heightmap") - progressBar.SetValue(1.0 / 11.0) + progressLabel.SetText("Step 1 of 13: Generating Heightmap") + progressBar.SetValue(1.0 / 13.0) }) noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, seedProvider.Next()) // Step 2: Generating Lakes fyne.Do(func() { - progressLabel.SetText("Step 2 of 11: Generating Lakes") - progressBar.SetValue(2.0 / 11.0) + progressLabel.SetText("Step 2 of 13: Generating Lakes") + progressBar.SetValue(2.0 / 13.0) }) var lakeImage image.Image = image.NewRGBA(image.Rect(0, 0, settings.Width, settings.Height)) if out, ok := runWithTimeout(generationStepTimeout, func() struct { @@ -693,8 +810,8 @@ func main() { // Step 3: Generating Rivers fyne.Do(func() { - progressLabel.SetText("Step 3 of 11: Generating Rivers") - progressBar.SetValue(3.0 / 11.0) + progressLabel.SetText("Step 3 of 13: Generating Rivers") + progressBar.SetValue(3.0 / 13.0) }) riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) finalImage := riverBase @@ -720,11 +837,71 @@ func main() { waterMask.Merge(riverMask) } - // Step 4: Generating Roads + // Step 4: Preparing Road Nodes fyne.Do(func() { - progressLabel.SetText("Step 4 of 11: Generating Roads") - progressBar.SetValue(4.0 / 11.0) + progressLabel.SetText("Step 4 of 13: Preparing Road Nodes") + progressBar.SetValue(4.0 / 13.0) + }) + var roadNodes []*PointOfInterest + var roadTarget int + var edgeToEdgeOnly bool + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + pois []*PointOfInterest + target int + edgeToEdge bool + } { + pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + return struct { + pois []*PointOfInterest + target int + edgeToEdge bool + }{pois: pois, target: target, edgeToEdge: edgeToEdge} + }); ok { + roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge + } else { + log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") + addTimeout("Road Nodes") + roadNodes = nil + roadTarget = 0 + edgeToEdgeOnly = false + } + + // Step 5: Generating Fortifications + + fyne.Do(func() { + progressLabel.SetText("Step 5 of 13: Generating Fortifications") + progressBar.SetValue(5.0 / 13.0) + }) + var wallLayout *FortificationLayout + fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + layout *FortificationLayout + } { + layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) + return struct { + layout *FortificationLayout + }{layout: layout} + }); ok { + wallLayout = out.layout + if wallLayout != nil { + wallMask = wallLayout.Mask + } else { + wallMask = NewPixelMask(settings.Width, settings.Height) + } + finalImage = fortBase + } else { + log.Println("GenerateFortifications timed out after 1 minute; continuing.") + addTimeout("Fortifications") + wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} + wallMask = wallLayout.Mask + } + + // Step 6: Generating Roads + + fyne.Do(func() { + progressLabel.SetText("Step 6 of 13: Generating Roads") + progressBar.SetValue(6.0 / 13.0) }) var roadAnchors []image.Point roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) @@ -734,7 +911,7 @@ func main() { ex *PixelMask anc []image.Point } { - rd, br, ex, anc := GenerateRoads(roadBase, settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + rd, br, ex, anc := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) return struct { rd *PixelMask br *PixelMask @@ -752,19 +929,26 @@ func main() { exitRoadMask = NewPixelMask(settings.Width, settings.Height) roadAnchors = nil } + placementMask := cloneMask(roadMask) + if placementMask == nil { + placementMask = NewPixelMask(settings.Width, settings.Height) + } + if wallMask != nil { + placementMask.Merge(wallMask) + } - // Step 5: Generating Buildings + // Step 7: Generating Buildings fyne.Do(func() { - progressLabel.SetText("Step 5 of 11: Generating Buildings") - progressBar.SetValue(5.0 / 11.0) + progressLabel.SetText("Step 7 of 13: Generating Buildings") + progressBar.SetValue(7.0 / 13.0) }) buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() struct { blds [][]image.Point bmsk *PixelMask } { - blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, roadMask, exitRoadMask, seedProvider.Next()) + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) return struct { blds [][]image.Point bmsk *PixelMask @@ -779,45 +963,45 @@ func main() { buildingMask = NewPixelMask(settings.Width, settings.Height) } - // Step 6: Darkening Water Areas + // Step 8: Darkening Water Areas fyne.Do(func() { - progressLabel.SetText("Step 6 of 11: Darkening Water Areas") - progressBar.SetValue(6.0 / 11.0) + progressLabel.SetText("Step 8 of 13: Darkening Water Areas") + progressBar.SetValue(8.0 / 13.0) }) darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) - // Step 7: Flattening Building Areas + // Step 9: Flattening Building Areas fyne.Do(func() { - progressLabel.SetText("Step 7 of 11: Flattening Building Areas") - progressBar.SetValue(7.0 / 11.0) + progressLabel.SetText("Step 9 of 13: Flattening Building Areas") + progressBar.SetValue(9.0 / 13.0) }) flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) fyne.Do(func() { - progressLabel.SetText("Step 8 of 11: Flattening Road Areas") - progressBar.SetValue(8.0 / 11.0) + progressLabel.SetText("Step 10 of 13: Flattening Road and Wall Areas") + progressBar.SetValue(10.0 / 13.0) }) - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, roadMask) + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) - // Step 8: Applying Roughness + // Step 11: Applying Roughness fyne.Do(func() { - progressLabel.SetText("Step 9 of 11: Applying Roughness") - progressBar.SetValue(9.0 / 11.0) + progressLabel.SetText("Step 11 of 13: Applying Roughness") + progressBar.SetValue(11.0 / 13.0) }) compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) - // Step 9: Generating Trees + // Step 12: Generating Trees fyne.Do(func() { - progressLabel.SetText("Step 10 of 11: Generating Trees") - progressBar.SetValue(10.0 / 11.0) + progressLabel.SetText("Step 12 of 13: Generating Trees") + progressBar.SetValue(12.0 / 13.0) }) treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { - return GenerateTrees(treeBase, waterMask, roadMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out finalImage = treeBase @@ -827,10 +1011,10 @@ func main() { treeMask = NewPixelMask(settings.Width, settings.Height) } - // Step 10: Generating Bump Map + // Step 13: Generating Bump Map fyne.Do(func() { - progressLabel.SetText("Step 11 of 11: Generating Bump Map") + progressLabel.SetText("Step 13 of 13: Generating Bump Map") progressBar.SetValue(1.0) }) bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) @@ -942,6 +1126,14 @@ func main() { roadCurvynessSlider, roadDistributionSlider, )) + + fortificationsTab := container.NewTabItem("Fortifications", container.NewVBox( + minWallWidthSlider, + maxWallWidthSlider, + numWallsSlider, + cityCoverageSlider, + wallCurvynessSlider, + )) numBuildingsSlider := newNumericInputSlider(0, 10000, float64(settings.NumBuildings), "%.0f", "Number of Buildings") numBuildingsSlider.entry.OnChanged = func(s string) { numBuildingsSlider.validate(s, func(hasError bool) { @@ -1219,6 +1411,7 @@ func main() { terrainTab, waterTab, roadsTab, + fortificationsTab, buildingsTab, ) @@ -1303,7 +1496,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, buildingMask *PixelMask) { +func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, buildingMask *PixelMask) { // Create UI elements for the save dialog fileNameEntry := widget.NewEntry() fileNameEntry.SetPlaceHolder("masks_folder") @@ -1387,6 +1580,7 @@ func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg im {name: "trees_mask." + imgFormat, make: func() image.Image { return maskToGray(treeMask) }}, {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: "buildings_mask." + imgFormat, make: func() image.Image { return maskToGray(buildingMask) }}, } diff --git a/roads.go b/roads.go index b9df1ce..596fc28 100644 --- a/roads.go +++ b/roads.go @@ -87,6 +87,41 @@ func GenerateRoads( settings *Settings, waterMask *PixelMask, seed int64, +) (*PixelMask, *PixelMask, *PixelMask, []image.Point) { + return GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed) +} + +func PrepareRoadNodes(width, height int, settings *Settings, waterMask *PixelMask, seed int64) ([]*PointOfInterest, int, bool) { + randSrc := rand.New(rand.NewSource(seed)) + + if settings.NumBuildings == 0 { + internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100))) + exitRoads := max(0, settings.RoadExits) + if internalRoads == 0 && exitRoads > 0 && settings.RoadDistribution <= 0 { + return nil, 0, true + } + if internalRoads > 0 { + roadTarget := internalRoads + return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false + } + return nil, 0, false + } + + roadTarget := estimateRoadTarget(settings) + return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false +} + +func GenerateRoadsWithPOIs( + img *image.RGBA, + width, + height int, + settings *Settings, + waterMask *PixelMask, + wallLayout *FortificationLayout, + pois []*PointOfInterest, + roadTarget int, + edgeToEdgeOnly bool, + seed int64, ) (*PixelMask, *PixelMask, *PixelMask, []image.Point) { if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) @@ -96,56 +131,40 @@ func GenerateRoads( bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255} // Edge-case mode: no buildings. - if settings.NumBuildings == 0 { + if settings.NumBuildings == 0 && roadTarget == 0 && !edgeToEdgeOnly { 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 } - - var roads []*Road if internalRoads > 0 { - roadTarget := internalRoads - pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) - if len(pois) >= 2 { - roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, roadTarget) - // Use existing exit-road logic when internal roads are present. - roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask) - } + roadTarget = internalRoads } else if settings.RoadDistribution <= 0 && exitRoads > 0 { - // Only in 0% distribution mode: exit roads are edge-to-edge. - roads = generateEdgeToEdgeExitRoads(exitRoads, width, height, settings, randSrc, waterMask) + edgeToEdgeOnly = true } - - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil - } - roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil - } - assignRoadWidths(roads, settings, randSrc, width, height) - - roadMask := NewPixelMask(width, height) - bridgeMask := NewPixelMask(width, height) - exitRoadMask := NewPixelMask(width, height) - for _, road := range roads { - drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, roadMask, bridgeMask) - if road.Start.IsExit || road.End.IsExit { - drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask) - } - } - return roadMask, bridgeMask, exitRoadMask, roadMask.ToPoints() } - roadTarget := estimateRoadTarget(settings) - pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) - if len(pois) < 2 { + var roads []*Road + if edgeToEdgeOnly { + roads = generateEdgeToEdgeExitRoads(max(0, settings.RoadExits), width, height, settings, randSrc, waterMask, wallLayout) + } else { + if roadTarget <= 0 { + roadTarget = estimateRoadTarget(settings) + } + if pois == nil { + pois = generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) + } + if len(pois) < 2 { + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), 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 } - - roads := connectPOIs(pois, width, height, settings, randSrc, waterMask, roadTarget) - roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask) + roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) if len(roads) == 0 { return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil } @@ -169,7 +188,7 @@ func GenerateRoads( return roadMask, bridgeMask, exitRoadMask, roadAnchors } -func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask) []*Road { +func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road { if exitRoads <= 0 { return nil } @@ -179,7 +198,7 @@ func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Setting start, end := sampleDifferentEdgePair(width, height, randSrc) start.IsExit = true end.IsExit = true - path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) + path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) roads = append(roads, &Road{ Start: start, End: end, @@ -367,7 +386,7 @@ func sampleTargetDegree(randSrc *rand.Rand) int { } } -func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, roadTarget int) []*Road { +func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, roadTarget int) []*Road { minAngle := settings.MinRoadAngle * math.Pi / 180.0 if minAngle < 0 { minAngle = 0 @@ -512,7 +531,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, for _, e := range selectedEdges { a := pois[e.a] b := pois[e.b] - path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) + path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) imp := a.Connections + b.Connections + int(math.Round((a.ArterialWeight+b.ArterialWeight)*4)) roads = append(roads, &Road{Start: a, End: b, Points: path, Importance: imp}) } @@ -520,7 +539,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, return roads } -func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask) []*Road { +func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road { if settings.RoadExits <= 0 || len(pois) == 0 { return roads } @@ -540,12 +559,36 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, continue } + path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(path, wallLayout)) == 0 { + bestScore := -1.0 + bestAnchor := anchor + bestPath := path + for _, cand := range pois { + testPath := calculateRoadPath(cand, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + if len(crossedWallIDs(testPath, wallLayout)) == 0 { + continue + } + d := math.Hypot(float64(cand.X-edgeNode.X), float64(cand.Y-edgeNode.Y)) + score := cand.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1) + if score > bestScore { + bestScore = score + bestAnchor = cand + bestPath = testPath + } + } + anchor = bestAnchor + path = bestPath + } + if wallLayout != nil && wallLayout.Mask != nil && len(wallLayout.Coverages) > 0 && len(crossedWallIDs(path, wallLayout)) == 0 { + // Exit roads should pass through walls when walls exist. + continue + } + anchor.Connections++ edgeNode.IsExit = true edgeNode.TargetDegree = 1 edgeNode.Connections = 1 - - path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) importance := anchor.Connections + edgeNode.Connections + int(math.Round(anchor.ArterialWeight*3)) roads = append(roads, &Road{ Start: anchor, @@ -821,7 +864,7 @@ func bresenhamRoad(path []image.Point) []image.Point { } // calculateRoadPath computes the path for a road including curves and bridges. -func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask) []PathPoint { +func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []PathPoint { dx := end.X - start.X dy := end.Y - start.Y dist := math.Hypot(float64(dx), float64(dy)) @@ -834,14 +877,14 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r curve := clamp(curvyness, 0, 1) if curve <= 0 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } // Non-linear scaling: low values stay fairly straight, high values become very winding. strength := math.Pow(curve, 1.35) if strength < 0.001 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } baseControls := int(math.Max(12, dist/(22.0-14.0*strength))) @@ -902,17 +945,194 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r } points := bresenhamRoad(controlPoints) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } func toPathPoints(points []image.Point, waterMask *PixelMask) []PathPoint { pathPoints := make([]PathPoint, len(points)) for i, p := range points { - pathPoints[i] = PathPoint{Point: p, IsBridge: waterMask.GetPoint(p)} + isBridge := false + if waterMask != nil { + isBridge = waterMask.GetPoint(p) + } + pathPoints[i] = PathPoint{Point: p, IsBridge: isBridge} } return pathPoints } +func wallIDAtPoint(p image.Point, wallLayout *FortificationLayout) int { + if wallLayout == nil || wallLayout.Mask == nil { + return 0 + } + if !wallLayout.Mask.InBounds(p.X, p.Y) { + return 0 + } + if len(wallLayout.WallIDByPixel) != wallLayout.Mask.Width*wallLayout.Mask.Height { + return 0 + } + return wallLayout.WallIDByPixel[p.Y*wallLayout.Mask.Width+p.X] +} + +func straightenPathAcrossWalls(points []PathPoint, wallLayout *FortificationLayout, waterMask *PixelMask) []PathPoint { + if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { + return points + } + + straight := make([]image.Point, 0, len(points)) + i := 0 + for i < len(points) { + curr := points[i].Point + currWallID := wallIDAtPoint(curr, wallLayout) + if currWallID == 0 { + straight = append(straight, curr) + i++ + continue + } + + start := i + if start > 0 { + start-- + } + j := i + for j < len(points) && wallIDAtPoint(points[j].Point, wallLayout) != 0 { + j++ + } + end := j + if end >= len(points) { + end = len(points) - 1 + } + line := bresenhamRoad([]image.Point{points[start].Point, points[end].Point}) + for k, p := range line { + if len(straight) > 0 && k == 0 && straight[len(straight)-1] == p { + continue + } + straight = append(straight, p) + } + i = j + } + + return toPathPoints(straight, waterMask) +} + +func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int { + if wallLayout == nil || wallLayout.Mask == nil || len(points) == 0 { + return nil + } + seen := make(map[int]bool) + out := make([]int, 0, 2) + prevID := wallIDAtPoint(points[0].Point, wallLayout) + for i := 1; i < len(points); i++ { + currID := wallIDAtPoint(points[i].Point, wallLayout) + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + wid := currID + if wid == 0 { + wid = prevID + } + if wid > 0 && !seen[wid] { + seen[wid] = true + out = append(out, wid) + } + } + prevID = currID + } + return out +} + +func containsWallID(ids []int, wallID int) bool { + for _, id := range ids { + if id == wallID { + return true + } + } + return false +} + +func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, waterMask *PixelMask, randSrc *rand.Rand) []*Road { + if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil || len(wallLayout.Coverages) == 0 { + return roads + } + + // Straighten each wall crossing segment first. + for _, road := range roads { + road.Points = straightenPathAcrossWalls(road.Points, wallLayout, waterMask) + } + + type roadInfo struct { + road *Road + ids []int + } + infos := make([]roadInfo, 0, len(roads)) + for _, road := range roads { + infos = append(infos, roadInfo{road: road, ids: crossedWallIDs(road.Points, wallLayout)}) + } + + const repeatWallFactor = 0.55 + wallCrossCount := make(map[int]int) + requiredWalls := make(map[int]bool) + for i, cov := range wallLayout.Coverages { + if cov < 95 { + requiredWalls[i+1] = true + } + } + + keep := make([]bool, len(infos)) + for i, info := range infos { + if len(info.ids) == 0 { + keep[i] = true + continue + } + if info.road.Start.IsExit || info.road.End.IsExit { + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + continue + } + + keepProb := 1.0 + for _, wid := range info.ids { + c := wallCrossCount[wid] + if c > 0 { + keepProb *= math.Pow(repeatWallFactor, float64(c)) + } + } + if randSrc.Float64() <= keepProb { + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + } + } + + // Ensure at least one crossing on each wall unless its configured coverage is >= 95%. + for wallID := range requiredWalls { + if wallCrossCount[wallID] > 0 { + continue + } + for i, info := range infos { + if keep[i] { + continue + } + if !containsWallID(info.ids, wallID) { + continue + } + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + break + } + } + + filtered := make([]*Road, 0, len(roads)) + for i, info := range infos { + if keep[i] { + filtered = append(filtered, info.road) + } + } + return filtered +} + // drawLineMasked draws a line with specified width on the image and mask. func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int, mask *PixelMask) { dx := abs(x1 - x0) diff --git a/settings.go b/settings.go index d8c1839..31b57d2 100644 --- a/settings.go +++ b/settings.go @@ -44,6 +44,13 @@ type Settings struct { RoadDistribution float64 `json:"road_distribution"` MinRoadAngle float64 `json:"min_road_angle"` + // Fortification settings + MinWallWidth float64 `json:"min_wall_width"` + MaxWallWidth float64 `json:"max_wall_width"` + NumWalls int `json:"num_walls"` + CityCoverage float64 `json:"city_coverage"` + WallCurvyness float64 `json:"wall_curvyness"` + // Building settings NumBuildings int `json:"num_buildings"` MinBuildingSize float64 `json:"min_building_size"` @@ -139,6 +146,11 @@ func LoadSettings() (*Settings, error) { RoadCurvyness: 50, RoadDistribution: 50, MinRoadAngle: 18, + MinWallWidth: 1.5, + MaxWallWidth: 4.0, + NumWalls: 1, + CityCoverage: 70, + WallCurvyness: 35, NumBuildings: 200, MinBuildingSize: 3.5, MaxBuildingSize: 10.0, @@ -200,9 +212,44 @@ func LoadSettings() (*Settings, error) { if settings.BuildingsPerRoad == 0 { settings.BuildingsPerRoad = 6 } + if _, ok := rawKeys["min_wall_width"]; !ok { + settings.MinWallWidth = 1.5 + } + if _, ok := rawKeys["max_wall_width"]; !ok { + settings.MaxWallWidth = 4.0 + } + if settings.NumWalls == 0 { + settings.NumWalls = 1 + } + if settings.CityCoverage == 0 { + settings.CityCoverage = 70 + } if _, ok := rawKeys["min_road_angle"]; !ok { settings.MinRoadAngle = 18 } + if _, ok := rawKeys["wall_curvyness"]; !ok { + settings.WallCurvyness = 35 + } + + // Wall widths are percentages of average image dimension. + // Migrate older pixel-based values when they exceed the valid percentage range. + if settings.MinWallWidth > maxWallWidthPercent || settings.MaxWallWidth > maxWallWidthPercent { + avgDim := averageImageDimension(settings.Width, settings.Height) + if avgDim < 1 { + avgDim = 1 + } + settings.MinWallWidth = (settings.MinWallWidth / avgDim) * 100.0 + settings.MaxWallWidth = (settings.MaxWallWidth / avgDim) * 100.0 + } + settings.MinWallWidth, settings.MaxWallWidth = normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) + if settings.NumWalls < 1 { + settings.NumWalls = 1 + } + if settings.NumWalls > 5 { + settings.NumWalls = 5 + } + settings.CityCoverage = clamp(settings.CityCoverage, 1, 100) + settings.WallCurvyness = clamp(settings.WallCurvyness, 0, 100) // Tree sizes are percentages of average image dimension. // Migrate older pixel-based values when they exceed the valid percentage range.