diff --git a/config/s52/cm95_s52_style_compiler_v1.json b/config/s52/cm95_s52_style_compiler_v1.json index eaec23f..c0bfbda 100644 --- a/config/s52/cm95_s52_style_compiler_v1.json +++ b/config/s52/cm95_s52_style_compiler_v1.json @@ -19,18 +19,21 @@ "maxzoom": 12 }, "resources": { - "colors": "build/s52_poc_patterns_v1/s52_colors.json", - "symbol_metadata": "build/s52_poc_patterns_v1/s52_symbol_metadata.json", - "bitmap_pattern_metadata": "build/s52_poc_patterns_v1/s52_pattern_metadata.json", - "vector_pattern_metadata": "build/s52_ap_v3/s52-sprite-lc-ap-pattern-metadata.json", - "line_metadata": "build/s52_lc_v1/s52-sprite-lc-line-metadata.json", - "sprite_index": "build/s52_ap_v3/s52-sprite-lc-ap.json", - "lookup_ir": "build/s52_presentation_ir/lookup_rule_ir.json" + "colors": "build/s52_resources_v2/base/s52_colors.json", + "symbol_metadata": "build/s52_resources_v2/base/s52_symbol_metadata.json", + "bitmap_pattern_metadata": "build/s52_resources_v2/base/s52_pattern_metadata.json", + "vector_pattern_metadata": "build/s52_resources_v2/ap/s52-sprite-ap-pattern-metadata.json", + "line_metadata": "build/s52_resources_v2/final/s52-sprite-full-line-metadata.json", + "sprite_index": "build/s52_resources_v2/final/s52-sprite-full.json", + "lookup_ir": "build/s52_presentation_ir_v2/lookup_rule_ir.json", + "resource_contract": "build/s52_resources_v2/resource_contract.json" }, "source_layers": [ {"name": "anchor_caution_hazard_area", "capabilities": ["fill", "line", "symbol", "text"]}, {"name": "anchor_caution_hazard_point", "capabilities": ["symbol", "text"]}, {"name": "anchorage_area", "capabilities": ["fill", "line", "symbol", "text"]}, + {"name": "anchorage_outline", "capabilities": ["line", "symbol", "text"]}, + {"name": "anchorage_point", "capabilities": ["symbol", "text"]}, {"name": "baseline_area", "capabilities": ["fill", "line", "symbol", "text"]}, {"name": "baseline_line", "capabilities": ["line", "symbol", "text"]}, {"name": "bridge_structure", "capabilities": ["fill", "line", "symbol", "text"]}, diff --git a/src/cm95pbf/__init__.py b/src/cm95pbf/__init__.py index 7379af6..87b1952 100644 --- a/src/cm95pbf/__init__.py +++ b/src/cm95pbf/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.4.8-s52-mariner-runtime-boundary" -BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.8-s52-mariner-runtime-boundary" +__version__ = "0.4.10-s52-resource-contract" +BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.10-s52-resource-contract" diff --git a/src/cm95pbf/cli.py b/src/cm95pbf/cli.py index e2c25fa..7f0fc8e 100644 --- a/src/cm95pbf/cli.py +++ b/src/cm95pbf/cli.py @@ -135,6 +135,11 @@ def build_parser() -> argparse.ArgumentParser: prod_plan.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) prod_plan.add_argument("--output", default="build/production/cm95_production_plan_v2.json") prod_plan.add_argument("--s52-lookups", default=None, help="S-52 lookup_rule_ir.json;提供后冻结 SHA256 并进入 production builder") + prod_plan.add_argument( + "--s52-resource-contract", + default=None, + help="S-52 resource_contract.json;S-52 正式生产必须为 PASS,并冻结 SHA256", + ) prod_plan.add_argument("--s52-point-table", choices=["Simplified", "Paper"], default="Paper") prod_plan.add_argument("--s52-boundary-table", choices=["Plain", "Symbolized"], default="Plain") prod_plan.add_argument("--s52-mariner-mode", choices=["runtime", "reference"], default="runtime") @@ -232,6 +237,11 @@ def main() -> None: zooms=parse_zooms(args.zooms), profile=args.profile, s52_lookups_path=(Path(args.s52_lookups) if args.s52_lookups else None), + s52_resource_contract_path=( + Path(args.s52_resource_contract) + if args.s52_resource_contract + else None + ), s52_point_table=args.s52_point_table, s52_boundary_table=args.s52_boundary_table, s52_mariner_mode=args.s52_mariner_mode, @@ -306,6 +316,7 @@ def main() -> None: "contract_unmatched_count": (result.get("style_sprite_contract") or {}).get("contract_unmatched_count"), "sprite_missing_count": (result.get("style_sprite_contract") or {}).get("sprite_missing_count"), }, + "s52_resource_contract": result.get("s52_resource_contract"), "s52_runtime_contract": result.get("s52_runtime_contract"), "quantization_fallbacks": result.get("quantization_fallbacks"), "quantization_omissions": result.get("quantization_omissions"), diff --git a/src/cm95pbf/csp.py b/src/cm95pbf/csp.py index b3359e9..c5b33c4 100644 --- a/src/cm95pbf/csp.py +++ b/src/cm95pbf/csp.py @@ -636,7 +636,10 @@ def resolve_obstrn04_instruction( instruction = "SY(DANGER51)" sounding = True else: - if watlev in {1, 2}: + if watlev == -9: + instruction = "SY(DANGER01)" + sounding = True + elif watlev in {1, 2}: instruction = "SY(LNDARE01)" elif watlev == 3: instruction = "SY(DANGER52)" @@ -834,7 +837,7 @@ def resolve_slcons03_instruction( instructions.append("AP(CROSSX01)") if quapos_present and 2 <= quapos < 10: - instructions.append("LC(LOWACC01)") + instructions.append("LC(LOWACC21)") return ";".join(instructions) condtn = _int_attr(attrs, "CONDTN", 0) diff --git a/src/cm95pbf/production.py b/src/cm95pbf/production.py index f3f617a..3475435 100644 --- a/src/cm95pbf/production.py +++ b/src/cm95pbf/production.py @@ -35,7 +35,7 @@ from .policy import ( PRODUCTION_PLAN_SCHEMA_V1 = "navsea.cm95.production-plan/v1" PRODUCTION_PLAN_SCHEMA = "navsea.cm95.production-plan/v2" SUPPORTED_PRODUCTION_PLAN_SCHEMAS = {PRODUCTION_PLAN_SCHEMA_V1, PRODUCTION_PLAN_SCHEMA} -PRODUCTION_PIPELINE_VERSION = "cm95pbf-production/2.1-s52-aoi-complete" +PRODUCTION_PIPELINE_VERSION = "cm95pbf-production/2.2-s52-resource-contract" PRODUCTION_RUN_SCHEMA = "navsea.cm95.production-run/v1" TILE_ENUMERATION_MODES = {"auto", "coverage", "aoi-complete"} @@ -277,6 +277,7 @@ def create_production_plan( profile: str = "delivery", *, s52_lookups_path: Path | None = None, + s52_resource_contract_path: Path | None = None, s52_point_table: str = "Paper", s52_boundary_table: str = "Plain", s52_mariner_mode: str = "runtime", @@ -302,6 +303,35 @@ def create_production_plan( s52_path = Path(s52_lookups_path) if s52_lookups_path is not None else None if s52_path is not None and not s52_path.exists(): raise RuntimeError(f"S-52 lookup file not found: {s52_path}") + resource_contract_path = ( + Path(s52_resource_contract_path) + if s52_resource_contract_path is not None + else None + ) + if s52_path is not None and resource_contract_path is None: + raise RuntimeError( + "S-52 production requires --s52-resource-contract; " + "resource availability must be frozen before production" + ) + resource_contract_obj: dict[str, Any] | None = None + if resource_contract_path is not None: + if not resource_contract_path.exists(): + raise RuntimeError( + f"S-52 resource contract not found: {resource_contract_path}" + ) + resource_contract_obj = json.loads( + resource_contract_path.read_text(encoding="utf-8") + ) + if resource_contract_obj.get("schema") != "navsea.cm95.s52-resource-contract/v1": + raise RuntimeError( + f"unsupported S-52 resource contract schema: " + f"{resource_contract_obj.get('schema')}" + ) + if resource_contract_obj.get("status") != "PASS": + raise RuntimeError( + "S-52 resource contract is not PASS: " + f"{resource_contract_obj.get('failures')}" + ) aoi_bbox = None aoi_box_m = None @@ -417,6 +447,31 @@ def create_production_plan( s52_contract = { "enabled": s52_path is not None, "lookups_sha256": sha256_file(s52_path) if s52_path is not None else None, + "resource_contract_sha256": ( + sha256_file(resource_contract_path) + if resource_contract_path is not None + else None + ), + "resource_contract_status": ( + resource_contract_obj.get("status") + if resource_contract_obj is not None + else None + ), + "resource_contract_reference": ( + resource_contract_obj.get("reference") + if resource_contract_obj is not None + else None + ), + "resource_defined_counts": ( + resource_contract_obj.get("defined_resource_counts") + if resource_contract_obj is not None + else None + ), + "resource_active_counts": ( + resource_contract_obj.get("active_resource_counts") + if resource_contract_obj is not None + else None + ), "point_table": s52_point_table if s52_path is not None else None, "boundary_table": s52_boundary_table if s52_path is not None else None, "mariner_mode": s52_mariner_mode if s52_path is not None else None, @@ -452,6 +507,9 @@ def create_production_plan( "policy_path": str(policy_path), "manifest_path": str(manifest_path), "s52_lookups_path": str(s52_path) if s52_path is not None else None, + "s52_resource_contract_path": ( + str(resource_contract_path) if resource_contract_path is not None else None + ), "source_stats": source_stats, "job_count": len(job_rows), } @@ -637,6 +695,25 @@ def _validate_plan_inputs(db: DbConfig, plan: dict[str, Any]): raise RuntimeError(f"S-52 lookup file missing: {s52_path}") if sha256_file(s52_path) != str(s52.get("lookups_sha256") or ""): raise RuntimeError("S-52 lookup file changed after plan creation") + resource_path_raw = plan.get("s52_resource_contract_path") + if s52.get("resource_contract_sha256"): + if not resource_path_raw: + raise RuntimeError("S-52 enabled plan missing s52_resource_contract_path") + resource_path = Path(resource_path_raw) + if not resource_path.exists(): + raise RuntimeError(f"S-52 resource contract missing: {resource_path}") + if sha256_file(resource_path) != str( + s52.get("resource_contract_sha256") or "" + ): + raise RuntimeError( + "S-52 resource contract changed after plan creation" + ) + resource_obj = json.loads(resource_path.read_text(encoding="utf-8")) + if resource_obj.get("status") != "PASS": + raise RuntimeError( + "S-52 resource contract no longer PASS: " + f"{resource_obj.get('failures')}" + ) with _connect(db) as conn: fp = dataset_fingerprint(conn, int(plan["dataset_id"])) if fp != plan["dataset_fingerprint"]: diff --git a/src/cm95pbf/release.py b/src/cm95pbf/release.py index b488fa3..fb43f56 100644 --- a/src/cm95pbf/release.py +++ b/src/cm95pbf/release.py @@ -496,7 +496,58 @@ def finalize_release( else "FAIL" ) + s52_resource_contract = { + "status": "NOT_APPLICABLE", + "path": plan.get("s52_resource_contract_path"), + "sha256": None, + "expected_sha256": (plan.get("s52") or {}).get("resource_contract_sha256"), + "defined_resource_counts": None, + "active_resource_counts": None, + "failures": [], + } + expected_resource_sha = str( + (plan.get("s52") or {}).get("resource_contract_sha256") or "" + ) + if expected_resource_sha: + resource_path_raw = plan.get("s52_resource_contract_path") + resource_failures: list[str] = [] + resource_obj: dict[str, Any] = {} + if not resource_path_raw: + resource_failures.append("missing_resource_contract_path") + else: + resource_path = Path(resource_path_raw) + if not resource_path.exists(): + resource_failures.append("resource_contract_file_missing") + else: + actual_sha = sha256_file(resource_path) + s52_resource_contract["sha256"] = actual_sha + if actual_sha != expected_resource_sha: + resource_failures.append("resource_contract_sha256_mismatch") + try: + resource_obj = json.loads( + resource_path.read_text(encoding="utf-8") + ) + except Exception as exc: + resource_failures.append( + f"resource_contract_parse_error:{type(exc).__name__}" + ) + else: + if resource_obj.get("status") != "PASS": + resource_failures.append("resource_contract_not_pass") + s52_resource_contract["defined_resource_counts"] = ( + resource_obj.get("defined_resource_counts") + ) + s52_resource_contract["active_resource_counts"] = ( + resource_obj.get("active_resource_counts") + ) + s52_resource_contract["failures"] = resource_failures + s52_resource_contract["status"] = ( + "PASS" if not resource_failures else "FAIL" + ) + failures = [] + if expected_resource_sha and s52_resource_contract["status"] != "PASS": + failures.append("s52_resource_contract") if tile_enumeration_mode == "aoi-complete": if len(report_files) != int(plan["job_count"]): failures.append("aoi_complete_report_count_mismatch") @@ -537,6 +588,7 @@ def finalize_release( "tile_footprint_contract": tile_footprint_contract, "s52": plan.get("s52"), "aoi": plan.get("aoi"), + "s52_resource_contract": s52_resource_contract, "s52_runtime_contract": s52_runtime_contract, "style_sha256": style_sha, "sprite_json_sha256": sprite_sha, diff --git a/tools/cm95_s52_line_sprite.py b/tools/cm95_s52_line_sprite.py index 9d174ca..bac571d 100644 --- a/tools/cm95_s52_line_sprite.py +++ b/tools/cm95_s52_line_sprite.py @@ -81,6 +81,8 @@ def _line_style_svg( nominal_width = max(1.0, math.floor((dpi / 25.4) / 5.0)) current: tuple[float, float] | None = None elements: list[str] = [] + polygon_mode = False + polygon_circles: list[tuple[tuple[float, float], float, str]] = [] def cv(p: tuple[float, float]) -> tuple[float, float]: return ((p[0] - ox) * unit_to_px, (p[1] - oy) * unit_to_px) @@ -125,13 +127,30 @@ def _line_style_svg( 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}") + if polygon_mode: + polygon_circles.append(((cx, cy), radius, color)) + else: + stroke = max(1.0, pen_width * nominal_width) + elements.append( + f'' + ) + elif cmd == "ST": + # Symbol transparency command; PLNRTE03 uses ST0 before a filled circle. + continue + elif cmd == "PM": + if arg == "0": + polygon_mode = True + polygon_circles = [] + elif arg == "2": + polygon_mode = False + elif cmd == "FP": + for (cx, cy), radius, color in polygon_circles: + elements.append( + f'' + ) + polygon_circles = [] + polygon_mode = False else: raise RuntimeError(f"unsupported HPGL {cmd} in line style {name}") diff --git a/tools/cm95_s52_maplibre_poc.py b/tools/cm95_s52_maplibre_poc.py index 92c360d..dffd07c 100644 --- a/tools/cm95_s52_maplibre_poc.py +++ b/tools/cm95_s52_maplibre_poc.py @@ -4,10 +4,13 @@ from __future__ import annotations import argparse from collections import Counter 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 @@ -54,6 +57,25 @@ def _parse_bitmap(node: ET.Element) -> dict[str, int] | None: } +def _parse_vector(node: ET.Element) -> dict[str, Any] | None: + vector = node.find("vector") + if vector is None: + return None + origin = vector.find("origin") + pivot = vector.find("pivot") + if origin is None or pivot is None: + return None + return { + "width": int(vector.attrib.get("width", "0")), + "height": int(vector.attrib.get("height", "0")), + "origin_x": float(origin.attrib["x"]), + "origin_y": float(origin.attrib["y"]), + "pivot_x": float(pivot.attrib["x"]), + "pivot_y": float(pivot.attrib["y"]), + "hpgl": _text(vector, "HPGL"), + } + + def _parse_symbols(root: ET.Element) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} parent = root.find("symbols") @@ -63,13 +85,25 @@ def _parse_symbols(root: ET.Element) -> dict[str, dict[str, Any]]: name = _text(node, "name") if not name: continue - out[name] = { + candidate = { "name": name, "description": _text(node, "description"), "definition": _text(node, "definition") or None, "color_ref": _text(node, "color-ref") or None, "bitmap": _parse_bitmap(node), + "vector": _parse_vector(node), } + current = out.get(name) + if current is None: + out[name] = candidate + continue + # OpenCPN chartsymbols.xml contains duplicate vector/raster definitions + # for several sounding symbols. Prefer a real bitmap when available so + # the MapLibre sprite matches the raster symbol library exactly. + if current.get("bitmap") is None and candidate.get("bitmap") is not None: + out[name] = candidate + elif current.get("bitmap") is None and current.get("vector") is None: + out[name] = candidate return out @@ -151,6 +185,140 @@ def _parse_color_tables(root: ET.Element) -> dict[str, dict[str, Any]]: return out +def _pen_colors(color_ref: str | None, colors: dict[str, Any]) -> 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 + rgb = colors.get(token[1:]) + if isinstance(rgb, dict): + out[token[0]] = ( + f"#{int(rgb['r']):02x}{int(rgb['g']):02x}{int(rgb['b']):02x}" + ) + return out or {"A": "#000000"} + + +def _hpgl_points(text: str) -> list[tuple[float, float]]: + nums = [float(x) for x in text.split(",") if x.strip()] + return [(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)] + + +def _vector_symbol_svg( + symbol: dict[str, Any], + colors: dict[str, Any], + *, + dpi: float = 96.0, +) -> tuple[str, dict[str, float]]: + vector = symbol.get("vector") + if not isinstance(vector, dict): + raise RuntimeError(f"symbol {symbol.get('name')} has no vector definition") + unit = (dpi / 25.4) / 100.0 + pen_colors = _pen_colors(symbol.get("color_ref"), colors) + pen = next(iter(pen_colors)) + pen_width = 1.0 + current: tuple[float, float] | None = None + primitives: list[tuple[str, Any]] = [] + minx = miny = float("inf") + maxx = maxy = float("-inf") + + def include(x: float, y: float) -> None: + nonlocal minx, miny, maxx, maxy + minx = min(minx, x) + miny = min(miny, y) + maxx = max(maxx, x) + maxy = max(maxy, y) + + for raw in str(vector.get("hpgl") or "").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 = _hpgl_points(arg) + if pts: + current = pts[-1] + include(*current) + elif cmd == "PD": + pts = _hpgl_points(arg) + for target in pts: + if current is None: + current = target + include(*current) + continue + primitives.append( + ("line", (current, target, pen_colors.get(pen, "#000000"), pen_width)) + ) + include(*current) + include(*target) + current = target + elif cmd == "CI" and current is not None: + radius = float(arg) + primitives.append( + ("circle", (current, radius, pen_colors.get(pen, "#000000"), pen_width)) + ) + include(current[0] - radius, current[1] - radius) + include(current[0] + radius, current[1] + radius) + else: + raise RuntimeError( + f"unsupported HPGL {cmd} in vector symbol {symbol.get('name')}" + ) + + if not primitives or not math.isfinite(minx) or not math.isfinite(miny): + raise RuntimeError(f"vector symbol {symbol.get('name')} rendered no primitives") + + pad = 1.5 + width = max(1, int(math.ceil((maxx - minx) * unit + 2 * pad))) + height = max(1, int(math.ceil((maxy - miny) * unit + 2 * pad))) + + def cv(point: tuple[float, float]) -> tuple[float, float]: + return ( + (point[0] - minx) * unit + pad, + (point[1] - miny) * unit + pad, + ) + + parts: list[str] = [] + for kind, data in primitives: + if kind == "line": + p1, p2, color, width_hpgl = data + x1, y1 = cv(p1) + x2, y2 = cv(p2) + stroke = max(1.0, float(width_hpgl)) + parts.append( + f'' + ) + else: + center, radius, color, width_hpgl = data + cx, cy = cv(center) + r = max(0.5, float(radius) * unit) + stroke = max(1.0, float(width_hpgl)) + parts.append( + f'' + ) + + pivot = cv((float(vector["pivot_x"]), float(vector["pivot_y"]))) + svg = ( + f'' + "".join(parts) + "" + ) + return svg, { + "width": width, + "height": height, + "pivot_x": pivot[0], + "pivot_y": pivot[1], + } + + def _sprite_entry(bitmap: dict[str, int]) -> dict[str, int]: return { "x": bitmap["x"], @@ -270,7 +438,7 @@ def main() -> int: 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) + sorted(symbols) if sprite_scope == "all" else direct_symbols ) @@ -283,6 +451,7 @@ def main() -> int: pattern_metadata: dict[str, dict[str, Any]] = {} missing_symbols: list[str] = [] vector_only_symbols: list[str] = [] + vector_render_names: list[str] = [] out_of_bounds_symbols: list[str] = [] for name in sprite_symbols: @@ -293,6 +462,8 @@ def main() -> int: bitmap = symbol.get("bitmap") if bitmap is None: vector_only_symbols.append(name) + if symbol.get("vector") is not None: + vector_render_names.append(name) continue if ( bitmap["x"] < 0 @@ -323,13 +494,9 @@ def main() -> int: 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) @@ -338,6 +505,66 @@ def main() -> int: name: _sprite_entry(pattern_metadata[name]["bitmap"]) for name in sorted(pattern_metadata) }) + + if vector_render_names: + if shutil.which("convert") is None: + raise SystemExit("ImageMagick convert is required for vector-only S-52 symbols") + colors = color_tables[palette].get("colors") or {} + with tempfile.TemporaryDirectory(prefix="cm95-s52-symbol-") as td: + tmp = Path(td) + pngs: list[Path] = [] + y = atlas_height + for index, name in enumerate(vector_render_names): + symbol = symbols[name] + svg, vector_meta = _vector_symbol_svg(symbol, colors) + 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, + ) + width, height = _png_size(png_path) + bitmap = { + "x": 0, + "y": y, + "width": width, + "height": height, + "pivot_x": int(round(vector_meta["pivot_x"])), + "pivot_y": int(round(vector_meta["pivot_y"])), + } + materialized = { + **symbol, + "bitmap": bitmap, + "maplibre_icon_offset": _icon_offset(bitmap), + "materialized_from_vector": True, + } + metadata[name] = materialized + sprite_json[name] = _sprite_entry(bitmap) + y += height + pngs.append(png_path) + + subprocess.run( + [ + "convert", + str(atlas_path), + *[str(p) for p in pngs], + "-background", + "none", + "-gravity", + "west", + "-append", + str(output / "sprite.png"), + ], + check=True, + ) + else: + shutil.copy2(atlas_path, output / "sprite.png") + + collisions = sorted(set(metadata) & set(pattern_metadata)) + if collisions: + raise SystemExit(f"sprite symbol/pattern name collision: {collisions}") + (output / "sprite.json").write_text( json.dumps(sprite_json, ensure_ascii=False, indent=2), encoding="utf-8" ) diff --git a/tools/cm95_s52_resource_contract.py b/tools/cm95_s52_resource_contract.py new file mode 100755 index 0000000..e697d63 --- /dev/null +++ b/tools/cm95_s52_resource_contract.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import xml.etree.ElementTree as ET +from typing import Any + + +EXPECTED_COUNTS = {"SY": 1018, "AP": 30, "LC": 59} +EXPECTED_TOTAL = 1107 +EXPECTED_ACTIVE_TOTAL = 977 + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def _text(node: ET.Element, tag: str) -> str: + child = node.find(tag) + return (child.text or "").strip() if child is not None else "" + + +def _resource_definitions(root: ET.Element) -> dict[str, set[str]]: + return { + "SY": {_text(n, "name") for n in root.findall("./symbols/symbol") if _text(n, "name")}, + "AP": {_text(n, "name") for n in root.findall("./patterns/pattern") if _text(n, "name")}, + "LC": {_text(n, "name") for n in root.findall("./line-styles/line-style") if _text(n, "name")}, + } + + +def _lookup_refs(root: ET.Element) -> dict[str, set[str]]: + out = {"SY": set(), "AP": set(), "LC": set()} + for node in root.findall("./lookups/lookup"): + instruction = _text(node, "instruction") + for opcode in out: + for match in re.finditer(r"\b" + opcode + r"\(([^,;)]+)", instruction): + out[opcode].add(match.group(1).strip()) + return out + + +def _active_cpp_text(path: Path) -> str: + text = path.read_text(encoding="utf-8", errors="replace") + lines: list[str] = [] + disabled = 0 + for line in text.splitlines(): + stripped = line.strip() + if re.match(r"#if\s+0\b", stripped): + disabled += 1 + continue + if disabled: + if re.match(r"#(?:if|ifdef|ifndef)\b", stripped): + disabled += 1 + elif re.match(r"#endif\b", stripped): + disabled -= 1 + continue + lines.append(line) + text = "\n".join(lines) + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"//.*", "", text) + + +def _cpp_refs(path: Path) -> dict[str, set[str]]: + text = _active_cpp_text(path) + out = {"SY": set(), "AP": set(), "LC": set()} + for opcode in out: + for match in re.finditer(r"\b" + opcode + r"\(([^,;)]+)", text): + name = match.group(1).strip().strip('"') + if "%" not in name: + out[opcode].add(name) + return out + + +def _load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _built_resources(args: argparse.Namespace) -> tuple[dict[str, set[str]], dict[str, str]]: + sprite = _load_json(args.sprite_index) + symbols = _load_json(args.symbol_metadata) + bitmap_patterns = _load_json(args.bitmap_pattern_metadata) + vector_patterns = _load_json(args.vector_pattern_metadata) + line_metadata = _load_json(args.line_metadata) + + built_sy = {name for name in symbols if name in sprite} + built_ap = {name for name in bitmap_patterns if name in sprite} + for name, meta in vector_patterns.items(): + key = str((meta or {}).get("sprite_key") or "") + if key and key in sprite: + built_ap.add(name) + built_lc: set[str] = set() + for name, meta in line_metadata.items(): + key = str((meta or {}).get("sprite_key") or "") + if key and key in sprite: + built_lc.add(name) + hashes = { + "symbol_metadata_sha256": sha256_file(args.symbol_metadata), + "bitmap_pattern_metadata_sha256": sha256_file(args.bitmap_pattern_metadata), + "vector_pattern_metadata_sha256": sha256_file(args.vector_pattern_metadata), + "line_metadata_sha256": sha256_file(args.line_metadata), + "sprite_index_sha256": sha256_file(args.sprite_index), + } + return {"SY": built_sy, "AP": built_ap, "LC": built_lc}, hashes + + +def main() -> int: + ap = argparse.ArgumentParser(description="Audit the frozen CM95/OpenCPN S-52 resource contract.") + ap.add_argument("--chartsymbols", required=True, type=Path) + ap.add_argument("--opencpn-csp-source", required=True, type=Path) + ap.add_argument("--symbol-metadata", required=True, type=Path) + ap.add_argument("--bitmap-pattern-metadata", required=True, type=Path) + ap.add_argument("--vector-pattern-metadata", required=True, type=Path) + ap.add_argument("--line-metadata", required=True, type=Path) + ap.add_argument("--sprite-index", required=True, type=Path) + ap.add_argument("--reference-commit", required=True) + ap.add_argument("--output", required=True, type=Path) + args = ap.parse_args() + + root = ET.parse(args.chartsymbols).getroot() + defined = _resource_definitions(root) + lookup = _lookup_refs(root) + cpp = _cpp_refs(args.opencpn_csp_source) + active = {kind: (lookup[kind] | cpp[kind]) & defined[kind] for kind in defined} + active["SY"] |= { + name for name in defined["SY"] + if re.fullmatch(r"SOUND[GS](?:[0-5][0-9]|A1|B1|C2)", name) + } + + built, artifact_hashes = _built_resources(args) + defined_missing = {kind: sorted(defined[kind] - built[kind]) for kind in defined} + active_missing = {kind: sorted(active[kind] - built[kind]) for kind in defined} + unresolved = { + kind: sorted((lookup[kind] | cpp[kind]) - defined[kind]) + for kind in defined + } + counts = {kind: len(defined[kind]) for kind in defined} + active_counts = {kind: len(active[kind]) for kind in active} + failures: list[str] = [] + if counts != EXPECTED_COUNTS or sum(counts.values()) != EXPECTED_TOTAL: + failures.append("reference_resource_count_mismatch") + if sum(active_counts.values()) != EXPECTED_ACTIVE_TOTAL: + failures.append("reference_active_resource_count_mismatch") + if any(defined_missing.values()): + failures.append("defined_resource_missing_from_bundle") + if any(active_missing.values()): + failures.append("active_resource_missing_from_bundle") + + result = { + "schema": "navsea.cm95.s52-resource-contract/v1", + "status": "PASS" if not failures else "FAIL", + "reference": { + "project": "OpenCPN", + "commit": args.reference_commit, + "chartsymbols_sha256": sha256_file(args.chartsymbols), + "csp_source_sha256": sha256_file(args.opencpn_csp_source), + }, + "defined_resource_counts": {**counts, "total": sum(counts.values())}, + "active_resource_counts": {**active_counts, "total": sum(active_counts.values())}, + "built_resource_counts": { + **{kind: len(built[kind]) for kind in built}, + "total": sum(len(built[kind]) for kind in built), + }, + "defined_missing_from_bundle": defined_missing, + "active_missing_from_bundle": active_missing, + "upstream_undefined_references": unresolved, + "undefined_reference_fallback": { + "SY": "QUESMRK1", + "LC": "QUESMRK1 symbol fallback", + "AP": "QUESMRK1 pattern fallback", + "source": "OpenCPN S52PLIB StringToRules", + }, + "artifact_hashes": artifact_hashes, + "failures": failures, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if not failures else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cm95_s52_style_compiler.py b/tools/cm95_s52_style_compiler.py index 16a2b7d..46e4490 100755 --- a/tools/cm95_s52_style_compiler.py +++ b/tools/cm95_s52_style_compiler.py @@ -287,6 +287,110 @@ def _scan_pbf_usage(path: Path) -> dict[str, Any]: } +def _merge_pbf_usages(usages: list[dict[str, Any]], label: str) -> dict[str, Any]: + global_values: dict[str, set[str]] = {field: set() for field in USAGE_VALUE_FIELDS} + layers: dict[str, dict[str, Any]] = {} + geometry_ignored: Counter[tuple[str, str, str]] = Counter() + render_kind_counts: dict[str, Counter[tuple[str, str, str]]] = {} + + for usage in usages: + for field, values in (usage.get("global_values") or {}).items(): + global_values.setdefault(field, set()).update(str(value) for value in values) + for layer_name, row in (usage.get("layers") or {}).items(): + merged = layers.setdefault( + layer_name, + { + "feature_count": 0, + "fields_present": set(), + "field_values": {}, + "field_priorities": {}, + }, + ) + merged["feature_count"] += int(row.get("feature_count") or 0) + merged["fields_present"].update(row.get("fields_present") or []) + for field, values in (row.get("field_values") or {}).items(): + merged["field_values"].setdefault(field, set()).update( + str(value) for value in values + ) + for field, values in (row.get("field_priorities") or {}).items(): + merged["field_priorities"].setdefault(field, set()).update( + str(value) for value in values + ) + + for row in usage.get("geometry_ignored") or []: + key = ( + str(row.get("kind") or ""), + str(row.get("source_layer") or ""), + str(row.get("geometry_type") or ""), + ) + geometry_ignored[key] += int(row.get("feature_count") or 0) + + for kind, rows in (usage.get("render_kind_counts") or {}).items(): + counter = render_kind_counts.setdefault(kind, Counter()) + for row in rows: + key = ( + str(row.get("source_layer") or ""), + str(row.get("priority") or ""), + str(row.get("category") or ""), + ) + counter[key] += int(row.get("feature_count") or 0) + + normalized_layers = {} + for layer_name, row in layers.items(): + normalized_layers[layer_name] = { + "feature_count": int(row["feature_count"]), + "fields_present": sorted(row["fields_present"]), + "field_values": { + key: sorted(values) + for key, values in row["field_values"].items() + if values + }, + "field_priorities": { + key: sorted(values) + for key, values in row["field_priorities"].items() + if values + }, + } + + return { + "pbf": label, + "pbf_count": len(usages), + "layers": normalized_layers, + "global_values": { + key: sorted(values) for key, values in global_values.items() if values + }, + "geometry_ignored": [ + { + "kind": kind, + "source_layer": layer, + "geometry_type": geometry, + "feature_count": count, + } + for (kind, layer, geometry), count in sorted(geometry_ignored.items()) + ], + "render_kind_counts": { + kind: [ + { + "source_layer": layer, + "priority": priority, + "category": category, + "feature_count": count, + } + for (layer, priority, category), count in sorted(counter.items()) + ] + for kind, counter in render_kind_counts.items() + if counter + }, + } + + +def _scan_pbf_directory(path: Path) -> dict[str, Any]: + files = sorted(path.rglob("*.pbf")) + if not files: + raise SystemExit(f"--usage-pbf-dir contains no PBF files: {path}") + usages = [_scan_pbf_usage(file) for file in files] + return _merge_pbf_usages(usages, str(path)) + def _field_used( usage: dict[str, Any] | None, layer_name: str, field: str, priority: str ) -> bool: @@ -318,6 +422,7 @@ def _compile_style( line_metadata: dict[str, dict[str, Any]], sprite_index: dict[str, Any], lookup_ir: list[dict[str, Any]], + resource_contract: dict[str, Any] | None = None, usage: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: source = dict(config["source"]) @@ -331,6 +436,10 @@ def _compile_style( background_name = str(config.get("background_color", "DEPDW")) background = _rgb_hex(colors.get(background_name, {"r": 212, "g": 234, "b": 238})) usage_values = dict((usage or {}).get("global_values") or {}) + upstream_undefined = dict((resource_contract or {}).get("upstream_undefined_references") or {}) + undefined_symbols = set(str(x) for x in upstream_undefined.get("SY") or []) + undefined_patterns = set(str(x) for x in upstream_undefined.get("AP") or []) + undefined_lines = set(str(x) for x in upstream_undefined.get("LC") or []) available_symbol_names = sorted(set(symbol_metadata) & set(sprite_index)) symbol_names = list(available_symbol_names) @@ -367,6 +476,14 @@ def _compile_style( used = set(usage_values.get("s52_line_complex_code") or []) lc_mapping = {name: value for name, value in lc_mapping.items() if name in used} + fallback_symbol_name = "QUESMRK1" if "QUESMRK1" in available_symbol_names else None + fallback_symbol_offset = ( + symbol_metadata.get("QUESMRK1", {}).get("maplibre_icon_offset", [0, 0]) + if fallback_symbol_name + else [0, 0] + ) + fallback_ap_key = available_ap_mapping.get("QUESMRK1") + text_offsets = _text_offset_pairs(lookup_ir) lookup_opcode_priorities: dict[str, set[str]] = { key: set() for key in (*STATIC_OPCODES, "TX/TE") @@ -495,6 +612,43 @@ def _compile_style( }, ) compiled_placements["AP"].add((layer_name, priority)) + if ( + "fill" in caps + and fallback_ap_key + and priority in opcode_priorities["AP"] + and _field_used(usage, layer_name, "s52_area_pattern_code", priority) + ): + _add_layer( + layers, + compiled_counts, + "AP", + { + "id": f"{prefix}-ap-fallback-{layer_id}", + "type": "fill", + "source": source_id, + "source-layer": layer_name, + "filter": [ + "all", + *_base_filter( + priority=priority, + categories=categories, + required_field="s52_area_pattern_code", + )[1:], + [ + "!", + _supported_filter( + "s52_area_pattern_code", + list(available_ap_mapping), + ), + ], + ], + "paint": { + "fill-pattern": fallback_ap_key, + "fill-opacity": float(config.get("area_pattern_opacity", 0.85)), + }, + }, + ) + compiled_placements["AP"].add((layer_name, priority)) if ( "line" in caps @@ -562,6 +716,49 @@ def _compile_style( }, ) compiled_placements["LC"].add((layer_name, priority)) + if ( + "line" in caps + and fallback_symbol_name + and priority in opcode_priorities["LC"] + and _field_used(usage, layer_name, "s52_line_complex_code", priority) + ): + _add_layer( + layers, + compiled_counts, + "LC", + { + "id": f"{prefix}-lc-fallback-{layer_id}", + "type": "symbol", + "source": source_id, + "source-layer": layer_name, + "filter": [ + "all", + *_base_filter( + priority=priority, + categories=categories, + required_field="s52_line_complex_code", + )[1:], + [ + "!", + _supported_filter( + "s52_line_complex_code", + list(available_lc_mapping), + ), + ], + ], + "layout": { + "symbol-placement": "line", + "symbol-spacing": 80, + "icon-image": fallback_symbol_name, + "icon-anchor": "center", + "icon-offset": fallback_symbol_offset, + "icon-size": 1, + "icon-allow-overlap": True, + "icon-ignore-placement": True, + }, + }, + ) + compiled_placements["LC"].add((layer_name, priority)) if ( "symbol" in caps @@ -611,6 +808,60 @@ def _compile_style( }, ) compiled_placements["SY"].add((layer_name, priority)) + if ( + "symbol" in caps + and fallback_symbol_name + and priority in opcode_priorities["SY"] + and _field_used(usage, layer_name, "s52_symbol_code", priority) + ): + _add_layer( + layers, + compiled_counts, + "SY", + { + "id": f"{prefix}-sy-fallback-{layer_id}", + "type": "symbol", + "source": source_id, + "source-layer": layer_name, + "filter": [ + "all", + *_base_filter( + priority=priority, + categories=categories, + required_field="s52_symbol_code", + forbidden_field="s52_sounding_symbol_sequence", + )[1:], + [ + "!", + _supported_filter( + "s52_symbol_code", + available_symbol_names, + ), + ], + [ + "!", + [ + "all", + ["has", "s52_light_symbol_code"], + [ + "==", + ["get", "s52_symbol_code"], + ["get", "s52_light_symbol_code"], + ], + ], + ], + ], + "layout": { + "icon-image": fallback_symbol_name, + "icon-anchor": "center", + "icon-offset": fallback_symbol_offset, + "icon-size": 1, + "icon-allow-overlap": True, + "icon-ignore-placement": True, + }, + }, + ) + compiled_placements["SY"].add((layer_name, priority)) if ( "symbol" in caps @@ -649,6 +900,46 @@ def _compile_style( }, ) compiled_placements["LIGHT_SY"].add((layer_name, priority)) + if ( + "symbol" in caps + and fallback_symbol_name + and _field_used(usage, layer_name, "s52_light_symbol_code", priority) + ): + _add_layer( + layers, + compiled_counts, + "LIGHTS05-SY", + { + "id": f"{prefix}-lights05-sy-fallback-{layer_id}", + "type": "symbol", + "source": source_id, + "source-layer": layer_name, + "filter": [ + "all", + *_base_filter( + priority=priority, + categories=categories, + required_field="s52_light_symbol_code", + )[1:], + [ + "!", + _supported_filter( + "s52_light_symbol_code", + available_symbol_names, + ), + ], + ], + "layout": { + "icon-image": fallback_symbol_name, + "icon-anchor": "center", + "icon-offset": fallback_symbol_offset, + "icon-size": 1, + "icon-allow-overlap": True, + "icon-ignore-placement": True, + }, + }, + ) + compiled_placements["LIGHT_SY"].add((layer_name, priority)) if ( "text" in caps @@ -772,6 +1063,21 @@ def _compile_style( "layers": layers, } hard_gate_failures: list[dict[str, Any]] = [] + if resource_contract is not None and resource_contract.get("status") != "PASS": + hard_gate_failures.append({ + "code": "S52_RESOURCE_CONTRACT_NOT_PASS", + "values": list(resource_contract.get("failures") or []), + }) + if (undefined_symbols or undefined_lines) and fallback_symbol_name is None: + hard_gate_failures.append({ + "code": "S52_UNDEFINED_FALLBACK_SYMBOL_MISSING", + "values": ["QUESMRK1"], + }) + if undefined_patterns and fallback_ap_key is None: + hard_gate_failures.append({ + "code": "S52_UNDEFINED_FALLBACK_PATTERN_MISSING", + "values": ["QUESMRK1"], + }) if usage is not None: contract_names = {str(row["name"]) for row in source_layers} actual_names = set((usage.get("layers") or {}).keys()) @@ -783,7 +1089,9 @@ def _compile_style( }) used_symbols = set(usage_values.get("s52_symbol_code") or []) - missing_used_symbols = sorted(used_symbols - set(available_symbol_names)) + missing_used_symbols = sorted( + (used_symbols - set(available_symbol_names)) - undefined_symbols + ) if missing_used_symbols: hard_gate_failures.append({ "code": "USED_SYMBOL_RESOURCE_GAP", @@ -792,7 +1100,7 @@ def _compile_style( used_light_symbols = set(usage_values.get("s52_light_symbol_code") or []) missing_used_light_symbols = sorted( - used_light_symbols - set(available_symbol_names) + (used_light_symbols - set(available_symbol_names)) - undefined_symbols ) if missing_used_light_symbols: hard_gate_failures.append({ @@ -801,7 +1109,9 @@ def _compile_style( }) used_patterns = set(usage_values.get("s52_area_pattern_code") or []) - missing_used_patterns = sorted(used_patterns - set(available_ap_mapping)) + missing_used_patterns = sorted( + (used_patterns - set(available_ap_mapping)) - undefined_patterns + ) if missing_used_patterns: hard_gate_failures.append({ "code": "USED_PATTERN_RESOURCE_GAP", @@ -809,7 +1119,9 @@ def _compile_style( }) used_complex_lines = set(usage_values.get("s52_line_complex_code") or []) - missing_used_complex_lines = sorted(used_complex_lines - set(available_lc_mapping)) + missing_used_complex_lines = sorted( + (used_complex_lines - set(available_lc_mapping)) - undefined_lines + ) if missing_used_complex_lines: hard_gate_failures.append({ "code": "USED_COMPLEX_LINE_RESOURCE_GAP", @@ -891,6 +1203,12 @@ def _compile_style( "schema": "navsea.cm95.s52-style-compiler-audit/v1", "hard_gate_failures": hard_gate_failures, "palette": config.get("palette", "DAY_BRIGHT"), + "resource_contract_status": ( + resource_contract.get("status") if resource_contract is not None else None + ), + "resource_contract_reference": ( + resource_contract.get("reference") if resource_contract is not None else None + ), "source_id": source_id, "source_layer_count": len(source_layers), "display_categories": categories, @@ -934,6 +1252,7 @@ def _compile_style( if usage is None else { "pbf": usage.get("pbf"), + "pbf_count": int(usage.get("pbf_count") or 1), "source_layer_count": len(usage.get("layers") or {}), "feature_count": sum( int(row.get("feature_count") or 0) @@ -968,7 +1287,14 @@ def main() -> int: type=Path, help="Optionally scope generated layers/resources to fields actually present in one PBF tile.", ) + parser.add_argument( + "--usage-pbf-dir", + type=Path, + help="Scope generated layers/resources to the aggregate usage of all *.pbf files under a release/AOI directory.", + ) args = parser.parse_args() + if args.usage_pbf is not None and args.usage_pbf_dir is not None: + raise SystemExit("--usage-pbf and --usage-pbf-dir are mutually exclusive") project_root = Path(__file__).resolve().parents[1] config = _load_json(args.config) @@ -981,6 +1307,7 @@ def main() -> int: "line_metadata", "sprite_index", "lookup_ir", + "resource_contract", ) missing = [name for name in required if not resources.get(name)] if missing: @@ -993,11 +1320,12 @@ def main() -> int: colors = dict(color_tables[palette].get("colors") or {}) if not colors: raise SystemExit(f"palette {palette} has no colors") - usage = ( - _scan_pbf_usage(_resolve_path(project_root, args.usage_pbf)) - if args.usage_pbf is not None - else None - ) + if args.usage_pbf_dir is not None: + usage = _scan_pbf_directory(_resolve_path(project_root, args.usage_pbf_dir)) + elif args.usage_pbf is not None: + usage = _scan_pbf_usage(_resolve_path(project_root, args.usage_pbf)) + else: + usage = None style, audit = _compile_style( config=config, @@ -1008,6 +1336,9 @@ def main() -> int: line_metadata=_load_json(_resolve_path(project_root, resources["line_metadata"])), sprite_index=_load_json(_resolve_path(project_root, resources["sprite_index"])), lookup_ir=_load_json(_resolve_path(project_root, resources["lookup_ir"])), + resource_contract=_load_json( + _resolve_path(project_root, resources["resource_contract"]) + ), usage=usage, ) diff --git a/tools/cm95_s52_vector_pattern_sprite.py b/tools/cm95_s52_vector_pattern_sprite.py index 653f4a0..2090d84 100644 --- a/tools/cm95_s52_vector_pattern_sprite.py +++ b/tools/cm95_s52_vector_pattern_sprite.py @@ -155,7 +155,12 @@ def main() -> int: 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", 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, @@ -174,7 +179,11 @@ def main() -> int: 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")) + 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: @@ -191,13 +200,12 @@ def main() -> int: 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 used - if name in by_name - and by_name[name].find("vector") is not None - and by_name[name].find("bitmap") is None + name for name in selected + if name in by_name and by_name[name].find("vector") is not None ] - missing = [name for name in used if name not in by_name] + missing = [name for name in selected if name not in by_name] if missing: raise SystemExit(f"missing patterns: {missing}")