feat: implement S-52 shoreline construction presentation

This commit is contained in:
OpenAI Codex
2026-09-17 10:32:23 +08:00
parent 3c2612faee
commit 47a5358718
3 changed files with 253 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
# CM95 S-52 岸线构造与位置精度阶段报告 v1
## 范围
本阶段实现 OpenCPN 对应的:
- `SLCONS03`
- `QUAPOS01`
- `QUALIN01`
- `QUAPNT01`
- S-52 bitmap area pattern → MapLibre Sprite 编译
基线:Linux `/root/sourceserver/cm95`。
正式 production release 未修改。
## 规则实现
`SLCONS03` 已覆盖 Point / Line / Area。
岸线构造根据 `QUAPOS / CONDTN / CATSLC / WATLEV` 生成 `SY / LC / LS / AP` Presentation Rule IR。
`QUAPOS01` 对线对象走 `QUALIN01`,点/面走 `QUAPNT01`;低精度位置转换为 `LOWACCxx / QUAPOSxx`。
## 福冈湾 z12 实测
Tile:`12/3531/1641`
- `SLCONS03`:169 / 169 已解析
- `QUAPOS01`:23 / 23 已解析
- 本阶段后剩余未解析 CSP:`RESTRN01=2`、`RESARE02=1`
- `SLCONS_QUAPOS_TILE_CONTRACT=PASS`
SLCONS 结果分布:
- LINE `SOLD,2,CSTLN`:135
- AREA `SOLD,2,CSTLN`:15
- AREA `DASH,2,CSTLN`:8
- AREA `SOLD,4,CSTLN`:5
- AREA `DASH,1,CSTLN`:3
- LINE `DASH,1,CSTLN`:2
- LINE `DASH,2,CSTLN`:1
- `AP(CROSSX01)`:31 个 AREA
## Sprite Pattern
S-52 Sprite 编译器现在同时读取 `<symbols>` 与 `<patterns>`。
当前 DAY_BRIGHT atlas 自动输出:
- bitmap symbol:1013
- bitmap pattern:8
- sprite 总 entry:1021
`CROSSX01`:16×16,atlas 坐标 `(400,1040)`,pivot `(8,8)`。
`PATTERN_SPRITE_CONTRACT=PASS`。
## 浏览器验收
`presentation_v8.html` 使用独立 `s52-sprite-v8` 避免旧 Sprite 缓存。
CDP 等待页面进入 READY 后确认:
- SLCONS03=169
- QUAPOS01=23
- `CROSSX01` 实际显示
- 非 favicon 浏览器错误:0
- LIGHTS05、SOUNDG、危险物、深度色与等深线保持正常

View File

