Add Object Pipeline =================== This document describes the complete architecture of the ``add_object`` pipeline in ``WolfMapViewer`` (``wolfhece/PyDraw.py``) and provides a step-by-step guide for registering a new object type. Overview -------- ``add_object`` is the **single entry point** for loading any object into the viewer. It handles file selection, ID assignment, tree registration, and delegates object construction to a per-type handler. .. code-block:: text add_object(which, ...) │ ├─ Phase 1 — file/dir dialog (_prompt_file_for_type) ├─ Phase 2 — path existence check ├─ Phase 3 — snapshot of existing IDs ├─ Phase 4 — type dispatch (_ADD_HANDLERS → _add_xxx method) │ │ │ ├─ returns (curtree, newobj, id) → continue │ ├─ returns None → self-managed, return 0 │ └─ returns -1 → error/cancel, return -1 ├─ Phase 5 — ID resolution (_resolve_object_id) └─ Phase 6 — tree registration (_register_object_in_tree) Files involved ~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 40 60 * - File - Role * - ``wolfhece/PyDraw.py`` - ``WolfMapViewer``: ``add_object``, all ``_add_xxx`` handlers, helper methods, class-level constants Class-level constants --------------------- Three dictionaries / string constants drive the pipeline. All are defined at class level in ``WolfMapViewer``. ``_ADD_FILTER_xxx`` — file dialog wildcards ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Named constants for the ``wildcard`` argument of ``wx.FileDialog``: .. list-table:: :header-rows: 1 :widths: 35 65 * - Constant - Used for * - ``_ADD_FILTER_ARRAY`` - Raster arrays (``.bin``, ``.tif``, ``.top``, ``.flt``, ``.npy``, ``.npz``, ``.vrt``) * - ``_ADD_FILTER_JSON`` - JSON files * - ``_ADD_FILTER_ALL`` - Wildcard ``*.*`` * - ``_ADD_FILTER_VECTOR`` - Vector / zone files (``.vec``, ``.vecz``, ``.dxf``, ``.shp``) * - ``_ADD_FILTER_CLOUD`` - Point-cloud files (``.xyz``, ``.laz``, ``.las``, ``.json``, ``.shp``) * - ``_ADD_FILTER_LAZ`` - LAZ/LAS/NPZ point clouds * - ``_ADD_FILTER_TRI`` - Triangulation files (``.tri``, ``.dxf``, ``.gltf``, ``.glb``) * - ``_ADD_FILTER_CS`` - Cross-section files (``.vecz``, ``.txt``, ``.sxy``, ``.xlsx``) * - ``_ADD_FILTER_IMAGE`` - GeoTIFF images ``_ADD_DIALOG_SPECS`` — dialog configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Maps a ``which`` string to a ``(dialog_kind, title, filter_attr)`` tuple used by ``_prompt_file_for_type``: * ``dialog_kind`` is ``'FileDialog'`` or ``'DirDialog'``. * ``filter_attr`` is the name of one of the ``_ADD_FILTER_xxx`` class attributes, or ``None`` for directory dialogs (which have no wildcard). Types that always require a pre-built ``newobj`` (e.g. ``'injector'``) are intentionally **absent** from this dict — ``_prompt_file_for_type`` returns ``('', 0)`` in that case and the pipeline skips the dialog. ``_ADD_HANDLERS`` — type dispatch ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Maps each ``which`` string to the name of the private handler method that constructs and wires up the object: .. list-table:: :header-rows: 1 :widths: 30 25 25 20 * - ``which`` value - Handler method - Collection attribute - Tree item * - ``'array'``, ``'array_crop'`` - ``_add_array`` - ``myarrays`` - ``myitemsarray`` * - ``'array_xyz'`` - ``_add_array_xyz`` - ``myarrays`` - ``myitemsarray`` * - ``'array_tiles'`` - ``_add_array_tiles`` - *(recursive* ``add_object`` *)* - — * - ``'array_lidar_first'``, ``'array_lidar_second'`` - ``_add_array_lidar`` - ``myarrays`` - ``myitemsarray`` * - ``'picture_collection'`` - ``_add_picture_collection`` - ``mypicturecollections`` - ``myitemspictcollection`` * - ``'imagestiles'`` - ``_add_imagestiles`` - ``myimagestiles`` - ``myitemsvector`` * - ``'bridges'`` - ``_add_bridges`` - ``myvectors`` - ``myitemsvector`` * - ``'weirs'`` - ``_add_weirs`` - ``myvectors`` - ``myitemsvector`` * - ``'tiles'``, ``'tilescomp'`` - ``_add_tiles`` - ``mytiles`` - ``myitemsvector`` * - ``'res2d'`` - ``_add_res2d`` - ``myres2D`` - ``myitemsres2d`` * - ``'res2d_gpu'`` - ``_add_res2d_gpu`` - ``myres2D`` - ``myitemsres2d`` * - ``'vector'`` - ``_add_vector`` - ``myvectors`` - ``myitemsvector`` * - ``'cross_sections'`` - ``_add_cross_sections`` - ``myvectors`` - ``myitemsvector`` * - ``'laz'`` - ``_add_laz`` - ``mylazdata`` - ``myitemslaz`` * - ``'cloud'`` - ``_add_cloud`` - ``myclouds`` - ``myitemscloud`` * - ``'clouds'`` - ``_add_clouds`` - ``myclouds`` - ``myitemscloud`` * - ``'triangulation'`` - ``_add_triangulation`` - ``mytri`` - ``myitemstri`` * - ``'other'`` - ``_add_other`` - ``myothers`` - ``myitemsothers`` * - ``'views'`` - ``_add_views`` - ``myviews`` - ``myitemsviews`` * - ``'wmsback'`` - ``_add_wmsback`` - ``mywmsback`` - ``myitemswmsback`` * - ``'wmsfore'`` - ``_add_wmsfore`` - ``mywmsfore`` - ``myitemswmsfore`` * - ``'particlesystem'`` - ``_add_particlesystem`` - ``mypartsystems`` - ``myitemsps`` * - ``'drowning'`` - ``_add_drowning`` - *(via* ``_drowning.register`` *)* - ``myitemsdrowning`` * - ``'dike'`` - ``_add_dike`` - *(via* ``_dike.register`` *)* - ``myitemsdike`` * - ``'injector'`` - ``_add_injector`` - ``myinjectors`` - ``myitemsinjector`` Public method: ``add_object`` ------------------------------ .. code-block:: python def add_object(self, which: Literal['array', 'vector', 'cloud', ...] = 'array', filename: str = '', newobj=None, ToCheck: bool = True, id: str = '') -> int: Parameters ~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 15 10 10 65 * - Parameter - Type - Default - Description * - ``which`` - ``str`` - ``'array'`` - Object category. Must be a key of ``_ADD_HANDLERS``. Case-insensitive (lowercased internally). * - ``filename`` - ``str`` - ``''`` - Path to the file or directory to load. When empty and ``newobj`` is also ``None``, a dialog is shown. * - ``newobj`` - object - ``None`` - Pre-constructed object. Bypasses both the dialog and object construction inside the handler. * - ``ToCheck`` - ``bool`` - ``True`` - Whether to check (make visible) the item in the treelist after insertion. * - ``id`` - ``str`` - ``''`` - Unique identifier for the object. When empty, a prompt is shown; uniqueness is enforced automatically. Return value ~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 15 85 * - Value - Meaning * - ``0`` - Success * - ``-1`` - Error or user cancellation Typical call sites ~~~~~~~~~~~~~~~~~~ .. code-block:: python # From a menu handler — open a file dialog, then load: self.add_object('array') # From code — supply an already-constructed object: self.add_object('cloud', newobj=my_cloud, id='survey_2024', ToCheck=False) # From code — supply a file path and an explicit ID: self.add_object('vector', filename='/path/to/zones.vec', id='flood_zones') Pipeline phases --------------- Phase 1 — File/directory dialog ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``_prompt_file_for_type(which)`` looks up ``_ADD_DIALOG_SPECS[which]`` and opens the appropriate dialog. Returns ``(filename, curfilter)`` on success or ``None`` if the user cancels (causing ``add_object`` to return ``-1``). Types absent from ``_ADD_DIALOG_SPECS`` (e.g. ``'injector'``) silently return ``('', 0)`` — the dialog is skipped. Phase 1 is skipped entirely when ``filename != ''`` or ``newobj is not None``. Phase 2 — Path existence check ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If ``filename`` is non-empty, ``os.path.exists(filename)`` is checked. A warning is logged and ``-1`` is returned when the path does not exist. Phase 3 — ID snapshot ~~~~~~~~~~~~~~~~~~~~~~~ ``get_list_keys(None, checked_state=None)`` collects all IDs currently loaded in the viewer *before* the new object is added. This snapshot is used later by ``_resolve_object_id`` to guarantee uniqueness. Phase 4 — Type dispatch ~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python handler_result = getattr(self, self._ADD_HANDLERS[which])( which, filename, newobj, curfilter, ToCheck, id) The handler receives the raw arguments and returns one of three values: .. list-table:: :header-rows: 1 :widths: 35 65 * - Return value - Meaning * - ``(curtree, newobj, id)`` - Construction succeeded; continue with phases 5–6 * - ``None`` - Handler managed everything itself (e.g. recursive ``add_object`` calls); return ``0`` immediately * - ``-1`` - Error or user cancellation; return ``-1`` Phase 5 — ID resolution ~~~~~~~~~~~~~~~~~~~~~~~~~ ``_resolve_object_id(id, filename, all_ids)``: 1. If ``id`` is empty, opens a ``wx.TextEntryDialog`` pre-filled with the filename stem. Loops until a non-empty, unique value is entered. 2. If the resolved ``id`` already exists (e.g. supplied programmatically but colliding), appends a zero-padded counter: ``name`` → ``name001``. Phase 6 — Tree registration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``_register_object_in_tree(newobj, curtree, id, ToCheck, filename)``: 1. Sets ``newobj.idx = id.lower()``. 2. Appends a ``treelist`` item under ``curtree``. 3. Checks the item (and its parent) when ``ToCheck`` is ``True``. 4. Sets ``newobj.checked = ToCheck``. 5. For ``crosssections`` objects, automatically calls ``add_object('cloud', ...)`` twice to register the intersection cloud and the full-survey cloud. Handler contract ----------------- Every ``_add_xxx`` method has the signature: .. code-block:: python def _add_xxx(self, which: str, filename: str, newobj, curfilter: int, ToCheck: bool, id: str): **Rules**: * Construct ``newobj`` from ``filename`` when ``newobj is None``; otherwise reuse the supplied object. * Append the object to its collection (``self.myarrays``, ``self.myvectors``, …). * Set the relevant ``active_xxx`` attribute on the viewer. * Call any required menu-creation helper (``self.menu_bridges()``, etc.). * Return ``(curtree, newobj, id)`` to hand off to phases 5–6. * Return ``None`` when the handler has already inserted everything itself (e.g. recursive ``add_object`` for ``.npz`` multi-array files or tile maps). * Return ``-1`` on error or user cancellation. .. important:: Handlers must **not** call ``_resolve_object_id`` or ``_register_object_in_tree`` themselves (unless they return ``None``). Adding a new object type: step-by-step guide --------------------------------------------- Step 1 — Add a file-dialog wildcard (if needed) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Add a class-level constant in ``WolfMapViewer``: .. code-block:: python _ADD_FILTER_MYTYPE = "MyType files (*.ext)|*.ext|all (*.*)|*.*" Step 2 — Register the dialog specification ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Add an entry to ``_ADD_DIALOG_SPECS``: .. code-block:: python 'mytype': ('FileDialog', "Choose a MyType file", '_ADD_FILTER_MYTYPE'), # or for a directory: 'mytype': ('DirDialog', "Choose a directory for MyType", None), Omit this entry only if ``add_object`` will always be called with an explicit ``filename`` or ``newobj`` (e.g. purely programmatic use). Step 3 — Register the handler name ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Add an entry to ``_ADD_HANDLERS``: .. code-block:: python 'mytype': '_add_mytype', Step 4 — Write the handler ~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python def _add_mytype(self, which, filename, newobj, curfilter, ToCheck, id): if newobj is None: with wx.BusyInfo(_('Importing MyType')): wait = wx.BusyCursor() newobj = MyType(filename, mapviewer=self) del wait self.mymytypes.append(newobj) self.active_mytype = newobj # call self.menu_mytype() if a dedicated context menu exists return self.myitemsmytype, newobj, id Add the corresponding ``treelist`` root item (e.g. ``myitemsmytype``) to ``WolfMapViewer.__init__`` alongside the other ``mytreelist.AppendItem`` calls. Step 5 — Expose via menu or toolbar ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python # Inside OnPopupItemSelected, OnMenu, or a dedicated handler: elif itemlabel == _('Add MyType'): self.add_object('mytype') Step 6 — Update the ``which`` type hint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Extend the ``Literal[...]`` annotation on ``add_object``'s ``which`` parameter: .. code-block:: python which: Literal[..., 'mytype'] = 'array', Step 7 — Unit tests ~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python class TestAddMyType(unittest.TestCase): def _make_viewer(self): v = MagicMock(spec=WolfMapViewer) v.mymytypes = [] v.active_mytype = None v.myitemsmytype = MagicMock() return v def test_nominal_with_newobj(self): v = self._make_viewer() obj = MagicMock() result = v._add_mytype('mytype', '', obj, 0, True, 'test_id') self.assertEqual(result[1], obj) self.assertIn(obj, v.mymytypes) def test_returns_minus1_on_cancel(self): v = self._make_viewer() # simulate user cancel inside the handler with patch('wolfhece.PyDraw.wx.BusyInfo', side_effect=Exception): result = v._add_mytype('mytype', 'missing.ext', None, 0, True, '') self.assertEqual(result, -1) Reference: helper methods -------------------------- ``_prompt_file_for_type(which)`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python def _prompt_file_for_type(self, which: str) -> tuple[str, int] | None: Opens a ``wx.FileDialog`` or ``wx.DirDialog`` according to ``_ADD_DIALOG_SPECS``. Returns ``(filename, curfilter)`` on success, ``None`` on cancel. ``curfilter`` is the zero-based index of the selected filter (always ``0`` for directory dialogs). ``_resolve_object_id(id, filename, all_ids)`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python def _resolve_object_id(self, id: str, filename: str, all_ids: list) -> str: Ensures the returned ``id`` is non-empty and unique: * If ``id`` is empty, prompts the user (pre-filled with ``Path(filename).stem``). * If the ``id`` collides, appends ``001``, ``002``, … until unique. ``_register_object_in_tree(newobj, curtree, id, ToCheck, filename)`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python def _register_object_in_tree(self, newobj, curtree, id: str, ToCheck: bool, filename: str) -> None: Final wiring step. Sets ``newobj.idx``, inserts the treelist item, checks it, and handles the ``crosssections`` special case (auto-registers two cloud objects). Checklist --------- .. code-block:: text ☐ _ADD_FILTER_MYTYPE constant added (if a new file format) ☐ Entry added to _ADD_DIALOG_SPECS (skip only for always-programmatic types) ☐ Entry added to _ADD_HANDLERS ☐ _add_mytype() handler written ☐ └─ appends to collection, sets active_xxx, calls menu helper ☐ └─ returns (curtree, newobj, id) / None / -1 correctly ☐ myitemsmytype treelist root added in __init__ ☐ Triggered from menu/toolbar via add_object('mytype') ☐ 'mytype' added to Literal[...] annotation on add_object ☐ Unit tests added