From 6de980d47f3d9a5373aaa5d7eb232fa5431d2895 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 17 Sep 2026 17:43:10 +0800 Subject: [PATCH] feat: compile S-52 vector area patterns --- docs/报告/CM95_S52_AP矢量纹理阶段报告_v1.md | 39 ++++ tools/cm95_s52_vector_pattern_sprite.py | 219 ++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 docs/报告/CM95_S52_AP矢量纹理阶段报告_v1.md create mode 100644 tools/cm95_s52_vector_pattern_sprite.py 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'' + "".join(elements) + "" + ) + 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())