Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
56 changes: 56 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
This file provides guidance to AI agents when working with code in this repository.

Kothic is a MapCSS parser/processor tailored for Organic Maps. It compiles MapCSS stylesheets into the binary `drules_proto*.bin` (+ optional `.txt`) drawing-rule files that the renderer consumes, plus auxiliary metadata files (`classificator.txt`, `visibility.txt`, `colors.txt`, `patterns.txt`, `types.txt`).

The parent project's `CLAUDE.md` (one level up at `omim/CLAUDE.md`) covers overall Organic Maps conventions; this file only documents kothic-specific details.

## Commands

```bash
# Install deps (Python >= 3.8, protobuf ~3.20 — matches Ubuntu's python3-protobuf)
pip3 install -r requirements.txt

# Unit tests — discover all test*.py under tests/ (must run from project root)
python3 -m unittest discover -s tests

# Single test module / method
python3 -m unittest tests.testLibkomwm
python3 -m unittest tests.testLibkomwm.LibKomwmTest.test_generate_drules_mini

# Lint (matches CI exactly — see .github/workflows/unit-tests.yml)
ruff check --exclude=src/drules_struct_pb2.py --target-version=py39

# Integration test — regenerates drules for all 6 themes from the real data/
cd integration-tests && python3 full_drules_gen.py -d ../../../data -o drules --txt

# Run the generator directly for one style
python3 src/libkomwm.py -s <style.mapcss> -o <output_prefix> -p <priorities_dir> [--txt]

# Full production regeneration (all 8 theme variants + merge) — run from anywhere
../unix/generate_drules.sh
```

## Architecture

**Entry point:** `src/libkomwm.py` — orchestrates the whole pipeline. `komap_mapswithme(options)` is the function callers (tests, `full_drules_gen.py`, `generate_drules.sh`) hook into. The bottom `main()` is the CLI wrapper.

**Parser:** `src/mapcss/` — the MapCSS engine.
- `__init__.py` defines the `MapCSS` class and all the regex-based tokenization (zoom, conditions, declarations, `@import`, variables).
- `StyleChooser.py` — a single CSS-like rule block (selector + declaration). `MapCSS.parse()` produces a list of these.
- `Rule.py` — one selector within a chooser (subject + zoom range + conditions). `type_matches` maps MapCSS subjects (`area`/`line`/`way`/`node`) to compatible geometry types.
- `Condition.py` — a single `[tag=value]` predicate (eq/ne/lt/gt/regex/set/unset).
- `Eval.py` — `eval('...')` MapCSS expressions, compiled to Python via `compile()`.
- `webcolors/` — vendored color parsing (`whatever_to_hex`, `whatever_to_cairo`).

**Protobuf output:** `src/drules_struct_pb2.py` is **auto-generated from `data/drules_struct.proto`** in the main repo — do not hand-edit. It defines `ContainerProto`, `ClassifElementProto`, `DrawElementProto`, etc. that `libkomwm` serializes.

**Priority ranges** (see the long comment block near the top of `libkomwm.py`): drawing rules live in four ordered ranges — `overlays` (icons/captions, ±10000), `FG` (foreground areas/lines, 0–1000), `BG-top` (water, -1000–0), `BG-by-size` (landcover, -2000–-1000). The companion `priorities_*.prio.txt` files in `data/styles/<style>/include/` are **re-formatted and re-sorted in place** every time the generator runs; the renderer's layering logic in `drape_frontend/stylist.cpp` mirrors these ranges.

**Test assets:** `tests/assets/` holds three case directories. `case-2-generate-drules-mini` is a stripped-down style (zooms 0–10, highway-only) whose generated output is checked for exact line/style counts — if you change the generator output format, those expected counts in `testLibkomwm.py` will need updating.

## Conventions specific to kothic

- This is a Python project inside a mostly-C++ repo; the parent `CLAUDE.md`'s C++ rules don't apply here. Follow PEP 8 / ruff defaults, target Python 3.9 (per CI).
- `src/drules_struct_pb2.py` is auto-generated — never modify directly; regenerate from `data/drules_struct.proto`.
- `libkomwm` carries module-level mutable state (`prio_ranges`, `visibilities`, `MULTIPROCESSING`). Tests and `full_drules_gen.py` `deepcopy` and restore it between runs — preserve that pattern if you add new global state.
- Integration tests are not run by CI (`unit-tests.yml` only runs `unittest discover -s tests`). The integration script exists to verify the generator against the real `data/` styles.
35 changes: 21 additions & 14 deletions src/mapcss/StyleChooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,29 @@ def get_runtime_conditions(self, tags):

# TODO: Rename to "applyStyles"
def updateStyles(self, sl, tags, xscale, zscale, filter_by_runtime_conditions):
# Are any of the ruleChains fulfilled?
rule_and_object_id = self.testChains(tags)

if not rule_and_object_id:
return sl

