2026-02-05 15:11:55 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"math"
|
|
|
|
|
"math/rand"
|
|
|
|
|
"sort"
|
2026-02-09 09:41:35 -06:00
|
|
|
"sync"
|
2026-02-05 15:11:55 -06:00
|
|
|
)
|
|
|
|
|
|
2026-02-26 12:49:01 -06:00
|
|
|
const (
|
|
|
|
|
minBuildingSizePercent = 0.5
|
|
|
|
|
maxBuildingSizePercent = 25.0
|
|
|
|
|
buildingSizePercentStep = 0.5
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func averageImageDimension(width, height int) float64 {
|
|
|
|
|
return (float64(width) + float64(height)) / 2.0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clampBuildingSizePercent(v float64) float64 {
|
|
|
|
|
if v < minBuildingSizePercent {
|
|
|
|
|
return minBuildingSizePercent
|
|
|
|
|
}
|
|
|
|
|
if v > maxBuildingSizePercent {
|
|
|
|
|
return maxBuildingSizePercent
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func snapBuildingSizePercent(v float64) float64 {
|
|
|
|
|
v = clampBuildingSizePercent(v)
|
|
|
|
|
steps := math.Round((v - minBuildingSizePercent) / buildingSizePercentStep)
|
|
|
|
|
return clampBuildingSizePercent(minBuildingSizePercent + steps*buildingSizePercentStep)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeBuildingSizePercentRange(minPercent, maxPercent float64) (float64, float64) {
|
|
|
|
|
minPercent = snapBuildingSizePercent(minPercent)
|
|
|
|
|
maxPercent = snapBuildingSizePercent(maxPercent)
|
|
|
|
|
if minPercent > maxPercent {
|
|
|
|
|
minPercent, maxPercent = maxPercent, minPercent
|
|
|
|
|
}
|
|
|
|
|
return minPercent, maxPercent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getBuildingSizeRangePixels(settings *Settings, width, height int) (float64, float64) {
|
|
|
|
|
minPercent, maxPercent := normalizeBuildingSizePercentRange(settings.MinBuildingSize, settings.MaxBuildingSize)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:21:04 -06:00
|
|
|
func getMaxBuildingsForImage(settings *Settings, width, height int) int {
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
minSizePx, maxSizePx := getBuildingSizeRangePixels(settings, width, height)
|
|
|
|
|
avgSizePx := (minSizePx + maxSizePx) / 2.0
|
|
|
|
|
if avgSizePx < 1 {
|
|
|
|
|
avgSizePx = 1
|
|
|
|
|
}
|
|
|
|
|
// Treat average building size as a side length to estimate per-building footprint.
|
|
|
|
|
avgFootprint := avgSizePx * avgSizePx
|
|
|
|
|
maxBuildings := int(float64(width*height) / avgFootprint)
|
|
|
|
|
if maxBuildings < 1 {
|
|
|
|
|
maxBuildings = 1
|
|
|
|
|
}
|
|
|
|
|
return maxBuildings
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capRequestedBuildingsToFit(settings *Settings, width, height int) int {
|
|
|
|
|
maxBuildings := getMaxBuildingsForImage(settings, width, height)
|
|
|
|
|
if settings.NumBuildings > maxBuildings {
|
|
|
|
|
settings.NumBuildings = maxBuildings
|
|
|
|
|
}
|
|
|
|
|
return settings.NumBuildings
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:39:39 -06:00
|
|
|
func sampleRandomLandPoint(width, height int, waterMask, roadMask *PixelMask, randSrc *rand.Rand) (image.Point, bool) {
|
|
|
|
|
const randomTries = 128
|
|
|
|
|
for i := 0; i < randomTries; i++ {
|
|
|
|
|
p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)}
|
|
|
|
|
if !waterMask.GetPoint(p) && !roadMask.GetPoint(p) {
|
|
|
|
|
return p, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
return image.Point{}, false
|
|
|
|
|
}
|
|
|
|
|
start := randSrc.Intn(width * height)
|
|
|
|
|
total := width * height
|
|
|
|
|
for i := 0; i < total; i++ {
|
|
|
|
|
idx := (start + i) % total
|
|
|
|
|
x := idx % width
|
|
|
|
|
y := idx / width
|
|
|
|
|
p := image.Point{X: x, Y: y}
|
|
|
|
|
if !waterMask.GetPoint(p) && !roadMask.GetPoint(p) {
|
|
|
|
|
return p, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return image.Point{}, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// GenerateBuildings creates and places buildings on the map.
|
2026-02-26 13:03:48 -06:00
|
|
|
func GenerateBuildings(
|
|
|
|
|
img *image.RGBA,
|
|
|
|
|
width,
|
|
|
|
|
height int,
|
|
|
|
|
settings *Settings,
|
|
|
|
|
roadAnchors []image.Point,
|
|
|
|
|
waterMask,
|
|
|
|
|
roadMask,
|
|
|
|
|
exitRoadMask *PixelMask,
|
|
|
|
|
seed int64,
|
|
|
|
|
) ([][]image.Point, *PixelMask) {
|
2026-02-05 17:50:55 -06:00
|
|
|
// Early exit if no buildings are to be generated
|
2026-02-05 15:11:55 -06:00
|
|
|
if settings.NumBuildings == 0 {
|
2026-02-09 09:41:35 -06:00
|
|
|
return nil, nil
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
randSrc := rand.New(rand.NewSource(seed))
|
|
|
|
|
buildingColor := color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray color for buildings
|
2026-02-26 13:03:48 -06:00
|
|
|
if waterMask == nil {
|
|
|
|
|
waterMask = NewPixelMask(width, height)
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-26 13:03:48 -06:00
|
|
|
if roadMask == nil {
|
|
|
|
|
roadMask = NewPixelMask(width, height)
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-26 13:03:48 -06:00
|
|
|
if exitRoadMask == nil {
|
|
|
|
|
exitRoadMask = NewPixelMask(width, height)
|
2026-02-26 11:16:39 -06:00
|
|
|
}
|
2026-02-26 13:03:48 -06:00
|
|
|
buildingMask := NewPixelMask(width, height)
|
2026-02-09 09:41:35 -06:00
|
|
|
var buildings [][]image.Point
|
2026-02-05 15:11:55 -06:00
|
|
|
var anchorPoints []image.Point
|
2026-02-26 11:16:39 -06:00
|
|
|
var normalRoadAnchors []image.Point
|
|
|
|
|
var exitRoadAnchors []image.Point
|
2026-02-05 17:50:55 -06:00
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
if len(roadAnchors) > 0 {
|
|
|
|
|
anchorPoints = roadAnchors
|
2026-02-26 11:16:39 -06:00
|
|
|
for _, p := range anchorPoints {
|
2026-02-26 13:03:48 -06:00
|
|
|
if exitRoadMask.GetPoint(p) {
|
2026-02-26 11:16:39 -06:00
|
|
|
exitRoadAnchors = append(exitRoadAnchors, p)
|
|
|
|
|
} else {
|
|
|
|
|
normalRoadAnchors = append(normalRoadAnchors, p)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-05 17:50:55 -06:00
|
|
|
|
2026-02-26 13:39:39 -06:00
|
|
|
// Early exit if no anchors and no valid land.
|
2026-02-05 15:11:55 -06:00
|
|
|
if len(anchorPoints) == 0 {
|
2026-02-26 13:39:39 -06:00
|
|
|
if _, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc); !ok {
|
|
|
|
|
return nil, nil
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-26 13:39:39 -06:00
|
|
|
} else {
|
|
|
|
|
// Sort anchor points for deterministic placement
|
|
|
|
|
sort.Slice(anchorPoints, func(i, j int) bool {
|
|
|
|
|
if anchorPoints[i].Y != anchorPoints[j].Y {
|
|
|
|
|
return anchorPoints[i].Y < anchorPoints[j].Y
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-26 13:39:39 -06:00
|
|
|
return anchorPoints[i].X < anchorPoints[j].X
|
|
|
|
|
})
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-05 17:50:55 -06:00
|
|
|
|
|
|
|
|
// Main loop for placing buildings
|
2026-02-05 15:11:55 -06:00
|
|
|
buildingsPlaced := 0
|
2026-02-05 17:50:55 -06:00
|
|
|
searchTries := 100 // Number of attempts to find a spot for a building around an anchor
|
2026-02-05 15:11:55 -06:00
|
|
|
maxPlacementAttempts := settings.NumBuildings * 5 // To prevent infinite loops
|
2026-02-26 12:49:01 -06:00
|
|
|
minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
|
2026-02-05 15:11:55 -06:00
|
|
|
|
|
|
|
|
for buildingsPlaced < settings.NumBuildings && maxPlacementAttempts > 0 {
|
|
|
|
|
maxPlacementAttempts--
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// Select an anchor point for the new building
|
2026-02-05 15:11:55 -06:00
|
|
|
var anchor image.Point
|
|
|
|
|
if randSrc.Float64() > settings.BuildingDistribution/100.0 {
|
2026-02-26 11:16:39 -06:00
|
|
|
// Buildings should only rarely use exit-road anchors.
|
|
|
|
|
useExitAnchor := len(exitRoadAnchors) > 0 && randSrc.Float64() < 0.02
|
|
|
|
|
if useExitAnchor {
|
|
|
|
|
anchor = exitRoadAnchors[randSrc.Intn(len(exitRoadAnchors))]
|
|
|
|
|
} else if len(normalRoadAnchors) > 0 {
|
|
|
|
|
anchor = normalRoadAnchors[randSrc.Intn(len(normalRoadAnchors))]
|
2026-02-26 13:39:39 -06:00
|
|
|
} else if len(anchorPoints) > 0 {
|
2026-02-26 11:16:39 -06:00
|
|
|
anchor = anchorPoints[randSrc.Intn(len(anchorPoints))]
|
2026-02-26 13:39:39 -06:00
|
|
|
} else {
|
|
|
|
|
p, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
anchor = p
|
2026-02-26 11:16:39 -06:00
|
|
|
}
|
2026-02-05 15:11:55 -06:00
|
|
|
} else {
|
2026-02-26 13:39:39 -06:00
|
|
|
p, ok := sampleRandomLandPoint(width, height, waterMask, roadMask, randSrc)
|
|
|
|
|
if !ok {
|
2026-02-05 15:11:55 -06:00
|
|
|
continue // No land to place buildings on
|
|
|
|
|
}
|
2026-02-26 13:39:39 -06:00
|
|
|
anchor = p
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// Search for a valid building location around the anchor
|
2026-02-05 15:11:55 -06:00
|
|
|
for i := 0; i < searchTries; i++ {
|
|
|
|
|
searchRadius := float64(i) * 2.0 // Search in expanding circles
|
|
|
|
|
angle := randSrc.Float64() * 2 * math.Pi
|
|
|
|
|
dist := searchRadius * randSrc.Float64()
|
|
|
|
|
center := image.Point{
|
|
|
|
|
X: anchor.X + int(dist*math.Cos(angle)),
|
|
|
|
|
Y: anchor.Y + int(dist*math.Sin(angle)),
|
|
|
|
|
}
|
2026-02-05 17:50:55 -06:00
|
|
|
// For fully random distribution, pick any point on the map
|
2026-02-05 15:11:55 -06:00
|
|
|
if settings.BuildingDistribution == 100 {
|
|
|
|
|
center = image.Point{
|
|
|
|
|
X: randSrc.Intn(width),
|
|
|
|
|
Y: randSrc.Intn(height),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// Ensure the center point is within the map boundaries
|
2026-02-05 15:11:55 -06:00
|
|
|
if center.X < 0 || center.Y < 0 || center.X >= width || center.Y >= height {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-05 17:50:55 -06:00
|
|
|
|
|
|
|
|
// Attempt to create a building at the selected center
|
2026-02-26 12:49:01 -06:00
|
|
|
size := minBuildingSizePx + randSrc.Float64()*(maxBuildingSizePx-minBuildingSizePx)
|
2026-02-06 09:01:09 -06:00
|
|
|
shape := settings.BuildingShape
|
|
|
|
|
if shape == "mixed" {
|
|
|
|
|
shape = chooseShape(randSrc, settings.BuildingShapeRatios)
|
|
|
|
|
}
|
2026-02-06 10:21:40 -06:00
|
|
|
|
|
|
|
|
var pixels []image.Point
|
|
|
|
|
var ok bool
|
|
|
|
|
|
|
|
|
|
if shape == "procedural" {
|
2026-02-26 13:03:48 -06:00
|
|
|
pixels, ok = getProceduralBuildingPixels(center, size, settings, waterMask, roadMask, buildingMask, width, height, randSrc)
|
2026-02-06 10:21:40 -06:00
|
|
|
} else {
|
2026-02-26 13:03:48 -06:00
|
|
|
pixels, ok = getBuildingPixels(center, size, shape, waterMask, roadMask, buildingMask, width, height, randSrc)
|
2026-02-06 10:21:40 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:11:55 -06:00
|
|
|
if ok {
|
2026-02-05 17:50:55 -06:00
|
|
|
// If successful, draw the building and update data structures
|
2026-02-05 15:11:55 -06:00
|
|
|
for _, p := range pixels {
|
|
|
|
|
img.Set(p.X, p.Y, buildingColor)
|
2026-02-26 13:03:48 -06:00
|
|
|
buildingMask.SetPoint(p)
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
2026-02-09 09:41:35 -06:00
|
|
|
buildings = append(buildings, pixels)
|
2026-02-05 15:11:55 -06:00
|
|
|
buildingsPlaced++
|
2026-02-05 17:50:55 -06:00
|
|
|
break // Move to the next building
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 13:03:48 -06:00
|
|
|
return buildings, buildingMask
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-06 10:21:40 -06:00
|
|
|
// getProceduralBuildingPixels generates a complex building by connecting multiple shapes.
|
2026-02-26 13:03:48 -06:00
|
|
|
func getProceduralBuildingPixels(center image.Point, size float64, settings *Settings, waterMask, roadMask, buildingMask *PixelMask, width, height int, randSrc *rand.Rand) ([]image.Point, bool) {
|
2026-02-06 10:21:40 -06:00
|
|
|
complexity := settings.MinBuildingComplexity
|
|
|
|
|
if settings.BuildingComplexityRatio > randSrc.Float64()*100 {
|
|
|
|
|
complexity = settings.MinBuildingComplexity + randSrc.Intn(settings.MaxBuildingComplexity-settings.MinBuildingComplexity+1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type shapeDescription struct {
|
|
|
|
|
shape string
|
|
|
|
|
center image.Point
|
|
|
|
|
size float64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var shapeDescriptions []shapeDescription
|
|
|
|
|
var buildingCenter image.Point
|
|
|
|
|
|
|
|
|
|
// Generate component shapes
|
|
|
|
|
for i := 0; i < complexity; i++ {
|
|
|
|
|
shape := chooseShape(randSrc, settings.BuildingShapeRatios)
|
|
|
|
|
componentSize := size * (0.5 + randSrc.Float64()*0.5) // Components can be 50-100% of the building size
|
|
|
|
|
|
|
|
|
|
var newCenter image.Point
|
|
|
|
|
if i == 0 {
|
|
|
|
|
newCenter = center
|
|
|
|
|
buildingCenter = center
|
|
|
|
|
} else {
|
|
|
|
|
prevShape := shapeDescriptions[randSrc.Intn(len(shapeDescriptions))]
|
|
|
|
|
angle := randSrc.Float64() * 2 * math.Pi
|
|
|
|
|
dist := componentSize * (0.25 + randSrc.Float64()*0.5) // Overlap between 25% and 75%
|
|
|
|
|
newCenter = image.Point{
|
|
|
|
|
X: prevShape.center.X + int(dist*math.Cos(angle)),
|
|
|
|
|
Y: prevShape.center.Y + int(dist*math.Sin(angle)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
shapeDescriptions = append(shapeDescriptions, shapeDescription{shape, newCenter, componentSize})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var minX, minY, maxX, maxY int
|
|
|
|
|
for i, sd := range shapeDescriptions {
|
|
|
|
|
halfSize := int(sd.size / 2)
|
|
|
|
|
if i == 0 {
|
|
|
|
|
minX, minY = sd.center.X-halfSize, sd.center.Y-halfSize
|
|
|
|
|
maxX, maxY = sd.center.X+halfSize, sd.center.Y+halfSize
|
|
|
|
|
} else {
|
|
|
|
|
if sd.center.X-halfSize < minX {
|
|
|
|
|
minX = sd.center.X - halfSize
|
|
|
|
|
}
|
|
|
|
|
if sd.center.Y-halfSize < minY {
|
|
|
|
|
minY = sd.center.Y - halfSize
|
|
|
|
|
}
|
|
|
|
|
if sd.center.X+halfSize > maxX {
|
|
|
|
|
maxX = sd.center.X + halfSize
|
|
|
|
|
}
|
|
|
|
|
if sd.center.Y+halfSize > maxY {
|
|
|
|
|
maxY = sd.center.Y + halfSize
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate scaling factor
|
|
|
|
|
currentWidth := float64(maxX - minX)
|
|
|
|
|
currentHeight := float64(maxY - minY)
|
|
|
|
|
scale := size / math.Max(currentWidth, currentHeight)
|
|
|
|
|
|
|
|
|
|
// Generate final pixels
|
|
|
|
|
var finalPixels []image.Point
|
|
|
|
|
pixelMap := make(map[image.Point]bool)
|
|
|
|
|
for _, sd := range shapeDescriptions {
|
|
|
|
|
scaledSize := sd.size * scale
|
|
|
|
|
scaledCenterX := buildingCenter.X + int((float64(sd.center.X)-float64(minX)-currentWidth/2)*scale)
|
|
|
|
|
scaledCenterY := buildingCenter.Y + int((float64(sd.center.Y)-float64(minY)-currentHeight/2)*scale)
|
|
|
|
|
pixels, ok := getComponentPixels(image.Point{X: scaledCenterX, Y: scaledCenterY}, scaledSize, sd.shape, randSrc)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, p := range pixels {
|
2026-02-26 13:03:48 -06:00
|
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height || waterMask.GetPoint(p) || roadMask.GetPoint(p) || buildingMask.GetPoint(p) {
|
2026-02-06 10:21:40 -06:00
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if !pixelMap[p] {
|
|
|
|
|
finalPixels = append(finalPixels, p)
|
|
|
|
|
pixelMap[p] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(finalPixels) == 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return finalPixels, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// scalePixels scales the building to the final size.
|
|
|
|
|
func scalePixels(pixels []image.Point, finalSize float64) []image.Point {
|
|
|
|
|
if len(pixels) == 0 {
|
|
|
|
|
return pixels
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
minX, minY := pixels[0].X, pixels[0].Y
|
|
|
|
|
maxX, maxY := pixels[0].X, pixels[0].Y
|
|
|
|
|
for _, p := range pixels {
|
|
|
|
|
if p.X < minX {
|
|
|
|
|
minX = p.X
|
|
|
|
|
}
|
|
|
|
|
if p.Y < minY {
|
|
|
|
|
minY = p.Y
|
|
|
|
|
}
|
|
|
|
|
if p.X > maxX {
|
|
|
|
|
maxX = p.X
|
|
|
|
|
}
|
|
|
|
|
if p.Y > maxY {
|
|
|
|
|
maxY = p.Y
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate the current dimensions
|
|
|
|
|
currentWidth := float64(maxX - minX)
|
|
|
|
|
currentHeight := float64(maxY - minY)
|
|
|
|
|
|
|
|
|
|
scale := finalSize / math.Max(currentWidth, currentHeight)
|
|
|
|
|
|
|
|
|
|
// Calculate the center of the bounding box
|
|
|
|
|
centerX := float64(minX) + currentWidth/2
|
|
|
|
|
centerY := float64(minY) + currentHeight/2
|
|
|
|
|
|
|
|
|
|
// Scale and translate the pixels
|
|
|
|
|
var scaledPixels []image.Point
|
|
|
|
|
pixelMap := make(map[image.Point]bool) // To avoid duplicate pixels
|
|
|
|
|
for _, p := range pixels {
|
|
|
|
|
// Translate to origin
|
|
|
|
|
translatedX := float64(p.X) - centerX
|
|
|
|
|
translatedY := float64(p.Y) - centerY
|
|
|
|
|
|
|
|
|
|
// Scale
|
|
|
|
|
scaledX := translatedX * scale
|
|
|
|
|
scaledY := translatedY * scale
|
|
|
|
|
|
|
|
|
|
// Translate back to the center
|
|
|
|
|
finalX := int(math.Round(scaledX + centerX))
|
|
|
|
|
finalY := int(math.Round(scaledY + centerY))
|
|
|
|
|
|
|
|
|
|
newPoint := image.Point{X: finalX, Y: finalY}
|
|
|
|
|
if !pixelMap[newPoint] {
|
|
|
|
|
scaledPixels = append(scaledPixels, newPoint)
|
|
|
|
|
pixelMap[newPoint] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return scaledPixels
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getComponentPixels generates the pixels for a single shape component without collision checks.
|
|
|
|
|
func getComponentPixels(center image.Point, size float64, shape string, randSrc *rand.Rand) ([]image.Point, bool) {
|
|
|
|
|
var pixels []image.Point
|
|
|
|
|
var halfSize = int(size / 2)
|
|
|
|
|
|
|
|
|
|
switch shape {
|
|
|
|
|
case "squares":
|
|
|
|
|
for y := center.Y - halfSize; y <= center.Y+halfSize; y++ {
|
|
|
|
|
for x := center.X - halfSize; x <= center.X+halfSize; x++ {
|
|
|
|
|
pixels = append(pixels, image.Point{X: x, Y: y})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case "circles":
|
|
|
|
|
r2 := (size / 2) * (size / 2)
|
|
|
|
|
for y := center.Y - halfSize; y <= center.Y+halfSize; y++ {
|
|
|
|
|
for x := center.X - halfSize; x <= center.X+halfSize; x++ {
|
|
|
|
|
dx, dy := float64(x-center.X), float64(y-center.Y)
|
|
|
|
|
if dx*dx+dy*dy <= r2 {
|
|
|
|
|
pixels = append(pixels, image.Point{X: x, Y: y})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case "rectangles":
|
|
|
|
|
longSide := size
|
|
|
|
|
shortSide := randSrc.Float64()*(size-float64(halfSize)) + float64(halfSize)
|
|
|
|
|
var w, h int
|
|
|
|
|
if randSrc.Intn(2) == 0 {
|
|
|
|
|
w, h = int(longSide), int(shortSide)
|
|
|
|
|
} else {
|
|
|
|
|
w, h = int(shortSide), int(longSide)
|
|
|
|
|
}
|
|
|
|
|
halfW, halfH := w/2, h/2
|
|
|
|
|
for y := center.Y - halfH; y <= center.Y+halfH; y++ {
|
|
|
|
|
for x := center.X - halfW; x <= center.X+halfW; x++ {
|
|
|
|
|
pixels = append(pixels, image.Point{X: x, Y: y})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pixels, len(pixels) > 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 09:01:09 -06:00
|
|
|
// chooseShape selects a building shape based on the provided ratios.
|
|
|
|
|
func chooseShape(randSrc *rand.Rand, ratios map[string]float64) string {
|
2026-02-26 13:46:32 -06:00
|
|
|
if len(ratios) == 0 {
|
|
|
|
|
return "squares"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deterministic iteration order so seeded runs remain reproducible.
|
|
|
|
|
baseOrder := []string{"squares", "circles", "rectangles"}
|
|
|
|
|
shapes := make([]string, 0, len(ratios))
|
|
|
|
|
seen := make(map[string]bool, len(ratios))
|
|
|
|
|
for _, shape := range baseOrder {
|
|
|
|
|
if _, ok := ratios[shape]; ok {
|
|
|
|
|
shapes = append(shapes, shape)
|
|
|
|
|
seen[shape] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Include any extra shapes in sorted order to keep behavior stable.
|
|
|
|
|
extras := make([]string, 0, len(ratios))
|
|
|
|
|
for shape := range ratios {
|
|
|
|
|
if !seen[shape] {
|
|
|
|
|
extras = append(extras, shape)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(extras)
|
|
|
|
|
shapes = append(shapes, extras...)
|
|
|
|
|
|
|
|
|
|
weights := make([]float64, 0, len(shapes))
|
2026-02-06 09:01:09 -06:00
|
|
|
var cumulativeWeight float64
|
2026-02-26 13:46:32 -06:00
|
|
|
for _, shape := range shapes {
|
|
|
|
|
weight := ratios[shape]
|
2026-02-06 09:01:09 -06:00
|
|
|
cumulativeWeight += weight
|
|
|
|
|
weights = append(weights, cumulativeWeight)
|
|
|
|
|
}
|
2026-02-26 13:46:32 -06:00
|
|
|
if cumulativeWeight <= 0 {
|
|
|
|
|
return shapes[0]
|
|
|
|
|
}
|
2026-02-06 09:01:09 -06:00
|
|
|
|
|
|
|
|
// Generate a random number between 0 and the total weight
|
|
|
|
|
randNum := randSrc.Float64() * cumulativeWeight
|
|
|
|
|
|
|
|
|
|
for i, weight := range weights {
|
|
|
|
|
if randNum < weight {
|
|
|
|
|
return shapes[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default to the first shape if something goes wrong
|
|
|
|
|
return shapes[0]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// getBuildingPixels determines the pixels for a single building based on its shape and checks for collisions.
|
2026-02-26 13:03:48 -06:00
|
|
|
func getBuildingPixels(center image.Point, size float64, shape string, waterMask, roadMask, buildingMask *PixelMask, width, height int, randSrc *rand.Rand) ([]image.Point, bool) {
|
2026-02-05 15:11:55 -06:00
|
|
|
var pixels []image.Point
|
|
|
|
|
var halfSize = int(size / 2)
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// Generate pixels based on the selected building shape
|
2026-02-05 15:11:55 -06:00
|
|
|
switch shape {
|
|
|
|
|
case "squares":
|
|
|
|
|
for y := center.Y - halfSize; y <= center.Y+halfSize; y++ {
|
|
|
|
|
for x := center.X - halfSize; x <= center.X+halfSize; x++ {
|
|
|
|
|
p := image.Point{X: x, Y: y}
|
2026-02-26 13:03:48 -06:00
|
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height || waterMask.GetPoint(p) || roadMask.GetPoint(p) || buildingMask.GetPoint(p) {
|
2026-02-05 17:50:55 -06:00
|
|
|
return nil, false // Collision detected
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
pixels = append(pixels, p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case "circles":
|
|
|
|
|
r2 := (size / 2) * (size / 2)
|
|
|
|
|
for y := center.Y - halfSize; y <= center.Y+halfSize; y++ {
|
|
|
|
|
for x := center.X - halfSize; x <= center.X+halfSize; x++ {
|
|
|
|
|
dx, dy := float64(x-center.X), float64(y-center.Y)
|
|
|
|
|
if dx*dx+dy*dy <= r2 {
|
|
|
|
|
p := image.Point{X: x, Y: y}
|
2026-02-26 13:03:48 -06:00
|
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height || waterMask.GetPoint(p) || roadMask.GetPoint(p) || buildingMask.GetPoint(p) {
|
2026-02-05 17:50:55 -06:00
|
|
|
return nil, false // Collision detected
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
pixels = append(pixels, p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case "rectangles":
|
|
|
|
|
longSide := size
|
|
|
|
|
shortSide := randSrc.Float64()*(size-float64(halfSize)) + float64(halfSize)
|
|
|
|
|
var w, h int
|
|
|
|
|
if randSrc.Intn(2) == 0 {
|
|
|
|
|
w, h = int(longSide), int(shortSide)
|
|
|
|
|
} else {
|
|
|
|
|
w, h = int(shortSide), int(longSide)
|
|
|
|
|
}
|
|
|
|
|
halfW, halfH := w/2, h/2
|
|
|
|
|
|
|
|
|
|
for y := center.Y - halfH; y <= center.Y+halfH; y++ {
|
|
|
|
|
for x := center.X - halfW; x <= center.X+halfW; x++ {
|
|
|
|
|
p := image.Point{X: x, Y: y}
|
2026-02-26 13:03:48 -06:00
|
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height || waterMask.GetPoint(p) || roadMask.GetPoint(p) || buildingMask.GetPoint(p) {
|
2026-02-05 17:50:55 -06:00
|
|
|
return nil, false // Collision detected
|
2026-02-05 15:11:55 -06:00
|
|
|
}
|
|
|
|
|
pixels = append(pixels, p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 17:50:55 -06:00
|
|
|
// Final check to ensure pixels were generated
|
2026-02-05 15:11:55 -06:00
|
|
|
if len(pixels) == 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return pixels, true
|
|
|
|
|
}
|
2026-02-09 09:41:35 -06:00
|
|
|
|
|
|
|
|
// isPixelInSlice checks if a pixel is already in a slice of pixels.
|
|
|
|
|
func isPixelInSlice(pixel image.Point, pixelSlice []image.Point) bool {
|
|
|
|
|
for _, p := range pixelSlice {
|
|
|
|
|
if p == pixel {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:32:31 -06:00
|
|
|
var (
|
|
|
|
|
u8BufferPool = sync.Pool{
|
|
|
|
|
New: func() any { return make([]uint8, 0) },
|
|
|
|
|
}
|
|
|
|
|
f32BufferPool = sync.Pool{
|
|
|
|
|
New: func() any { return make([]float32, 0) },
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func getU8Buffer(n int) []uint8 {
|
|
|
|
|
buf := u8BufferPool.Get().([]uint8)
|
|
|
|
|
if cap(buf) < n {
|
|
|
|
|
return make([]uint8, n)
|
|
|
|
|
}
|
|
|
|
|
return buf[:n]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func putU8Buffer(buf []uint8) {
|
|
|
|
|
if buf == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
u8BufferPool.Put(buf[:0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getF32Buffer(n int) []float32 {
|
|
|
|
|
buf := f32BufferPool.Get().([]float32)
|
|
|
|
|
if cap(buf) < n {
|
|
|
|
|
return make([]float32, n)
|
|
|
|
|
}
|
|
|
|
|
return buf[:n]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func putF32Buffer(buf []float32) {
|
|
|
|
|
if buf == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f32BufferPool.Put(buf[:0])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func chamferDistanceFieldInto(baseMask []uint8, w, h int, dist []float32) []float32 {
|
|
|
|
|
const maxF = 1e6
|
|
|
|
|
total := w * h
|
|
|
|
|
if len(dist) < total {
|
|
|
|
|
dist = make([]float32, total)
|
|
|
|
|
} else {
|
|
|
|
|
dist = dist[:total]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < total; i++ {
|
|
|
|
|
if baseMask[i] == 1 {
|
|
|
|
|
dist[i] = 0
|
|
|
|
|
} else {
|
|
|
|
|
dist[i] = maxF
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for y := 0; y < h; y++ {
|
|
|
|
|
for x := 0; x < w; x++ {
|
|
|
|
|
i := y*w + x
|
|
|
|
|
if dist[i] == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if x > 0 {
|
|
|
|
|
v := dist[i-1] + 1.0
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if y > 0 {
|
|
|
|
|
v := dist[i-w] + 1.0
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if x > 0 && y > 0 {
|
|
|
|
|
v := dist[i-w-1] + 1.41421356
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if x < w-1 && y > 0 {
|
|
|
|
|
v := dist[i-w+1] + 1.41421356
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for y := h - 1; y >= 0; y-- {
|
|
|
|
|
for x := w - 1; x >= 0; x-- {
|
|
|
|
|
i := y*w + x
|
|
|
|
|
if x < w-1 {
|
|
|
|
|
v := dist[i+1] + 1.0
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if y < h-1 {
|
|
|
|
|
v := dist[i+w] + 1.0
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if x < w-1 && y < h-1 {
|
|
|
|
|
v := dist[i+w+1] + 1.41421356
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if x > 0 && y < h-1 {
|
|
|
|
|
v := dist[i+w-1] + 1.41421356
|
|
|
|
|
if v < dist[i] {
|
|
|
|
|
dist[i] = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return dist
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 09:41:35 -06:00
|
|
|
// FlattenBuildingAreas flattens the terrain under buildings and blends the surrounding area.
|
|
|
|
|
func FlattenBuildingAreas(heightMap *image.RGBA, buildings [][]image.Point, width, height int) *image.RGBA {
|
|
|
|
|
if len(buildings) == 0 {
|
|
|
|
|
return heightMap
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newHeightMap := image.NewRGBA(heightMap.Bounds())
|
|
|
|
|
copy(newHeightMap.Pix, heightMap.Pix)
|
2026-02-26 13:32:31 -06:00
|
|
|
const bufferRadius = 5.0
|
2026-02-09 09:41:35 -06:00
|
|
|
|
|
|
|
|
for _, building := range buildings {
|
2026-02-26 13:32:31 -06:00
|
|
|
if len(building) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-09 09:41:35 -06:00
|
|
|
|
2026-02-26 13:32:31 -06:00
|
|
|
minX, minY := width-1, height-1
|
|
|
|
|
maxX, maxY := 0, 0
|
|
|
|
|
var totalGray uint32
|
|
|
|
|
for _, p := range building {
|
|
|
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height {
|
|
|
|
|
continue
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
2026-02-26 13:32:31 -06:00
|
|
|
if p.X < minX {
|
|
|
|
|
minX = p.X
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
2026-02-26 13:32:31 -06:00
|
|
|
if p.Y < minY {
|
|
|
|
|
minY = p.Y
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
2026-02-26 13:32:31 -06:00
|
|
|
if p.X > maxX {
|
|
|
|
|
maxX = p.X
|
|
|
|
|
}
|
|
|
|
|
if p.Y > maxY {
|
|
|
|
|
maxY = p.Y
|
|
|
|
|
}
|
|
|
|
|
srcIdx := p.Y*heightMap.Stride + p.X*4
|
|
|
|
|
totalGray += uint32(heightMap.Pix[srcIdx])
|
|
|
|
|
}
|
|
|
|
|
if minX > maxX || minY > maxY {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
avgGray := uint8(totalGray / uint32(len(building)))
|
2026-02-09 09:41:35 -06:00
|
|
|
|
2026-02-26 13:32:31 -06:00
|
|
|
pad := int(bufferRadius)
|
|
|
|
|
bx0 := max(0, minX-pad)
|
|
|
|
|
by0 := max(0, minY-pad)
|
|
|
|
|
bx1 := min(width-1, maxX+pad)
|
|
|
|
|
by1 := min(height-1, maxY+pad)
|
|
|
|
|
bw := bx1 - bx0 + 1
|
|
|
|
|
bh := by1 - by0 + 1
|
|
|
|
|
if bw <= 0 || bh <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-09 09:41:35 -06:00
|
|
|
|
2026-02-26 13:32:31 -06:00
|
|
|
maskSize := bw * bh
|
|
|
|
|
baseMask := getU8Buffer(maskSize)
|
|
|
|
|
for i := range baseMask {
|
|
|
|
|
baseMask[i] = 0
|
|
|
|
|
}
|
|
|
|
|
for _, p := range building {
|
|
|
|
|
if p.X < bx0 || p.X > bx1 || p.Y < by0 || p.Y > by1 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
localIdx := (p.Y-by0)*bw + (p.X - bx0)
|
|
|
|
|
baseMask[localIdx] = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
distBuf := getF32Buffer(maskSize)
|
|
|
|
|
dist := chamferDistanceFieldInto(baseMask, bw, bh, distBuf)
|
|
|
|
|
|
|
|
|
|
for y := by0; y <= by1; y++ {
|
|
|
|
|
localRow := (y - by0) * bw
|
|
|
|
|
rowOffset := y * newHeightMap.Stride
|
|
|
|
|
srcRowOffset := y * heightMap.Stride
|
|
|
|
|
for x := bx0; x <= bx1; x++ {
|
|
|
|
|
localIdx := localRow + (x - bx0)
|
|
|
|
|
idx := rowOffset + x*4
|
|
|
|
|
if baseMask[localIdx] == 1 {
|
|
|
|
|
newHeightMap.Pix[idx] = avgGray
|
|
|
|
|
newHeightMap.Pix[idx+1] = avgGray
|
|
|
|
|
newHeightMap.Pix[idx+2] = avgGray
|
|
|
|
|
newHeightMap.Pix[idx+3] = 255
|
|
|
|
|
continue
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:32:31 -06:00
|
|
|
d := float64(dist[localIdx])
|
|
|
|
|
if d > bufferRadius {
|
|
|
|
|
continue
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
2026-02-26 13:32:31 -06:00
|
|
|
blendFactor := d / bufferRadius
|
|
|
|
|
if blendFactor > 1 {
|
|
|
|
|
blendFactor = 1
|
|
|
|
|
}
|
|
|
|
|
srcIdx := srcRowOffset + x*4
|
|
|
|
|
origGray := float64(heightMap.Pix[srcIdx])
|
|
|
|
|
newGray := uint8(float64(avgGray)*(1.0-blendFactor) + origGray*blendFactor)
|
|
|
|
|
newHeightMap.Pix[idx] = newGray
|
|
|
|
|
newHeightMap.Pix[idx+1] = newGray
|
|
|
|
|
newHeightMap.Pix[idx+2] = newGray
|
|
|
|
|
newHeightMap.Pix[idx+3] = 255
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
2026-02-26 13:32:31 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
putF32Buffer(distBuf)
|
|
|
|
|
putU8Buffer(baseMask)
|
2026-02-09 09:41:35 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newHeightMap
|
|
|
|
|
}
|