diff --git a/README.md b/README.md index e2699c3..4d82df0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ All settings below are currently implemented in the UI. | **Number of Buildings** | Requested building count before fit-cap. | `0` to `10000` | | **Min Building Size** | Minimum building size as % of average image dimension `((width+height)/2)`. | `0.5%` to `25%` in `0.5%` steps | | **Max Building Size** | Maximum building size as % of average image dimension `((width+height)/2)`. | `0.5%` to `25%` in `0.5%` steps | -| **Building Distribution** | Placement preference from near roads toward random map-wide placement. | `0%` to `100%` | +| **Building Distribution** | How far buildings may spread away from roads: `0%` stays road-adjacent, `100%` becomes fully random map-wide placement. | `0%` to `100%` | | **Building Shape** | Primary building generation mode. | `squares`, `circles`, `rectangles`, `mixed`, `procedural` | | **Squares / Circles / Rectangles** | Shape ratio sliders used by `mixed` and `procedural` building modes. | `0%` to `100%` each (normalized together) | | **Min Building Complexity** | Minimum number of shape components in procedural buildings. | `1` to `6` | diff --git a/buildings.go b/buildings.go index a6961e4..fc47ae6 100644 --- a/buildings.go +++ b/buildings.go @@ -170,18 +170,28 @@ func GenerateBuildings( // Main loop for placing buildings buildingsPlaced := 0 - searchTries := 100 // Number of attempts to find a spot for a building around an anchor - maxPlacementAttempts := settings.NumBuildings * 5 // To prevent infinite loops + maxPlacementAttempts := settings.NumBuildings * 3 // To prevent infinite loops minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height) + avgBuildingSizePx := (minBuildingSizePx + maxBuildingSizePx) * 0.5 anchorUsage := make(map[image.Point]int, len(anchorPoints)) normalAnchorCap := 0 exitAnchorCap := 0 + distributionFactor := clamp01(settings.BuildingDistribution / 100.0) + roadPlacementBias := 1.0 - distributionFactor + roadAnchorMinOffset := clamp(avgBuildingSizePx*0.55, 2, 16) + roadAnchorMaxOffset := roadAnchorMinOffset + math.Pow(distributionFactor, 1.05)*clamp(avgBuildingSizePx*13.0, 16, 180) + roadSearchTries := 42 + randomSearchTries := 18 if len(normalRoadAnchors) > 0 { normalAnchorCap = max(2, int(math.Ceil((float64(settings.NumBuildings)/float64(len(normalRoadAnchors)))*1.15))) } if len(exitRoadAnchors) > 0 { exitAnchorCap = max(1, int(math.Ceil((float64(settings.NumBuildings)/float64(len(exitRoadAnchors)))*0.20))) } + if settings.NumBuildings <= 80 { + normalAnchorCap = max(normalAnchorCap, 6) + exitAnchorCap = max(exitAnchorCap, 2) + } pickAnchorWithCapacity := func(candidates []image.Point, capLimit int) (image.Point, bool) { if len(candidates) == 0 { @@ -209,13 +219,28 @@ func GenerateBuildings( return image.Point{}, false } + pickLeastUsedAnchor := func(candidates []image.Point) (image.Point, bool) { + if len(candidates) == 0 { + return image.Point{}, false + } + best := candidates[randSrc.Intn(len(candidates))] + bestCount := anchorUsage[best] + for _, candidate := range candidates { + if count := anchorUsage[candidate]; count < bestCount { + best = candidate + bestCount = count + } + } + return best, true + } + for buildingsPlaced < settings.NumBuildings && maxPlacementAttempts > 0 { maxPlacementAttempts-- // Select an anchor point for the new building var anchor image.Point usedRoadAnchor := false - if randSrc.Float64() > settings.BuildingDistribution/100.0 { + if len(anchorPoints) > 0 && randSrc.Float64() < roadPlacementBias { // Buildings should only rarely use exit-road anchors. useExitAnchor := len(exitRoadAnchors) > 0 && randSrc.Float64() < 0.02 if useExitAnchor { @@ -235,31 +260,72 @@ func GenerateBuildings( } } if !usedRoadAnchor { - p, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc) - if !ok { - continue + if a, ok := pickLeastUsedAnchor(normalRoadAnchors); ok { + anchor = a + usedRoadAnchor = true + } else if a, ok := pickLeastUsedAnchor(anchorPoints); ok { + anchor = a + usedRoadAnchor = true + } else { + p, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc) + if !ok { + continue + } + anchor = p } - anchor = p } - } else { + } + if !usedRoadAnchor { p, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc) if !ok { - continue // No land to place buildings on + if a, ok := pickLeastUsedAnchor(normalRoadAnchors); ok { + anchor = a + usedRoadAnchor = true + } else if a, ok := pickLeastUsedAnchor(anchorPoints); ok { + anchor = a + usedRoadAnchor = true + } else { + continue // No land to place buildings on + } + } else { + anchor = p } - anchor = p + } + + searchTries := randomSearchTries + if usedRoadAnchor { + searchTries = roadSearchTries } // Search for a valid building location around the anchor for i := 0; i < searchTries; i++ { - searchRadius := float64(i) * 2.0 // Search in expanding circles + // Sample building shape/size first so road-adjacent placement can leave enough setback. + size := minBuildingSizePx + randSrc.Float64()*(maxBuildingSizePx-minBuildingSizePx) + shape := settings.BuildingShape + if shape == "mixed" { + shape = chooseShape(randSrc, settings.BuildingShapeRatios) + } + angle := randSrc.Float64() * 2 * math.Pi - dist := searchRadius * randSrc.Float64() + dist := 0.0 + if usedRoadAnchor { + minSetback := max(roadAnchorMinOffset, size*0.85+2.0) + progress := float64(i) / float64(max(searchTries-1, 1)) + searchRadius := minSetback + progress*(roadAnchorMaxOffset-minSetback) + if searchRadius < minSetback { + searchRadius = minSetback + } + dist = minSetback + randSrc.Float64()*(searchRadius-minSetback) + } else { + searchRadius := clamp(float64(i+1)*avgBuildingSizePx*0.45, avgBuildingSizePx, avgBuildingSizePx*5.5) + dist = randSrc.Float64() * searchRadius + } center := image.Point{ X: anchor.X + int(dist*math.Cos(angle)), Y: anchor.Y + int(dist*math.Sin(angle)), } - // For fully random distribution, pick any point on the map - if settings.BuildingDistribution == 100 { + // Non-road placement is fully random over the map. + if !usedRoadAnchor { center = image.Point{ X: randSrc.Intn(width), Y: randSrc.Intn(height), @@ -271,13 +337,6 @@ func GenerateBuildings( continue } - // Attempt to create a building at the selected center - size := minBuildingSizePx + randSrc.Float64()*(maxBuildingSizePx-minBuildingSizePx) - shape := settings.BuildingShape - if shape == "mixed" { - shape = chooseShape(randSrc, settings.BuildingShapeRatios) - } - var pixels []image.Point var ok bool diff --git a/roads.go b/roads.go index 3ecd02e..9febd5c 100644 --- a/roads.go +++ b/roads.go @@ -1995,6 +1995,14 @@ func collectRoadAnchors(roads []*Road, settings *Settings, waterMask *PixelMask, minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height) spacing := int(math.Round(clamp((minBuildingSizePx+maxBuildingSizePx)*0.5, 8, 28))) + switch { + case settings.NumBuildings >= 2500: + spacing = int(math.Round(float64(spacing) * 0.72)) + case settings.NumBuildings >= 1000: + spacing = int(math.Round(float64(spacing) * 0.80)) + case settings.NumBuildings >= 300: + spacing = int(math.Round(float64(spacing) * 0.90)) + } if spacing < 6 { spacing = 6 }