#!/usr/bin/env python3
"""
Convert a TopoDroid SVG export (plan or profile) into a styled cave map.

Removed:  grid, centerline (legs), splays, station names.
Walls        -> thin black line + tan band on the rock side
wall:under/overlying -> dashed black line (passage above/below)
Pillars      -> small closed rock pockets filled tan
Pit          -> black line with ticks toward the drop
Chimney      -> dashed black line
Slope        -> thin line with alternating long/short hachures
Floor step   -> thin black line
Water areas  -> cyan; other areas (boulders) -> light grey blocks
Points       -> stalagmite ⅄, stalactite Y, water drip ↓, debris, continuation "?", labels
Profile view -> depth scale on the left (0 = topmost station unless --zero-station)
Plan view    -> metric scale bar + north arrow

Usage:
  python3 topodroid_to_cavemap.py in.svg out.svg [--view plan|profile]
                                  [--rotate180] [--zero-station N] [--px-per-m 20]
The view is guessed from the TopoDroid file name (…p.svg = plan, …s.svg = profile).
"""
import argparse
import math
import os
import re
from collections import Counter

import numpy as np
from lxml import etree
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import unary_union
from shapely.strtree import STRtree

SVG = "http://www.w3.org/2000/svg"
XLINK = "http://www.w3.org/1999/xlink"

TAN = "#EFD3A1"
BLACK = "#1A1A1A"
WATER = "#29ABE2"
BOULDER_FILL = "#E6E6E6"
BOULDER_STROKE = "#BBBBBB"
DRIP = "#6B8FD6"
GREY = "#9A9A9A"


# ------------------------------------------------------------------ parsing
def parse_d(d):
    """Parse an M/L/Z path -> (points, closed). Curves are reduced to end points."""
    toks = re.findall(r"[MLCZz]|-?\d+(?:\.\d+)?", d)
    pts, closed, i, cmd = [], False, 0, "M"
    while i < len(toks):
        t = toks[i]
        if t in "MLC":
            cmd = t
            i += 1
            continue
        if t in "Zz":
            closed = True
            i += 1
            continue
        if cmd == "C":
            pts.append((float(toks[i + 4]), float(toks[i + 5])))
            i += 6
        else:
            pts.append((float(toks[i]), float(toks[i + 1])))
            i += 2
    return pts, closed


def tag(el):
    return etree.QName(el).localname if isinstance(el.tag, str) else None


def matrix_xy(el):
    """Find the translation of the first matrix() transform at or below el."""
    for e in el.iter():
        if not isinstance(e.tag, str):
            continue
        m = re.search(r"matrix\(([^)]*)\)", e.get("transform", ""))
        if m:
            v = [float(x) for x in m.group(1).split(",")]
            return v[4], v[5]
    return None


def line_kind(cls, stroke):
    cls = (cls or "").lower()
    if cls.startswith("l_"):
        cls = cls[2:]
    if cls in ("wall", "wall_presumed", "wall:presumed", "wall:blocks", "wall:debris",
               "wall:sand", "wall:clay", "wall:pebbles"):
        return "wall"
    if cls.startswith("wall:"):
        return "wall-hidden" if cls.split(":")[1] in ("underlying", "overlying") else "wall"
    return cls or "other"


