diff --git a/docs/报告/CM95_S52_AP矢量纹理阶段报告_v1.md b/docs/报告/CM95_S52_AP矢量纹理阶段报告_v1.md
new file mode 100644
index 0000000..bc0560b
--- /dev/null
+++ b/docs/报告/CM95_S52_AP矢量纹理阶段报告_v1.md
@@ -0,0 +1,39 @@
+# CM95 S-52 AP 矢量纹理阶段报告 v1
+
+## 结论
+
+已建立 S-52 `AP(...)` vector-only pattern 到 MapLibre fill-pattern 的编译路径。
+
+全国 Dataset 1 实际使用的 AP 中:
+- bitmap pattern 继续复用 OpenCPN 原 atlas;
+- vector-only pattern 共 10 种,10/10 编译成功;
+- 未对 bitmap+vector 双定义资源重复生成。
+
+vector-only pattern:
+`PRTSUR01 / QUESMRK1 / MARCUL02 / VEGATN03 / SNDWAV01 / ICEARE04 / FSHFAC03 / TSSJCT02 / AIRARE02 / VEGATN04`。
+
+## 实现
+
+新增 `tools/cm95_s52_vector_pattern_sprite.py`:
+1. 读取全国实际 `pattern_resource_counts`;
+2. 只选择 vector-only pattern;
+3. 解析实际 HPGL `SP/SW/PU/PD/CI`;
+4. 按 DAY_BRIGHT 色表生成透明 PNG;
+5. 追加至现有 Symbol + Pattern + LC Sprite。
+
+## 福冈湾回归
+
+`z12/3531/1641` 实际包含:
+- `PRTSUR01 ×31`
+- `QUESMRK1 ×5`
+
+v12 浏览器实际验证:
+- `s52-ap-prtsur01` rendered features = 19(当前视口)
+- `s52-ap-quesmrk1` rendered features = 2(当前视口)
+- `map.hasImage(ap-PRTSUR01)` = true
+- `map.hasImage(ap-QUESMRK1)` = true
+- 页面 READY;唯一 HTTP 404 为 `favicon.ico`。
+
+## 边界
+
+正式 style compiler 后续应根据 `s52_area_pattern_code` 自动生成 fill-pattern layer,不应长期手工维护 v12 中的验证层。
diff --git a/tools/cm95_s52_vector_pattern_sprite.py b/tools/cm95_s52_vector_pattern_sprite.py
new file mode 100644
index 0000000..b9dcdc3
--- /dev/null
+++ b/tools/cm95_s52_vector_pattern_sprite.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import math
+from pathlib import Path
+import shutil
+import struct
+import subprocess
+import tempfile
+import xml.etree.ElementTree as ET
+from typing import Any
+
+
+def _png_size(path: Path) -> tuple[int, int]:
+ data = path.read_bytes()[:24]
+ if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
+ raise RuntimeError(f"not a PNG: {path}")
+ return struct.unpack(">II", data[16:24])
+
+
+def _text(node: ET.Element, tag: str) -> str:
+ child = node.find(tag)
+ return (child.text or "").strip() if child is not None else ""
+
+
+def _parse_palette(root: ET.Element, palette: str) -> dict[str, str]:
+ parent = root.find("color-tables")
+ if parent is None:
+ raise RuntimeError("missing color-tables")
+ for table in parent.findall("color-table"):
+ if table.attrib.get("name") != palette:
+ continue
+ return {
+ c.attrib["name"]: f"#{int(c.attrib['r']):02x}{int(c.attrib['g']):02x}{int(c.attrib['b']):02x}"
+ for c in table.findall("color")
+ }
+ raise RuntimeError(f"unknown palette: {palette}")
+
+
+def _parse_pen_colors(color_ref: str, palette: dict[str, str]) -> dict[str, str]:
+ compact = "".join(str(color_ref or "").split())
+ out: dict[str, str] = {}
+ for i in range(0, len(compact), 6):
+ token = compact[i:i + 6]
+ if len(token) == 6:
+ out[token[0]] = palette.get(token[1:], "#000000")
+ return out or {"A": "#000000"}
+
+
+def _points(text: str) -> list[tuple[float, float]]:
+ nums = [float(x) for x in text.split(",") if x != ""]
+ return [(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)]
+
+
+def _svg_for_pattern(node: ET.Element, palette: dict[str, str], dpi: float) -> tuple[str, dict[str, Any]]:
+ name = _text(node, "name")
+ vector = node.find("vector")
+ if vector is None:
+ raise RuntimeError(f"pattern {name} has no vector")
+ origin = vector.find("origin")
+ if origin is None:
+ raise RuntimeError(f"pattern {name} has no origin")
+ unit = (dpi / 25.4) / 100.0
+ width = max(1, int(math.ceil(float(vector.attrib.get("width", "0")) * unit)))
+ height = max(1, int(math.ceil(float(vector.attrib.get("height", "0")) * unit)))
+ ox = float(origin.attrib["x"])
+ oy = float(origin.attrib["y"])
+ pen_colors = _parse_pen_colors(_text(node, "color-ref"), palette)
+ pen = next(iter(pen_colors))
+ pen_width = 1.0
+ nominal = max(1.0, math.floor((dpi / 25.4) / 5.0))
+ current: tuple[float, float] | None = None
+ elements: list[str] = []
+
+ def cv(p: tuple[float, float]) -> tuple[float, float]:
+ return ((p[0] - ox) * unit, (p[1] - oy) * unit)
+
+ for raw in _text(node, "HPGL").split(";"):
+ raw = raw.strip()
+ if not raw:
+ continue
+ cmd, arg = raw[:2], raw[2:]
+ if cmd == "SP" and arg:
+ pen = arg[0]
+ elif cmd == "SW":
+ try:
+ pen_width = max(1.0, float(arg))
+ except ValueError:
+ pen_width = 1.0
+ elif cmd == "PU":
+ pts = _points(arg)
+ if pts:
+ current = pts[-1]
+ elif cmd == "PD":
+ for target in _points(arg):
+ if current is None:
+ current = target
+ continue
+ x1, y1 = cv(current)
+ x2, y2 = cv(target)
+ color = pen_colors.get(pen, next(iter(pen_colors.values())))
+ stroke = max(1.0, pen_width * nominal)
+ elements.append(
+ f''
+ )
+ current = target
+ elif cmd == "CI" and current is not None:
+ try:
+ radius = float(arg) * unit
+ except ValueError:
+ continue
+ cx, cy = cv(current)
+ color = pen_colors.get(pen, next(iter(pen_colors.values())))
+ stroke = max(1.0, pen_width * nominal)
+ elements.append(
+ f''
+ )
+ else:
+ raise RuntimeError(f"unsupported HPGL {cmd} in pattern {name}")
+
+ svg = (
+ f'"
+ )
+ return svg, {
+ "name": name,
+ "width": width,
+ "height": height,
+ "filltype": _text(node, "filltype"),
+ "spacing": _text(node, "spacing"),
+ "color_ref": _text(node, "color-ref"),
+ "description": _text(node, "description"),
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Compile used S-52 vector AP patterns into a MapLibre sprite.")
+ ap.add_argument("--chartsymbols", required=True, type=Path)
+ ap.add_argument("--base-sprite-png", required=True, type=Path)
+ ap.add_argument("--base-sprite-json", required=True, type=Path)
+ ap.add_argument("--usage-audit", required=True, type=Path)
+ ap.add_argument("--output-prefix", required=True, type=Path)
+ ap.add_argument("--palette", default="DAY_BRIGHT")
+ ap.add_argument("--dpi", default=96.0, type=float)
+ args = ap.parse_args()
+
+ if shutil.which("convert") is None:
+ raise SystemExit("ImageMagick convert is required")
+ root = ET.parse(args.chartsymbols).getroot()
+ palette = _parse_palette(root, args.palette)
+ patterns = root.find("patterns")
+ if patterns is None:
+ raise SystemExit("missing patterns")
+ by_name = {_text(n, "name"): n for n in patterns.findall("pattern")}
+ usage = json.loads(args.usage_audit.read_text(encoding="utf-8"))
+ used = [str(row[0]) for row in usage.get("pattern_resource_counts", [])]
+ names = [
+ name for name in used
+ if name in by_name
+ and by_name[name].find("vector") is not None
+ and by_name[name].find("bitmap") is None
+ ]
+ missing = [name for name in used if name not in by_name]
+ if missing:
+ raise SystemExit(f"missing patterns: {missing}")
+
+ sprite = json.loads(args.base_sprite_json.read_text(encoding="utf-8"))
+ base_w, base_h = _png_size(args.base_sprite_png)
+ metadata: dict[str, dict[str, Any]] = {}
+ args.output_prefix.parent.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryDirectory(prefix="cm95-s52-ap-") as td:
+ tmp = Path(td)
+ pngs: list[Path] = []
+ y = base_h
+ for idx, name in enumerate(names):
+ svg, meta = _svg_for_pattern(by_name[name], palette, args.dpi)
+ sp = tmp / f"{idx:03d}-{name}.svg"
+ pp = tmp / f"{idx:03d}-{name}.png"
+ sp.write_text(svg, encoding="utf-8")
+ subprocess.run(["convert", "-background", "none", str(sp), str(pp)], check=True)
+ w, h = _png_size(pp)
+ key = f"ap-{name}"
+ sprite[key] = {"x": 0, "y": y, "width": w, "height": h, "pixelRatio": 1}
+ meta.update({"sprite_key": key, "x": 0, "y": y})
+ metadata[name] = meta
+ y += h
+ pngs.append(pp)
+
+ out_png = args.output_prefix.with_suffix(".png")
+ out_json = args.output_prefix.with_suffix(".json")
+ subprocess.run([
+ "convert", str(args.base_sprite_png), *[str(p) for p in pngs],
+ "-background", "none", "-gravity", "west", "-append", str(out_png)
+ ], check=True)
+ out_json.write_text(json.dumps(sprite, ensure_ascii=False, indent=2), encoding="utf-8")
+ args.output_prefix.with_name(args.output_prefix.name + "-pattern-metadata.json").write_text(
+ json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+
+ w, h = _png_size(out_png)
+ print(json.dumps({
+ "schema": "navsea.cm95.s52-vector-pattern-sprite/v1",
+ "palette": args.palette,
+ "vector_pattern_count": len(names),
+ "vector_patterns": names,
+ "base_sprite": {"width": base_w, "height": base_h},
+ "output_sprite": {"width": w, "height": h},
+ "sprite_entry_count": len(sprite),
+ }, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())