first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
// Package fog 解析与生成世界迷雾(Fog of World)的快照数据。
//
// 快照是一个 zip,其中 Model/*/ 下的每个文件是一块「瓦片」:全球 512x512 块瓦片,
// 每块瓦片 128x128 个块,每块是 64x64 的位图,一个位对应全球 2^22 x 2^22 网格里
// 约 9.55m*cos(纬度) 宽的一格。网格走网页墨卡托,经纬度即 WGS-84(已实测确认)。
// 文件内容 zlib 压缩:头 32768 字节是 16384 个 uint16(LE),值 = 该块在块体里的
// 序号(1 基,0 表示无数据);块体每块 512 字节位图 + 3 字节附加(地区码与置位校验)。
package fog
import (
"archive/zip"
"bytes"
"compress/zlib"
"crypto/md5"
"encoding/binary"
"fmt"
"io"
"math"
"path"
"strconv"
"strings"
)
const (
// MapTiles 全球瓦片数(每边)。
MapTiles = 512
// TileBlocks 每块瓦片的块数(每边)。
TileBlocks = 128
// BlockCells 每块的格子数(每边)。
BlockCells = 64
// WorldCells 全球格子数(每边),2^22。
WorldCells = MapTiles * TileBlocks * BlockCells
bitmapSize = BlockCells * BlockCells / 8 // 512
extraSize = 3
blockSize = bitmapSize + extraSize // 515
headerLen = TileBlocks * TileBlocks
headerSize = headerLen * 2 // 32768
mask1 = "olhwjsktri"
mask2 = "eizxdwknmo"
)
// Block 是一片 64x64 的已探索位图。GX/GY 为全局块坐标(0..65535),
// Bitmap 固定 512 字节,行优先、每行 8 字节、字节内高位在前。
type Block struct {
GX, GY int
Bitmap []byte
}
// CellCount 返回位图里的置位数(已探索格子数)。
func (b *Block) CellCount() int {
n := 0
for _, v := range b.Bitmap {
n += int(popcount[v])
}
return n
}
var popcount = func() [256]byte {
var t [256]byte
for i := range t {
t[i] = byte(strings.Count(strconv.FormatUint(uint64(i), 2), "1"))
}
return t
}()
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
func tileIDFromName(name string) (int, bool) {
if len(name) < 7 || len(name[4:]) < 3 {
return 0, false
}
digits := make([]byte, 0, len(name))
for _, c := range name[4 : len(name)-2] {
v := strings.IndexRune(mask1, c)
if v < 0 {
return 0, false
}
digits = append(digits, byte('0'+v))
}
id, err := strconv.Atoi(string(digits))
if err != nil {
return 0, false
}
s := strconv.Itoa(id)
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
return 0, false
}
tail := name[len(name)-2:]
for i := 0; i < 2; i++ {
if int(s[len(s)-2+i]-'0') != strings.IndexByte(mask2, tail[i]) {
return 0, false
}
}
return id, true
}
// tileName 把瓦片编号编码回文件名。
func tileName(id int) string {
s := strconv.Itoa(id)
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
var b strings.Builder
b.WriteString(sum[:4])
for i := 0; i < len(s); i++ {
b.WriteByte(mask1[s[i]-'0'])
}
for i := len(s) - 2; i < len(s); i++ {
b.WriteByte(mask2[s[i]-'0'])
}
return b.String()
}
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
func Decode(data []byte) ([]Block, int, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, 0, fmt.Errorf("打开迷雾快照: %w", err)
}
merged := map[[2]int][]byte{}
cells := 0
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
id, ok := tileIDFromName(path.Base(f.Name))
if !ok {
continue
}
rc, err := f.Open()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
raw, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
}
zdata, err := zlibDecompress(raw)
if err != nil {
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
}
if len(zdata) < headerSize {
continue
}
tx, ty := id%MapTiles, id/MapTiles
for i := 0; i < headerLen; i++ {
idx := int(binary.LittleEndian.Uint16(zdata[2*i:]))
if idx == 0 {
continue
}
off := headerSize + (idx-1)*blockSize
if off+blockSize > len(zdata) {
return nil, 0, fmt.Errorf("%s: 块体越界", f.Name)
}
bm := make([]byte, bitmapSize)
copy(bm, zdata[off:off+bitmapSize])
key := [2]int{tx*TileBlocks + i%TileBlocks, ty*TileBlocks + i/TileBlocks}
if old, dup := merged[key]; dup {
for j := range bm {
old[j] |= bm[j]
}
continue
}
merged[key] = bm
cells += popcountSum(bm)
}
}
blocks := make([]Block, 0, len(merged))
for k, bm := range merged {
blocks = append(blocks, Block{GX: k[0], GY: k[1], Bitmap: bm})
}
sortBlocks(blocks)
return blocks, cells, nil
}
func popcountSum(bm []byte) int {
n := 0
for _, v := range bm {
n += int(popcount[v])
}
return n
}
func sortBlocks(bs []Block) {
for i := 1; i < len(bs); i++ {
for j := i; j > 0 && (bs[j-1].GY > bs[j].GY || (bs[j-1].GY == bs[j].GY && bs[j-1].GX > bs[j].GX)); j-- {
bs[j-1], bs[j] = bs[j], bs[j-1]
}
}
}
func zlibDecompress(raw []byte) ([]byte, error) {
zr, err := zlib.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer zr.Close()
return io.ReadAll(zr)
}
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
// layers 是留存的非已探索层原始文件(Model/~ 与 Model/#),原样写回——
// 它们不参与渲染,但少了它们 fog of world 那边看到的就是残缺快照。
func Encode(blocks []Block, layers map[string][]byte) ([]byte, error) {
byTile := map[[2]int][]Block{}
for _, b := range blocks {
tx, ty := b.GX/TileBlocks, b.GY/TileBlocks
byTile[[2]int{tx, ty}] = append(byTile[[2]int{tx, ty}], b)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for t, list := range byTile {
sortByLinear(list)
header := make([]byte, headerSize)
body := make([]byte, 0, len(list)*blockSize)
for k, b := range list {
i := (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks
binary.LittleEndian.PutUint16(header[2*i:], uint16(k+1))
body = append(body, b.Bitmap...)
body = append(body, extraBytes(b)...)
}
var raw bytes.Buffer
zc := zlib.NewWriter(&raw)
if _, err := zc.Write(header); err != nil {
return nil, err
}
if _, err := zc.Write(body); err != nil {
return nil, err
}
if err := zc.Close(); err != nil {
return nil, err
}
id := t[0] + t[1]*MapTiles
name := "Model/*/" + tileName(id)
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write(raw.Bytes()); err != nil {
return nil, err
}
}
// 其它层直接写原始字节,不做二次压缩,免得多一层格式风险
for p, data := range layers {
w, err := zw.Create(p)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sortByLinear(list []Block) {
lin := func(b Block) int { return (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks }
for i := 1; i < len(list); i++ {
for j := i; j > 0 && lin(list[j-1]) > lin(list[j]); j-- {
list[j-1], list[j] = list[j], list[j-1]
}
}
}
// extraBytes 生成块尾那 3 字节。真机存的是一个**大端 24 位数**,值为 置位数*2+1:
// 实测真机包 8701/8701 个块精确满足(如 118 格 -> 0x0000ED = 237 = 118*2+1)。
// 一个 64x64 块最多 4096 格,该值 <= 0x2001,所以最高字节在数学上恒为 0。
// (旧实现写的是 0x10 / 0x80|…,纯属臆造,会让导出包里每个块的元信息都与真机对不上。)
func extraBytes(b Block) []byte {
sum := b.CellCount()*2 + 1
return []byte{0x00, byte(sum >> 8), byte(sum & 0xFF)}
}
// CellSideM 是格子在赤道的边长(米)。宽随纬度 cos 收缩,高恒定(网页墨卡托)。
const CellSideM = 9.55462861
// CellOf 把 WGS-84 经纬度投到全球格坐标(网页墨卡托,2^22 网格)。
func CellOf(lat, lon float64) (cx, cy int) {
cx = int((lon + 180.0) / 360.0 * WorldCells)
rad := lat * math.Pi / 180
cy = int((math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells)
return
}
// AreaKm2 估算块集覆盖的实地面积:每格面积按块中心纬度做 cos 修正。
func AreaKm2(blocks []Block) float64 {
var m2 float64
for _, b := range blocks {
m2 += float64(b.CellCount()) * cellAreaM2(b.GX, b.GY)
}
return m2 / 1e6
}
// cellAreaM2 是某块内单格的面积(m²):一格宽 CellSideM、随纬度乘 cos 收缩,高恒定。
func cellAreaM2(gx, gy int) float64 {
_, lat := cellLonLat(gx*BlockCells+BlockCells/2, gy*BlockCells+BlockCells/2)
return CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
}
+185
View File
@@ -0,0 +1,185 @@
package fog
import (
"archive/zip"
"bytes"
"encoding/binary"
"io"
"os"
"path"
"strings"
"testing"
)
// TestNativeSnapshotRoundtrip 守住「真机快照解码再编码后内容不变」。
// 需要环境变量 FOG_SRC 指向真机导出的 .fwss;未设置时跳过。
//
// 重点守三件事:
// 1. Model/* 的每块逐字节一致(512 字节位图 + 尾 3 字节元信息)。尾 3 字节曾按臆造的
// 0x10/0x80 生成,实测 8701/8701 个块与真机全不符——它其实是一个大端 24 位数,
// 值为置位数*2+1;
// 2. Model/* 的条目集合与文件名不变(名字是 md5 前缀 + 掩码数字,重建时必须能原样还原);
// 3. Model/~ 与 Model/# 两层原样带出,条目名与内容一个不差——少了它们快照就残缺 55%。
//
// 比对按「格」而不是按块体槽位:槽位序号只是头里的指针,顺序允许不同,指向同一张位图即可。
func TestNativeSnapshotRoundtrip(t *testing.T) {
src := os.Getenv("FOG_SRC")
if src == "" {
t.Skip("设置 FOG_SRC 后运行迷雾快照往返检查")
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("读取 %s: %v", src, err)
}
blocks, cells, err := Decode(data)
if err != nil {
t.Fatalf("解码: %v", err)
}
layers, err := RawLayers(data)
if err != nil {
t.Fatalf("读取其它层: %v", err)
}
out, err := Encode(blocks, layers)
if err != nil {
t.Fatalf("编码: %v", err)
}
orig, err := unzipAll(data)
if err != nil {
t.Fatalf("解开原始包: %v", err)
}
got, err := unzipAll(out)
if err != nil {
t.Fatalf("解开导出包: %v", err)
}
if len(orig) != len(got) {
t.Errorf("条目数不一致: 原始 %d vs 导出 %d", len(orig), len(got))
}
for name := range orig {
if _, ok := got[name]; !ok {
t.Errorf("导出包缺少条目 %s", name)
}
}
for name := range got {
if _, ok := orig[name]; !ok {
t.Errorf("导出包多出条目 %s", name)
}
}
checked := 0
for name, a := range orig {
if !isModelStar(name) {
continue
}
b, ok := got[name]
if !ok {
continue
}
pa, err := zlibDecompress(a)
if err != nil {
t.Fatalf("%s 原始载荷解压失败: %v", name, err)
}
pb, err := zlibDecompress(b)
if err != nil {
t.Fatalf("%s 导出载荷解压失败: %v", name, err)
}
if len(pa) != len(pb) {
t.Errorf("%s: 载荷长度 %d vs %d", name, len(pa), len(pb))
continue
}
for cell := 0; cell < headerLen; cell++ {
ba, bb := slotAt(pa, cell), slotAt(pb, cell)
if (ba == nil) != (bb == nil) {
t.Errorf("%s: 格 %d 一边有块一边没有", name, cell)
continue
}
if ba == nil {
continue
}
if !bytes.Equal(ba[:bitmapSize], bb[:bitmapSize]) {
t.Errorf("%s: 格 %d 的块位图不一致", name, cell)
}
if !bytes.Equal(ba[bitmapSize:], bb[bitmapSize:]) {
t.Errorf("%s: 格 %d 的块元信息不一致: 原始 %x vs 导出 %x(置位数 %d)",
name, cell, ba[bitmapSize:], bb[bitmapSize:], popcountSum(ba[:bitmapSize]))
}
checked++
}
}
if checked == 0 {
t.Error("没有比对到任何块,样本可能不对")
}
// 其它层:条目内容(zlib 流本身)逐字节一致
for name, v := range layers {
if !bytes.Equal(got[name], v) {
t.Errorf("其它层 %s 未原样带出", name)
}
}
t.Logf("已核对 %d 个块、%d 个其它层条目(%d 格)", checked, len(layers), cells)
}
// TestExtraBytesEncoding 钉住块尾 3 字节的编码:大端 24 位数,值 = 置位数*2+1,最高字节恒为 0。
func TestExtraBytesEncoding(t *testing.T) {
bm := make([]byte, bitmapSize)
for _, c := range []struct{ bit, cells int }{{0, 1}, {7, 2}, {8, 3}, {511, 4}} {
bm[c.bit/8] |= 0x80 >> (c.bit % 8)
if got := extraBytes(Block{Bitmap: bm}); got[0] != 0 || int(got[1])<<8|int(got[2]) != c.cells*2+1 {
t.Errorf("置位数 %d 时得到 %x", c.cells, got)
}
}
// 满块 4096 格:值 8193 = 0x2001,最高字节仍应为 0
full := make([]byte, bitmapSize)
for i := range full {
full[i] = 0xFF
}
if got := extraBytes(Block{Bitmap: full}); !bytes.Equal(got, []byte{0x00, 0x20, 0x01}) {
t.Errorf("满块应为 002001,实际 %x", got)
}
}
func isModelStar(name string) bool {
name = strings.ReplaceAll(name, "\\", "/")
rest := strings.TrimPrefix(name, "Model/")
return rest != name && strings.HasPrefix(rest, "*/")
}
// unzipAll 读出包内全部条目的内容——那是 zlib 流本身,要 zlibDecompress 才是载荷。
func unzipAll(data []byte) (map[string][]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
out := map[string][]byte{}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
b, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, err
}
out[path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))] = b
}
return out, nil
}
// slotAt 取出某个格子所指向的块(头里存的是 1 基槽位序号,0 表示该格没有数据)。
func slotAt(payload []byte, cell int) []byte {
idx := int(binary.LittleEndian.Uint16(payload[2*cell:]))
if idx == 0 {
return nil
}
off := headerSize + (idx-1)*blockSize
if off+blockSize > len(payload) {
return nil
}
return payload[off : off+blockSize]
}
+66
View File
@@ -0,0 +1,66 @@
package fog
import (
"archive/zip"
"bytes"
"fmt"
"io"
"path"
"strings"
)
// layerMaxBytes 是单个留存条目的解压上限(实测最大约 250KB)。
const layerMaxBytes = 8 << 20
// RawLayers 取出快照里「不属于已探索层」的条目(内容原样,条目名也原样)。
//
// fog of world 的快照里有三类瓦片:
//
// Model/* 已探索层:128×128 块,每块 512 字节位图 + 3 字节元信息(本包解析的就是它)
// Model/~ 另一层位图:每块 512 字节,没有那 3 字节
// Model/# 只有元信息:每块 3 字节
//
// 后两类我们不解析(站点只用已探索层来渲染),但**必须原样留住**——
// 否则重新导出的快照会丢掉这 55% 的内容,fog of world 那边看到的就是残缺数据。
//
// 留存范围是「快照里的全部条目,除去已探索层与被 Decode 解析掉的那些瓦片」:
// Decode 认的是「文件名能通过瓦片校验」,不认目录,所以判断也要按文件名来,
// 否则同一个文件会被解析一遍、又原样写一遍,包里出现两份。
func RawLayers(data []byte) (map[string][]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
out := map[string][]byte{}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
p := path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))
// Model/* 之下就是已探索层,由 fog_blocks 重建
if rest := strings.TrimPrefix(p, "Model/"); rest != p {
if i := strings.Index(rest, "/"); i > 0 && rest[:i] == "*" {
continue
}
}
// 文件名能通过瓦片校验的会被 Decode 解析成块,写回时也走 Model/*,这里不重复留存
if _, ok := tileIDFromName(path.Base(p)); ok {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
// 留存的条目不解析、也不该被截断:超限就报错,别把半份数据悄悄带进导出包
raw, err := io.ReadAll(io.LimitReader(rc, layerMaxBytes+1))
rc.Close()
if err != nil {
return nil, err
}
if int64(len(raw)) > layerMaxBytes {
return nil, fmt.Errorf("条目 %s 超过 %d 字节,疑似异常快照", p, layerMaxBytes)
}
out[p] = raw
}
return out, nil
}
+188
View File
@@ -0,0 +1,188 @@
package fog
import (
"fmt"
"hash/fnv"
"math"
"strconv"
"rond-with-you/internal/geo"
)
// Mask 是一块「对访客隐藏」的圆形区域:中心(**GCJ-02**,与 places 同系)+ 半径(米)。
//
// 迷雾网格是 WGS-84、底图可能是 GCJ-02,所以判定前必须把中心换算到网格坐标,
// 与渲染瓦片时对 GCJ 底图做的纠偏是同一套变换——不然遮罩会整体偏出几百米。
type Mask struct {
ID int64
Name string
Lat float64
Lon float64
RadiusM float64
}
// maskGrid 是遮罩在基网格里的预计算形式。
//
// 网格的纵向边长恒为 CellSideM、横向边长随纬度乘 cos(φ) 收缩,
// 所以「实地上的圆」在网格里是**椭圆**(横向更宽)。半径换算错这一步,
// 高纬度地区会明显偏窄。
type maskGrid struct {
m Mask
gx, gy float64 // 圆心(格坐标,浮点)
rx, ry float64 // 横向 / 纵向半径(格)
x0, x1 int // 包围盒(格),用于快速排除
y0, y1 int
}
func newMaskGrid(m Mask) maskGrid {
wlat, wlon := geo.GCJ02ToWGS84(m.Lat, m.Lon)
gx, gy := mercX(wlon), mercY(wlat)
cos := math.Cos(wlat * math.Pi / 180)
if cos < 1e-6 {
cos = 1e-6
}
ry := m.RadiusM / CellSideM
rx := ry / cos
g := maskGrid{m: m, gx: gx, gy: gy, rx: rx, ry: ry}
g.x0 = int(math.Floor(gx-rx)) - 1
g.x1 = int(math.Ceil(gx+rx)) + 1
g.y0 = int(math.Floor(gy-ry)) - 1
g.y1 = int(math.Ceil(gy+ry)) + 1
return g
}
func (g maskGrid) hitCell(cx, cy int) bool {
if cx < g.x0 || cx > g.x1 || cy < g.y0 || cy > g.y1 {
return false
}
dx := (float64(cx) + 0.5 - g.gx) / g.rx
dy := (float64(cy) + 0.5 - g.gy) / g.ry
return dx*dx+dy*dy <= 1
}
// MaskSet 是一组遮罩的判定集合。零值/nil 表示不遮任何东西。
type MaskSet struct {
grids []maskGrid
}
// NewMaskSet 预计算一组遮罩;传空切片会返回 nil。
func NewMaskSet(masks []Mask) *MaskSet {
if len(masks) == 0 {
return nil
}
s := &MaskSet{grids: make([]maskGrid, 0, len(masks))}
for _, m := range masks {
if m.RadiusM <= 0 {
continue
}
s.grids = append(s.grids, newMaskGrid(m))
}
if len(s.grids) == 0 {
return nil
}
return s
}
// Empty 报告集合里有没有遮罩(nil 安全)。
func (s *MaskSet) Empty() bool { return s == nil || len(s.grids) == 0 }
// Hit 报告某个全球格是否被遮罩盖住。nil 安全,未命中时只花一次包围盒比较。
func (s *MaskSet) Hit(cx, cy int) bool {
if s == nil {
return false
}
for _, g := range s.grids {
if g.hitCell(cx, cy) {
return true
}
}
return false
}
// HitLatLon 按经纬度判定(同样传 GCJ-02),用于地点级的过滤。
func (s *MaskSet) HitLatLon(lat, lon float64) bool {
if s == nil {
return false
}
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
return s.Hit(CellOf(wlat, wlon))
}
// maskedStats 统计被遮罩盖住的置位格数与面积(m²),用于从「已探索」的总量里扣掉。
//
// 三个要点:
// - 逐格判一次 masks.Hit,而不是按遮罩逐个累加:遮罩可以互相重叠,按遮罩累加会把
// 重叠区域的格子算好几遍(格数扣多,和面积也会对不上);
// - 只遍历遮罩包围盒覆盖到的块(块坐标去重),不扫全库;
// - 面积用 cellAreaM2,与 AreaKm2 同一套算法。
func maskedStats(blocks map[[2]int][]byte, masks *MaskSet) (cells int, areaM2 float64) {
if masks.Empty() {
return 0, 0
}
seen := make(map[[2]int]bool)
for _, g := range masks.grids {
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
key := [2]int{bx, by}
if seen[key] {
continue // 两块遮罩可能盖到同一块,不能算两遍
}
seen[key] = true
bm, ok := blocks[key]
if !ok || len(bm) != bitmapSize {
continue
}
n := 0
for row := 0; row < BlockCells; row++ {
line := bm[row*8 : row*8+8]
for colB, v := range line {
if v == 0 {
continue
}
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
if masks.Hit(bx*BlockCells+colB*8+bit, by*BlockCells+row) {
n++
}
}
}
}
if n > 0 {
cells += n
areaM2 += float64(n) * cellAreaM2(bx, by)
}
}
}
}
return cells, areaM2
}
// MaskedStats 在读锁内统计被遮罩盖住的部分。调用方本来拿不到块表(它不导出),
// 而走 All() 会为一次统计复制并排序整张块表(上万块),所以这里直接在索引上算。
func (ix *Index) MaskedStats(masks *MaskSet) (cells int, areaM2 float64) {
if masks.Empty() {
return 0, 0
}
ix.mu.RLock()
defer ix.mu.RUnlock()
return maskedStats(ix.blocks, masks)
}
// MaskFingerprint 返回一组遮罩的短指纹,供前端把它拼进迷雾瓦片的 URL。
//
// 遮罩不在 fogIndex 里(每次请求都实时读库),所以服务端改完立刻生效;
// 但访客的瓦片是允许公共缓存的,URL 不变的话浏览器 / CDN 会继续拿缓存里那份
// (最长 1 小时)——设完遮罩看不到变化,很容易被当成「遮罩不生效」。
// 指纹变了 URL 就变,缓存自然失效。
func MaskFingerprint(masks []Mask) string {
if len(masks) == 0 {
return "0"
}
h := fnv.New64a()
for _, m := range masks {
fmt.Fprintf(h, "%d|%.6f|%.6f|%.1f|", m.ID, m.Lat, m.Lon, m.RadiusM)
}
return strconv.FormatUint(h.Sum64(), 16)
}
+143
View File
@@ -0,0 +1,143 @@
package fog
import (
"math"
"testing"
)
// allOnesBlock 造一块位图全置位的块(每格都已探索)。
func allOnesBlock(gx, gy int) Block {
bm := make([]byte, BlockCells*BlockCells/8)
for i := range bm {
bm[i] = 0xFF
}
return Block{GX: gx, GY: gy, Bitmap: bm}
}
// blocksAround 造出覆盖这些遮罩包围盒的全部块。
func blocksAround(masks ...Mask) []Block {
var out []Block
seen := map[[2]int]bool{}
for _, m := range masks {
g := newMaskGrid(m)
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
if seen[[2]int{bx, by}] {
continue
}
seen[[2]int{bx, by}] = true
out = append(out, allOnesBlock(bx, by))
}
}
}
return out
}
// TestMaskedCellsUnion 遮罩重叠时,被遮住的格子只能算一次。
// 曾经的实现是「按遮罩逐个累加命中格数」,重叠区域会被统计两三遍,
// 于是覆盖率里扣掉的格数偏大、与面积(另一套逐格算法)对不上。
func TestMaskedCellsUnion(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
onlyA, onlyB := NewMaskSet([]Mask{a}), NewMaskSet([]Mask{b})
both := NewMaskSet([]Mask{a, b})
if onlyA == nil || onlyB == nil || both == nil {
t.Fatal("遮罩集合不应为空")
}
blocks := blocksAround(a, b)
var want int
for _, blk := range blocks {
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if both.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
want++
}
}
}
}
// 用例前提:两块遮罩真的有重叠,否则测不出重复计数
if sum := countMasked(blocks, onlyA) + countMasked(blocks, onlyB); sum <= want {
t.Fatalf("两块遮罩没有重叠(单独 %d + %d,并集 %d),用例无效", sum, want, want)
}
if got := countMasked(blocks, both); got != want {
t.Errorf("重叠遮罩下 MaskedCells = %d,应为并集 %d(偏大即重复计数)", got, want)
}
}
// TestMaskedAreaMatchesCells 面积与格数必须出自同一个集合:
// 扣掉的面积换算成格数后要与统计出的格数相等。
func TestMaskedAreaMatchesCells(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
set := NewMaskSet([]Mask{a, b})
blocks := blocksAround(a, b)
// 逐块按块中心纬度算格子面积(与 AreaKm2Excluding 内同一套算法)
var lostM2 float64
for _, blk := range blocks {
_, lat := cellLonLat(blk.GX*BlockCells+BlockCells/2, blk.GY*BlockCells+BlockCells/2)
cell := CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
n := 0
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
n++
}
}
}
lostM2 += float64(n) * cell
}
byKey := map[[2]int][]byte{}
for _, blk := range blocks {
byKey[[2]int{blk.GX, blk.GY}] = blk.Bitmap
}
gotCells, gotM2 := maskedStats(byKey, set)
if gotCells != countMasked(blocks, set) {
t.Errorf("遮罩格数 %d,暴力统计 %d", gotCells, countMasked(blocks, set))
}
if diff := math.Abs(gotM2 - lostM2); diff > 1e-9 {
t.Errorf("遮罩面积 %.6f m²,按格数算应为 %.6f m²(差 %.3f)", gotM2, lostM2, diff)
}
}
// countMasked 暴力数一遍被遮住的格子(测试基准)。
func countMasked(blocks []Block, set *MaskSet) int {
n := 0
for _, blk := range blocks {
for row := 0; row < BlockCells; row++ {
for col := 0; col < BlockCells; col++ {
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
n++
}
}
}
}
return n
}
// TestMaskFingerprint 指纹要能反映遮罩的任何改动,否则访客的瓦片缓存不会失效。
func TestMaskFingerprint(t *testing.T) {
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 1500}
if MaskFingerprint(nil) != "0" {
t.Error("空遮罩的指纹应为 0")
}
base := MaskFingerprint([]Mask{a})
cases := map[string][]Mask{
"多了一块": {a, b},
"半径改了": {{ID: 1, Lat: a.Lat, Lon: a.Lon, RadiusM: 3000}},
"位置挪了": {{ID: 1, Lat: a.Lat + 0.001, Lon: a.Lon, RadiusM: a.RadiusM}},
"换成另一块": {b},
}
for name, list := range cases {
if MaskFingerprint(list) == base {
t.Errorf("%s 之后指纹没变,访客缓存不会失效", name)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package fog
import (
"image"
"math"
"rond-with-you/internal/geo"
)
// 迷雾覆盖层配色:填充用偏亮的蓝,边界再描一圈更深的轮廓,
// 这样在浅色底图上也能一眼看出「已揭开」的范围。
var (
FogColor = [4]byte{48, 122, 224, 132}
FogEdge = [4]byte{16, 52, 132, 232}
)
// RenderTile 渲染一张 256x256 的迷雾透明瓦片。
//
// z/x/y 为 slippy 瓦片坐标。crs 指明底图坐标系:
// 迷雾网格是 WGS-84,而高德这类底图把 GCJ-02 坐标画在自己的名义位置上,
// 所以 gcj02 底图下每个块要先做 WGS84→GCJ02 正变换再投影;
// 块只有 600m 见方,偏移场在块内变化不足 1px,按块中心算一次即可。
// wgs84 底图(天地图/自备)则不偏移。
// masks 是被遮罩盖住、对访客不显示的区域(nil = 全部显示)。被遮住的格子既不画填充,
// 也不算作「未探索」的邻居——否则遮罩边缘会被描出一圈轮廓,等于把隐藏范围本身暴露了。
func RenderTile(blocks map[[2]int][]byte, z, x, y int, crs string, masks *MaskSet) *image.NRGBA {
if z < 0 || z > 22 {
return nil
}
step := WorldCells >> z // 该瓦片每边覆盖的格子数
scale := 256.0 / float64(step)
ox, oy := float64(x*step), float64(y*step)
// 第一遍只画掩码,第二遍再按邻域决定填充色还是轮廓色:
// 只有紧邻「未探索」的像素才描边,已探索区域内部保持平涂。
var mask [256 * 256]bool
// hole 记录「因为遮罩而不画」的像素,第二遍描边时把它当作正常像素跳过
var hole []bool
if !masks.Empty() {
hole = make([]bool, 256*256)
}
mark := func(px, py int) {
if px < 0 || px > 255 || py < 0 || py > 255 {
return
}
mask[py*256+px] = true
}
fill := func(px, py, w, h int) {
for r := 0; r < h; r++ {
for c := 0; c < w; c++ {
mark(px+c, py+r)
}
}
}
// markHole 把一段像素标成「被遮罩隐去」
markHole := func(px, py, w, h int) {
if hole == nil {
return
}
for r := 0; r < h; r++ {
for c := 0; c < w; c++ {
x, y := px+c, py+r
if x >= 0 && x <= 255 && y >= 0 && y <= 255 {
hole[y*256+x] = true
}
}
}
}
// 直接遍历块表而不是全球块范围:低缩放级下范围循环会膨胀到数十亿次空查询。
// GCJ 偏移最多约 700m(74 格),粗裁剪时两侧各放宽两块保证不漏。
const margin = 2 * BlockCells
lo, hi := float64(x*step-margin), float64((x+1)*step+margin)
rlo, rhi := float64(y*step-margin), float64((y+1)*step+margin)
shift := crs == "gcj02"
for key, bm := range blocks {
gbx, gby := key[0], key[1]
bx, by := float64(gbx*BlockCells), float64(gby*BlockCells)
if bx+BlockCells <= lo || bx >= hi || by+BlockCells <= rlo || by >= rhi {
continue
}
ddx, ddy := 0.0, 0.0
if shift {
ddx, ddy = blockShift(gbx, gby)
}
u0 := (bx + ddx - ox) * scale
v0 := (by + ddy - oy) * scale
if scale >= 1 {
w := int(math.Ceil(scale))
for row := 0; row < BlockCells; row++ {
line := bm[row*8 : row*8+8]
if line[0]|line[1]|line[2]|line[3]|line[4]|line[5]|line[6]|line[7] == 0 {
continue
}
py := int(math.Floor(v0 + float64(row)*scale))
for colB, byteVal := range line {
if byteVal == 0 {
continue
}
for bit := 0; bit < 8; bit++ {
if byteVal&(0x80>>bit) == 0 {
continue
}
px := int(math.Floor(u0 + float64(colB*8+bit)*scale))
if masks.Hit(gbx*BlockCells+colB*8+bit, gby*BlockCells+row) {
markHole(px, py, w, w)
continue
}
fill(px, py, w, w)
}
}
}
continue
}
// 格子比像素还小(低缩放级):整块只要有探索就整块涂上。
// 这时按块中心判遮罩——缩到这种级别,块本身还不到一个像素。
if popcountSum(bm) == 0 {
continue
}
if masks.Hit(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2) {
continue
}
u1 := (bx + BlockCells + ddx - ox) * scale
v1 := (by + BlockCells + ddy - oy) * scale
px0, py0 := int(math.Floor(u0)), int(math.Floor(v0))
px1, py1 := int(math.Ceil(u1)), int(math.Ceil(v1))
if px1-px0 < 1 {
px1 = px0 + 1
}
if py1-py0 < 1 {
py1 = py0 + 1
}
fill(px0, py0, px1-px0, py1-py0)
}
// 第二遍着色。瓦片外的邻格按「已探索」处理,避免在瓦片接缝处
// 凭空描出一条网格线(真实边界由邻瓦片自己画)。
img := image.NewNRGBA(image.Rect(0, 0, 256, 256))
for py := 0; py < 256; py++ {
for px := 0; px < 256; px++ {
if !mask[py*256+px] {
continue
}
// 已探索的邻居:被遮罩隐去的也算「不算未探索」,免得把隐藏范围描出轮廓
lit := func(i int) bool { return mask[i] || (hole != nil && hole[i]) }
edge := false
if px > 0 && !lit(py*256+px-1) {
edge = true
} else if px < 255 && !lit(py*256+px+1) {
edge = true
} else if py > 0 && !lit((py-1)*256+px) {
edge = true
} else if py < 255 && !lit((py+1)*256+px) {
edge = true
}
c := FogColor
if edge {
c = FogEdge
}
i := (py*256 + px) * 4
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c[0], c[1], c[2], c[3]
}
}
return img
}
// blockShift 返回该块中心在 GCJ-02 底图下的像素偏移(单位:基网格格子)。
func blockShift(gbx, gby int) (float64, float64) {
lon, lat := cellLonLat(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2)
glat, glon := geo.WGS84ToGCJ02(lat, lon)
return mercX(glon) - mercX(lon), mercY(glat) - mercY(lat)
}
func mercX(lon float64) float64 { return (lon + 180.0) / 360.0 * WorldCells }
func mercY(lat float64) float64 {
rad := lat * math.Pi / 180
return (math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells
}
func cellLonLat(cx, cy int) (float64, float64) {
lon := (float64(cx)+0.5)/WorldCells*360.0 - 180.0
n := 2 * math.Pi * (float64(cy) + 0.5) / WorldCells
lat := math.Atan(math.Sinh(math.Pi-n)) * 180.0 / math.Pi
return lon, lat
}
+300
View File
@@ -0,0 +1,300 @@
package fog
import (
"database/sql"
"errors"
"fmt"
"sync"
"time"
)
// Index 是某个用户迷雾位图的内存索引,按块粒度持有,
// 供瓦片渲染做范围查询;写路径先落库再刷新内存。
type Index struct {
mu sync.RWMutex
blocks map[[2]int][]byte
cells int
// areaM2 是全部置位格的实地面积(m²),写入索引时一并算好。
// 覆盖率统计每次都要这个数,现算得把上万块的位图全扫一遍。
areaM2 float64
ver int64 // 最近一次更新的 unix 秒,用作瓦片缓存版本号
}
// NewIndex 建一个空索引。
func NewIndex() *Index {
return &Index{blocks: map[[2]int][]byte{}}
}
// Load 从库里加载某用户的全部迷雾块。
func (ix *Index) Load(db *sql.DB, userID int64) error {
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks WHERE user_id=$1`, userID)
if err != nil {
return err
}
defer rows.Close()
blocks := map[[2]int][]byte{}
cells, areaM2 := 0, 0.0
for rows.Next() {
var gx, gy int
var bm []byte
if err := rows.Scan(&gx, &gy, &bm); err != nil {
return err
}
blocks[[2]int{gx, gy}] = bm
n := popcountSum(bm)
cells += n
areaM2 += float64(n) * cellAreaM2(gx, gy)
}
if err := rows.Err(); err != nil {
return err
}
ix.mu.Lock()
ix.blocks = blocks
ix.cells = cells
ix.areaM2 = areaM2
ix.ver = time.Now().Unix()
ix.mu.Unlock()
return nil
}
// LoadAll 从库里加载全部迷雾块(站点为单用户展示,公开读路径不区分归属)。
func (ix *Index) LoadAll(db *sql.DB) error {
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks`)
if err != nil {
return err
}
defer rows.Close()
blocks := map[[2]int][]byte{}
cells, areaM2 := 0, 0.0
for rows.Next() {
var gx, gy int
var bm []byte
if err := rows.Scan(&gx, &gy, &bm); err != nil {
return err
}
blocks[[2]int{gx, gy}] = bm
n := popcountSum(bm)
cells += n
areaM2 += float64(n) * cellAreaM2(gx, gy)
}
if err := rows.Err(); err != nil {
return err
}
ix.mu.Lock()
ix.blocks = blocks
ix.cells = cells
ix.areaM2 = areaM2
ix.ver = time.Now().Unix()
ix.mu.Unlock()
return nil
}
// Stats 返回块数与格子数。
func (ix *Index) Stats() (blocks, cells int) {
ix.mu.RLock()
defer ix.mu.RUnlock()
return len(ix.blocks), ix.cells
}
// AreaKm2 返回已探索面积(km²,按块中心纬度做 cos 修正)。写入索引时算好,这里只读。
func (ix *Index) AreaKm2() float64 {
ix.mu.RLock()
defer ix.mu.RUnlock()
return ix.areaM2 / 1e6
}
// Version 返回数据版本号,随每次写入变化。
func (ix *Index) Version() int64 {
ix.mu.RLock()
defer ix.mu.RUnlock()
return ix.ver
}
// View 在读锁内执行渲染查询,避免渲染中途被写换代。
func (ix *Index) View(fn func(blocks map[[2]int][]byte)) {
ix.mu.RLock()
defer ix.mu.RUnlock()
fn(ix.blocks)
}
// Import 把解析出的块写入库:merge 模式按位或合并进已有数据,
// replace 模式先清空该用户全部迷雾再写入。完成后刷新内存索引。
func (ix *Index) Import(db *sql.DB, userID int64, blocks []Block, merge bool) (applied int, err error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if !merge {
if _, err := tx.Exec(`DELETE FROM fog_blocks WHERE user_id=$1`, userID); err != nil {
return 0, err
}
}
var upsert *sql.Stmt
if upsert, err = tx.Prepare(`INSERT INTO fog_blocks (user_id, gbx, gby, bitmap, updated_at)
VALUES ($1,$2,$3,$4,now())
ON CONFLICT (user_id, gbx, gby) DO UPDATE SET bitmap=$4, updated_at=now()`); err != nil {
return 0, err
}
defer upsert.Close()
for _, b := range blocks {
if merge {
var old []byte
err := tx.QueryRow(`SELECT bitmap FROM fog_blocks WHERE user_id=$1 AND gbx=$2 AND gby=$3`,
userID, b.GX, b.GY).Scan(&old)
switch {
case err == nil && len(old) == len(b.Bitmap):
for i := range b.Bitmap {
b.Bitmap[i] |= old[i]
}
case err != nil && !errors.Is(err, sql.ErrNoRows):
return 0, err
}
}
if _, err := upsert.Exec(userID, b.GX, b.GY, b.Bitmap); err != nil {
return 0, err
}
applied++
}
if err := tx.Commit(); err != nil {
return 0, err
}
if err := ix.Load(db, userID); err != nil {
return applied, err
}
return applied, nil
}
// CellLit 报告某个全局格是否已探索。
func (ix *Index) CellLit(cx, cy int) bool {
ix.mu.RLock()
defer ix.mu.RUnlock()
bm, ok := ix.blocks[[2]int{cx / BlockCells, cy / BlockCells}]
if !ok {
return false
}
col, row := cx%BlockCells, cy%BlockCells
return bm[row*8+col/8]&(0x80>>(col%8)) != 0
}
// All 返回全部块(导出用)。
func (ix *Index) All() []Block {
ix.mu.RLock()
defer ix.mu.RUnlock()
out := make([]Block, 0, len(ix.blocks))
for k, bm := range ix.blocks {
out = append(out, Block{GX: k[0], GY: k[1], Bitmap: bm})
}
sortBlocks(out)
return out
}
// LoadOrNewIndex 返回某用户的迷雾索引;首次访问时从库里加载。
func LoadOrNewIndex(db *sql.DB, userID int64, existing *Index) (*Index, error) {
if existing != nil {
return existing, nil
}
ix := NewIndex()
if err := ix.Load(db, userID); err != nil {
return nil, fmt.Errorf("加载迷雾数据: %w", err)
}
return ix, nil
}
// SaveRawLayers 保存快照里其它图层的原始条目(path -> zlib 流本身,一个字节都不动)。
// replace 为真时先清空(「整体替换」与「清空迷雾」都该把旧的一并带走),
// 否则同名覆盖——后传的快照更完整。
func (ix *Index) SaveRawLayers(db *sql.DB, userID int64, layers map[string][]byte, replace bool) error {
if !replace && len(layers) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if replace {
if _, err := tx.Exec(`DELETE FROM fog_raw_files WHERE user_id=$1`, userID); err != nil {
return err
}
}
for p, data := range layers {
if _, err := tx.Exec(`INSERT INTO fog_raw_files (user_id, path, data) VALUES ($1,$2,$3)
ON CONFLICT (user_id, path) DO UPDATE SET data=EXCLUDED.data`, userID, p, data); err != nil {
return err
}
}
return tx.Commit()
}
// DropRawLayers 丢弃留存的其它图层。清空迷雾时调用(与 fog_blocks 一样是站点级,
// 不按用户区分),否则导出的快照里会留着已经没有对应 Model/* 瓦片的图层条目。
func DropRawLayers(db *sql.DB) error {
_, err := db.Exec(`DELETE FROM fog_raw_files`)
return err
}
// RawLayers 读出留存的非已探索层文件(path -> 原始字节)。
func LoadRawLayers(db *sql.DB, userID int64) (map[string][]byte, error) {
rows, err := db.Query(`SELECT path, data FROM fog_raw_files WHERE user_id=$1`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]byte{}
for rows.Next() {
var p string
var data []byte
if err := rows.Scan(&p, &data); err != nil {
return nil, err
}
out[p] = data
}
return out, rows.Err()
}
// ---------- 迷雾遮罩区(对访客隐藏的范围) ----------
// SaveMask 新增或更新一块遮罩(ID 为 0 时新增),返回其 ID。
func SaveMask(db *sql.DB, userID int64, m Mask) (int64, error) {
if m.ID > 0 {
_, err := db.Exec(`UPDATE fog_masks SET name=$1, lat=$2, lon=$3, radius_m=$4 WHERE id=$5 AND user_id=$6`,
m.Name, m.Lat, m.Lon, m.RadiusM, m.ID, userID)
if err != nil {
return 0, err
}
return m.ID, nil
}
var id int64
err := db.QueryRow(`INSERT INTO fog_masks (user_id, name, lat, lon, radius_m) VALUES ($1,$2,$3,$4,$5)
RETURNING id`, userID, m.Name, m.Lat, m.Lon, m.RadiusM).Scan(&id)
return id, err
}
// DeleteMask 删除一块遮罩。
func DeleteMask(db *sql.DB, userID, id int64) error {
_, err := db.Exec(`DELETE FROM fog_masks WHERE id=$1 AND user_id=$2`, id, userID)
return err
}
// LoadMasks 读出全部遮罩。遮罩与迷雾一样是站点级数据(单站长的站点),
// 展示与渲染时不分归属,与 fog_blocks 的 LoadAll 保持一致。
func LoadMasks(db *sql.DB) ([]Mask, error) {
rows, err := db.Query(`SELECT id, COALESCE(name,''), lat, lon, radius_m FROM fog_masks
WHERE radius_m > 0 ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mask
for rows.Next() {
var m Mask
if err := rows.Scan(&m.ID, &m.Name, &m.Lat, &m.Lon, &m.RadiusM); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}