Files
CM95/tools/cm95_s52_maplibre_poc.py

406 lines
14 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import re
import shutil
import struct
import xml.etree.ElementTree as ET
from typing import Any
DEFAULT_TARGETS = ("LIGHTS", "LNDMRK", "BCNLAT", "BOYLAT", "TOPMAR")
def _text(node: ET.Element, tag: str) -> str:
child = node.find(tag)
return (child.text or "").strip() if child is not None else ""
def _calls(instruction: str, opcode: str) -> list[str]:
pattern = re.compile(rf"(?:^|;)\s*{re.escape(opcode)}\(([^)]*)\)")
out: list[str] = []
for match in pattern.finditer(instruction or ""):
first = match.group(1).split(",", 1)[0].strip().strip("'\"")
if first:
out.append(first)
return out
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 _parse_bitmap(node: ET.Element) -> dict[str, int] | None:
bitmap = node.find("bitmap")
if bitmap is None:
return None
loc = bitmap.find("graphics-location")
pivot = bitmap.find("pivot")
if loc is None or pivot is None:
return None
return {
"x": int(loc.attrib["x"]),
"y": int(loc.attrib["y"]),
"width": int(bitmap.attrib["width"]),
"height": int(bitmap.attrib["height"]),
"pivot_x": int(pivot.attrib["x"]),
"pivot_y": int(pivot.attrib["y"]),
}
def _parse_symbols(root: ET.Element) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
parent = root.find("symbols")
if parent is None:
return out
for node in parent.findall("symbol"):
name = _text(node, "name")
if not name:
continue
out[name] = {
"name": name,
"description": _text(node, "description"),
"definition": _text(node, "definition") or None,
"color_ref": _text(node, "color-ref") or None,
"bitmap": _parse_bitmap(node),
}
return out
def _parse_patterns(root: ET.Element) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
parent = root.find("patterns")
if parent is None:
return out
for node in parent.findall("pattern"):
name = _text(node, "name")
if not name:
continue
out[name] = {
"name": name,
"description": _text(node, "description"),
"definition": _text(node, "definition") or None,
"filltype": _text(node, "filltype") or None,
"spacing": _text(node, "spacing") or None,
"color_ref": _text(node, "color-ref") or None,
"bitmap": _parse_bitmap(node),
}
return out
def _parse_lookups(root: ET.Element, targets: set[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
parent = root.find("lookups")
if parent is None:
return out
for node in parent.findall("lookup"):
# OpenCPN matches OBCL case-sensitively. Keep standard TOPMAR
# separate from lowercase Inland/extension object names such as topmar.
object_class = node.attrib.get("name", "")
if object_class not in targets:
continue
instruction = _text(node, "instruction")
out.append({
"id": node.attrib.get("id"),
"rcid": node.attrib.get("RCID"),
"object_class": object_class,
"geometry_type": _text(node, "type"),
"display_priority": _text(node, "disp-prio"),
"radar_priority": _text(node, "radar-prio"),
"table_name": _text(node, "table-name"),
"attribute_conditions_raw": [
(x.text or "").strip()
for x in node.findall("attrib-code")
if (x.text or "").strip()
],
"instruction": instruction,
"display_category": _text(node, "display-cat"),
"comment": _text(node, "comment"),
"sy": _calls(instruction, "SY"),
"cs": _calls(instruction, "CS"),
"ap": _calls(instruction, "AP"),
"lc": _calls(instruction, "LC"),
})
return out
def _parse_color_tables(root: ET.Element) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
parent = root.find("color-tables")
if parent is None:
return out
for node in parent.findall("color-table"):
graphics = node.find("graphics-file")
out[node.attrib.get("name", "")] = {
"graphics_file": graphics.attrib.get("name") if graphics is not None else None,
"colors": {
c.attrib["name"]: {
"r": int(c.attrib["r"]),
"g": int(c.attrib["g"]),
"b": int(c.attrib["b"]),
}
for c in node.findall("color")
},
}
return out
def _sprite_entry(bitmap: dict[str, int]) -> dict[str, int]:
return {
"x": bitmap["x"],
"y": bitmap["y"],
"width": bitmap["width"],
"height": bitmap["height"],
"pixelRatio": 1,
}
def _icon_offset(bitmap: dict[str, int]) -> list[float]:
return [
bitmap["width"] / 2.0 - bitmap["pivot_x"],
bitmap["height"] / 2.0 - bitmap["pivot_y"],
]
def _offset_expression(metadata: dict[str, dict[str, Any]]) -> list[Any]:
expr: list[Any] = ["match", ["get", "s52_symbol_code"]]
for name in sorted(metadata):
offset = metadata[name]["maplibre_icon_offset"]
expr.extend([name, ["literal", offset]])
expr.append(["literal", [0, 0]])
return expr
def _build_style(
source_id: str,
source_layer: str,
sprite_url: str,
tiles_template: str,
metadata: dict[str, dict[str, Any]],
) -> dict[str, Any]:
symbol_names = sorted(metadata)
return {
"version": 8,
"name": "CM95 S-52 MapLibre PoC v1",
"sprite": sprite_url,
"sources": {
source_id: {
"type": "vector",
"tiles": [tiles_template],
"minzoom": 0,
"maxzoom": 12,
}
},
"layers": [{
"id": "s52-static-point-symbols",
"type": "symbol",
"source": source_id,
"source-layer": source_layer,
"filter": [
"all",
["has", "s52_symbol_code"],
["match", ["get", "s52_symbol_code"], symbol_names, True, False],
],
"layout": {
"icon-image": ["get", "s52_symbol_code"],
"icon-anchor": "center",
"icon-offset": _offset_expression(metadata),
"icon-allow-overlap": True,
"icon-ignore-placement": True,
},
}],
}
def _load_config(path: Path | None) -> dict[str, Any]:
if path is None:
return {}
return json.loads(path.read_text(encoding="utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(
description="Compile OpenCPN S-52 resources into CM95/MapLibre PoC artifacts."
)
parser.add_argument("--s57data", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--config", type=Path)
parser.add_argument("--targets")
parser.add_argument("--palette")
args = parser.parse_args()
config = _load_config(args.config)
targets_text = args.targets or ",".join(config.get("targets", DEFAULT_TARGETS))
targets = {x.strip().upper() for x in targets_text.split(",") if x.strip()}
palette = args.palette or config.get("palette", "DAY_BRIGHT")
sprite_scope = str(config.get("sprite_scope", "all"))
if sprite_scope not in {"all", "target"}:
raise SystemExit(f"invalid sprite_scope: {sprite_scope}")
source_id = config.get("source_id", "navsea_delivery")
source_layer = config.get("source_layer", "navigation_marks")
sprite_url = config.get("sprite_url", "./sprite")
tiles_template = config.get("tiles_template", "https://REPLACE-ME/{z}/{x}/{y}.pbf")
xml_path = args.s57data / "chartsymbols.xml"
if not xml_path.is_file():
raise SystemExit(f"missing chartsymbols.xml: {xml_path}")
root = ET.parse(xml_path).getroot()
symbols = _parse_symbols(root)
patterns = _parse_patterns(root)
lookups = _parse_lookups(root, targets)
color_tables = _parse_color_tables(root)
if palette not in color_tables:
raise SystemExit(f"unknown palette {palette}; choices={sorted(color_tables)}")
atlas_name = color_tables[palette].get("graphics_file")
if not atlas_name:
raise SystemExit(f"palette {palette} has no graphics-file")
atlas_path = args.s57data / atlas_name
if not atlas_path.is_file():
raise SystemExit(f"missing atlas: {atlas_path}")
atlas_width, atlas_height = _png_size(atlas_path)
direct_symbols = sorted({name for row in lookups for name in row["sy"]})
direct_patterns = sorted({name for row in lookups for name in row["ap"]})
csp_names = sorted({name for row in lookups for name in row["cs"]})
sprite_symbols = (
sorted(name for name, symbol in symbols.items() if symbol.get("bitmap") is not None)
if sprite_scope == "all"
else direct_symbols
)
sprite_patterns = (
sorted(name for name, pattern in patterns.items() if pattern.get("bitmap") is not None)
if sprite_scope == "all"
else direct_patterns
)
metadata: dict[str, dict[str, Any]] = {}
pattern_metadata: dict[str, dict[str, Any]] = {}
missing_symbols: list[str] = []
vector_only_symbols: list[str] = []
out_of_bounds_symbols: list[str] = []
for name in sprite_symbols:
symbol = symbols.get(name)
if symbol is None:
missing_symbols.append(name)
continue
bitmap = symbol.get("bitmap")
if bitmap is None:
vector_only_symbols.append(name)
continue
if (
bitmap["x"] < 0
or bitmap["y"] < 0
or bitmap["x"] + bitmap["width"] > atlas_width
or bitmap["y"] + bitmap["height"] > atlas_height
):
out_of_bounds_symbols.append(name)
continue
metadata[name] = {
**symbol,
"maplibre_icon_offset": _icon_offset(bitmap),
}
for name in sprite_patterns:
pattern = patterns.get(name)
if pattern is None:
continue
bitmap = pattern.get("bitmap")
if bitmap is None:
continue
if (
bitmap["x"] < 0
or bitmap["y"] < 0
or bitmap["x"] + bitmap["width"] > atlas_width
or bitmap["y"] + bitmap["height"] > atlas_height
):
continue
pattern_metadata[name] = dict(pattern)
collisions = sorted(set(metadata) & set(pattern_metadata))
if collisions:
raise SystemExit(f"sprite symbol/pattern name collision: {collisions}")
output = args.output
output.mkdir(parents=True, exist_ok=True)
shutil.copy2(atlas_path, output / "sprite.png")
sprite_json = {
name: _sprite_entry(metadata[name]["bitmap"])
for name in sorted(metadata)
}
sprite_json.update({
name: _sprite_entry(pattern_metadata[name]["bitmap"])
for name in sorted(pattern_metadata)
})
(output / "sprite.json").write_text(
json.dumps(sprite_json, ensure_ascii=False, indent=2), encoding="utf-8"
)
(output / "s52_symbol_metadata.json").write_text(
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
)
(output / "s52_pattern_metadata.json").write_text(
json.dumps(pattern_metadata, ensure_ascii=False, indent=2), encoding="utf-8"
)
(output / "s52_target_lookups.json").write_text(
json.dumps(lookups, ensure_ascii=False, indent=2), encoding="utf-8"
)
(output / "s52_colors.json").write_text(
json.dumps(color_tables, ensure_ascii=False, indent=2), encoding="utf-8"
)
conditional_rows = [row for row in lookups if row["cs"]]
static_rows = [row for row in lookups if row["sy"] and not row["cs"]]
(output / "s52_csp_inventory.json").write_text(
json.dumps(conditional_rows, ensure_ascii=False, indent=2), encoding="utf-8"
)
style = _build_style(
source_id, source_layer, sprite_url, tiles_template, metadata
)
(output / "style.poc.json").write_text(
json.dumps(style, ensure_ascii=False, indent=2), encoding="utf-8"
)
by_class = Counter(row["object_class"] for row in lookups)
direct_missing = sorted(name for name in direct_symbols if name not in symbols)
report = {
"schema": "navsea.cm95.s52-maplibre-poc/v1",
"targets": sorted(targets),
"palette": palette,
"atlas": {"file": atlas_name, "width": atlas_width, "height": atlas_height},
"lookup_count": len(lookups),
"lookup_count_by_object_class": dict(sorted(by_class.items())),
"static_direct_symbol_lookup_count": len(static_rows),
"conditional_lookup_count": len(conditional_rows),
"conditional_procedures": csp_names,
"direct_symbol_reference_count": len(direct_symbols),
"direct_pattern_reference_count": len(direct_patterns),
"sprite_scope": sprite_scope,
"sprite_symbol_entry_count": len(metadata),
"sprite_pattern_entry_count": len(pattern_metadata),
"sprite_entry_count": len(sprite_json),
"missing_symbol_definitions": direct_missing,
"vector_only_symbol_definitions": vector_only_symbols,
"atlas_out_of_bounds": out_of_bounds_symbols,
"production_boundary": {
"static_lookup_matching": "materialize_in_cm95_builder",
"pbf_symbol_field": "s52_symbol_code",
"conditional_symbology": "explicit_builder_derived_or_runtime_renderer",
"formal_release": "not_modified_by_this_poc",
},
}
(output / "audit.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())