1// Warping keeps the lesser picture. When two sources capture the same map,2// one raster is usually finer -- an official scan beside a wiki's rasterized3// in-game rendering -- and the registry would otherwise simply hide the4// lesser one. Instead, the lesser raster is resampled into the finer layer's5// world, using the transformation the two sources' shared named places6// determine, and offered as an additional variant of the same map: nothing7// is discarded, both rasters answer to one grid, and every pin lands on8// either.9package main1011import (12 "fmt"13 "image"14 "image/color"15 "math"16 "path/filepath"17 "sort"18 "strconv"1920 "github.com/FelineStateMachine/atlas/internal/blend"21)2223// planWarps finds every game captured by more than one source and plans the24// lesser layers' resampling into the finest one's world. Sources do not have25// to agree on how the world divides into maps: whether two captures picture26// the same ground is decided by the fit itself, which refuses pairs that27// share too few named places to align. Plans arrive after asset paths are28// settled, so the warped names stay stable and unique.29func planWarps(pending []pendingPlan) []pendingPlan {30 // One representative layer per map directory: its deepest layer is the31 // picture worth aligning; further layers of the same map are alternate32 // art of the same world, already aligned by their shared window. A33 // capture is the game directory above the maps -- one source's whole34 // account of the game -- and only maps of different captures are ever35 // paired: a single source already divided its world deliberately.36 deepestByDir := make(map[string]int)37 capturesByGame := make(map[string]map[string]bool)38 for index, entry := range pending {39 held, ok := deepestByDir[entry.plan.MapDir]40 if !ok || worldDepth(entry.plan) > worldDepth(pending[held].plan) {41 deepestByDir[entry.plan.MapDir] = index42 }43 slug := entry.plan.GameSlug44 if capturesByGame[slug] == nil {45 capturesByGame[slug] = make(map[string]bool)46 }47 capturesByGame[slug][captureDir(entry.plan)] = true48 }49 byGame := make(map[string][]int)50 var order []string51 for _, index := range deepestByDir {52 slug := pending[index].plan.GameSlug53 if len(capturesByGame[slug]) < 2 {54 continue55 }56 if _, seen := byGame[slug]; !seen {57 order = append(order, slug)58 }59 byGame[slug] = append(byGame[slug], index)60 }61 sort.Strings(order)6263 var warps []pendingPlan64 for _, slug := range order {65 group := byGame[slug]66 sort.Slice(group, func(a, b int) bool {67 return pending[group[a]].plan.MapDir < pending[group[b]].plan.MapDir68 })69 base := group[0]70 for _, candidate := range group[1:] {71 if worldDepth(pending[candidate].plan) > worldDepth(pending[base].plan) {72 base = candidate73 }74 }75 for _, donor := range group {76 if donor == base || captureDir(pending[donor].plan) == captureDir(pending[base].plan) {77 continue78 }79 warp, ok := planWarp(&pending[base].plan, &pending[donor].plan, pending[donor].gameTitle)80 if ok {81 warps = append(warps, warp)82 }83 }84 }85 return warps86}8788// captureDir names one source's whole account of a game: the directory the89// map directories sit inside.90func captureDir(plan tilePlan) string {91 return filepath.Dir(filepath.Dir(plan.MapDir))92}9394// worldDepth is the finest resolution a layer draws its world at, in pixels95// across its own square.96func worldDepth(plan tilePlan) int {97 return tileSize * (1 << (plan.MaxFullZoom - plan.Frame.BaseZoom))98}99100func planWarp(base, donor *tilePlan, donorGameTitle string) (pendingPlan, bool) {101 affine, report, err := blend.Fit(donor.Anchors, base.Anchors)102 if err != nil {103 fmt.Printf("skip aligning %s onto %s: %v\n", donor.SourcePath, base.SourcePath, err)104 return pendingPlan{}, false105 }106107 // The warp renders at the base-frame zoom nearest the donor's real108 // resolution: deep enough to keep what the donor drew, never so deep as109 // to invent detail that was never there.110 effective := float64(worldDepth(*donor)) * affine.Scale()111 targetZoom := int(math.Round(math.Log2(effective / tileSize)))112 targetZoom = max(0, min(targetZoom, base.MaxFullZoom-base.Frame.BaseZoom))113114 fmt.Printf("align %s onto %s: %s, rendered at z%d\n",115 donor.SourcePath, base.SourcePath, report, targetZoom)116117 warped := tilePlan{118 AssetPath: base.AssetPath + "__aligned-" + slugify(donor.SourcePath),119 SourcePath: donor.SourcePath + "=>" + base.SourcePath,120 Frame: base.Frame,121 Interpolate: true,122 MapDir: donor.MapDir,123 MaxFullZoom: targetZoom + base.Frame.BaseZoom,124 MaxSourceZoom: targetZoom + base.Frame.BaseZoom,125 PreferredFormat: "jpg",126 GameSlug: base.GameSlug,127 MapSlug: base.MapSlug,128 SetName: donor.SetName,129 // The donor's deepest complete level is what the warp samples, and130 // listing it here is what lets the stamp notice when those tiles131 // change.132 Levels: map[int][]tileFile{donor.MaxFullZoom: donor.Levels[donor.MaxFullZoom]},133 Warp: &warpSpec{134 Donor: donor,135 Base: base,136 Affine: affine,137 TargetZoom: targetZoom,138 },139 }140 return pendingPlan{gameTitle: donorGameTitle, title: base.MapSlug, plan: warped}, true141}142143// buildWarpedPyramid renders the donor through the affine into the base144// frame at the target zoom, then folds the shallower levels down exactly as145// every other pyramid is folded.146func buildWarpedPyramid(root string, plan tilePlan) (lensManifest, error) {147 warp := plan.Warp148 donor := warp.Donor149 donorFiles := donor.Levels[donor.MaxFullZoom]150151 // The donor's own background paints everywhere the donor never drew.152 placeholder := placeholderHash(donorFiles)153 backgroundHex, err := placeholderColor(donorFiles, placeholder)154 if err != nil {155 return lensManifest{}, err156 }157 background := parseHexColor(backgroundHex)158159 sampler, err := newDonorSampler(donor, background)160 if err != nil {161 return lensManifest{}, err162 }163164 // Where the donor's content lands in the base world.165 bounds := warpedBounds(donor, warp.Affine)166 window := tileWindowFor(bounds, warp.TargetZoom)167168 targetZoom := warp.TargetZoom169 formats := make([]string, targetZoom+1)170 coverage := make(map[string]*levelCoverage)171 builder := &coverageBuilder{total: (1 << targetZoom) * (1 << targetZoom)}172 inverse, ok := warp.Affine.Invert()173 if !ok {174 return lensManifest{}, fmt.Errorf("alignment is singular")175 }176 // Base world pixels per rendered pixel at the target zoom.177 levelPixels := tileSize * (1 << targetZoom)178 span := float64(worldSize) / float64(levelPixels)179180 for tileY := window[1]; tileY <= window[3]; tileY++ {181 for tileX := window[0]; tileX <= window[2]; tileX++ {182 img := image.NewNRGBA(image.Rect(0, 0, tileSize, tileSize))183 drewAny := false184 for v := 0; v < tileSize; v++ {185 for u := 0; u < tileSize; u++ {186 baseX := (float64(tileX*tileSize+u) + 0.5) * span187 baseY := (float64(tileY*tileSize+v) + 0.5) * span188 donorX, donorY := inverse.Apply(baseX, baseY)189 sampled, drew := sampler.sample(donorX, donorY)190 if drew {191 drewAny = true192 }193 img.SetNRGBA(u, v, sampled)194 }195 }196 if !drewAny {197 continue198 }199 path := tilePath(root, plan.AssetPath, targetZoom, tileX, tileY, "jpg")200 if err := encodeImage(path, img, "jpg"); err != nil {201 return lensManifest{}, err202 }203 builder.mark(tileX, tileY)204 }205 }206 formats[targetZoom] = "jpg"207 if mask := builder.build(); mask != nil {208 coverage[itoa(targetZoom)] = mask209 }210211 for localZoom := targetZoom - 1; localZoom >= 0; localZoom-- {212 mask, err := deriveLevel(root, plan.AssetPath, localZoom, formats[localZoom+1], "jpg", false, background)213 if err != nil {214 return lensManifest{}, err215 }216 formats[localZoom] = "jpg"217 if mask != nil {218 coverage[itoa(localZoom)] = mask219 }220 }221 if len(coverage) == 0 {222 coverage = nil223 }224225 return lensManifest{226 SourcePath: plan.SourcePath,227 AssetPath: plan.AssetPath,228 MinZoom: 0,229 MaxZoom: targetZoom,230 FullZoom: targetZoom,231 SourceZoom: targetZoom + warp.Base.Frame.BaseZoom,232 Grid: warp.Base.Frame.grid(),233 Formats: formats,234 Bounds: bounds,235 Interpolate: true,236 Background: backgroundHex,237 Coverage: coverage,238 Name: plan.SetName,239 AlignedWith: warp.Base.SourcePath,240 }, nil241}242243// warpedBounds sends the donor's content box through the alignment and clips244// it to the base world.245func warpedBounds(donor *tilePlan, affine blend.Affine) *contentBounds {246 box := donor.Bounds247 if box == nil {248 box = &contentBounds{X: 0, Y: 0, Width: worldSize, Height: worldSize}249 }250 corners := [4][2]float64{251 {float64(box.X), float64(box.Y)},252 {float64(box.X + box.Width), float64(box.Y)},253 {float64(box.X), float64(box.Y + box.Height)},254 {float64(box.X + box.Width), float64(box.Y + box.Height)},255 }256 minX, minY := math.Inf(1), math.Inf(1)257 maxX, maxY := math.Inf(-1), math.Inf(-1)258 for _, corner := range corners {259 x, y := affine.Apply(corner[0], corner[1])260 minX, maxX = math.Min(minX, x), math.Max(maxX, x)261 minY, maxY = math.Min(minY, y), math.Max(maxY, y)262 }263 minX, minY = math.Max(minX, 0), math.Max(minY, 0)264 maxX, maxY = math.Min(maxX, worldSize), math.Min(maxY, worldSize)265 if maxX <= minX || maxY <= minY {266 return &contentBounds{}267 }268 return &contentBounds{269 X: int(minX),270 Y: int(minY),271 Width: int(math.Ceil(maxX)) - int(minX),272 Height: int(math.Ceil(maxY)) - int(minY),273 }274}275276func tileWindowFor(bounds *contentBounds, zoom int) [4]int {277 levelPixels := tileSize * (1 << zoom)278 span := float64(worldSize) / float64(levelPixels)279 last := (1 << zoom) - 1280 return [4]int{281 max(0, int(float64(bounds.X)/span/tileSize)),282 max(0, int(float64(bounds.Y)/span/tileSize)),283 min(last, int(float64(bounds.X+bounds.Width)/span/tileSize)),284 min(last, int(float64(bounds.Y+bounds.Height)/span/tileSize)),285 }286}287288// donorSampler reads the donor's deepest complete level as one continuous289// picture, decoding tiles as the warp first touches them and holding a290// bounded number decoded.291type donorSampler struct {292 files map[[2]int]string293 decoded map[[2]int]*image.NRGBA294 order [][2]int295 origin int296 pixelScale float64 // donor level pixels per donor world pixel297 levelSpan int // donor level pixels across the level298 background color.NRGBA299}300301const donorCacheTiles = 256302303func newDonorSampler(donor *tilePlan, background color.Color) (*donorSampler, error) {304 files := make(map[[2]int]string)305 for _, file := range donor.Levels[donor.MaxFullZoom] {306 files[[2]int{file.Record.X, file.Record.Y}] = file.Path307 }308 if len(files) == 0 {309 return nil, fmt.Errorf("donor %s has no deepest level", donor.SourcePath)310 }311 r, g, b, _ := background.RGBA()312 return &donorSampler{313 files: files,314 decoded: make(map[[2]int]*image.NRGBA),315 origin: donor.Frame.origin(donor.MaxFullZoom),316 pixelScale: math.Pow(2, float64(donor.MaxFullZoom-donor.Frame.BaseZoom-worldZoomLevels)),317 levelSpan: tileSize * (1 << (donor.MaxFullZoom - donor.Frame.BaseZoom)),318 background: color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff},319 }, nil320}321322// sample reads the donor at a world coordinate, bilinearly, clamped within323// the containing tile. The second result says whether the donor drew here at324// all -- outside the level, or over a tile the capture never held, it did325// not, and the background stands in.326func (s *donorSampler) sample(worldX, worldY float64) (color.NRGBA, bool) {327 pixelX := worldX * s.pixelScale328 pixelY := worldY * s.pixelScale329 if pixelX < 0 || pixelY < 0 || pixelX >= float64(s.levelSpan) || pixelY >= float64(s.levelSpan) {330 return s.background, false331 }332 tileX := int(pixelX) / tileSize333 tileY := int(pixelY) / tileSize334 img, ok := s.tile(tileX, tileY)335 if !ok {336 return s.background, false337 }338 localX := pixelX - float64(tileX*tileSize) - 0.5339 localY := pixelY - float64(tileY*tileSize) - 0.5340 x0 := clampInt(int(math.Floor(localX)), 0, tileSize-1)341 y0 := clampInt(int(math.Floor(localY)), 0, tileSize-1)342 x1 := clampInt(x0+1, 0, tileSize-1)343 y1 := clampInt(y0+1, 0, tileSize-1)344 fx := clampFloat(localX-float64(x0), 0, 1)345 fy := clampFloat(localY-float64(y0), 0, 1)346347 blendChannel := func(c00, c10, c01, c11 uint8) uint8 {348 top := float64(c00)*(1-fx) + float64(c10)*fx349 bottom := float64(c01)*(1-fx) + float64(c11)*fx350 return uint8(top*(1-fy) + bottom*fy + 0.5)351 }352 p00 := img.NRGBAAt(x0, y0)353 p10 := img.NRGBAAt(x1, y0)354 p01 := img.NRGBAAt(x0, y1)355 p11 := img.NRGBAAt(x1, y1)356 return color.NRGBA{357 R: blendChannel(p00.R, p10.R, p01.R, p11.R),358 G: blendChannel(p00.G, p10.G, p01.G, p11.G),359 B: blendChannel(p00.B, p10.B, p01.B, p11.B),360 A: 0xff,361 }, true362}363364func (s *donorSampler) tile(x, y int) (*image.NRGBA, bool) {365 key := [2]int{x + s.origin, y + s.origin}366 if img, held := s.decoded[key]; held {367 return img, img != nil368 }369 path, exists := s.files[key]370 if !exists {371 s.remember(key, nil)372 return nil, false373 }374 decoded, err := decodeImage(path)375 if err != nil {376 s.remember(key, nil)377 return nil, false378 }379 img := asNRGBA(decoded)380 s.remember(key, img)381 return img, true382}383384func (s *donorSampler) remember(key [2]int, img *image.NRGBA) {385 if len(s.order) >= donorCacheTiles {386 delete(s.decoded, s.order[0])387 s.order = s.order[1:]388 }389 s.decoded[key] = img390 s.order = append(s.order, key)391}392393func asNRGBA(img image.Image) *image.NRGBA {394 if typed, ok := img.(*image.NRGBA); ok {395 return typed396 }397 bounds := img.Bounds()398 out := image.NewNRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))399 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {400 for x := bounds.Min.X; x < bounds.Max.X; x++ {401 r, g, b, _ := img.At(x, y).RGBA()402 out.SetNRGBA(x-bounds.Min.X, y-bounds.Min.Y, color.NRGBA{403 R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff,404 })405 }406 }407 return out408}409410func clampInt(value, low, high int) int {411 return max(low, min(high, value))412}413414func clampFloat(value, low, high float64) float64 {415 return math.Max(low, math.Min(high, value))416}417418func itoa(value int) string {419 return strconv.Itoa(value)420}