feat: compile S-52 complex line patterns
This commit is contained in:
39
docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md
Normal file
39
docs/报告/CM95_S52_LC复杂线型阶段报告_v1.md
Normal 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。
|
||||
257
tools/cm95_s52_line_sprite.py
Normal file
257
tools/cm95_s52_line_sprite.py
Normal 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("&", "&").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'<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())
|
||||
Reference in New Issue
Block a user