def load(path):
    root = etree.parse(path).getroot()
    d = {"lines": [], "areas": [], "legs": [], "splays": [], "stations": {},
         "points": [], "labels": []}

    def group(name):
        return root.find(f".//{{{SVG}}}g[@id='{name}']")

    seen = set()
    # ---- centerline
    for gid, key in (("legs", "legs"), ("splays", "splays")):
        g = group(gid)
        if g is not None:
            for p in g.iter(f"{{{SVG}}}path"):
                pts, _ = parse_d(p.get("d", ""))
                if len(pts) >= 2:
                    d[key].append(pts)
    g = group("stations")
    if g is not None:
        for t in g.iter(f"{{{SVG}}}text"):
            d["stations"][t.text.strip()] = (float(t.get("x")), float(t.get("y")))
        for sg in g.iter(f"{{{SVG}}}g"):           # newer format: marker path holds the position
            t = sg.find(f"{{{SVG}}}text")
            p = sg.find(f"{{{SVG}}}path")
            if t is not None and p is not None:
                d["stations"][t.text.strip()] = parse_d(p.get("d"))[0][0]

    skip_ids = {"grid", "grid1", "grid10", "grid100", "legs", "splays", "stations",
                "centerline", "xsections"}

    def inside_skipped(el):
        e = el.getparent()
        while e is not None:
            if e.get("id") in skip_ids:
                return True
            e = e.getparent()
        return False

    # ---- lines & areas
    for p in root.iter(f"{{{SVG}}}path"):
        if inside_skipped(p):
            continue
        par = p.getparent()
        cls = p.get("class", "")
        dd = p.get("d", "")
        in_areas = any(a.get("id", "").startswith("areas") for a in p.iterancestors())
        in_lines = any(a.get("id", "").startswith("lines") for a in p.iterancestors())
        if in_areas or cls.startswith("a_"):
            pts, _ = parse_d(dd)
            fill = (p.get("fill") or "").lower()
            kind = "water" if ("water" in cls or fill in ("#3366ff", "#0000ff")) else "boulder"
            d["areas"].append((kind, pts))
        elif in_lines or cls.startswith("l_"):
            if dd in seen:
                continue
            seen.add(dd)
            pts, closed = parse_d(dd)
            if len(pts) >= 2:
                d["lines"].append((line_kind(cls, p.get("stroke")), pts, closed))

    # ---- points (comment "point TYPE" precedes each element)
    for pg in root.iter(f"{{{SVG}}}g"):
        if not pg.get("id", "").startswith("points"):
            continue
        kind = None
        for c in pg:
            if isinstance(c, etree._Comment):
                m = re.match(r"\s*point\s+(\S+)", c.text or "")
                kind = m.group(1) if m else None
                continue
            if kind is None:
                continue
            if tag(c) == "text":
                d["labels"].append(((float(c.get("x")), float(c.get("y"))), (c.text or "").strip()))
            else:
                xy = matrix_xy(c)
                if xy:
                    d["points"].append((kind, xy))
            kind = None
    return d


# ------------------------------------------------------------ geometry utils
def interior_targets(d):
    pts = []
    for seg in d["legs"] + d["splays"]:
        (x0, y0), (x1, y1) = seg[0], seg[-1]
        L = math.hypot(x1 - x0, y1 - y0)
        n = max(2, int(L / 5))
        for k in range(n):
            t = 0.9 * k / (n - 1)
            pts.append((x0 + t * (x1 - x0), y0 + t * (y1 - y0)))
    pts += list(d["stations"].values())
    return np.array(pts)


def normals(a):
    out = []
    for i in range(len(a)):
        p0 = a[max(i - 1, 0)]
        p1 = a[min(i + 1, len(a) - 1)]
        t = p1 - p0
        n = np.hypot(*t) or 1.0
        out.append(np.array([-t[1], t[0]]) / n)
    return np.array(out)