@@ -14,6 +14,7 @@ class S52MarinerSettings:
safety_depth_m: float = 3.0
deep_contour_m: float = 6.0
two_shades: bool = False
symbolized_boundaries: bool = False
def _float(attrs: dict[str, Any], key: str, default: float) -> float:
@@ -632,6 +633,129 @@ def resolve_wrecks02_instruction(
instruction += ";" + udw
return instruction, extra
def resolve_quapnt01_instruction(attrs: dict[str, Any]) -> str:
quapos = _int_attr(attrs, "QUAPOS", 10)
if not (2 <= quapos < 10):
return ""
if quapos == 4:
return "SY(QUAPOS01)"
if quapos == 5:
return "SY(QUAPOS02)"
if quapos in {7, 8}:
return "SY(QUAPOS03)"
return "SY(LOWACC03)"
def resolve_qualin01_instruction(
object_class: str,
attrs: dict[str, Any],
) -> str:
quapos_present = attrs.get("QUAPOS") is not None
quapos = _int_attr(attrs, "QUAPOS", 0)
if quapos_present:
if 2 <= quapos < 10:
return "LC(LOWACC21)"
return ""
if object_class == "COALNE":
conrad = _int_attr(attrs, "CONRAD", 0)
if conrad == 1:
return "LS(SOLD,3,CHMGF);LS(SOLD,1,CSTLN)"
return "LS(SOLD,1,CSTLN)"
def resolve_quapos01_instruction(
object_class: str,
geometry_type: str,
attrs: dict[str, Any],
) -> str:
if str(geometry_type).upper() == "LINE":
return resolve_qualin01_instruction(object_class, attrs)
return resolve_quapnt01_instruction(attrs)
def resolve_slcons03_instruction(
geometry_type: str,
attrs: dict[str, Any],
) -> str:
geom = str(geometry_type).upper()
quapos_present = attrs.get("QUAPOS") is not None
quapos = _int_attr(attrs, "QUAPOS", 0)
if geom == "POINT":
if quapos_present and 2 <= quapos < 10:
return "SY(LOWACC01)"
return ""
instructions: list[str] = []
if geom == "AREA":
instructions.append("AP(CROSSX01)")
if quapos_present and 2 <= quapos < 10:
instructions.append("LC(LOWACC01)")
return ";".join(instructions)
condtn = _int_attr(attrs, "CONDTN", 0)
catslc = _int_attr(attrs, "CATSLC", 0)
watlev = _int_attr(attrs, "WATLEV", 0)
if condtn in {1, 2}:
instructions.append("LS(DASH,1,CSTLN)")
elif catslc in {6, 15, 16}:
instructions.append("LS(SOLD,4,CSTLN)")
elif watlev == 2:
instructions.append("LS(SOLD,2,CSTLN)")
elif watlev in {3, 4}:
instructions.append("LS(DASH,2,CSTLN)")
else:
instructions.append("LS(SOLD,2,CSTLN)")
return ";".join(instructions)
def resolve_restrn01_instruction(attrs: dict[str, Any]) -> str:
restrn = set(_int_list(attrs.get("RESTRN")))
if not restrn:
return ""
if restrn & {7, 8, 14}:
symbol = "ENTRES61" if restrn & {1, 2, 3, 4, 5, 6} else "ENTRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "ENTRES51"
elif restrn & {1, 2}:
symbol = "ACHRES61" if restrn & {3, 4, 5, 6} else "ACHRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "ACHRES51"
elif restrn & {3, 4, 5, 6}:
symbol = "FSHRES71" if restrn & {9, 10, 11, 12, 13, 14, 15} else "FSHRES51"
else:
symbol = "INFARE51" if restrn & {9, 10, 11, 12, 13, 14, 15} else "RSRDEF51"
return f"SY({symbol})"
def resolve_resare02_instruction(
attrs: dict[str, Any], settings: S52MarinerSettings
) -> str:
restrn = set(_int_list(attrs.get("RESTRN")))
catrea = set(_int_list(attrs.get("CATREA")))
group_a = {1, 8, 9, 12, 14, 19, 21, 25}
group_b = {4, 5, 6, 7, 10, 18, 20, 22, 23, 24}
priority = False
if restrn:
if restrn & {7, 8, 14}:
symbol = "ENTRES61" if (restrn & {1,2,3,4,5,6} or catrea & group_a) else "ENTRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "ENTRES51"
line_code = "RESARE51"; priority = True
elif restrn & {1, 2}:
symbol = "ACHRES61" if (restrn & {3,4,5,6} or catrea & group_a) else "ACHRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "RESTRN51"
line_code = "RESARE51"; priority = True
elif restrn & {3,4,5,6}:
symbol = "FSHRES51" if catrea & group_a else "FSHRES71" if (restrn & {9,10,11,12,13,14,15} or catrea & group_b) else "FSHRES51"
line_code = "FSHRES51"; priority = True
else:
symbol = "INFARE51" if restrn & {9,10,11,12,13,14,15} else "RSRDEF51"
line_code = "CTYARE51"
else:
symbol = "CTYARE71" if (catrea & group_a and catrea & group_b) else "CTYARE51" if catrea & group_a else "INFARE51" if catrea & group_b else "RSRDEF51"
line_code = "CTYARE51"
line = f"LC({line_code})" if settings.symbolized_boundaries else "LS(DASH,2,CHMGD)"
parts = [line, f"SY({symbol})"]
if priority:
parts.insert(0, "OP(6---)")
return ";".join(parts)
def resolve_csp_hints(
csp_name: str,
object_class: str,
@@ -679,6 +803,14 @@ def resolve_csp_hints(
geometry_type, attrs, settings, associated_depth_ranges=associated_depth_ranges
)
extra.update(hazard_extra)
elif name == "QUAPOS01":
instruction = resolve_quapos01_instruction(object_class, geometry_type, attrs)
elif name == "SLCONS03":
instruction = resolve_slcons03_instruction(geometry_type, attrs)
elif name == "RESTRN01":
instruction = resolve_restrn01_instruction(attrs)
elif name == "RESARE02":
instruction = resolve_resare02_instruction(attrs, settings)
else:
return {}

View File

@@ -73,6 +73,27 @@ def _parse_symbols(root: ET.Element) -> dict[str, dict[str, Any]]:
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")
@@ -231,6 +252,7 @@ def main() -> int:
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)
@@ -245,13 +267,20 @@ def main() -> int:
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] = []
@@ -278,6 +307,26 @@ def main() -> int:
"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")
@@ -285,12 +334,19 @@ def main() -> int:
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"
)
@@ -323,7 +379,10 @@ def main() -> int:
"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,