feat: compile S-52 complex line patterns

This commit is contained in:
OpenAI Codex
2026-09-17 17:40:39 +08:00
parent 6a768ca782
commit 3d9befc3f2
2 changed files with 296 additions and 0 deletions

View File

@@ -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。

View File

@@ -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("&", "&amp;").replace('"', "&quot;")
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'<line x1="{x1:.3f}" y1="{y1:.3f}" x2="{x2:.3f}" y2="{y2:.3f}" '
f'stroke="{color}" stroke-width="{stroke:.3f}" stroke-linecap="round" />'
)
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'<circle cx="{cx:.3f}" cy="{cy:.3f}" r="{radius:.3f}" fill="none" '
f'stroke="{color}" stroke-width="{stroke:.3f}" />'
)
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'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">' + "".join(elements) + "</svg>"
)
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())