def rock_side(pts, tree, targets, d=3.0, k=6, window=12):
    """Per-vertex +1/-1: which normal side is rock (the side less visible from the survey).
    Votes are smoothed along the line so a long merged wall can change side where it turns."""
    a = np.array(pts)
    N = normals(a)
    raw = np.zeros(len(a))
    for i in range(0, len(a), max(1, len(a) // 200)):
        vis = []
        for s in (+1, -1):
            q = a[i] + s * d * N[i]
            near = targets[np.argsort(np.hypot(*(targets - q).T))[:k]]
            vis.append(sum(len(tree.query(LineString([q, tp]), predicate="intersects")) == 0
                           for tp in near))
        raw[i] = 0 if vis[0] == vis[1] else (-1 if vis[0] > vis[1] else 1)
    out = np.zeros(len(a))
    for i in range(len(a)):
        w = raw[max(0, i - window): i + window + 1].sum()
        out[i] = 1 if w > 0 else (-1 if w < 0 else 0)
    # fill undecided from neighbours (or the line majority)
    default = 1 if raw.sum() >= 0 else -1
    for i in range(len(out)):
        if out[i] == 0:
            out[i] = out[i - 1] if i and out[i - 1] else default
    return out


def offset(pts, dist):
    a = np.array(pts)
    return a + dist * normals(a)


def resample(pts, step):
    """Points every `step` along a polyline with their unit tangents."""
    ls = LineString(pts)
    out = []
    n = int(ls.length // step)
    for k in range(1, n + 1):
        s = k * step - step / 2
        p = ls.interpolate(s)
        q = ls.interpolate(min(s + 0.5, ls.length))
        r = ls.interpolate(max(s - 0.5, 0))
        t = np.array([q.x - r.x, q.y - r.y])
        t /= (np.hypot(*t) or 1)
        out.append((np.array([p.x, p.y]), t))
    return out


def signed_area(pts):
    a = np.array(pts)
    return 0.5 * np.sum(a[:-1, 0] * a[1:, 1] - a[1:, 0] * a[:-1, 1])



# --------------------------------------------------------------- join gaps
def join_walls(lines, tol):
    """Close small gaps between wall ends the surveyor left unconnected.

    1. Ends of two wall lines of the same kind closer than `tol` -> merged into one line.
    2. A line whose own two ends are closer than `tol` -> closed.
    3. Any remaining free end closer than `tol` to another wall line -> extended to touch it.
    Returns (new_lines, report).
    """
    WALLS = ("wall", "wall-hidden")
    walls = [[k, list(p), c] for k, p, c in lines if k in WALLS]
    rest = [l for l in lines if l[0] not in WALLS]
    report = []

    merged = True
    while merged:
        merged = False
        best = None
        for i in range(len(walls)):
            if walls[i][2]:
                continue
            for j in range(i + 1, len(walls)):
                if walls[j][2] or walls[i][0] != walls[j][0]:
                    continue
                for ei in (0, -1):
                    for ej in (0, -1):
                        dd = math.dist(walls[i][1][ei], walls[j][1][ej])
                        if dd < tol and (best is None or dd < best[0]):
                            best = (dd, i, ei, j, ej)
        if best:
            dd, i, ei, j, ej = best
            a, b = walls[i][1], walls[j][1]
            if ei == 0:
                a = a[::-1]
            if ej == -1:
                b = b[::-1]
            walls[i][1] = a + b
            report.append(("merged", round(dd, 2)))
            del walls[j]
            merged = True

    for w in walls:
        p = w[1]
        L = LineString(p).length
        if not w[2] and math.dist(p[0], p[-1]) < tol and L > 4 * tol:
            w[1] = p + [p[0]]
            w[2] = True
            report.append(("closed", round(math.dist(p[0], p[-1]), 2)))

    geoms = [LineString(w[1]) for w in walls]
    for i, w in enumerate(walls):
        if w[2]:
            continue
        for e in (0, -1):
            q = Point(w[1][e])
            best = None
            for j, g in enumerate(geoms):
                if j == i:
                    continue
                dd = g.distance(q)
                if 0 < dd < tol and (best is None or dd < best[0]):
                    best = (dd, j)
            if best:
                g = geoms[best[1]]
                np_ = g.interpolate(g.project(q))
                if e == 0:
                    w[1].insert(0, (np_.x, np_.y))
                else:
                    w[1].append((np_.x, np_.y))
                report.append(("extended", round(best[0], 2)))
    return [tuple(w) for w in walls] + rest, report


def open_gaps(lines, upto):
    """Free wall ends that are still within `upto` of another wall (for reporting)."""
    walls = [(p, c) for k, p, c in lines if k in ("wall", "wall-hidden")]
    geoms = [LineString(p) for p, _ in walls]
    gaps = []
    for i, (p, c) in enumerate(walls):
        if c:
            continue
        for e in (0, -1):
            q = Point(p[e])
            dd = min((g.distance(q) for j, g in enumerate(geoms) if j != i), default=1e9)
            if 0.01 < dd < upto:
                gaps.append((p[e], dd))
    return gaps

# ------------------------------------------------------------------ convert
def convert(src, dst, view=None, rotate180=False, zero_station=None, px_per_m=None, join_m=0.25):
    d = load(src)
    if view is None:
        view = "plan" if re.search(r"p\.svg$", os.path.basename(src)) else "profile"
    if px_per_m is None:
        txt = open(src).read()
        px_per_m = 28.3465 if 'id="grid' in txt else 20.0   # TopoDroid default = 20 units/m
    k = px_per_m / 28.3465                                  # style scale factor
    WALL_W, BAND_W, SYM = 1.4 * k, 5.5 * k, 28.0 * k

    joins = []
    if join_m > 0:
        d["lines"], joins = join_walls(d["lines"], join_m * px_per_m)
    gaps = [(p, round(g / px_per_m, 2)) for p, g in open_gaps(d["lines"], 1.0 * px_per_m)]

    walls = [(pts, c) for kind, pts, c in d["lines"] if kind == "wall"]
    wall_geoms = [LineString(p) for p, _ in walls]
    tree = STRtree(wall_geoms)
    targets = interior_targets(d)
    sides = [rock_side(p, tree, targets) for p, _ in walls]

    # pillars: wall-enclosed pockets with no station / splay end inside
    inside_pts = [Point(p) for p in d["stations"].values()] + [Point(s[-1]) for s in d["splays"]]
    gap = 2.5 * k
    merged = unary_union([g.buffer(gap) for g in wall_geoms])
    pillars, pillar_idx = [], set()
    for poly in getattr(merged, "geoms", [merged]):
        for ring in poly.interiors:
            hole = Polygon(ring)
            if hole.area > 6 * px_per_m ** 2 or any(hole.contains(p) for p in inside_pts):
                continue
            filled = hole.buffer(gap, join_style=2)
            pillars.append(list(filled.exterior.coords))
            for i, g in enumerate(wall_geoms):
                if filled.buffer(1).contains(g):
                    pillar_idx.add(i)

    # ---- transform
    allpts = [p for _, pts, _ in d["lines"] for p in pts] + [p for _, a in d["areas"] for p in a]
    xs, ys = zip(*allpts)
    minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
    left = 170 * k if view == "profile" else 40 * k
    top = 40 * k
    bottom_pad = 60 * k if view == "profile" else 140 * k

    def T(p):
        x, y = p
        if rotate180:
            x, y = maxx + minx - x, maxy + miny - y
        return (x - minx + left, y - miny + top)

    def fmt(pts, close=False):
        s = "M " + " L ".join(f"{x:.2f} {y:.2f}" for x, y in (T(p) for p in pts))
        return s + (" Z" if close else "")

    swap = {"stalactite": "stalagmite", "stalagmite": "stalactite"} if rotate180 else {}
    W = (maxx - minx) + left + 40 * k
    H = (maxy - miny) + top + bottom_pad

    out = [f'<svg xmlns="{SVG}" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" '
           f'width="{W:.0f}px" height="{H:.0f}px" viewBox="0 0 {W:.2f} {H:.2f}">',
           '<rect width="100%" height="100%" fill="white"/>']

    def layer(name, attrs):
        out.append(f'<g id="{name.replace(" ", "_")}" inkscape:groupmode="layer" '
                   f'inkscape:label="{name}" {attrs}>')

    def end():
        out.append("</g>")

    # areas
    layer("Boulders", f'fill="{BOULDER_FILL}" stroke="{BOULDER_STROKE}" stroke-width="{1.2*k:.2f}" stroke-linejoin="round"')
    out += [f'<path d="{fmt(a, True)}"/>' for kd, a in d["areas"] if kd == "boulder"]
    end()
    layer("Water", f'fill="{WATER}" stroke="{BLACK}" stroke-width="{WALL_W:.2f}"')
    out += [f'<path d="{fmt(a, True)}"/>' for kd, a in d["areas"] if kd == "water"]
    end()

    # rock band, pillars, walls
    layer("Rock band", f'fill="none" stroke="{TAN}" stroke-width="{BAND_W:.2f}" stroke-linejoin="round" stroke-linecap="round"')
    for i, ((p, _), sg) in enumerate(zip(walls, sides)):
        if i in pillar_idx:
            continue
        # split into runs with the same rock side
        start = 0
        for j in range(1, len(p) + 1):
            if j == len(p) or sg[j] != sg[start]:
                run = p[max(start - 1, 0): j + 1] if j - start >= 1 else None
                if run and len(run) >= 2:
                    out.append(f'<path d="{fmt(offset(run, sg[start] * BAND_W / 2))}"/>')
                start = j
    end()
    layer("Pillars", f'fill="{TAN}" stroke="{BLACK}" stroke-width="{WALL_W:.2f}"')
    out += [f'<path d="{fmt(p, True)}"/>' for p in pillars]
    end()
    layer("Walls", f'fill="none" stroke="{BLACK}" stroke-width="{WALL_W:.2f}" stroke-linejoin="round" stroke-linecap="round"')
    out += [f'<path d="{fmt(p, c)}"/>' for p, c in walls]
    end()

    other = Counter()
    by = {}
    for kind, pts, c in d["lines"]:
        by.setdefault(kind, []).append((pts, c))

    layer("Hidden walls", f'fill="none" stroke="{BLACK}" stroke-width="{WALL_W*0.8:.2f}" stroke-dasharray="{6*k:.1f} {4*k:.1f}"')
    out += [f'<path d="{fmt(p, c)}"/>' for p, c in by.get("wall-hidden", [])]
    end()

    layer("Chimneys", f'fill="none" stroke="{BLACK}" stroke-width="{WALL_W*0.8:.2f}" stroke-dasharray="{3*k:.1f} {3*k:.1f}"')
    out += [f'<path d="{fmt(p, c)}"/>' for p, c in by.get("chimney", [])]
    end()

    layer("Floor steps", f'fill="none" stroke="{BLACK}" stroke-width="{WALL_W*0.6:.2f}"')
    out += [f'<path d="{fmt(p, c)}"/>' for p, c in by.get("floor-step", [])]
    end()

    # pits: line + ticks toward the drop (inside for closed loops, left side otherwise)
    layer("Pits", f'fill="none" stroke="{BLACK}" stroke-width="{WALL_W*0.8:.2f}"')
    for p, c in by.get("pit", []):
        closed = c or math.dist(p[0], p[-1]) < 4 * k
        out.append(f'<path d="{fmt(p, closed)}"/>')
        sgn = 1
        if closed and len(p) > 2:
            sgn = 1 if signed_area(p + [p[0]]) < 0 else -1
        ticks = []
        for q, t in resample(p + ([p[0]] if closed else []), 4.5 * k):
            n = sgn * np.array([t[1], -t[0]])
            ticks.append(f"M {q[0]:.2f} {q[1]:.2f} L {q[0]+n[0]*3.5*k:.2f} {q[1]+n[1]*3.5*k:.2f}")
        if ticks:
            out.append(f'<path d="{" ".join(_tick_T(ticks, T))}"/>')
    end()

    # slopes: hachures, alternating long/short
    layer("Slopes", f'fill="none" stroke="{GREY}" stroke-width="{WALL_W*0.6:.2f}"')
    for p, c in by.get("slope", []):
        segs = []
        for j, (q, t) in enumerate(resample(p, 4 * k)):
            n = np.array([t[1], -t[0]])
            L = (9 if j % 2 == 0 else 4.5) * k
            segs.append(f"M {q[0]:.2f} {q[1]:.2f} L {q[0]+n[0]*L:.2f} {q[1]+n[1]*L:.2f}")
        out.append(f'<path d="{fmt(p)} {" ".join(_tick_T(segs, T))}"/>')
    end()

    for kind in by:
        if kind not in ("wall", "wall-hidden", "chimney", "floor-step", "pit", "slope"):
            other[f"line:{kind}"] += len(by[kind])
    if any(kd not in ("wall", "wall-hidden", "chimney", "floor-step", "pit", "slope") for kd in by):
        layer("Other lines", f'fill="none" stroke="{GREY}" stroke-width="{WALL_W*0.6:.2f}"')
        for kind, lst in by.items():
            if kind not in ("wall", "wall-hidden", "chimney", "floor-step", "pit", "slope"):
                out += [f'<path d="{fmt(p, c)}"/>' for p, c in lst]
        end()

    # ---- point symbols
    groups = Counter()
    pts_by = {}
    for kind, p in d["points"]:
        kind = swap.get(kind, kind)
        pts_by.setdefault(kind, []).append(T(p))
    h = SYM

    layer("Stalactites", f'fill="none" stroke="{GREY}" stroke-width="{1.2*k:.2f}"')
    for x, y in pts_by.get("stalactite", []):
        out.append(f'<path d="M {x-h*.28:.2f} {y-h*.5:.2f} L {x:.2f} {y-h*.15:.2f} L {x+h*.28:.2f} {y-h*.5:.2f} '
                   f'M {x:.2f} {y-h*.15:.2f} L {x:.2f} {y+h*.5:.2f}"/>')
    end()
    layer("Stalagmites", f'fill="none" stroke="{BLACK}" stroke-width="{2.4*k:.2f}"')
    for x, y in pts_by.get("stalagmite", []):
        out.append(f'<path d="M {x:.2f} {y-h*.5:.2f} L {x:.2f} {y+h*.15:.2f} M {x-h*.28:.2f} {y+h*.5:.2f} '
                   f'L {x:.2f} {y+h*.15:.2f} L {x+h*.28:.2f} {y+h*.5:.2f}"/>')
    end()
    layer("Water drips", f'fill="none" stroke="{DRIP}" stroke-width="{1.4*k:.2f}"')
    for x, y in pts_by.get("water-drip", []):
        out.append(f'<path stroke-dasharray="{3*k:.1f} {2.5*k:.1f}" d="M {x:.2f} {y-h*.5:.2f} L {x:.2f} {y+h*.45:.2f}"/>'
                   f'<path d="M {x-h*.2:.2f} {y+h*.25:.2f} L {x:.2f} {y+h*.5:.2f} L {x+h*.2:.2f} {y+h*.25:.2f}"/>')
    end()
    layer("Debris", f'fill="{BOULDER_FILL}" stroke="{BLACK}" stroke-width="{0.8*k:.2f}" stroke-linejoin="round"')
    for x, y in pts_by.get("debris", []):
        s = h * 0.35
        for dx, dy, r in ((-.5, .1, 0), (.45, .25, 40), (0, -.45, 75)):
            cx, cy = x + dx * s, y + dy * s
            tri = [(cx + s * .45 * math.cos(math.radians(a + r)), cy + s * .45 * math.sin(math.radians(a + r)))
                   for a in (90, 210, 330)]
            out.append('<path d="M ' + " L ".join(f"{u:.2f} {v:.2f}" for u, v in tri) + ' Z"/>')
    end()
    layer("Continuations", f'fill="{BLACK}" stroke="none" font-family="Arial, Helvetica, sans-serif" '
          f'font-weight="bold" font-size="{h*.8:.1f}" text-anchor="middle"')
    out += [f'<text x="{x:.2f}" y="{y+h*.28:.2f}">?</text>' for x, y in pts_by.get("continuation", [])]
    end()
    layer("Labels", f'fill="{BLACK}" stroke="none" font-family="Arial, Helvetica, sans-serif" '
          f'font-size="{h*.6:.1f}" text-anchor="middle"')
    for p, txt in d["labels"]:
        x, y = T(p)
        out.append(f'<text x="{x:.2f}" y="{y:.2f}">{txt}</text>')
    end()
    known = {"stalactite", "stalagmite", "water-drip", "debris", "continuation"}
    for kind, lst in pts_by.items():
        if kind not in known:
            other[f"point:{kind}"] += len(lst)

    # ---- scale
    layer("Scale", f'stroke="{BLACK}" stroke-width="{1*k:.2f}" fill="{BLACK}" '
          f'font-family="Arial, Helvetica, sans-serif" font-size="{26*k:.1f}"')
    info = {}
    if view == "profile":
        st = {n: T(p) for n, p in d["stations"].items()}
        if zero_station is None:
            zero_station = min(st, key=lambda n: st[n][1])
        y0 = st[zero_station][1]
        depth = math.ceil((max(T(p)[1] for p in allpts) - y0) / px_per_m)
        depth += depth % 2
        sx = 100 * k
        y_end = y0 + depth * px_per_m
        out.append(f'<path fill="none" d="M {sx:.2f} {y0:.2f} L {sx:.2f} {y_end:.2f}"/>')
        for m in range(0, depth + 1, 2):
            y = y0 + m * px_per_m
            major = m % 10 == 0
            out.append(f'<path fill="none" d="M {sx-(25 if major else 12)*k:.2f} {y:.2f} L {sx:.2f} {y:.2f}"/>')
            if major:
                out.append(f'<text stroke="none" x="{sx-32*k:.2f}" y="{y+9*k:.2f}" text-anchor="end">{m}</text>')
        if depth % 10:
            out.append(f'<path fill="none" d="M {sx-38*k:.2f} {y_end:.2f} L {sx+38*k:.2f} {y_end:.2f}"/>')
            out.append(f'<text stroke="none" x="{sx-42*k:.2f}" y="{y_end+9*k:.2f}" text-anchor="end">{depth}</text>')
        info = {"zero_station": zero_station, "depth_m": depth}
    else:
        span = (maxx - minx) / px_per_m
        L = 10 if span > 40 else 5
        x0, yb = 60 * k, H - 50 * k
        for i in range(L // (L // 5 if L >= 10 else 1)):
            pass
        seg = 5 if L == 10 else 1
        for j in range(L // seg):
            fill = BLACK if j % 2 == 0 else "white"
            out.append(f'<rect x="{x0 + j*seg*px_per_m:.2f}" y="{yb:.2f}" width="{seg*px_per_m:.2f}" '
                       f'height="{6*k:.2f}" fill="{fill}"/>')
        for m in range(0, L + 1, seg):
            out.append(f'<text stroke="none" x="{x0 + m*px_per_m:.2f}" y="{yb-8*k:.2f}" '
                       f'text-anchor="middle" font-size="{20*k:.1f}">{m}</text>')
        out.append(f'<text stroke="none" x="{x0 + L*px_per_m + 10*k:.2f}" y="{yb+7*k:.2f}" '
                   f'font-size="{20*k:.1f}">m</text>')
        # north arrow (TopoDroid plan: up = north)
        nx, ny = W - 60 * k, 70 * k
        a = 22 * k
        out.append(f'<path stroke-linejoin="round" d="M {nx:.2f} {ny-a:.2f} L {nx+a*.45:.2f} {ny+a:.2f} '
                   f'L {nx:.2f} {ny+a*.55:.2f} Z"/>')
        out.append(f'<path fill="white" stroke-linejoin="round" d="M {nx:.2f} {ny-a:.2f} L {nx-a*.45:.2f} {ny+a:.2f} '
                   f'L {nx:.2f} {ny+a*.55:.2f} Z"/>')
        out.append(f'<text stroke="none" x="{nx:.2f}" y="{ny-a-8*k:.2f}" text-anchor="middle">N</text>')
        info = {"scale_bar_m": L}
    end()

    out.append("</svg>")
    with open(dst, "w") as f:
        f.write("\n".join(out))
    return {"view": view, "px_per_m": px_per_m, "walls": len(walls), "pillars": len(pillars),
            "lines": {k_: len(v) for k_, v in by.items()},
            "points": {k_: len(v) for k_, v in pts_by.items()}, "labels": len(d["labels"]),
            "unhandled": dict(other), "joins": joins,
            "remaining_gaps_m": sorted(g for _, g in gaps), **info}


def _tick_T(segs, T):
    """Apply the output transform to a list of 'M x y L x y' strings."""
    res = []
    for s in segs:
        v = [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", s)]
        (a, b), (c, e) = T((v[0], v[1])), T((v[2], v[3]))
        res.append(f"M {a:.2f} {b:.2f} L {c:.2f} {e:.2f}")
    return res


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("src")
    ap.add_argument("dst")
    ap.add_argument("--view", choices=["plan", "profile"])
    ap.add_argument("--rotate180", action="store_true")
    ap.add_argument("--zero-station")
    ap.add_argument("--px-per-m", type=float)
    ap.add_argument("--join", type=float, default=0.25,
                    help="close gaps between wall ends shorter than this (metres, 0 = off)")
    a = ap.parse_args()
    print(convert(a.src, a.dst, a.view, a.rotate180, a.zero_station, a.px_per_m, a.join))
