Files
CM95/tools/cm95_s52_vector_pattern_sprite.py
2026-09-19 08:32:51 +08:00

274 lines
11 KiB
Python

#!/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":
targets = _points(arg)
if not targets and current is not None:
# OpenCPN RenderFromHPGL draws an argument-less PD as a
# one-screen-pixel horizontal segment, then advances the
# current point to that segment end. DRGARE01 relies on it.
x1, y1 = cv(current)
color = pen_colors.get(pen, next(iter(pen_colors.values())))
stroke = max(1.0, pen_width * nominal)
elements.append(
f'<line x1="{x1:.3f}" y1="{y1:.3f}" x2="{x1 + 1.0:.3f}" y2="{y1:.3f}" '
f'stroke="{color}" stroke-width="{stroke:.3f}" stroke-linecap="round" />'
)
current = (current[0] + (1.0 / unit), current[1])
for target in targets:
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'<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
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'<circle cx="{cx:.3f}" cy="{cy:.3f}" r="{radius:.3f}" fill="none" '
f'stroke="{color}" stroke-width="{stroke:.3f}" />'
)
else:
raise RuntimeError(f"unsupported HPGL {cmd} in pattern {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,
"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", type=Path)
ap.add_argument(
"--all-patterns",
action="store_true",
help="Compile every vector S-52 pattern in chartsymbols.xml; production resource bundles should use this.",
)
ap.add_argument(
"--usage-pbf",
type=Path,
help="Optionally union materialized s52_area_pattern_code values from a PBF tile.",
)
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"))
if args.usage_audit is not None
else {}
)
lookup_used = {str(row[0]) for row in usage.get("pattern_resource_counts", [])}
pbf_used: set[str] = set()
if args.usage_pbf is not None:
try:
import mapbox_vector_tile
except ImportError as exc:
raise SystemExit(
"--usage-pbf requires mapbox_vector_tile; run with the project .venv Python"
) from exc
decoded = mapbox_vector_tile.decode(args.usage_pbf.read_bytes())
for layer in decoded.values():
for feature in layer.get("features") or []:
value = (feature.get("properties") or {}).get("s52_area_pattern_code")
if value not in (None, ""):
pbf_used.add(str(value))
used = sorted(lookup_used | pbf_used)
selected = sorted(by_name) if args.all_patterns else used
names = [
name for name in selected
if name in by_name and by_name[name].find("vector") is not None
]
missing = [name for name in selected 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)
alpha_probe = subprocess.run(
[
"convert", str(pp), "-alpha", "extract",
"-format", "%[fx:maxima]", "info:",
],
check=True, capture_output=True, text=True,
).stdout.strip()
if not alpha_probe or float(alpha_probe) <= 0.0:
raise RuntimeError(f"pattern {name} rendered fully transparent")
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,
"lookup_usage_pattern_count": len(lookup_used),
"pbf_usage_pattern_count": len(pbf_used),
"pbf_usage_patterns": sorted(pbf_used),
"effective_usage_pattern_count": len(used),
"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())