From 3d9befc3f25bd0e7c8c1fab1601b4ec47ca3ac04 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 17 Sep 2026 17:40:39 +0800 Subject: [PATCH] feat: compile S-52 complex line patterns --- docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md | 39 +++ tools/cm95_s52_line_sprite.py | 257 ++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md create mode 100644 tools/cm95_s52_line_sprite.py diff --git a/docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md b/docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md new file mode 100644 index 0000000..aa66860 --- /dev/null +++ b/docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md @@ -0,0 +1,39 @@ +# CM95 S-52 LC 复杂线型阶段报告 v1 + +## 结论 + +已建立 S-52 `LC(...)` 到 MapLibre `line-pattern` 的资源编译路径。 + +- 全国 Dataset 1 实际命中的 LC 资源:16 种。 +- 16/16 均可从 OpenCPN `chartsymbols.xml` 的 `line-styles` HPGL 定义编译为透明 PNG pattern。 +- 输出 pattern 与现有 S-52 Symbol/Pattern 共用同一 MapLibre Sprite。 +- 未跳过全国实际使用的 LC 资源。 + +## 福冈湾回归 + +测试 tile:`z12/3531/1641`。 + +实际含 LC: +- `CBLSUB06 × 2` +- `PIPSOL06 × 1` + +浏览器验证页面:`presentation_v11.html`。 + +实际 MapLibre `queryRenderedFeatures`: +- `s52-lc-cblsub06 = 2` +- `s52-lc-pipsol06 = 1` + +`map.hasImage()` 对两个 `lc-*` pattern 均为 `true`。页面 READY,原 LIGHTS/SOUNDG/危险物/SLCONS 等统计保持正常。 + +## 实现 + +新增 `tools/cm95_s52_line_sprite.py`: +1. 读取 S-52 `line-styles`。 +2. 解析实际使用的 HPGL。 +3. 按 DAY_BRIGHT 色表生成透明 SVG/PNG pattern。 +4. 追加至现有 Sprite atlas/json。 +5. 输出 LC metadata。 + +## 边界 + +本阶段完成 LC 资源编译和 MapLibre 实际渲染验证;正式 style compiler 后续应按 `s52_line_complex_code` 自动生成/合并 line-pattern layers,避免手工维护每种 LC。 diff --git a/tools/cm95_s52_line_sprite.py b/tools/cm95_s52_line_sprite.py new file mode 100644 index 0000000..9d174ca --- /dev/null +++ b/tools/cm95_s52_line_sprite.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import re +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]: + tables = root.find("color-tables") + if tables is None: + raise RuntimeError("missing color-tables") + for table in tables.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: + continue + 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 _esc(value: str) -> str: + return value.replace("&", "&").replace('"', """) + +def _line_style_svg( + 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"line style {name} has no vector") + origin = vector.find("origin") + if origin is None: + raise RuntimeError(f"line style {name} has no origin") + unit_to_px = (dpi / 25.4) / 100.0 + width = max(1, int(math.ceil(float(vector.attrib["width"]) * unit_to_px))) + height = max(1, int(math.ceil(float(vector.attrib["height"]) * unit_to_px))) + ox = float(origin.attrib["x"]) + oy = float(origin.attrib["y"]) + pen_colors = _parse_pen_colors(_text(node, "color-ref"), palette) + pen_code = next(iter(pen_colors)) + pen_width = 1 + nominal_width = 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_to_px, (p[1] - oy) * unit_to_px) + + 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_code = arg[0] + elif cmd == "SW": + try: + pen_width = max(1, int(float(arg))) + except ValueError: + pen_width = 1 + elif cmd == "PU": + pts = _points(arg) + if pts: + current = pts[-1] + elif cmd == "PD": + pts = _points(arg) + if not pts and current is not None: + pts = [(current[0] + 1.0, current[1])] + for target in pts: + if current is None: + current = target + continue + x1, y1 = cv(current) + x2, y2 = cv(target) + color = _esc(pen_colors.get(pen_code, next(iter(pen_colors.values())))) + stroke = max(1.0, pen_width * nominal_width) + elements.append( + f'' + ) + current = target + elif cmd == "CI" and current is not None: + try: + radius = float(arg) * unit_to_px + except ValueError: + continue + cx, cy = cv(current) + color = _esc(pen_colors.get(pen_code, next(iter(pen_colors.values())))) + stroke = max(1.0, pen_width * nominal_width) + elements.append( + f'' + ) + elif cmd in {"ST", "PM", "FP"}: + raise RuntimeError(f"unsupported HPGL {cmd} in line style {name}") + else: + raise RuntimeError(f"unsupported HPGL {cmd} in line style {name}") + + svg = ( + f'' + "".join(elements) + "" + ) + return svg, { + "name": name, + "width": width, + "height": height, + "dpi": dpi, + "color_ref": _text(node, "color-ref"), + "description": _text(node, "description"), + } + +def _selected_names(usage_path: Path | None, root: ET.Element) -> list[str]: + if usage_path is None: + parent = root.find("line-styles") + if parent is None: + return [] + return sorted(_text(n, "name") for n in parent.findall("line-style")) + usage = json.loads(usage_path.read_text(encoding="utf-8")) + return [str(row[0]) for row in usage.get("complex_line_resource_counts", [])] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Compile S-52 HPGL complex line styles into MapLibre sprite patterns." + ) + parser.add_argument("--chartsymbols", required=True, type=Path) + parser.add_argument("--base-sprite-png", required=True, type=Path) + parser.add_argument("--base-sprite-json", required=True, type=Path) + parser.add_argument("--output-prefix", required=True, type=Path) + parser.add_argument("--usage-audit", type=Path) + parser.add_argument("--palette", default="DAY_BRIGHT") + parser.add_argument("--dpi", default=96.0, type=float) + args = parser.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) + parent = root.find("line-styles") + if parent is None: + raise SystemExit("missing line-styles") + by_name = {_text(n, "name"): n for n in parent.findall("line-style")} + names = _selected_names(args.usage_audit, root) + missing = sorted(name for name in names if name not in by_name) + if missing: + raise SystemExit(f"missing line styles: {missing}") + + base_json = json.loads(args.base_sprite_json.read_text(encoding="utf-8")) + base_width, base_height = _png_size(args.base_sprite_png) + args.output_prefix.parent.mkdir(parents=True, exist_ok=True) + metadata: dict[str, dict[str, Any]] = {} + + with tempfile.TemporaryDirectory(prefix="cm95-s52-lc-") as td: + tmp = Path(td) + pngs: list[Path] = [] + y = base_height + for index, name in enumerate(names): + svg, meta = _line_style_svg(by_name[name], palette, args.dpi) + svg_path = tmp / f"{index:03d}-{name}.svg" + png_path = tmp / f"{index:03d}-{name}.png" + svg_path.write_text(svg, encoding="utf-8") + subprocess.run( + ["convert", "-background", "none", str(svg_path), str(png_path)], + check=True, + ) + w, h = _png_size(png_path) + key = f"lc-{name}" + if key in base_json: + raise RuntimeError(f"sprite key collision: {key}") + base_json[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(png_path) + + out_png = args.output_prefix.with_suffix(".png") + out_json = args.output_prefix.with_suffix(".json") + command = [ + "convert", + str(args.base_sprite_png), + *[str(p) for p in pngs], + "-background", "none", + "-gravity", "west", + "-append", + str(out_png), + ] + subprocess.run(command, check=True) + out_json.write_text( + json.dumps(base_json, ensure_ascii=False, indent=2), encoding="utf-8" + ) + meta_path = args.output_prefix.with_name(args.output_prefix.name + "-line-metadata.json") + meta_path.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + final_width, final_height = _png_size(out_png) + report = { + "schema": "navsea.cm95.s52-line-sprite/v1", + "palette": args.palette, + "dpi": args.dpi, + "line_style_count": len(names), + "line_styles": names, + "base_sprite": {"width": base_width, "height": base_height}, + "output_sprite": {"width": final_width, "height": final_height}, + "sprite_entry_count": len(base_json), + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())