When two sources capture one map, the registry showed the newer and hid the rest -- and Night City's wiki rendering, cruder than the official scan, still draws badlands roads the scan crops out. Nothing needs hiding. internal/blend finds the affine between two sources' pixel worlds from the places both pin by name, unambiguously, trimming the sloppily-placed and refusing outright when the fit will not close: the shared pins are what make the projection deterministic rather than eyeballed. The tiles tool then resamples the lesser layer into the finer one's world at the zoom its real resolution earns, and offers it as one more variant of the same map -- stamped over its donor tiles and its alignment, carried incrementally like every other pyramid. Because the warp renders in the base map's window, the one-grid rule holds and every pin lands correctly on either raster; the viewer's layer picker simply gains an "IGN Wiki" entry, no frontend change. Night City aligns on 99 anchors at a median of 26 pixels, verified against the scan by overlay: the solar farm, the airport, and the coastline sit where the official map draws them.
- commit
- dd53d27f32e89c2fb028aff33d25609d96274338
- parent
- c15288db96a51f172a1ca6552ffb9ef09ca99112
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 260 | +++++++++++++++++++ |
| A | internal/ | 134 | ++++++++++ |
| M | internal/ | 5 | +- |
| M | internal/ | 17 | + |
| M | tools/ | 39 | +++- |
| M | tools/ | 1 | + |
| M | tools/ | 122 | ++++++++- |
| M | tools/ | 13 | +- |
| A | tools/ | 404 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,260 @@
1+// Package blend finds how two sources' pictures of the same ground line up.2+//3+// Two captures of one game map draw the same places at different scales and4+// offsets: a scan of the official map and a rasterized in-game rendering do5+// not share a pixel grid, and neither says how to reach the other. What they6+// do share is places -- fast travel points, shops, landmarks -- pinned by7+// name in both. Every name both sources pin, unambiguously, is a calibration8+// point, and enough of them determine the affine transformation between the9+// two pixel spaces deterministically: the same captures always align the10+// same way, with a residual report that says how well.11+package blend12+13+import (14+ "fmt"15+ "math"16+ "regexp"17+ "sort"18+ "strings"19+)20+21+// Anchor is one named point in a source's own world pixels.22+type Anchor struct {23+ Title string24+ X float6425+ Y float6426+}27+28+// Affine carries base = A·donor + t, spelled per axis:29+// x' = AX·x + BX·y + CX, and y' likewise. The cross terms carry any small30+// rotation or shear between the renderings; north-up sources leave them31+// near zero.32+type Affine struct {33+ AX, BX, CX float6434+ AY, BY, CY float6435+}36+37+// Apply sends a donor-space point into base space.38+func (a Affine) Apply(x, y float64) (float64, float64) {39+ return a.AX*x + a.BX*y + a.CX, a.AY*x + a.BY*y + a.CY40+}41+42+// Invert solves the transformation the other way.43+func (a Affine) Invert() (Affine, bool) {44+ det := a.AX*a.BY - a.BX*a.AY45+ if math.Abs(det) < 1e-12 {46+ return Affine{}, false47+ }48+ inv := Affine{49+ AX: a.BY / det, BX: -a.BX / det,50+ AY: -a.AY / det, BY: a.AX / det,51+ }52+ inv.CX = -(inv.AX*a.CX + inv.BX*a.CY)53+ inv.CY = -(inv.AY*a.CX + inv.BY*a.CY)54+ return inv, true55+}56+57+// Scale reports how many base pixels one donor pixel spans, averaged over58+// the axes.59+func (a Affine) Scale() float64 {60+ sx := math.Hypot(a.AX, a.AY)61+ sy := math.Hypot(a.BX, a.BY)62+ return (sx + sy) / 263+}64+65+// Report says what the fit stood on and how well it closed, in base pixels.66+type Report struct {67+ Anchors int68+ MedianPx float6469+ P90Px float6470+ WorstPx float6471+}72+73+func (r Report) String() string {74+ return fmt.Sprintf("%d anchors, median %.1fpx, p90 %.1fpx, worst %.1fpx",75+ r.Anchors, r.MedianPx, r.P90Px, r.WorstPx)76+}77+78+// The fit stands only on names that are unambiguous in both sources, needs79+// enough of them to mean something, trims its worst tenth twice to shed the80+// pins one source simply placed sloppily, and refuses to answer at all when81+// what remains still does not close: a bad alignment drawn confidently is82+// worse than none.83+const (84+ minAnchors = 1285+ trimRounds = 286+ trimKeep = 0.987+ maxMedianPx = 9688+ maxConditioned = 1e-1289+)90+91+// Fit finds the affine sending donor pixels onto base pixels from the places92+// both name. It is pure: the same anchors always yield the same answer.93+func Fit(donor, base []Anchor) (Affine, Report, error) {94+ matchedDonor, matchedBase := matchByTitle(donor, base)95+ if len(matchedDonor) < minAnchors {96+ return Affine{}, Report{}, fmt.Errorf(97+ "only %d unambiguous shared names; %d needed", len(matchedDonor), minAnchors)98+ }99+100+ keep := make([]int, len(matchedDonor))101+ for index := range keep {102+ keep[index] = index103+ }104+ var affine Affine105+ for round := 0; ; round++ {106+ var ok bool107+ affine, ok = solve(matchedDonor, matchedBase, keep)108+ if !ok {109+ return Affine{}, Report{}, fmt.Errorf("anchors are degenerate; no transformation stands")110+ }111+ if round == trimRounds {112+ break113+ }114+ sort.Slice(keep, func(a, b int) bool {115+ return residual(affine, matchedDonor[keep[a]], matchedBase[keep[a]]) <116+ residual(affine, matchedDonor[keep[b]], matchedBase[keep[b]])117+ })118+ trimmed := int(float64(len(keep)) * trimKeep)119+ if trimmed < minAnchors {120+ trimmed = min(minAnchors, len(keep))121+ }122+ keep = keep[:trimmed]123+ }124+125+ residuals := make([]float64, len(keep))126+ for at, index := range keep {127+ residuals[at] = residual(affine, matchedDonor[index], matchedBase[index])128+ }129+ sort.Float64s(residuals)130+ report := Report{131+ Anchors: len(keep),132+ MedianPx: residuals[len(residuals)/2],133+ P90Px: residuals[int(float64(len(residuals))*0.9)],134+ WorstPx: residuals[len(residuals)-1],135+ }136+ if report.MedianPx > maxMedianPx {137+ return Affine{}, report, fmt.Errorf(138+ "alignment does not close: %s", report)139+ }140+ return affine, report, nil141+}142+143+func residual(a Affine, donor, base Anchor) float64 {144+ x, y := a.Apply(donor.X, donor.Y)145+ return math.Hypot(x-base.X, y-base.Y)146+}147+148+// matchByTitle pairs anchors by normalized name, using only names that149+// appear exactly once in each source: a name two pins share could pair150+// either way, and a guessed pair is a corrupted measurement.151+func matchByTitle(donor, base []Anchor) ([]Anchor, []Anchor) {152+ donorByName := uniqueByName(donor)153+ baseByName := uniqueByName(base)154+ names := make([]string, 0, len(donorByName))155+ for name := range donorByName {156+ if _, shared := baseByName[name]; shared {157+ names = append(names, name)158+ }159+ }160+ sort.Strings(names)161+ matchedDonor := make([]Anchor, len(names))162+ matchedBase := make([]Anchor, len(names))163+ for at, name := range names {164+ matchedDonor[at] = donorByName[name]165+ matchedBase[at] = baseByName[name]166+ }167+ return matchedDonor, matchedBase168+}169+170+func uniqueByName(anchors []Anchor) map[string]Anchor {171+ counts := make(map[string]int, len(anchors))172+ held := make(map[string]Anchor, len(anchors))173+ for _, anchor := range anchors {174+ name := normalizeTitle(anchor.Title)175+ if name == "" {176+ continue177+ }178+ counts[name]++179+ held[name] = anchor180+ }181+ for name, count := range counts {182+ if count > 1 {183+ delete(held, name)184+ }185+ }186+ return held187+}188+189+var (190+ fastTravelSuffix = regexp.MustCompile(`\s*[-–—]?\s*fast travel( point)?$`)191+ nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)192+)193+194+// normalizeTitle folds the spellings apart sources give one place. The fast195+// travel suffix goes because one source titles the place and another titles196+// the service at it, and they are the same anchor.197+func normalizeTitle(title string) string {198+ title = strings.ToLower(title)199+ title = fastTravelSuffix.ReplaceAllString(title, "")200+ return strings.TrimSpace(nonAlphanumeric.ReplaceAllString(title, " "))201+}202+203+// solve fits one axis at a time: three unknowns against every kept anchor,204+// by the normal equations, eliminated in place.205+func solve(donor, base []Anchor, keep []int) (Affine, bool) {206+ xRow, ok := solveAxis(donor, base, keep, func(a Anchor) float64 { return a.X })207+ if !ok {208+ return Affine{}, false209+ }210+ yRow, ok := solveAxis(donor, base, keep, func(a Anchor) float64 { return a.Y })211+ if !ok {212+ return Affine{}, false213+ }214+ return Affine{215+ AX: xRow[0], BX: xRow[1], CX: xRow[2],216+ AY: yRow[0], BY: yRow[1], CY: yRow[2],217+ }, true218+}219+220+func solveAxis(donor, base []Anchor, keep []int, target func(Anchor) float64) ([3]float64, bool) {221+ var sxx, sxy, syy, sx, sy, n float64222+ var stx, sty, st float64223+ for _, index := range keep {224+ x, y := donor[index].X, donor[index].Y225+ t := target(base[index])226+ sxx += x * x227+ sxy += x * y228+ syy += y * y229+ sx += x230+ sy += y231+ n++232+ stx += t * x233+ sty += t * y234+ st += t235+ }236+ matrix := [3][3]float64{{sxx, sxy, sx}, {sxy, syy, sy}, {sx, sy, n}}237+ vector := [3]float64{stx, sty, st}238+ for i := range 3 {239+ pivot := matrix[i][i]240+ if math.Abs(pivot) < maxConditioned {241+ return [3]float64{}, false242+ }243+ for j := i + 1; j < 3; j++ {244+ factor := matrix[j][i] / pivot245+ for k := range 3 {246+ matrix[j][k] -= factor * matrix[i][k]247+ }248+ vector[j] -= factor * vector[i]249+ }250+ }251+ var out [3]float64252+ for i := 2; i >= 0; i-- {253+ out[i] = vector[i]254+ for k := i + 1; k < 3; k++ {255+ out[i] -= matrix[i][k] * out[k]256+ }257+ out[i] /= matrix[i][i]258+ }259+ return out, true260+}@@ -0,0 +1,134 @@
1+package blend2+3+import (4+ "fmt"5+ "math"6+ "testing"7+)8+9+// truth is the kind of transformation two renderings of one map actually10+// differ by: a scale, an offset, and a whisper of shear.11+var truth = Affine{12+ AX: 1.38, BX: 0.02, CX: -1620,13+ AY: 0.005, BY: 1.41, CY: -2050,14+}15+16+// jitter is deterministic pin sloppiness: sources place the same shop a few17+// pixels apart, and the fit has to see through that without any randomness18+// in the test.19+func jitter(seed int) (float64, float64) {20+ return float64((seed*37)%17) - 8, float64((seed*53)%19) - 921+}22+23+func anchors() ([]Anchor, []Anchor) {24+ var donor, base []Anchor25+ for i := range 60 {26+ x := float64(500 + (i%10)*700)27+ y := float64(400 + (i/10)*1200)28+ donor = append(donor, Anchor{Title: fmt.Sprintf("Place %d", i), X: x, Y: y})29+ bx, by := truth.Apply(x, y)30+ jx, jy := jitter(i)31+ base = append(base, Anchor{Title: fmt.Sprintf("place %d", i), X: bx + jx, Y: by + jy})32+ }33+ return donor, base34+}35+36+func TestFitRecoversTheTransformation(t *testing.T) {37+ donor, base := anchors()38+ affine, report, err := Fit(donor, base)39+ if err != nil {40+ t.Fatal(err)41+ }42+ // The fit sees jittered pins, so it recovers the truth to within the43+ // jitter's reach, not exactly.44+ if math.Abs(affine.AX-truth.AX) > 0.01 || math.Abs(affine.BY-truth.BY) > 0.01 {45+ t.Fatalf("scale drifted: %+v", affine)46+ }47+ if math.Abs(affine.CX-truth.CX) > 25 || math.Abs(affine.CY-truth.CY) > 25 {48+ t.Fatalf("offset drifted: %+v", affine)49+ }50+ if report.MedianPx > 15 {51+ t.Fatalf("fit did not close: %s", report)52+ }53+54+ again, _, err := Fit(donor, base)55+ if err != nil || again != affine {56+ t.Fatalf("fit is not deterministic: %+v then %+v (%v)", affine, again, err)57+ }58+}59+60+func TestFitShedsOutliers(t *testing.T) {61+ donor, base := anchors()62+ // A handful of pins one source simply misplaced.63+ for i := 0; i < 5; i++ {64+ base[i*7].X += 90065+ base[i*7].Y -= 70066+ }67+ affine, report, err := Fit(donor, base)68+ if err != nil {69+ t.Fatal(err)70+ }71+ if math.Abs(affine.AX-truth.AX) > 0.02 {72+ t.Fatalf("outliers pulled the fit: %+v", affine)73+ }74+ if report.WorstPx > 60 {75+ t.Fatalf("an outlier survived the trim: %s", report)76+ }77+}78+79+func TestFitRefusesThinEvidence(t *testing.T) {80+ donor, base := anchors()81+ if _, _, err := Fit(donor[:5], base[:5]); err == nil {82+ t.Fatal("five anchors were enough for a transformation")83+ }84+85+ // Ambiguous names carry no evidence: every anchor named alike must drop,86+ // leaving too few.87+ for i := range donor {88+ if i >= 10 {89+ donor[i].Title = "Duplicate"90+ base[i].Title = "Duplicate"91+ }92+ }93+ if _, _, err := Fit(donor, base); err == nil {94+ t.Fatal("ambiguous names were treated as calibration points")95+ }96+}97+98+func TestFitRefusesNonsense(t *testing.T) {99+ donor, base := anchors()100+ // The same names placed with no geometric relationship at all.101+ for i := range base {102+ base[i].X = float64((i * 811) % 8000)103+ base[i].Y = float64((i * 977) % 8000)104+ }105+ if _, _, err := Fit(donor, base); err == nil {106+ t.Fatal("a fit that cannot close was still offered")107+ }108+}109+110+func TestNormalizeTitleFoldsFastTravelSpellings(t *testing.T) {111+ cases := map[string]string{112+ "Kennedy North - Fast Travel Point": "kennedy north",113+ "Kennedy North Fast Travel": "kennedy north",114+ "KENNEDY NORTH": "kennedy north",115+ "Skippy, The Talking Gun ": "skippy the talking gun",116+ }117+ for given, want := range cases {118+ if got := normalizeTitle(given); got != want {119+ t.Errorf("normalizeTitle(%q) = %q, want %q", given, got, want)120+ }121+ }122+}123+124+func TestInvertRoundTrips(t *testing.T) {125+ inverse, ok := truth.Invert()126+ if !ok {127+ t.Fatal("the truth is singular")128+ }129+ x, y := truth.Apply(1234, 5678)130+ backX, backY := inverse.Apply(x, y)131+ if math.Abs(backX-1234) > 1e-6 || math.Abs(backY-5678) > 1e-6 {132+ t.Fatalf("round trip landed at %.6f,%.6f", backX, backY)133+ }134+}@@ -178,7 +178,10 @@ func mapTitle(capture *Capture) string {
178178 // measured against the MapGenie window this map does not sit in.179179 func tileSet(capture *Capture, scope string) mgdoc.TileSet {180180 set := mgdoc.TileSet{181- Name: "Default",181+ // The name rides into the legend's layer picker -- and, when this182+ // raster is resampled into a finer source's map, it names the183+ // variant there too, so it says where the picture came from.184+ Name: "IGN Wiki",182185 Path: scope,183186 MinZoom: 0,184187 MaxZoom: capture.Map.MaxZoom,@@ -108,6 +108,23 @@ func SyntheticLatitude(y float64) float64 {
108108 return math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi109109 }110110 111+// ProjectX and ProjectY are the viewer's projection, parameterised by the112+// window a map is cut from: the inverse of the synthetic coordinates above113+// for the shared window, and of MapGenie's own for every other. They exist114+// here so anything comparing two sources' pins can land both in pixels115+// without carrying its own copy of the formula.116+func ProjectX(longitude float64, sourceZoom, firstTile int) float64 {117+ worldTiles := math.Pow(2, float64(sourceZoom))118+ xTile := ((longitude + 180) / 360) * worldTiles119+ return (xTile - float64(firstTile)) * TileSize120+}121+122+func ProjectY(latitude float64, sourceZoom, firstTile int) float64 {123+ worldTiles := math.Pow(2, float64(sourceZoom))124+ yTile := (1 - math.Asinh(math.Tan(latitude*math.Pi/180))/math.Pi) / 2 * worldTiles125+ return (yTile - float64(firstTile)) * TileSize126+}127+111128 // IDSpace hands out the numeric identifiers the pipeline expects, derived112129 // from the stable names a source publishes, so the same capture always113130 // numbers itself the same way. Identifiers stay within a positive int31 --@@ -224,6 +224,11 @@ type tileVariantManifest struct {
224224 Interpolate bool `json:"interpolate"`225225 Background string `json:"background"`226226 Coverage map[string]*levelCoverage `json:"coverage"`227+ // Name and AlignedWith mark a pyramid another source's raster was228+ // resampled into this map's world from: it attaches as an additional229+ // variant of whichever map draws the layer AlignedWith names.230+ Name string `json:"name"`231+ AlignedWith string `json:"alignedWith"`227232 }228233 229234 type catalogGroup struct {@@ -351,7 +356,12 @@ func run(source, tileManifestPath, bundleDir string) error {
351356 return err352357 }353358 tilesByPath := make(map[string]tileVariantManifest, len(tiles.Variants))359+ alignedWith := make(map[string][]tileVariantManifest)354360 for _, variant := range tiles.Variants {361+ if variant.AlignedWith != "" {362+ alignedWith[variant.AlignedWith] = append(alignedWith[variant.AlignedWith], variant)363+ continue364+ }355365 tilesByPath[variant.SourcePath] = variant356366 }357367 @@ -364,7 +374,7 @@ func run(source, tileManifestPath, bundleDir string) error {
364374 },365375 }366376 for _, gameRef := range index.Games {367- game, err := buildGame(archiveRoot, tilesByPath, gameRef, out.TileGrid)377+ game, err := buildGame(archiveRoot, tilesByPath, alignedWith, gameRef, out.TileGrid)368378 if err != nil {369379 return fmt.Errorf("%s: %w", gameRef.Title, err)370380 }@@ -380,6 +390,7 @@ func run(source, tileManifestPath, bundleDir string) error {
380390 func buildGame(381391 archiveRoot string,382392 tilesByPath map[string]tileVariantManifest,393+ alignedWith map[string][]tileVariantManifest,383394 ref archiveGame,384395 grid tileGrid,385396 ) (catalogGame, error) {@@ -394,7 +405,7 @@ func buildGame(
394405 if err != nil || !info.IsDir() {395406 continue396407 }397- pieces, gameSlug, err := buildMap(mapDir, tilesByPath, grid)408+ pieces, gameSlug, err := buildMap(mapDir, tilesByPath, alignedWith, grid)398409 if err != nil {399410 if errors.Is(err, errMapNotReady) {400411 continue@@ -456,6 +467,7 @@ func sortGameMaps(gameSlug string, maps []catalogMap) {
456467 func buildMap(457468 mapDir string,458469 tilesByPath map[string]tileVariantManifest,470+ alignedWith map[string][]tileVariantManifest,459471 grid tileGrid,460472 ) ([]catalogMap, string, error) {461473 var snapshots []snapshotIndex@@ -541,6 +553,29 @@ func buildMap(
541553 Coverage: tiles.Coverage,542554 })543555 }556+ // Another source's raster, resampled into this map's world, arrives as557+ // one more way to see the same ground. It was rendered in this map's558+ // window, so it passes the same agreement every native layer passes.559+ for _, set := range raw.Config.TileSets {560+ for _, aligned := range alignedWith[set.Path] {561+ if aligned.Grid.SourceZoom != grid.SourceZoom || aligned.Grid.FirstTile != grid.FirstTile {562+ return nil, "", fmt.Errorf("aligned layer %s was rendered in a different window", aligned.SourcePath)563+ }564+ m.Variants = append(m.Variants, variant{565+ Name: aligned.Name,566+ Tiles: aligned.AssetPath,567+ MinZoom: aligned.MinZoom,568+ MaxZoom: aligned.MaxZoom,569+ FullZoom: aligned.FullZoom,570+ SourceZoom: aligned.SourceZoom,571+ Formats: aligned.Formats,572+ Bounds: aligned.Bounds,573+ Interpolate: aligned.Interpolate,574+ Background: aligned.Background,575+ Coverage: aligned.Coverage,576+ })577+ }578+ }544579 for _, rawGroup := range raw.Groups {545580 group := catalogGroup{ID: rawGroup.ID, Title: rawGroup.Title}546581 for _, rawCategory := range rawGroup.Categories {@@ -90,6 +90,7 @@ func TestBuildGameSkipsMapWithoutSnapshotIndex(t *testing.T) {
9090 game, err := buildGame(9191 archiveRoot,9292 nil,93+ nil,9394 archiveGame{9495 Directory: "games/pokemon-red-blue-yellow-246",9596 ID: 246,@@ -26,7 +26,9 @@ import (
2626 // none, so levels derived from them are written as JPEG or PNG.2727 _ "golang.org/x/image/webp"2828 29+ "github.com/FelineStateMachine/atlas/internal/blend"2930 "github.com/FelineStateMachine/atlas/internal/ignmap"31+ "github.com/FelineStateMachine/atlas/internal/mgdoc"3032 "github.com/FelineStateMachine/atlas/internal/pbmap"3133 )3234 @@ -111,10 +113,26 @@ type snapshotIndex struct {
111113 }112114 113115 type rawMap struct {114- Title string `json:"title"`115- Slug string `json:"slug"`116- Config rawConfig `json:"config"`117- Game rawGame `json:"game"`116+ Title string `json:"title"`117+ Slug string `json:"slug"`118+ Config rawConfig `json:"config"`119+ Game rawGame `json:"game"`120+ Groups []rawGroup `json:"groups"`121+}122+123+type rawGroup struct {124+ Categories []struct {125+ Locations []rawLocation `json:"locations"`126+ } `json:"categories"`127+}128+129+// rawLocation reads only what alignment needs: a name and a place. The130+// coordinates arrive as numbers from translated captures and as either131+// numbers or quoted strings from MapGenie's own snapshots.132+type rawLocation struct {133+ Title string `json:"title"`134+ Latitude json.RawMessage `json:"latitude"`135+ Longitude json.RawMessage `json:"longitude"`118136 }119137 120138 type rawGame struct {@@ -174,6 +192,32 @@ type tilePlan struct {
174192 MaxSourceZoom int175193 SourcePath string176194 PreferredFormat string195+ // The map this layer pictures, and the places its capture pins on it.196+ // Two sources capturing the same game and map are pictures of the same197+ // ground, and the shared names among their anchors are what align them.198+ GameSlug string199+ MapSlug string200+ SetName string201+ Anchors []blend.Anchor202+ // Warp, when set, makes this a derived plan: the donor layer resampled203+ // into another layer's world so both rasters answer to one grid.204+ Warp *warpSpec205+}206+207+// warpSpec says how a donor layer lands in a base layer's world.208+type warpSpec struct {209+ Donor *tilePlan210+ Base *tilePlan211+ Affine blend.Affine212+ TargetZoom int // local zoom in the base frame the warp renders at213+}214+215+// pendingPlan is one layer waiting to be derived, with the names its216+// progress is reported under.217+type pendingPlan struct {218+ gameTitle string219+ title string220+ plan tilePlan177221 }178222 179223 type manifest struct {@@ -205,6 +249,12 @@ type variantManifest struct {
205249 // tiles and the tool that reduced them -- so a later run can tell that206250 // nothing has moved and keep what it already built.207251 Stamp string `json:"stamp,omitempty"`252+ // Name and AlignedWith mark a warped variant: a donor layer resampled253+ // into the world of the layer AlignedWith names, offered to the catalog254+ // as an additional way to see that map. Ordinary layers leave both empty255+ // and take their name from their map's own configuration.256+ Name string `json:"name,omitempty"`257+ AlignedWith string `json:"alignedWith,omitempty"`208258 }209259 210260 type gridSpec struct {@@ -308,11 +358,6 @@ func run(source, output string, force bool) error {
308358 var carried int309359 derived := make(map[string]bool)310360 311- type pendingPlan struct {312- gameTitle string313- title string314- plan tilePlan315- }316361 var pending []pendingPlan317362 for _, game := range index.Games {318363 mapDirs, err := filepath.Glob(filepath.Join(source, game.Directory, "maps", "*"))@@ -360,6 +405,8 @@ func run(source, output string, force bool) error {
360405 }361406 }362407 408+ pending = append(pending, planWarps(pending)...)409+363410 out := manifest{TileSize: tileSize, Size: worldSize}364411 for _, entry := range pending {365412 plan := entry.plan@@ -368,13 +415,23 @@ func run(source, output string, force bool) error {
368415 // The window is read from the archive rather than derived369416 // from the pyramid, so it is answered from the plan even370417 // for a layer nothing has changed under.371- kept.Grid = plan.Frame.grid()418+ if plan.Warp == nil {419+ kept.Grid = plan.Frame.grid()420+ } else {421+ kept.Grid = plan.Warp.Base.Frame.grid()422+ }372423 out.Variants = append(out.Variants, kept)373424 carried++374425 continue375426 }376427 fmt.Printf("tile %s / %s / %s\n", entry.gameTitle, entry.title, plan.SourcePath)377- variant, err := buildPyramid(temp, plan)428+ var variant variantManifest429+ var err error430+ if plan.Warp == nil {431+ variant, err = buildPyramid(temp, plan)432+ } else {433+ variant, err = buildWarpedPyramid(temp, plan)434+ }378435 if err != nil {379436 return fmt.Errorf("%s / %s: %w", entry.title, plan.SourcePath, err)380437 }@@ -567,11 +624,54 @@ func inspectMap(mapDir string) ([]tilePlan, string, string, error) {
567624 MaxSourceZoom: maxSourceZoom,568625 PreferredFormat: normalizeFormat(set.Extension),569626 SourcePath: set.Path,627+ GameSlug: raw.Game.Slug,628+ MapSlug: raw.Slug,629+ SetName: set.Name,630+ Anchors: anchorsOf(raw, frame.grid()),570631 })571632 }572633 return plans, raw.Title, "", nil573634 }574635 636+// anchorsOf lands every named location in the layer's own world pixels.637+// These are the places alignment stands on when another source pictures the638+// same map.639+func anchorsOf(raw rawMap, grid gridSpec) []blend.Anchor {640+ var anchors []blend.Anchor641+ for _, group := range raw.Groups {642+ for _, category := range group.Categories {643+ for _, location := range category.Locations {644+ latitude, ok := numberValue(location.Latitude)645+ if !ok {646+ continue647+ }648+ longitude, ok := numberValue(location.Longitude)649+ if !ok {650+ continue651+ }652+ anchors = append(anchors, blend.Anchor{653+ Title: location.Title,654+ X: mgdoc.ProjectX(longitude, grid.SourceZoom, grid.FirstTile),655+ Y: mgdoc.ProjectY(latitude, grid.SourceZoom, grid.FirstTile),656+ })657+ }658+ }659+ }660+ return anchors661+}662+663+// numberValue reads the coordinate spellings the snapshots hold: a number,664+// or the same number in quotes.665+func numberValue(raw json.RawMessage) (float64, bool) {666+ trimmed := strings.TrimSpace(string(raw))667+ trimmed = strings.Trim(trimmed, `"`)668+ if trimmed == "" || trimmed == "null" {669+ return 0, false670+ }671+ value, err := strconv.ParseFloat(trimmed, 64)672+ return value, err == nil673+}674+575675 // readLevel collects every cached tile of one source level. It reports whether576676 // the level covers its expected window completely; partial levels are still577677 // returned so they can be carried as extra detail above the complete pyramid.@@ -23,7 +23,7 @@ import (
2323 // where the content bounds land has to invalidate every pyramid, and a stamp2424 // that only watched the archive would quietly keep serving the old derivation.2525 //26-//go:embed main.go stamp.go26+//go:embed main.go stamp.go warp.go2727 var toolSource embed.FS2828 2929 var toolStamp = sync.OnceValue(func() string {@@ -64,6 +64,17 @@ func planStamp(plan tilePlan) string {
6464 fmt.Fprintf(sum, "bounds\x00%d\x00%d\x00%d\x00%d\x00",6565 plan.Bounds.X, plan.Bounds.Y, plan.Bounds.Width, plan.Bounds.Height)6666 }67+ // A warped pyramid is derived from its alignment as much as from its68+ // tiles: the same donor resampled through a different transformation is69+ // a different picture.70+ if plan.Warp != nil {71+ fmt.Fprintf(sum, "warp\x00%s\x00%d\x00%d\x00%d\x00",72+ plan.Warp.Base.SourcePath, plan.Warp.TargetZoom,73+ plan.Warp.Base.Frame.BaseZoom, plan.Warp.Base.Frame.BaseTile)74+ affine := plan.Warp.Affine75+ fmt.Fprintf(sum, "%.9f\x00%.9f\x00%.9f\x00%.9f\x00%.9f\x00%.9f\x00",76+ affine.AX, affine.BX, affine.CX, affine.AY, affine.BY, affine.CY)77+ }6778 6879 zooms := make([]int, 0, len(plan.Levels))6980 for zoom := range plan.Levels {@@ -0,0 +1,404 @@
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.9+package main10+11+import (12+ "fmt"13+ "image"14+ "image/color"15+ "math"16+ "sort"17+ "strconv"18+19+ "github.com/FelineStateMachine/atlas/internal/blend"20+)21+22+// planWarps finds every map captured by more than one source and plans the23+// lesser layers' resampling into the finest one's world. Plans arrive after24+// asset paths are settled, so the warped names stay stable and unique.25+func planWarps(pending []pendingPlan) []pendingPlan {26+ // One representative layer per capture: a map directory is one source's27+ // account of one map, and its deepest layer is the picture worth28+ // aligning. Further layers of the same capture are alternate art of the29+ // same world, already aligned by their shared window.30+ deepestByDir := make(map[string]int)31+ for index, entry := range pending {32+ held, ok := deepestByDir[entry.plan.MapDir]33+ if !ok || worldDepth(entry.plan) > worldDepth(pending[held].plan) {34+ deepestByDir[entry.plan.MapDir] = index35+ }36+ }37+ byMap := make(map[[2]string][]int)38+ var order [][2]string39+ for _, index := range deepestByDir {40+ key := [2]string{pending[index].plan.GameSlug, pending[index].plan.MapSlug}41+ if _, seen := byMap[key]; !seen {42+ order = append(order, key)43+ }44+ byMap[key] = append(byMap[key], index)45+ }46+ sort.Slice(order, func(a, b int) bool {47+ if order[a][0] != order[b][0] {48+ return order[a][0] < order[b][0]49+ }50+ return order[a][1] < order[b][1]51+ })52+53+ var warps []pendingPlan54+ for _, key := range order {55+ group := byMap[key]56+ if len(group) < 2 {57+ continue58+ }59+ base := group[0]60+ for _, candidate := range group[1:] {61+ if worldDepth(pending[candidate].plan) > worldDepth(pending[base].plan) {62+ base = candidate63+ }64+ }65+ for _, donor := range group {66+ if donor == base {67+ continue68+ }69+ warp, ok := planWarp(&pending[base].plan, &pending[donor].plan, pending[donor].gameTitle)70+ if ok {71+ warps = append(warps, warp)72+ }73+ }74+ }75+ return warps76+}77+78+// worldDepth is the finest resolution a layer draws its world at, in pixels79+// across its own square.80+func worldDepth(plan tilePlan) int {81+ return tileSize * (1 << (plan.MaxFullZoom - plan.Frame.BaseZoom))82+}83+84+func planWarp(base, donor *tilePlan, donorGameTitle string) (pendingPlan, bool) {85+ affine, report, err := blend.Fit(donor.Anchors, base.Anchors)86+ if err != nil {87+ fmt.Printf("skip aligning %s onto %s: %v\n", donor.SourcePath, base.SourcePath, err)88+ return pendingPlan{}, false89+ }90+91+ // The warp renders at the base-frame zoom nearest the donor's real92+ // resolution: deep enough to keep what the donor drew, never so deep as93+ // to invent detail that was never there.94+ effective := float64(worldDepth(*donor)) * affine.Scale()95+ targetZoom := int(math.Round(math.Log2(effective / tileSize)))96+ targetZoom = max(0, min(targetZoom, base.MaxFullZoom-base.Frame.BaseZoom))97+98+ fmt.Printf("align %s onto %s: %s, rendered at z%d\n",99+ donor.SourcePath, base.SourcePath, report, targetZoom)100+101+ warped := tilePlan{102+ AssetPath: base.AssetPath + "__aligned-" + slugify(donor.SourcePath),103+ SourcePath: donor.SourcePath + "=>" + base.SourcePath,104+ Frame: base.Frame,105+ Interpolate: true,106+ MapDir: donor.MapDir,107+ MaxFullZoom: targetZoom + base.Frame.BaseZoom,108+ MaxSourceZoom: targetZoom + base.Frame.BaseZoom,109+ PreferredFormat: "jpg",110+ GameSlug: base.GameSlug,111+ MapSlug: base.MapSlug,112+ SetName: donor.SetName,113+ // The donor's deepest complete level is what the warp samples, and114+ // listing it here is what lets the stamp notice when those tiles115+ // change.116+ Levels: map[int][]tileFile{donor.MaxFullZoom: donor.Levels[donor.MaxFullZoom]},117+ Warp: &warpSpec{118+ Donor: donor,119+ Base: base,120+ Affine: affine,121+ TargetZoom: targetZoom,122+ },123+ }124+ return pendingPlan{gameTitle: donorGameTitle, title: base.MapSlug, plan: warped}, true125+}126+127+// buildWarpedPyramid renders the donor through the affine into the base128+// frame at the target zoom, then folds the shallower levels down exactly as129+// every other pyramid is folded.130+func buildWarpedPyramid(root string, plan tilePlan) (variantManifest, error) {131+ warp := plan.Warp132+ donor := warp.Donor133+ donorFiles := donor.Levels[donor.MaxFullZoom]134+135+ // The donor's own background paints everywhere the donor never drew.136+ placeholder := placeholderHash(donorFiles)137+ backgroundHex, err := placeholderColor(donorFiles, placeholder)138+ if err != nil {139+ return variantManifest{}, err140+ }141+ background := parseHexColor(backgroundHex)142+143+ sampler, err := newDonorSampler(donor, background)144+ if err != nil {145+ return variantManifest{}, err146+ }147+148+ // Where the donor's content lands in the base world.149+ bounds := warpedBounds(donor, warp.Affine)150+ window := tileWindowFor(bounds, warp.TargetZoom)151+152+ targetZoom := warp.TargetZoom153+ formats := make([]string, targetZoom+1)154+ coverage := make(map[string]*levelCoverage)155+ builder := &coverageBuilder{total: (1 << targetZoom) * (1 << targetZoom)}156+ inverse, ok := warp.Affine.Invert()157+ if !ok {158+ return variantManifest{}, fmt.Errorf("alignment is singular")159+ }160+ // Base world pixels per rendered pixel at the target zoom.161+ levelPixels := tileSize * (1 << targetZoom)162+ span := float64(worldSize) / float64(levelPixels)163+164+ for tileY := window[1]; tileY <= window[3]; tileY++ {165+ for tileX := window[0]; tileX <= window[2]; tileX++ {166+ img := image.NewNRGBA(image.Rect(0, 0, tileSize, tileSize))167+ drewAny := false168+ for v := 0; v < tileSize; v++ {169+ for u := 0; u < tileSize; u++ {170+ baseX := (float64(tileX*tileSize+u) + 0.5) * span171+ baseY := (float64(tileY*tileSize+v) + 0.5) * span172+ donorX, donorY := inverse.Apply(baseX, baseY)173+ sampled, drew := sampler.sample(donorX, donorY)174+ if drew {175+ drewAny = true176+ }177+ img.SetNRGBA(u, v, sampled)178+ }179+ }180+ if !drewAny {181+ continue182+ }183+ path := tilePath(root, plan.AssetPath, targetZoom, tileX, tileY, "jpg")184+ if err := encodeImage(path, img, "jpg"); err != nil {185+ return variantManifest{}, err186+ }187+ builder.mark(tileX, tileY)188+ }189+ }190+ formats[targetZoom] = "jpg"191+ if mask := builder.build(); mask != nil {192+ coverage[itoa(targetZoom)] = mask193+ }194+195+ for localZoom := targetZoom - 1; localZoom >= 0; localZoom-- {196+ mask, err := deriveLevel(root, plan.AssetPath, localZoom, formats[localZoom+1], "jpg", false, background)197+ if err != nil {198+ return variantManifest{}, err199+ }200+ formats[localZoom] = "jpg"201+ if mask != nil {202+ coverage[itoa(localZoom)] = mask203+ }204+ }205+ if len(coverage) == 0 {206+ coverage = nil207+ }208+209+ return variantManifest{210+ SourcePath: plan.SourcePath,211+ AssetPath: plan.AssetPath,212+ MinZoom: 0,213+ MaxZoom: targetZoom,214+ FullZoom: targetZoom,215+ SourceZoom: targetZoom + warp.Base.Frame.BaseZoom,216+ Grid: warp.Base.Frame.grid(),217+ Formats: formats,218+ Bounds: bounds,219+ Interpolate: true,220+ Background: backgroundHex,221+ Coverage: coverage,222+ Name: plan.SetName,223+ AlignedWith: warp.Base.SourcePath,224+ }, nil225+}226+227+// warpedBounds sends the donor's content box through the alignment and clips228+// it to the base world.229+func warpedBounds(donor *tilePlan, affine blend.Affine) *contentBounds {230+ box := donor.Bounds231+ if box == nil {232+ box = &contentBounds{X: 0, Y: 0, Width: worldSize, Height: worldSize}233+ }234+ corners := [4][2]float64{235+ {float64(box.X), float64(box.Y)},236+ {float64(box.X + box.Width), float64(box.Y)},237+ {float64(box.X), float64(box.Y + box.Height)},238+ {float64(box.X + box.Width), float64(box.Y + box.Height)},239+ }240+ minX, minY := math.Inf(1), math.Inf(1)241+ maxX, maxY := math.Inf(-1), math.Inf(-1)242+ for _, corner := range corners {243+ x, y := affine.Apply(corner[0], corner[1])244+ minX, maxX = math.Min(minX, x), math.Max(maxX, x)245+ minY, maxY = math.Min(minY, y), math.Max(maxY, y)246+ }247+ minX, minY = math.Max(minX, 0), math.Max(minY, 0)248+ maxX, maxY = math.Min(maxX, worldSize), math.Min(maxY, worldSize)249+ if maxX <= minX || maxY <= minY {250+ return &contentBounds{}251+ }252+ return &contentBounds{253+ X: int(minX),254+ Y: int(minY),255+ Width: int(math.Ceil(maxX)) - int(minX),256+ Height: int(math.Ceil(maxY)) - int(minY),257+ }258+}259+260+func tileWindowFor(bounds *contentBounds, zoom int) [4]int {261+ levelPixels := tileSize * (1 << zoom)262+ span := float64(worldSize) / float64(levelPixels)263+ last := (1 << zoom) - 1264+ return [4]int{265+ max(0, int(float64(bounds.X)/span/tileSize)),266+ max(0, int(float64(bounds.Y)/span/tileSize)),267+ min(last, int(float64(bounds.X+bounds.Width)/span/tileSize)),268+ min(last, int(float64(bounds.Y+bounds.Height)/span/tileSize)),269+ }270+}271+272+// donorSampler reads the donor's deepest complete level as one continuous273+// picture, decoding tiles as the warp first touches them and holding a274+// bounded number decoded.275+type donorSampler struct {276+ files map[[2]int]string277+ decoded map[[2]int]*image.NRGBA278+ order [][2]int279+ origin int280+ pixelScale float64 // donor level pixels per donor world pixel281+ levelSpan int // donor level pixels across the level282+ background color.NRGBA283+}284+285+const donorCacheTiles = 256286+287+func newDonorSampler(donor *tilePlan, background color.Color) (*donorSampler, error) {288+ files := make(map[[2]int]string)289+ for _, file := range donor.Levels[donor.MaxFullZoom] {290+ files[[2]int{file.Record.X, file.Record.Y}] = file.Path291+ }292+ if len(files) == 0 {293+ return nil, fmt.Errorf("donor %s has no deepest level", donor.SourcePath)294+ }295+ r, g, b, _ := background.RGBA()296+ return &donorSampler{297+ files: files,298+ decoded: make(map[[2]int]*image.NRGBA),299+ origin: donor.Frame.origin(donor.MaxFullZoom),300+ pixelScale: math.Pow(2, float64(donor.MaxFullZoom-donor.Frame.BaseZoom-worldZoomLevels)),301+ levelSpan: tileSize * (1 << (donor.MaxFullZoom - donor.Frame.BaseZoom)),302+ background: color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff},303+ }, nil304+}305+306+// sample reads the donor at a world coordinate, bilinearly, clamped within307+// the containing tile. The second result says whether the donor drew here at308+// all -- outside the level, or over a tile the capture never held, it did309+// not, and the background stands in.310+func (s *donorSampler) sample(worldX, worldY float64) (color.NRGBA, bool) {311+ pixelX := worldX * s.pixelScale312+ pixelY := worldY * s.pixelScale313+ if pixelX < 0 || pixelY < 0 || pixelX >= float64(s.levelSpan) || pixelY >= float64(s.levelSpan) {314+ return s.background, false315+ }316+ tileX := int(pixelX) / tileSize317+ tileY := int(pixelY) / tileSize318+ img, ok := s.tile(tileX, tileY)319+ if !ok {320+ return s.background, false321+ }322+ localX := pixelX - float64(tileX*tileSize) - 0.5323+ localY := pixelY - float64(tileY*tileSize) - 0.5324+ x0 := clampInt(int(math.Floor(localX)), 0, tileSize-1)325+ y0 := clampInt(int(math.Floor(localY)), 0, tileSize-1)326+ x1 := clampInt(x0+1, 0, tileSize-1)327+ y1 := clampInt(y0+1, 0, tileSize-1)328+ fx := clampFloat(localX-float64(x0), 0, 1)329+ fy := clampFloat(localY-float64(y0), 0, 1)330+331+ blendChannel := func(c00, c10, c01, c11 uint8) uint8 {332+ top := float64(c00)*(1-fx) + float64(c10)*fx333+ bottom := float64(c01)*(1-fx) + float64(c11)*fx334+ return uint8(top*(1-fy) + bottom*fy + 0.5)335+ }336+ p00 := img.NRGBAAt(x0, y0)337+ p10 := img.NRGBAAt(x1, y0)338+ p01 := img.NRGBAAt(x0, y1)339+ p11 := img.NRGBAAt(x1, y1)340+ return color.NRGBA{341+ R: blendChannel(p00.R, p10.R, p01.R, p11.R),342+ G: blendChannel(p00.G, p10.G, p01.G, p11.G),343+ B: blendChannel(p00.B, p10.B, p01.B, p11.B),344+ A: 0xff,345+ }, true346+}347+348+func (s *donorSampler) tile(x, y int) (*image.NRGBA, bool) {349+ key := [2]int{x + s.origin, y + s.origin}350+ if img, held := s.decoded[key]; held {351+ return img, img != nil352+ }353+ path, exists := s.files[key]354+ if !exists {355+ s.remember(key, nil)356+ return nil, false357+ }358+ decoded, err := decodeImage(path)359+ if err != nil {360+ s.remember(key, nil)361+ return nil, false362+ }363+ img := asNRGBA(decoded)364+ s.remember(key, img)365+ return img, true366+}367+368+func (s *donorSampler) remember(key [2]int, img *image.NRGBA) {369+ if len(s.order) >= donorCacheTiles {370+ delete(s.decoded, s.order[0])371+ s.order = s.order[1:]372+ }373+ s.decoded[key] = img374+ s.order = append(s.order, key)375+}376+377+func asNRGBA(img image.Image) *image.NRGBA {378+ if typed, ok := img.(*image.NRGBA); ok {379+ return typed380+ }381+ bounds := img.Bounds()382+ out := image.NewNRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))383+ for y := bounds.Min.Y; y < bounds.Max.Y; y++ {384+ for x := bounds.Min.X; x < bounds.Max.X; x++ {385+ r, g, b, _ := img.At(x, y).RGBA()386+ out.SetNRGBA(x-bounds.Min.X, y-bounds.Min.Y, color.NRGBA{387+ R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff,388+ })389+ }390+ }391+ return out392+}393+394+func clampInt(value, low, high int) int {395+ return max(low, min(high, value))396+}397+398+func clampFloat(value, low, high float64) float64 {399+ return math.Max(low, math.Min(high, value))400+}401+402+func itoa(value int) string {403+ return strconv.Itoa(value)404+}