Handoff — XML Open3D visualizer: show all schema 1.1 feature classes
Summary
Target: extend the Open3D XML viewer
~/dev/3dai.lanefinder/scripts/visualize_xml_open3d.py (repo 3dai.lanefinder, master @ 8a99587,
v0.9.1, clean apart from an untracked .wrangler/) so a HighwayData schema 1.1 document is shown with
every feature class visibly distinguishable — point symbols (trees, signs, shafts, gullies, KM signs, …),
tree/trunk/crown shapes, direction arrows, closed outlines, annotation text — instead of the current look where only the lane
lines and road axes stand out.
Nothing implemented yet. Schema 1.1 is finished and merged on the producer side
(~/dev/3dai.iolabs.pointcloud.modellingexport, PR #4, ticket AI3D-376): spec, one-feature-per-class fixture and a realistic
example XML exist there and are the inputs.
At handoff: uv run --extra dev pytest test/test_visualize_xml_open3d.py -q → 3 passed;
the script parses the 1.1 example headlessly (--no-viewer) in ~3 s without errors — it just draws the new content invisibly/uniformly.
Current state of the viewer (922 lines, single script)
| Piece | Today |
|---|---|
parse_and_accumulate() |
Walks Features/Feature; per feature computes lane_color = _lane_id_to_color01(LaneID) (md5 hash of the LaneID string)
and calls _handle_points / _handle_lines / _handle_polylines / _handle_splines / _handle_labels. All four containers are already parsed
— Feature/Type is never read. |
_handle_points |
Each Point → one vertex in a single PointCloud, colour from Point/Color if parsable, else lane colour. 1-px dots → point-symbol classes are invisible at road scale.
Direction, Shapes, Annotation, Attributes ignored. |
_handle_polylines |
Consecutive-vertex segments into a LineSet, colour gain 0.95. Closed, Polyline/Type, SortingCode, Annotation ignored → tree-group rings stay open. |
_handle_splines |
Bezier sampling when fit/ctrl counts match, else fit polyline, else control polygon (this last branch is what 1.1 Tree Line/Bush hit: empty Fit_Points, ≥2 Control_Points). Works. |
_handle_labels |
Text meshes (o3d.t.geometry.TriangleMesh.create_text) for the feature ID (anchored at first child) and every child ID. Label offset/scale/z-lift/depth CLI-tunable. No annotation text. |
GeometryAccumulator |
Flat lists per primitive kind (points / lines / polylines / splines / fit / ctrl / labels); build_open3d() honours --hide-* flags; build_open3d_by_type() + save_geometries_as_ply() export one PLY per kind. |
| Viewer | Legacy o3d.visualization.Visualizer, white background, lighting off, back-faces on (so text meshes read from any angle). WSLg needs X11: the script re-execs itself with WAYLAND_DISPLAY unset (_reexec_with_x11_for_open3d_if_needed); see docs/Open3D_WSLg_Wayland_GLFW_workaround.md. |
| Extras | --show-surface-obbs/--show-surface-meshes overlay Step 4 road-surface PLYs (build_surface_overlay_geometries). Keep intact. |
| Tests | test/test_visualize_xml_open3d.py: 3 tests (label positions from a legacy-style XML with id attributes and <LaneExport> root; surface overlay meshes/OBBs/labels). Imports via from scripts import visualize_xml_open3d as viz. |
Recent history on the file: AI3D-331 (smaller labels, anchor at first segment), AI3D-174 (labels + surface overlays), AI3D-242 (WSLg workaround).
What schema 1.1 adds and why it is invisible now
Spec: ~/dev/3dai.iolabs.pointcloud.modellingexport/docs/xml_schema_1_1_spec.md — §3 element tree, §4 element reference, §7.1 summary table
(32 Feature/Type values; the authoritative type→container list is PROFILES in
src/iolabs_point_cloud_modelling_export/feature_classes.py).
- Colour by LaneID — every 1.1 profile sets
LaneID = "0"(R-7.0.3), so all 27 new classes get one identical hash colour, while lane markings keep their per-lane colours. Nothing separates a fence from a wall from a tree line. - Point symbols (
Tree,Terrain Point,Traffic Sign,Shaft,Emergency Call Box,Gully,KM Sign,Verge Post) — onePointeach, drawn as a 1-px dot. Each carries a mandatoryDirectionunit vector (§5.5: plate normal towards traffic for signs, road tangent for the rest). Shapes/Shape@role@typeonTree(trunk cylinder + crown cylinder|cone, R-5.8.7), optional post cylinder onTraffic Sign/Verge Post:Base,Top(cylinder) orApex(cone),Diameter; all absolute export-frame metres (§5.8). Real 3D bodies, currently dropped.Annotation(Display,Legible,Text*) on Point/Polyline/Spline/Line: tree labelØ= 0,35/6,0m, KM sign up to 3 lines (KM 46.5/Abs. 90/Station 0,25, may contain?/?…marks), guardrail type labelEDSPon the polyline.Terrain PointhasDisplay=truewith noText— consumer rendersZ + Geoshift/Zwith three decimals (N-5.7.2).Polyline/Closed=true(Tree Group, ≥3 vertices, first vertex not repeated) — ring drawn open.Polyline/Typetop/bottom +SortingCode(Trench/Trench BottompairsG<i>$A/$B,Slope= one feature with two polylinesB<i>$A/$B) — pairs not visually related.Attributes(height_m,guardrail_type,km, …) — could feed labels; ignored.- Frozen 1.0 classes (§7.21–7.23:
Axis of the Edge,Center Lines, the threeCentral Axistypes,Cross Section) are byte-identical to 1.0 — today's rendering of them must stay recognisable.
Recommended design
Keep the script as the CLI entry (tests import it) but move new parsing/builders into a module — AGENTS.md wants modules < 500 lines and the script is already 922.
The empty dirs src/visualization/ and src/xml_tools/ exist; check whether they are packages before choosing, e.g. src/visualization/xml_feature_symbols.py.
1. Read Feature/Type; colour by type
- Add
--color-by {type,lane}, defaulttype. Intypemode: fixed palette dict keyed byFeature/Typefor all §7.1 classes (families: vegetation greens, built structures oranges/browns, earthworks purple/brown withbottompolylines darker (gain 0.7), point symbols saturated cyan/magenta/red/yellow, guardrail family grey-blue); unknown types → hash of the type string. Keep the lane hash colour for the frozen lane-marking/axis classes so the existing look for lanes is preserved (they are lane-bound, the 1.1 classes are not). - Add
--only-type NAME/--hide-type NAME(repeatable, exactFeature/Typematch) and print a console legend at the end:type → rgb → feature count / geometry count(legacy viewer has no legend widget).
2. Point symbols as bodies, not dots
- For point classes other than
Cross Section: sphere markerTriangleMesh.create_sphere(radius=--symbol-radius, default 0.3)at the point, painted in the type colour.Cross Sectionstays a PointCloud (hundreds of samples per XML) — optionally bigger viaRenderOption.point_size. Direction→create_arrow()(z-aligned, base at origin) rotated onto the vector with a z→v rotation (Rodrigues /get_rotation_matrix_from_axis_angle), length--arrow-length(default 1.5 m), same colour, slightly brighter. Guard|v|≈0.Shapes→create_cylinder(radius=D/2, height=|Top−Base|)/create_cone(radius=D/2, height=|Apex−Base|). Open3D's cylinder is centred at the origin (translate to the midpoint), the cone's base is at z=0 (translate toBase); both are z-aligned → rotate ontoBase→Top|Apex. Role colours: trunk brown, crown green (alpha not available in the legacy viewer — use--shapes-wireframeto draw asLineSet.create_from_triangle_meshif solid crowns hide the road). Toggle--hide-shapes.
3. Lines/polylines
Closed=true→ add the closing segment.- Pairs:
SortingCodesuffix$B→ darker shade; optionally a thin connector LineSet between the closest vertices of the A/B pair (--show-pairs). Note legacy Open3D lines are always 1 px — check whetherRenderOption.line_widthexists in 0.19 before promising thicker lines.
4. Labels
- Extend
_handle_labels: whenAnnotation/Display=trueandTextpresent, add one text mesh perTextline stacked below the ID label (line height ≈label_scale × 1.2), verbatim incl.?marks (N-4.24.6). Flag--hide-annotations. --elevation-labels: forTerrain Point(andCross Sectionif requested) renderf"{z + geoshift_z:.3f}"— needs the rootGeoshift/Zparsed (currently the script never readsGeoshift).- Optional
--label-attributes: appendname=valuepairs fromAttributes(e.g.guardrail_type=EDSP,height_m=…). - Watch label count: the example XML already yields ~1 400 ID labels; consider defaulting child-ID labels off for point-symbol classes (feature ID + annotation is enough).
5. Export + agent-verifiability
save_geometries_as_ply: add_symbols.ply(merged TriangleMesh of spheres/arrows/shapes viawrite_triangle_mesh). The prefix directory must exist (Open3D fails silently otherwise — that is the only thing that went wrong at handoff).- Add
--screenshot PATH(and--view top) usingVisualizer.capture_screen_imageafterpoll_events/update_rendererorrendering.OffscreenRenderer, so agents can do a vision pass without a window. The X11 re-exec already makes windowed capture work on WSLg.
Next steps (ordered)
- Branch in LaneFinder:
git checkout -b ai3d-376-xml-viz-1-1-classes(reuse AI3D-376 unless Miro gives a new ticket; commit styleAI3D-376 <summary>). - Copy
~/dev/3dai.iolabs.pointcloud.modellingexport/tests/fixtures/sample_v1_1.xmltotest/fixtures/(one feature per class, coordinates within ±60 m, Geoshift 2600000/1200000/400) as the test input. - TDD, one slice each (follow the existing test style, headless — never open a window in tests): (a) type palette +
--color-by; (b) point-symbol spheres + direction arrows (assert geometry counts/types and centroid positions); (c) shapes cylinder/cone placement (assert axis endpoints after transform, ±1e-6); (d) closed polyline segment count; (e) annotation + elevation labels inacc.label_specs; (f) type filters; (g) PLY/screenshot export. - Refactor into a module if the script crosses the 500-line guideline further; keep
parse_and_accumulate,build_surface_overlay_geometries,_surface_label_textimportable fromscripts.visualize_xml_open3d(tests depend on them). - Visual check on the realistic example XML (below), iterate on radii/arrow length/label scale from screenshots.
- Update README section "Visualization helpers" (lines ~309–326) with the new flags; flake8 clean.
- Review (Opus + Sol in parallel, Fable final pass per Miro's model policy), Bitbucket PR via
bitbucket-pr.
Verification
cd ~/dev/3dai.lanefinder
uv run --extra dev pytest test/test_visualize_xml_open3d.py -q # 3 passed at handoff (~2 s)
# NB: plain `uv run pytest` fails — pytest lives in the dev extra despite AGENTS.md's shorter command
XML=~/dev/3dai.iolabs.pointcloud.modellingexport/docs/examples/run7_branch_001_schema_1_1.xml
mkdir -p /tmp/viz11
uv run python scripts/visualize_xml_open3d.py --xml "$XML" --no-viewer --save-ply-prefix /tmp/viz11/ex # ~3 s at handoff
env -u WAYLAND_DISPLAY XDG_SESSION_TYPE=x11 uv run python scripts/visualize_xml_open3d.py --xml "$XML" # interactive (WSLg)
The example XML = real Abschnitt 1 / branch 001 lane geometry (first ~290 m, frozen classes) + synthetic instances of every non-frozen class placed
beside that road; Geoshift 694541.44 / 5386372.37 / 467.41; 39 features, 1 112 Point elements (mostly cross-section samples + spline points), 13 polylines, 11 splines, 60 lines.
Not run at handoff: any interactive window; anything rendering 1.1 content (nothing does yet).
Risks and open questions
- real data Step 7 (
run_7_cluster_stepper.py) still emits only the frozen classes; all 1.1 features available today are synthetic (scripts/build_example_xml.pyin modellingexport; its input/tmp/run7/branch_001_trimmed.jsonis gone). Tune sizes with that in mind. - clutter Solid crown cones (Ø 6–8 m) and ~1 400 ID labels can hide the road; wireframe shapes and reduced child labels for point classes are the escape hatches. Ask Miro which default he prefers if unsure.
- Open3D legacy viewer No transparency, 1-px lines (verify
RenderOption.line_widthin 0.19.0), text meshes are geometry (many labels → slow). Do not migrate to the newrendering/GUI stack in this task. - frames
ShapeBase/Top/Apex are export-frame absolute metres likePoint/X,Y,Z(R-5.8.2) — no geoshift handling needed inside the viewer, only for elevation label text. - API Tests call
parse_and_accumulate(...)with the full keyword set; add new parameters with defaults only. Keep the legacy test XML shape (idattributes, noType) working: an absentFeature/Typemust fall back to lane colouring. - WSLg Interactive runs need the X11 prefix or the built-in re-exec (only when both
WAYLAND_DISPLAYandDISPLAYare set).
Key artifacts
- Viewer:
~/dev/3dai.lanefinder/scripts/visualize_xml_open3d.py; teststest/test_visualize_xml_open3d.py; docsREADME.md(~309–326),docs/Open3D_WSLg_Wayland_GLFW_workaround.md,AGENTS.md(conventions: background pipeline runs, commit format, <500-line modules, flake8). - Producer repo
~/dev/3dai.iolabs.pointcloud.modellingexport(master @c0339bf): specdocs/xml_schema_1_1_spec.md(HTML copydocs/pages/xml-schema-1-1-spec-20260828.html); fixturetests/fixtures/sample_v1_1.xml; exampledocs/examples/run7_branch_001_schema_1_1.xml; profilessrc/iolabs_point_cloud_modelling_export/feature_classes.py(PROFILES). - Real 1.0 XMLs for regression viewing:
~/dev/3dai.lanefinder/data/00_external/260605_Abschnitt_3_full/lane_points/run7_lanes_20260615-055503.xml(Geoshift 0,0,0) andrun7_lanes_speedup_compare.xml(real UTM geoshift). - Environment: Open3D 0.19.0 in the LaneFinder uv env (primitives
create_sphere/cylinder/cone/arrowavailable); Python ≥3.11.
Suggested skills / delegation
superpowers:test-driven-developmentper slice;superpowers:brainstormingonly if Miro wants to revisit the visual defaults first.- Implementation via
cursor-delegate(Grok 4.6, medium effort) — escalate to Opus 5 for the rotation/placement maths if it stalls. Vision pass over screenshots: Opus 5. bitbucket-prfor the PR. No package release needed — the viewer is a repo script.