rule = rule_and_object_id[0]
object_id = rule_and_object_id[1]
# A single rule block can have comma-separated selectors that target
# different ::object-id subparts, e.g.
# node|z16-[addr:housenumber][addr:street],
# node|z16-[addr:housenumber][addr:street]::int_name,
# {text: none;}
# Apply the body to every matching subpart (deduped by object-id), not
# just the first one — otherwise the other selectors silently keep
# whatever values the cascade brought in from earlier choosers.
seen_object_ids = set()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this seen_object_ids? Should we ignored repeated object_id ?

for rule in self.ruleChains:
object_id = rule.test(tags)
if not object_id or object_id in seen_object_ids:
continue
if (filter_by_runtime_conditions is not None
and rule.runtime_conditions is not None
and filter_by_runtime_conditions != rule.runtime_conditions):
continue
seen_object_ids.add(object_id)
self._applyBodyToObjectId(sl, tags, xscale, zscale, object_id)

if (filter_by_runtime_conditions is not None
and rule.runtime_conditions is not None
and filter_by_runtime_conditions != rule.runtime_conditions):
return sl
return sl

def _applyBodyToObjectId(self, sl, tags, xscale, zscale, object_id):
for r in self.styles:
if self.has_evals:
ra = {}
Expand Down Expand Up @@ -178,8 +187,6 @@ def updateStyles(self, sl, tags, xscale, zscale, filter_by_runtime_conditions):
allinit.update(ra)
sl.append(allinit)

return sl

def testChains(self, tags):
"""
Tests an object against a chain
Expand Down
54 changes: 47 additions & 7 deletions tests/testStyleChooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,11 @@ def test_update_styles_by_class(self):
sc = StyleChooser((15, 19))

sc.newObject()
sc.addCondition(Condition("eq", ("::class", "::flats") )) # `sc` styles should apply only to `::flats` class
sc.addCondition(Condition("eq", ("::class", "::flats") ))
sc.addCondition(parseCondition("oneway?"))

sc.newObject()
sc.addCondition(Condition("eq", ("::class", "::bridgeblack") )) # This class is ignored by StyleChooser
sc.addCondition(Condition("eq", ("::class", "::bridgeblack") ))
sc.addCondition(parseCondition("oneway?"))

sc.addStyles([{
Expand All @@ -227,19 +227,22 @@ def test_update_styles_by_class(self):

object_tags = {"highway": "service", "oneway": "yes"}

# Apply new style to predefined styles with filter by class
# Apply new style to predefined styles. Both ::flats and ::bridgeblack
# selectors match (oneway=yes); body must be applied to each, leaving
# ::default — which has no matching ruleChain — alone.
new_styles = sc.updateStyles(styles, object_tags, 1.0, 1.0, False)

expected_new_styles = [{ # The first style changes
expected_new_styles = [{
"some-width": 1.5,
"other-offset": 4.0,
"object-id": "::flats"
},
{ # Style not changed (class is not `::flats`)
"some-width": 3.5,
{
"some-width": 1.5,
"other-offset": 4.0,
"object-id": "::bridgeblack"
},
{ # Style not changed (class is not `::flats`)
{ # No matching ruleChain for ::default
"some-width": 4.5,
"object-id": "::default"
}]
Expand Down Expand Up @@ -291,5 +294,42 @@ def test_runtime_conditions(self):
# TODO: Create test with sc.addRuntimeCondition(Condition(condType, ('extra_tag', cond)))
pass

def test_update_styles_multi_object_id(self):
"""Regression: one rule block with comma-separated selectors targeting
different ::object-id subparts must apply the body to every matching
subpart, not just the first one. Mirrors the real-world case of:
node|z16-[addr:housenumber][addr:street],
node|z16-[addr:housenumber][addr:street]::int_name,
{text: none;}
"""
# Predefined styles already in the cascade — both ::default and
# ::int_name come in with a populated text field that we want to clear.
styles = [
{"text": "name", "object-id": "::default"},
{"text": "int_name", "object-id": "::int_name"},
]

sc = StyleChooser((15, 19))

sc.newObject()
sc.addCondition(parseCondition("addr:housenumber"))
sc.addCondition(parseCondition("addr:street"))

sc.newObject()
sc.addCondition(parseCondition("addr:housenumber"))
sc.addCondition(parseCondition("addr:street"))
sc.addCondition(Condition("eq", ("::class", "::int_name")))

sc.addStyles([{"text": "none"}])

tags = {"addr:housenumber": "12", "addr:street": "Baker street"}
new_styles = sc.updateStyles(styles, tags, 1.0, 1.0, False)

by_oid = {s["object-id"]: s for s in new_styles}
self.assertEqual(by_oid["::default"]["text"], "none")
self.assertEqual(by_oid["::int_name"]["text"], "none",
"::int_name body must be applied even though "
"::default selector matches first")

if __name__ == '__main__':
unittest.main()
Loading